Compare commits

..
Author SHA1 Message Date
jiang 0ae0a41e61 feat(charts): manage investigation charts in a scrolling workspace
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m8s
2026-11-10 17:56:23 +08:00
jiang 865491cb23 fix: show alert focus rings only for keyboard input
Radix focus transfers can preserve :focus-visible during mouse hover and
when returning focus to the alert trigger. Track actual input modality so
pointer interactions keep the thin hover border and keyboard navigation
retains its focus rings.

Cover desktop and narrow layouts, pointer selection, keyboard return focus,
and switching input modes with browser regressions.
2026-11-10 10:33:43 +08:00
jiang d7eb01652a fix: deduplicate workbench requests and remove unused map paths
Generic Container CI/CD / test-build-publish (push) Successful in 2m7s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m7s
2026-11-09 18:49:32 +08:00
jiang 038d3458bc feat(map): manage agent overlays in layer panel 2026-11-09 17:59:36 +08:00
jiang 692bbf609c refactor: consolidate requests and finalize agent tool states
Share project JSON transport, interaction replies and report selectors.
Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
2026-11-09 17:55:00 +08:00
jiang ad1f7ca7a9 fix(charts): move floating windows without per-event renders
Generic Container CI/CD / test-build-publish (push) Successful in 1m20s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m20s
The earlier placement fix exposed chart headings but retained react-rnd state updates on every mousemove, so dragging still stalled with several charts open. Move the active window through pointer capture and a frame-scheduled DOM translation, then commit its bounds once on release.

Cover cancellation, resize and restore controls, and eight-window dragging. The regression records 40 React transform writes on the old code and one on the fix while other windows and chart SVGs stay unchanged.
2026-11-08 04:16:42 +08:00
jiang aa9b12662e fix(tasks): accept execution scope in task responses
Generic Container CI/CD / test-build-publish (push) Successful in 1m10s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m10s
Server responses now require execution_scope, but the strict frontend schema rejected it and prevented investigation and appointment loading. Declare it in the shared response schema and align task fixtures; regression coverage exercises summaries, details and both task categories.
2026-11-08 03:44:25 +08:00
jiang b948551c1f fix(map): stabilize notifications during map navigation
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
Ignore obsolete tile cancellations and update source notices without dismissing their existing toast. Keep notice input above the task ticker and disable swipe removal.

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
2026-11-08 03:23:00 +08:00
jiang e0fe4a5a10 fix(charts): preserve comparison types and draggable window headings
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap.
2026-11-08 03:04:14 +08:00
jiang 4e3ed5098e fix(workbench): retry approved task actions after dispatch failure
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
2026-11-07 22:20:04 +08:00
jiang 8a63926284 fix(charts): simplify time labels in investigation views
Generic Container CI/CD / test-build-publish (push) Successful in 1m25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m26s
2026-11-07 21:03:43 +08:00
jiang 4a7781d5e5 fix(charts): fit analysis windows across viewport sizes
Generic Container CI/CD / test-build-publish (push) Successful in 2m34s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m34s
2026-11-07 20:13:43 +08:00
jiang e0436217a8 style(workbench): refine notification cards and hit targets 2026-11-07 20:04:28 +08:00
jiang ea3b62573c feat(workbench): refine alert actions and removal transitions 2026-11-07 19:56:14 +08:00
jiang eee65ac8fd style: remove trailing blank line from event picker 2026-11-07 19:50:31 +08:00
jiang ba71e2d529 feat(workbench): expand SCADA monitoring and investigation presentation 2026-11-07 19:50:14 +08:00
237 changed files with 13375 additions and 2589 deletions
+1
View File
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
pnpm test # 运行 Vitest 单元测试 pnpm test # 运行 Vitest 单元测试
pnpm test:watch # 监听模式运行单元测试 pnpm test:watch # 监听模式运行单元测试
pnpm test:browser # 运行 Playwright 浏览器测试 pnpm test:browser # 运行 Playwright 浏览器测试
pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
``` ```
## Docker ## Docker
+10 -2
View File
@@ -78,7 +78,15 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。 短结果及说明直接在对话内阅读;包含表格、图表、补充表单或长正文的回答提供展开入口。窗口尺寸、滚动与放大由宿主管理,不作为模型输出样式。
`Chart` 的页签、图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。 `Chart` 的页签、位于绘图区下方的图例、区间摘要与数据点详情由前端固定实现,Agent 只提供标题、初始图形类型、类别、序列和来源。鼠标切换折线、柱状和数据表时使用 180ms Motion 滑块与内容过渡;键盘操作与减少动态效果模式即时完成。数据点详情只在点击图中数据点,或聚焦图形后按回车、方向键、Home/End 选点时出现,关闭后焦点回到图形;数据表页签不重复展示区间摘要和点详情,搜索与排序在视图切换后保留。
SCADA 的压力、水头(m)及流量、管流(m³/h)数值轴使用对齐的整数上下界与整数刻度,不直接显示数据极值外加余量的小数端点。序列、数据表与点详情保持原始读数精度;其他单位及通用小量程图沿用原有刻度,不自动换算单位。
图表发布及 Dev 示例的对象聚合、同期校验和历史读取边界统一见 [调查展示 Dev 测试](investigation-dev-tools.md#对象复合图与验证规则)。结构化 Chart 保持单图语义,对象聚合通过 open_chart 的独立对象数据集实现。
[图表和窗口管理](investigation-dev-tools.md#图表和窗口管理)由浏览器宿主负责:总窗口收纳过程图组与报告 Chart,子图表保留独立弹窗放大。总窗口提供单图、左右、四宫格固定槽位,全部图表沿纵向连续排列,末行按列数补齐空槽;整条位置栏提供拖拽和悬停反馈,拖放到目标位置交换内容。窄窗口使用单图,快速定位会滚动到目标位置。同一图表更新保留位置,删图留空,新图先填空槽。顶部窗口列表可定位、置顶已打开的总窗口、子图表和表格,并统一关闭。窗口缩放期间,图表按窗口实际尺寸适配并保留图表实例与筛选状态;松开后确认最终尺寸,Escape 或指针取消恢复原尺寸。
任务打开、切换、刷新、已完成任务载入和历史恢复只更新内容,不弹窗;运行中新收到的 open_chart 由总窗口统一处理,关闭后的实时更新不重新弹出。排列、布局和窗口操作不新增模型输出或 wire 协议字段,也不改变图表校验与证据规则。具体自动弹出与关闭边界以上述管理说明为准。
其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。 其余组件使用 `answer-motion.tsx` 统一交互过渡,沿用现有实体面板、字体、语义颜色和圆角。首次加载即时显示;鼠标展开、收起使用 180ms 透明度和位移,键盘与减少动态效果模式即时完成。折叠中的内容立即设为 `inert` 并退出无障碍树,动画结束后隐藏;保留原 React 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
@@ -87,7 +95,7 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 | | `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 | | `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
| `Metric` | 直接展示原始数值、单位与来源 | | `Metric` | 直接展示原始数值、单位与来源 |
| `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 | | `Table` | 搜索排序即时响应;表头悬停与排序点击覆盖整列宽度;仅当前排序列显示升序或降序箭头,未排序时不显示;无勾选列、选行提示或操作栏 |
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 | | `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 | | `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 | | `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
+2
View File
@@ -118,6 +118,8 @@ Agent 面板
图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。 图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。
图表通过“放大查看”打开独立浮窗,标题栏的“最大化窗口”按钮铺满可用视口,保留还原与关闭入口。内容宽度达到640px时,主图占据剩余空间,序列与摘要放在右侧;较窄窗口将资料放在主图下方独立滚动。展开、最大化与还原共享视图、序列显隐及表格筛选状态,调整窗口尺寸沿用同一图表实例。时间轴集中标注UTC+8,采样详情保留完整日期与偏移。工作台在独立层叠上下文内排列业务面板,浮窗与后续打开的菜单、对话框通过Portal显示。
正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。 正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。
空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史、引用任务或已有工具权限审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。 空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史、引用任务或已有工具权限审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。
+49 -8
View File
@@ -1,8 +1,12 @@
# 调查任务展示与 Dev 测试 # 调查任务展示与 Dev 测试
“调查测试”顶部的“全屏爆管视觉警告”区域提供持续预览、三档强度和“模拟事件处理结束”。默认使用标准档;关闭按钮位于屏幕底部状态提示右上角。真实警告随监测事件持续至处理结束,查看调查后继续显示;手动关闭按项目与事件在当前标签页记忆。预览操作只改变本地视觉状态。
可直接打开顶部 Dev 入口载入、编辑、校验并执行本地测试案例,无需先选择调查或开启实时跟随。纯数据和界面块可独立预览,包含地图或图表展示指令时需等待地图就绪。验证实际调查联动时,在“工况任务 → 调查任务”选择一项任务,再打开 Dev。Dev 默认打开“调查测试”页签,保留当前选择,显示调查代次、实时图表数、已保存报告中的图表/表格/地图入口数,以及最近的展示动作。上一轮报告会明确标注,不当成本轮新成果。 可直接打开顶部 Dev 入口载入、编辑、校验并执行本地测试案例,无需先选择调查或开启实时跟随。纯数据和界面块可独立预览,包含地图或图表展示指令时需等待地图就绪。验证实际调查联动时,在“工况任务 → 调查任务”选择一项任务,再打开 Dev。Dev 默认打开“调查测试”页签,保留当前选择,显示调查代次、实时图表数、已保存报告中的图表/表格/地图入口数,以及最近的展示动作。上一轮报告会明确标注,不当成本轮新成果。
Dev 分为“调查测试”和“地图调试”两个页签:前者按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程,后者保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。 Dev 分为“调查测试”“通知测试”和“地图调试”三个页签:调查测试按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程;通知测试提供五种状态、报告与任务入口、长文本和连续六条通知;地图调试保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。
通知测试中的信息、成功提示默认 3.2 秒后关闭;警告和错误可手动关闭,加载状态通过“模拟加载完成”更新为成功提示。带操作入口的通知持续保留,点击查看仍保留,正文显示“保留至手动关闭”;多条通知沿用原有堆叠,关闭当前通知后可继续查看其余通知。报告和任务按钮仅记录本地点击反馈。“清除测试通知”只移除本页测试样例,不影响真实通知。
## 当前识别链路 ## 当前识别链路
@@ -16,11 +20,31 @@ Dev 分为“调查测试”和“地图调试”两个页签:前者按“准
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。 Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
## 图表和窗口管理
调查、本地预览与对话展示面板提供“图表窗口”入口,在一个可拖动、缩放、最大化的总窗口中集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭总窗口收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。
槽内图表使用紧凑控件,绘图区下方的图例独立滚动,筛选、详情与依据通过入口展开。多对象图在宽槽右侧显示对象按钮,窄槽使用对象选择器;独立放大后使用完整控件。
整条图表位置栏均为拖拽手柄,悬停反馈覆盖整栏;在目标位置释放后交换两处内容,拖到空槽则移动到空槽,其他位置不推移。悬停时整张卡片显示轻量浅色高亮,拖动源与目标位置保留整卡浅色提示;位置栏使用柔和浅底和深色文字,不使用深色横幅。图表保持原有清晰度,目标位置使用细线提示,轻量标题预览沿用中性边框。原图表留在原位,不克隆或随指针重绘。不再提供“移动到”下拉菜单,下方图表通过纵向滚动和快速定位查看。
槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。
工作台顶部的窗口列表入口列出已打开的总图表窗口、独立子图表和表格窗口,选择条目可将对应窗口置顶并定位到标题栏。列表中的“关闭全部窗口”统一收起当前打开的窗口,不删除已加载的图表、表格或任务结果;图表窗口可从原展示入口重新打开。
总窗口与子图表使用同一缩放交互:指针拖动窗口边缘时,图表按窗口实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择;松开后确认最终尺寸。拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸;标题栏的 Shift 加方向键继续用于逐步调整大小。
只有正在跟随的运行中调查新收到 open_chart 时,才自动弹出一次总窗口,不自动弹出各子图表,不抢走键盘焦点;后续指令按 chart_id 更新其中的数据。打开、切换、刷新任务、SSE 重连补载、历史回看和回到当前都只恢复内容,已完成任务也不弹窗。用户关闭总窗口后,本次任务查看期间的后续更新不使其再次弹出;手动收起展示面板期间的新指令也不请求弹窗。总窗口已经打开时,历史回看只更新其中的快照,实时数据不覆盖正在查看的历史。切换任务、项目或执行代次关闭原窗口并重新静默载入,返回之前看过的任务也不重放自动弹出意图。Chat 与本地 Dev 预览使用手动入口。
open_chart 仍只提交 chart_id/chart/evidence_refs,close_chart 仍按 chart_id 移除对象;不新增 display 或 objects 字段。排列、布局与窗口打开意图仅存在当前浏览器中,不进入保存的展示快照或事件。displayed 回执表示展示内容已加载,不保证弹出了独立窗口。
## 本地测试步骤 ## 本地测试步骤
1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。 1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。 2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。 3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;手动打开“图表窗口”,核对单图、左右、四宫格的固定槽位、拖放交换、末行空槽与纵向滚动,再检查快速定位和子图表独立放大,并从顶部窗口列表切换已打开的窗口。Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。 4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。
样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。 样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
@@ -31,8 +55,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 案例 | 核对内容 | | 案例 | 核对内容 |
| --- | --- | | --- | --- |
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 | | 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 |
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 | | 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 |
| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 |
| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 |
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 | | 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 | | 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
| 合成影响范围 | 当前地图中心附近的合成 Polygon | | 合成影响范围 | 当前地图中心附近的合成 Polygon |
@@ -43,6 +69,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 | | 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 |
| 错误:不支持的动作 | 拒绝协议未支持的动作 | | 错误:不支持的动作 | 拒绝协议未支持的动作 |
| 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 | | 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 |
| 错误:同期时刻错位 | 拒绝历史时间与横轴槽位不一致的复合图,整批不执行 |
切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。 切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。
@@ -56,7 +83,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"} {"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"}
``` ```
仓库内的可复制数据示例为 [unified-presentation.jsonl](../tests/fixtures/unified-presentation.jsonl),覆盖十种 command、一个 `phase: final` 界面块和带来源的普通数据行;`P1` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。 仓库内的可复制数据示例为 [unified-presentation.jsonl](../tests/fixtures/unified-presentation.jsonl),覆盖十种 command、两个可聚合的对象复合图、一个 `phase: final` 界面块和带来源的普通数据行;`P1` 是浏览器测试夹具中的测点,业务项目应使用已核实的实际编号。内置案例从同一个记录生成器输出,并在生成时通过当前校验器。错误案例单独保留,专门验证拒绝行为。
每行必须显式使用 `record_type: presentation`、`record_type: ui_block`,或带来源的调查数据类型。裸 command、裸 `answer-ui@1` block 和旧 chat 的 name/params 格式均拒绝。展示指令统一接受 `locate_features/zoom_to_map/highlight_features/clear_highlights/open_chart/close_chart/set_overlay/clear_overlay/render_scada_analysis/clear_scada_analysis`,不能加入项目、运行身份、URL、脚本或任意命令。Dev 的覆盖层样例直接提供合成几何,仅用于本地渲染测试;任务 Agent 的 `set_overlay` 可只传证据编号,由 Server 提取几何;传入 data 时必须与保存的证据快照一致。Chat 和 Dev 执行已提供并校验的 data。不能把 Dev 格式当成 Server 写入接口。 每行必须显式使用 `record_type: presentation`、`record_type: ui_block`,或带来源的调查数据类型。裸 command、裸 `answer-ui@1` block 和旧 chat 的 name/params 格式均拒绝。展示指令统一接受 `locate_features/zoom_to_map/highlight_features/clear_highlights/open_chart/close_chart/set_overlay/clear_overlay/render_scada_analysis/clear_scada_analysis`,不能加入项目、运行身份、URL、脚本或任意命令。Dev 的覆盖层样例直接提供合成几何,仅用于本地渲染测试;任务 Agent 的 `set_overlay` 可只传证据编号,由 Server 提取几何;传入 data 时必须与保存的证据快照一致。Chat 和 Dev 执行已提供并校验的 data。不能把 Dev 格式当成 Server 写入接口。
@@ -69,15 +96,16 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
```bash ```bash
pnpm exec vitest run src/features/workbench/utils/investigation-dev-preview.test.ts src/features/workbench/components/map-dev-panel.test.tsx src/features/workbench/hooks/investigation-presentation-follower.test.ts pnpm exec vitest run src/features/workbench/utils/investigation-dev-preview.test.ts src/features/workbench/components/map-dev-panel.test.tsx src/features/workbench/hooks/investigation-presentation-follower.test.ts
PLAYWRIGHT_PORT=5194 pnpm exec playwright test tests/browser/investigation-dev-tools.e2e.ts tests/browser/floating-panel-pointer.e2e.ts --workers=1 --reporter=line PLAYWRIGHT_PORT=5194 pnpm exec playwright test tests/browser/investigation-dev-tools.e2e.ts tests/browser/floating-panel-pointer.e2e.ts --workers=1 --reporter=line
PLAYWRIGHT_PORT=5195 pnpm exec playwright test tests/browser/investigation-chart-manager.e2e.ts tests/browser/investigation-chart-groups.e2e.ts tests/browser/investigation-chart-layout.e2e.ts tests/browser/investigation-chart-resize.e2e.ts --workers=1 --reporter=line
``` ```
浏览器用隔离 API/SSE fixture 验证所选任务到真实 MapLibre/React 展示的消费链、Dev 开启与收起、JSONL 校验和下载、任务切换清理、紧凑窗口及面板外地图输入;不调用付费模型或真实工单、设备接口。真实 Agent 是否发布了正确的事件、报告或 JSONL 文件,仍需按实际项目运行记录核对。 浏览器用隔离 API/SSE fixture 验证所选任务到真实 MapLibre/React 展示的消费链、Dev 开启与收起、JSONL 校验和下载、任务静默恢复、任务切换清理、子图表管理、紧凑窗口、实际尺寸适配及面板外地图输入;不调用付费模型或真实工单、设备接口。真实 Agent 是否发布了正确的事件、报告或 JSONL 文件,仍需按实际项目运行记录核对。
## 统一展示动作 ## 统一展示动作
Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.frontend_action` 承载 `frontend-action@2`,动作字段为 command,不再接收 name/params。浏览器回传 `frontend-action-result@2`。任务事件和 Dev JSONL 的 command 使用相同结构,并进入同一执行器。Chat、调查和 Dev 预览分别拥有地图源、SCADA 图层及图表状态;清理、切换会话和切换任务不会误清其他入口。Chat 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。 Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.frontend_action` 承载 `frontend-action@2`,动作字段为 command,不再接收 name/params。浏览器回传 `frontend-action-result@2`。任务事件和 Dev JSONL 的 command 使用相同结构,并进入同一执行器。Chat、调查和 Dev 预览分别拥有地图源、SCADA 图层及图表状态;清理、切换会话和切换任务不会误清其他入口。Chat 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。
动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema 和 Agent/Server JSON Schema;运行同一命令加 `--check` 检查一致性。Server 任务入口继续执行证据归属和快照校验。 动作 schema 的维护源是 `src/features/agent/presentation/command-schema.ts`。修改后在前端仓库运行 `node scripts/export-view-command-contract.mjs`,同步 Agent 主机/原生工具共用的 TypeScript schema、Agent/Server JSON Schema,以及两端 tests/fixtures 下的统一 JSONL 示例与图表正反例;运行同一命令加 `--check` 检查内容一致性。JSON Schema 表达字段和数量约束,跨字段的时间语义由运行时校验和共享测试矩阵保证。Server 任务入口继续执行证据归属和快照校验。
## 已有案例与示例迁移 ## 已有案例与示例迁移
@@ -91,7 +119,20 @@ Chat 使用 `present_view({command, reason})`,在线通过 SSE 的 `tjwater.fr
| 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 | | 调查数据 | 保留 `record_type/origin/source_evidence_refs`,不把内嵌 action 提升为指令 |
| Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 | | Chat 调用 | `present_view({command, reason})`,不再调用旧的独立展示工具 |
本轮迁移只覆盖仓库内的内置案例、夹具和文档示例,未修改历史会话、数据库或外部下载文件。没有增加运行时旧格式转换。 迁移覆盖内置复杂案例、可复制 JSONL、跨端校验夹具及文档,不修改历史会话、数据库或外部下载文件。旧的已保存图表仍按结构校验读取;新输入与发布按下述规则校验。
## 对象复合图与验证规则
协议继续使用 `open_chart`,每个对象有独立稳定的 `chart_id`;不增加 `objects` 字段。多对象同期对比使用 `chart_type: time_comparison`。标题写为 `<对象ID> <同组标题>`,每条序列名以同一 `<对象ID> ` 开头,建议后接 `当前 YYYY-MM-DD` 或历史日期。对象 ID 以字母或数字开头,仅使用字母、数字、下划线、点或短横线。图例显示在绘图区下方;宽槽右侧的对象按钮单选切换数据集,窄槽使用对象选择器;独立放大的窄窗口把对象按钮移到下方。
仅 `open_chart` 中带历史 scatter 的明确对象图自动聚合。同组标题、chart_type、横纵轴名称、横轴槽位及当前序列名称去掉对象前缀后的部分、当前 sample_times 必须完全一致;不同单位或窗口分开。普通 line/bar 图和结构化回答中的独立 Chart 保持各自图表,统一在总窗口管理。关闭子窗口只收起放大视图,关闭聚合卡片移除组内所有对象;按原 chart_id 的 close_chart 仅移除该对象。
历史读取还兼容已保存的 `测点 <对象ID> <同组标题>`、`压力测点 <对象ID> <描述>` 和 `流量测点 <对象ID> <描述>` 标题;后两种以指标名称连接描述作为同组标题,压力和流量分开。对象归属仍要求序列名带同一对象前缀。历史 scatter 仅写 `MM-DD` 的旧图,只有在标题与唯一当前 line 明确指向同一对象,且每条日期序列至少有一个有效采样时间、有效值与时间一一对应、时间换算为 UTC+8 后均为同一完整日期并与日期名相符时,才兼容日期型历史图例;缺失时间、日期不符、跨对象序列或歧义标题保持单图。这些兼容只影响显示聚合,保留原指令、标题、序列、采样时间和证据,不修改保存的数据,也不放宽新发布的同期规则。
当前与历史共用 UTC+8 的 HH:mm 或 HH:mm:ss 槽位,时刻精度一致,窗口按顺序且短于一天,允许跨午夜。每对象最多 100 个槽位、8 组序列,包含当前 line 和历史 scatter,不能混入 bar。每组 sample_times 与 data 等长,有效值保留带时区的真实采样时间;缺测值和时间均为 null,零值保留。采样时间换算为 UTC+8 后须落在对应槽位;当前曲线共享同一窗口,每组历史来自此前某个对应日期,不能混入当天、未来或不同日期。普通 line/bar 不套用这些同期语义,但不能用 line 冒充 line 与历史 scatter 复合图。
Dev 的 presentation 与 ui_block.Chart、新的 Chat open_chart(Agent 主机与原生工具)及任务图表发布使用相同的同期规则。历史读取的结构校验不追溯拒绝旧混合图。Server 在任务展示和报告发布时另外校验证据归属与同期语义,错误码为 TIME_COMPARISON_INVALID;本地预览不证明证据有效或业务发布成功。共享正反例的维护源为 [chart-publication-cases.json](../tests/fixtures/chart-publication-cases.json),随导出脚本同步到 Agent 与 Server,并在各仓库独立运行测试。
## 展示动作历史 ## 展示动作历史
+3 -1
View File
@@ -32,7 +32,9 @@
凤阳使用已有的 `fengyang_v2` 工作空间及元数据地图范围。固定奉贤演示工况、场景与空间覆盖不会在凤阳兜底显示。奉贤原始 GIS 图层不复制,图层入口根据当前工作空间中的实际数据探测。 凤阳使用已有的 `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` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。
## 验证 ## 验证
+20 -2
View File
@@ -32,9 +32,9 @@ Agent 输入框使用 `--shadow-agent-composer` 的柔和双层阴影区分白
助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 hover 反馈,使用对称的水平留白。普通步骤和工具调用复用同一状态图标组件:24px 圆形容器、14px 线性图标,完成为圆圈勾选、失败为圆圈叉号、进行中为加载图标。操作图标统一为 16px、2px 描边,按钮高 32px;窄容器下语音与复制收起可见标签,保留中文可访问名称和提示。复制反馈不改变操作栏布局,过程展开、语音播放与复制各自独立。 助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 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 回答的展开阅读面板,以及这些界面的子弹窗和提示。 Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。
@@ -52,8 +52,20 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
- 危险操作使用明确的确认步骤和 `destructive` 按钮变体;失败保留输入与原内容。审批、提问与历史操作的业务规则由对应功能文档维护。 - 危险操作使用明确的确认步骤和 `destructive` 按钮变体;失败保留输入与原内容。审批、提问与历史操作的业务规则由对应功能文档维护。
- Agent 打开的既有地图或业务工具仍遵循其所属模块规范。`AgentPersona` 动画资源按专用设计文档维护,周边面板与控件遵循本节。 - Agent 打开的既有地图或业务工具仍遵循其所属模块规范。`AgentPersona` 动画资源按专用设计文档维护,周边面板与控件遵循本节。
### 系统弹窗通知
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
## 2. Tailwind 定制方式 ## 2. Tailwind 定制方式
SCADA 回放控制使用一个有边框的连续区域:顶部集中显示演示区间和事件时间;中部将模式、日期与时间读数放在同一行,下接 FM 刻度窗,播放按钮组居中并限制最大宽度;底部集中放置单步监测、停止和重试入口,已处理与下次监测时间并排呈现,窄屏自然换行。保留播放、暂停和重新开始三个独立控制,“监测选定时刻”与“下一步并监测”并排,前者检测指针指向的精确时间,后者按项目周期推进;“回到开始时间”只定位到区间起点。监测已处理进度之前的历史时刻会建立新一轮回放,操作前说明原记录保留,避免使用后续状态影响历史判断。
SCADA 监测事件采用持续视觉警告,跟随关联事件至完成或取消;指标恢复后,仍在处理的事件继续显示。刷新页面时恢复进行中的事件,手动关闭以当前标签页的项目与事件编号记忆;新事件重新提示。采用1px细边框、轻量边缘光和4.8秒四角呼吸光。四角通过横向渐隐与纵向遮罩相乘,形成沿两条相邻边衰减的柔和光晕;宽度随视口限制在72至180px,高度在52至100px。默认“标准”强度,中央保持清晰与可操作。底部状态提示复用系统弹出通知的 `glass-transient` 亚克力透明材质、背景模糊、细边框与浮层阴影,避开时间轴;关闭按钮固定在提示右上角;手机将“查看调查”放在下一行,查看操作保留警告。减少动态效果时使用静态光,强制颜色模式使用系统边框。Dev面板的“调查测试”提供“柔和/标准/醒目”强度比较、“测试爆管告警效果”和“模拟事件处理结束”,均为本地视觉操作。
样式统一进入 [src/styles.css](../src/styles.css)。Tailwind 工具类负责布局、间距、尺寸、排版、响应式及交互状态;共享变量负责颜色和材质参数,共享变体负责重复的组件外观。 样式统一进入 [src/styles.css](../src/styles.css)。Tailwind 工具类负责布局、间距、尺寸、排版、响应式及交互状态;共享变量负责颜色和材质参数,共享变体负责重复的组件外观。
1. 先选组件,再选语义表面,最后调整布局与状态。常用差异通过组件的 `variant`、`size` 表达;局部差异通过 `className` 与 [cn](../src/shared/ui/cn.ts) 合并。 1. 先选组件,再选语义表面,最后调整布局与状态。常用差异通过组件的 `variant`、`size` 表达;局部差异通过 `className` 与 [cn](../src/shared/ui/cn.ts) 合并。
@@ -178,8 +190,14 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
`WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。 `WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。
带查看入口的系统弹出通知采用上下两区:上方排列状态图标、标题和正文,右上角预留关闭按钮;下方用细分隔线划出操作行,查看入口靠右并带跳转箭头,使用轻量按钮。无操作的通知不显示底部操作行,长正文自然换行,关闭与查看入口的点击区域互不重叠。
每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。 每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。
MapTool、地名搜索候选项、Header 普通菜单与窗口列表通过 `mapOptionInteractionVariants` 统一交互。普通项仅在支持 hover 的设备上切换为 `surface-reading` 底色、`surface-border` 边框与 `action-blue-hover` 标题及操作图标;键盘 `focus-visible`、菜单 `data-highlighted` 使用相同反馈并保留焦点提示。说明文字保持 muted,图层示意图保留业务颜色。整行、整张卡片响应,边框预留空间;行和卡片使用 12px 圆角,紧凑工具保持原有尺寸与圆角。颜色过渡为 150ms,减少动态效果时关闭,不增加缩放或位移。 标注工具切换选项时,图标继承按钮实时文字颜色;图标容器仅对底色做同周期过渡,避免按钮底色仍在渐变时图标先切白色。
选中项保留底色、文字和状态标识,悬停及键盘高亮仅加深边框;主操作使用蓝底白字并在高亮时加深,危险操作统一使用 `status-danger-soft/border/foreground`。禁用项不响应 hover 或按压;危险语义由业务显式声明,不按文案推断。`MAP_ACTION_ROW_INTERACTION_CLASS_NAME` 为普通菜单提供布局之外的默认底色、圆角与上述交互组合。此规则仅用于地图和工作台入口;Agent、调查展示等面板及共享 DropdownMenu 保留本地 shadcn/ui 主题,业务状态色继续独立表达。
地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。 地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。 弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
+4
View File
@@ -10,6 +10,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs", "export:answer-ui-contract": "node scripts/export-answer-ui-contract.mjs",
"check:contracts": "node scripts/export-answer-ui-contract.mjs --check && node scripts/export-view-command-contract.mjs --check",
"build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts", "build": "tsc --noEmit && vite build && pnpm build:verify-css && pnpm build:tts",
"build:verify-css": "node scripts/verify-production-css.mjs", "build:verify-css": "node scripts/verify-production-css.mjs",
"build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs", "build:tts": "esbuild server/edge-tts-server.ts --bundle --platform=node --format=cjs --outfile=dist-server/edge-tts-server.cjs",
@@ -23,6 +24,8 @@
}, },
"dependencies": { "dependencies": {
"@ai-sdk/react": "^4.0.9", "@ai-sdk/react": "^4.0.9",
"@dnd-kit/collision": "0.5.3",
"@dnd-kit/react": "0.5.3",
"@fontsource-variable/noto-sans-sc": "5.3.0", "@fontsource-variable/noto-sans-sc": "5.3.0",
"@json-render/core": "^0.21.0", "@json-render/core": "^0.21.0",
"@json-render/react": "^0.21.0", "@json-render/react": "^0.21.0",
@@ -59,6 +62,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-resizable-panels": "^4.14.7", "react-resizable-panels": "^4.14.7",
"react-rnd": "10.5.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"streamdown": "^2.5.0", "streamdown": "^2.5.0",
"swr": "^2.4.2", "swr": "^2.4.2",
+145
View File
@@ -15,6 +15,12 @@ importers:
'@ai-sdk/react': '@ai-sdk/react':
specifier: ^4.0.9 specifier: ^4.0.9
version: 4.0.34(react@19.2.7)(zod@3.25.76) version: 4.0.34(react@19.2.7)(zod@3.25.76)
'@dnd-kit/collision':
specifier: 0.5.3
version: 0.5.3
'@dnd-kit/react':
specifier: 0.5.3
version: 0.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
'@fontsource-variable/noto-sans-sc': '@fontsource-variable/noto-sans-sc':
specifier: 5.3.0 specifier: 5.3.0
version: 5.3.0 version: 5.3.0
@@ -123,6 +129,9 @@ importers:
react-resizable-panels: react-resizable-panels:
specifier: ^4.14.7 specifier: ^4.14.7
version: 4.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.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: sonner:
specifier: ^2.0.7 specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7) version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
@@ -467,6 +476,27 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@dnd-kit/abstract@0.5.3':
resolution: {integrity: sha512-KZQ2vftkO9BHupVzlUqB5d7JETL7ITlvslx3hrj4aObwjliAzuzV/n5wCpQllNd0Hxvg7FaQtLekJ8dplqKwbA==}
'@dnd-kit/collision@0.5.3':
resolution: {integrity: sha512-DBo8czkI72xnsHRWOMTn/GAyYgSWy9bmde85mrZ7ShqLhcbIbcu4PVJ3ZU9MBikePCSAVOoUTZE2gS40jogrEw==}
'@dnd-kit/dom@0.5.3':
resolution: {integrity: sha512-JJe+Qu/JmofGzHes3np91sQYwp5spvLcknjRkq+VO59JTqSnQNPXlhHXavRXTwQFabaaRyRzXyH/faAvubCNGw==}
'@dnd-kit/geometry@0.5.3':
resolution: {integrity: sha512-U+zscNjoHfJ1xUV+XDGF89jPCoILLivWSb/U9mfM7h83f6o3X9AS9uIwG46bXUpCqPvlI/9JcOlyga5uvfyjgw==}
'@dnd-kit/react@0.5.3':
resolution: {integrity: sha512-BGn6bakgcRSoKMRQP8cRIGu59e5lGC3/sL0zVWmDMo/9CMPSxgjCcQltDn5TDTwAARnEbuwNEksor4rLtH/yEg==}
peerDependencies:
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
'@dnd-kit/state@0.5.3':
resolution: {integrity: sha512-NJTQkBglAT/SXLc+IOLiKwNvsTrWmPG5AO+7Gn0F0n8XyVaf4VCbTCuPuU48sDBfcpg23O0tfGkfZNiZbYTvMw==}
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -822,6 +852,9 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
'@preact/signals-core@1.14.5':
resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==}
'@radix-ui/number@1.1.2': '@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==} resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -3180,6 +3213,10 @@ packages:
longest-streak@3.1.0: longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} 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: lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
@@ -3481,6 +3518,10 @@ packages:
nwsapi@2.2.24: nwsapi@2.2.24:
resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} 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: obug@2.1.4:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'} engines: {node: '>=12.20.0'}
@@ -3590,6 +3631,9 @@ packages:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} 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: property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
@@ -3603,11 +3647,26 @@ packages:
quickselect@3.0.0: quickselect@3.0.0:
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} 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: react-dom@19.2.7:
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
peerDependencies: peerDependencies:
react: ^19.2.7 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: react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
@@ -3637,6 +3696,12 @@ packages:
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
react-dom: ^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: react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -3937,6 +4002,9 @@ packages:
tslib@2.3.0: tslib@2.3.0:
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
tslib@2.8.1: tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -4529,6 +4597,45 @@ snapshots:
'@csstools/css-tokenizer@3.0.4': {} '@csstools/css-tokenizer@3.0.4': {}
'@dnd-kit/abstract@0.5.3':
dependencies:
'@dnd-kit/geometry': 0.5.3
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/collision@0.5.3':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/geometry': 0.5.3
tslib: 2.8.1
'@dnd-kit/dom@0.5.3':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/collision': 0.5.3
'@dnd-kit/geometry': 0.5.3
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/geometry@0.5.3':
dependencies:
'@dnd-kit/state': 0.5.3
tslib: 2.8.1
'@dnd-kit/react@0.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
dependencies:
'@dnd-kit/abstract': 0.5.3
'@dnd-kit/dom': 0.5.3
'@dnd-kit/state': 0.5.3
react: 19.2.7
react-dom: 19.2.7(react@19.2.7)
tslib: 2.8.1
'@dnd-kit/state@0.5.3':
dependencies:
'@preact/signals-core': 1.14.5
tslib: 2.8.1
'@esbuild/aix-ppc64@0.28.1': '@esbuild/aix-ppc64@0.28.1':
optional: true optional: true
@@ -4822,6 +4929,8 @@ snapshots:
dependencies: dependencies:
playwright: 1.61.1 playwright: 1.61.1
'@preact/signals-core@1.14.5': {}
'@radix-ui/number@1.1.2': {} '@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.6': {} '@radix-ui/primitive@1.1.6': {}
@@ -7088,6 +7197,10 @@ snapshots:
longest-streak@3.1.0: {} longest-streak@3.1.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
lru-cache@10.4.3: {} lru-cache@10.4.3: {}
lru-cache@5.1.1: lru-cache@5.1.1:
@@ -7654,6 +7767,8 @@ snapshots:
nwsapi@2.2.24: {} nwsapi@2.2.24: {}
object-assign@4.1.1: {}
obug@2.1.4: {} obug@2.1.4: {}
oniguruma-parser@0.12.2: {} oniguruma-parser@0.12.2: {}
@@ -7760,6 +7875,12 @@ snapshots:
ansi-styles: 5.2.0 ansi-styles: 5.2.0
react-is: 17.0.2 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: {} property-information@7.2.0: {}
protocol-buffers-schema@3.6.1: {} protocol-buffers-schema@3.6.1: {}
@@ -7768,11 +7889,25 @@ snapshots:
quickselect@3.0.0: {} 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): react-dom@19.2.7(react@19.2.7):
dependencies: dependencies:
react: 19.2.7 react: 19.2.7
scheduler: 0.27.0 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-is@17.0.2: {}
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7): react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
@@ -7799,6 +7934,14 @@ snapshots:
react: 19.2.7 react: 19.2.7
react-dom: 19.2.7(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): react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
dependencies: dependencies:
get-nonce: 1.0.1 get-nonce: 1.0.1
@@ -8148,6 +8291,8 @@ snapshots:
tslib@2.3.0: {} tslib@2.3.0: {}
tslib@2.6.2: {}
tslib@2.8.1: {} tslib@2.8.1: {}
tw-animate-css@1.4.0: {} tw-animate-css@1.4.0: {}
+12 -5
View File
@@ -1,11 +1,13 @@
import process from "node:process"; import process from "node:process";
import { writeFile } from "node:fs/promises"; import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts"; import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
const targets = process.argv[2] const check = process.argv.includes("--check");
? [resolve(process.argv[2])] const destination = process.argv.slice(2).find((arg) => arg !== "--check");
const targets = destination
? [resolve(destination)]
: [ : [
resolve("../next-tjwater-agent/src/chat/answer-ui-catalog.json"), resolve("../next-tjwater-agent/src/chat/answer-ui-catalog.json"),
resolve("../next-tjwater-server/contracts/answer-ui-catalog.json") resolve("../next-tjwater-server/contracts/answer-ui-catalog.json")
@@ -23,7 +25,12 @@ const contract = {
]) ])
) )
}; };
const content = `${JSON.stringify(contract, null, 2)}\n`;
for (const target of targets) { for (const target of targets) {
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`); if (check) {
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`); if (await readFile(target, "utf8") !== content) throw new Error(`Answer UI contract differs: ${target}`);
} else {
await writeFile(target, content);
}
process.stdout.write(`${check ? "Checked" : "Exported"} ${contract.components.length} components ${check ? "in" : "to"} ${target}\n`);
} }
+9 -1
View File
@@ -1,5 +1,6 @@
import process from "node:process"; import process from "node:process";
import { readFile, writeFile } from "node:fs/promises"; import { mkdir, readFile, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
import { fileURLToPath, URL } from "node:url"; import { fileURLToPath, URL } from "node:url";
import { zodToJsonSchema } from "zod-to-json-schema"; import { zodToJsonSchema } from "zod-to-json-schema";
import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts"; import { viewCommandSchema } from "../src/features/agent/presentation/command-schema.ts";
@@ -22,11 +23,18 @@ const targets = [
], ],
[new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema] [new URL("../../next-tjwater-server/contracts/view-command.schema.json", import.meta.url), schema]
]; ];
// The same executable examples and rejection matrix travel with each independent repository.
for (const name of ["unified-presentation.jsonl", "chart-publication-cases.json"]) {
const fixture = await readFile(new URL(`../tests/fixtures/${name}`, import.meta.url), "utf8");
for (const repository of ["next-tjwater-agent", "next-tjwater-server"])
targets.push([new URL(`../../${repository}/tests/fixtures/${name}`, import.meta.url), fixture]);
}
for (const [url, content] of targets) { for (const [url, content] of targets) {
if (process.argv.includes("--check")) { if (process.argv.includes("--check")) {
if ((await readFile(url, "utf8")) !== content) if ((await readFile(url, "utf8")) !== content)
throw new Error(`View command contract differs: ${fileURLToPath(url)}`); throw new Error(`View command contract differs: ${fileURLToPath(url)}`);
} else { } else {
await mkdir(dirname(fileURLToPath(url)), { recursive: true });
await writeFile(url, content); await writeFile(url, content);
} }
} }
@@ -0,0 +1,58 @@
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("时间");
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);
});
it("removes chart timezone wording while preserving the window, data and sample instants", () => {
const samples = ["2026-07-15T23:30:00Z", "2026-07-15T23:45:00Z"];
const spec: Spec = { root: "chart", elements: {
chart: { type: "Chart", props: {
title: "回放流量:487434(m³/h,上海时间 07-16 04:45–07:45)",
x_axis_name: "上海时间", x_data: ["07:30", "07:45"],
series: [{ name: "当前回放(2026-07-16,UTC+8)", data: [420, 380], sample_times: samples }]
}, children: [] }
} };
const result = withAnswerDisplayTimes(spec);
expect(result.elements.chart.props.title).toBe("回放流量:487434(m³/h,07-16 04:45–07:45)");
expect(result.elements.chart.props.x_axis_name).toBe("时间");
expect(result.elements.chart.props.x_data).toEqual(["07:30", "07:45"]);
expect(result.elements.chart.props.series).toEqual([
{ name: "当前回放(2026-07-16)", data: [420, 380], sample_times: samples }
]);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
});
@@ -0,0 +1,30 @@
import type { Spec } from "@json-render/core";
import { formatDisplayText, withChartDisplayTimes } 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") {
Object.assign(props, withChartDisplayTimes(props as Parameters<typeof withChartDisplayTimes>[0]));
}
return [id, { ...node, props }];
})) };
}
@@ -7,6 +7,7 @@ export type AnswerUiMapTarget = {
}; };
export type AnswerUiInteractions = { export type AnswerUiInteractions = {
disabled: boolean; disabled: boolean;
chartPresentation?: "tile";
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void; onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void; onSubmit?: (text: string) => Promise<void> | void;
evidenceFilter?: string; evidenceFilter?: string;
@@ -189,6 +189,30 @@ describe("local interactive answer renderer", () => {
expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true"); expect(screen.getByRole("tab", { name: /^柱状$/ })).toHaveAttribute("aria-selected", "true");
expect(screen.queryByRole("table")).not.toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument();
}); });
it("updates chart titles and readings after the same spec is changed in place", async () => {
const user = userEvent.setup();
const fixture = block("Chart", {
title: "原始压力图", chart_type: "line", source: "监测数据",
x_data: ["08:00", "09:00"], series: [{ name: "原始压力", data: [0.3, 0.2] }]
});
const { rerender } = render(<AnswerUiView block={fixture} />);
await user.click(screen.getByRole("tab", { name: /^数据$/ }));
expect(screen.getByRole("table")).toHaveTextContent("0.2");
const props = fixture.spec.elements.node.props;
props.title = "更新压力图";
const series = props.series as { name: string; data: number[] }[];
series[0].name = "更新后压力";
series[0].data[1] = 0.17;
rerender(<AnswerUiView block={fixture} />);
expect(screen.getByRole("group", { name: "更新压力图" })).toBeVisible();
expect(screen.queryByRole("group", { name: "原始压力图" })).not.toBeInTheDocument();
await waitFor(() => expect(screen.getByRole("columnheader", { name: "更新后压力" })).toBeVisible());
expect(screen.getByRole("table")).toHaveTextContent("0.17");
expect(screen.getByRole("table")).not.toHaveTextContent("0.2");
expect(screen.getByRole("tab", { name: /^数据$/ })).toHaveAttribute("aria-selected", "true");
});
it("runs map location only after a user click and reports missing IDs", async () => { it("runs map location only after a user click and reports missing IDs", async () => {
const locate = vi const locate = vi
.fn() .fn()
@@ -1,6 +1,6 @@
import { LocateButton, FollowUpForm } from "./answer-action-components"; import { LocateButton, FollowUpForm } from "./answer-action-components";
import { Renderer, defineRegistry } from "@json-render/react"; import { Renderer, defineRegistry } from "@json-render/react";
import { useContext, useRef, useState, type ReactNode } from "react"; import { useContext, useMemo, useRef, useState, type ReactNode } from "react";
import { import {
Info, Info,
TriangleAlert, TriangleAlert,
@@ -28,6 +28,7 @@ import { AnswerContentNav } from "./answer-content-nav";
import { getAnswerTaskLayout } from "./answer-task-layout"; import { getAnswerTaskLayout } from "./answer-task-layout";
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider"; import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state"; import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
import { withAnswerDisplayTimes } from "./answer-display-time";
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, { const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
actions: {}, actions: {},
@@ -102,14 +103,17 @@ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>; type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) { export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
const serializedSpec = JSON.stringify(props.block.spec);
const displayedSpec = useMemo(() => withAnswerNodeIds(withAnswerDisplayTimes(JSON.parse(serializedSpec) as AnswerUiBlock["spec"])), [serializedSpec]);
return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}> return <AnswerViewStateProvider key={props.block.block_id} spec={props.block.spec} registry={registry} viewState={viewState} onViewStateChange={onViewStateChange}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(props.block.spec) }} /> <AnswerUiBody {...props} block={{ ...props.block, spec: displayedSpec }} />
</AnswerViewStateProvider>; </AnswerViewStateProvider>;
} }
function AnswerUiBody({ function AnswerUiBody({
block, block,
disabled = false, disabled = false,
chartPresentation,
onLocate, onLocate,
onSubmit, onSubmit,
submitMode = "send" submitMode = "send"
@@ -130,10 +134,10 @@ function AnswerUiBody({
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section"); const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />); const renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return ( return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter }}> <InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartPresentation }}>
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}> <AnswerMotionScope className={chartPresentation === "tile" ? "answer-ui-root flex h-full min-h-0 flex-col" : "answer-ui-root"} data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
<AnswerContentNav block={block} contentRef={contentRef} /> {chartPresentation !== "tile" ? <AnswerContentNav block={block} contentRef={contentRef} /> : null}
<div ref={contentRef}> <div ref={contentRef} className={chartPresentation === "tile" ? "flex h-full min-h-0 flex-1 flex-col" : undefined}>
<> <>
{taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}> {taskLayout ? <div className={hasTaskLayout ? "answer-task-layout" : "answer-task-layout-simple"} data-answer-task-layout={hasTaskLayout || undefined}>
{taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null} {taskLayout.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
@@ -10,6 +10,10 @@ import {
const NodeContext = createContext<string | undefined>(undefined); const NodeContext = createContext<string | undefined>(undefined);
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined); const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
export function AnswerNodeStateScope({ id, children }: { id: string; children: ReactNode }) {
return <NodeContext.Provider value={id}>{children}</NodeContext.Provider>;
}
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */ /** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
export function withAnswerNodeIds(spec: Spec): Spec { export function withAnswerNodeIds(spec: Spec): Spec {
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) }; return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { Spec } from "@json-render/core"; 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<string, unknown>): Spec { function spec(type: string, props: Record<string, unknown>): Spec {
return { root: "node", elements: { node: { type, props, children: [] } } }; 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 }); const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
describe("answer view state compatibility", () => { 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", () => { it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
const before = table(); const before = table();
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } }); const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
@@ -1,4 +1,5 @@
import type { Spec } from "@json-render/core"; 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. // `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[] }; export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
@@ -30,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
const start = index(proposed[0], labels.length); const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1)); const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length); const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar", const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true }; hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart); const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState; const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
@@ -53,9 +54,12 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
} }
export function answerChartTableData(node: Node, state: AnswerChartState) { 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<number | null>; sample_times?: Array<string | null> }>;
const visible = series.filter((_, index) => !state.hidden.includes(index)); 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 { export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
+8 -3
View File
@@ -1,6 +1,7 @@
import { defineCatalog } from "@json-render/core"; import { defineCatalog } from "@json-render/core";
import { schema } from "@json-render/react/schema"; import { schema } from "@json-render/react/schema";
import { z } from "zod"; import { z } from "zod";
import { createChartSampleTimeSchema } from "../presentation/command-schema.ts";
const shortText = z.string().max(200); const shortText = z.string().max(200);
const text = z.string().max(4000); const text = z.string().max(4000);
@@ -63,12 +64,16 @@ export const answerUiCatalog = defineCatalog(schema, {
props: z props: z
.object({ .object({
title: shortText, title: shortText,
chart_type: z.enum(["line", "bar"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
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), x_data: z.array(shortText).min(1).max(100),
series: z series: z
.array( .array(
z 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() .strict()
) )
.min(1) .min(1)
@@ -77,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
}) })
.strict(), .strict(),
description: description:
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。" "展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
}, },
Evidence: { Evidence: {
props: z props: z
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, 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("uses aligned integer boundaries and steps without rounding monitoring readings", () => {
const series = [{ name: "流量", data: [2494.274, 4742.033] }];
const original = structuredClone(series);
expect(chartValueBounds(series, 0, 1, true)).toEqual({ min: 2000, max: 5000, interval: 1000 });
expect(series).toEqual(original);
});
it("keeps a narrow pressure range near its readings instead of forcing a broad zero-based axis", () => {
expect(chartValueBounds([{ name: "压力", data: [28.125, 28.875] }], 0, 1, true))
.toEqual({ min: 28, max: 29, interval: 1 });
expect(chartValueBounds([{ name: "压力", data: [9999, 28.14, 29.03, -9999] }], 1, 2, true))
.toEqual({ min: 28, max: 30, interval: 1 });
});
it("preserves negative flow and provides distinct integer ticks for zero flow", () => {
expect(chartValueBounds([{ name: "反向流量", data: [-108.8, 12.125] }], 0, 1, true))
.toEqual({ min: -150, max: 50, interval: 50 });
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1, true))
.toEqual({ min: -1, max: 1, interval: 1 });
});
it("limits integer ticks to confirmed monitoring names and units while preserving generic small ranges", () => {
for (const name of ["压力(m)", "压力 ( m )", "压力水头(m)", "水头(m)", "流量(m³/h)", "流量 ( m3 / h )", "管流(m³/h)"])
expect(chartUsesIntegerTicks(name)).toBe(true);
for (const name of [undefined, "压力(MPa)", "流量(L/s)", "比率", ""])
expect(chartUsesIntegerTicks(name)).toBe(false);
const series = [{ name: "压力(MPa)", data: [0.2131, 0.2149] }];
const bounds = chartValueBounds(series, 0, 1, chartUsesIntegerTicks("压力(MPa)"));
expect(bounds.min).toBeGreaterThan(0.21);
expect(bounds.max).toBeLessThan(0.22);
expect(bounds).not.toHaveProperty("interval");
});
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");
});
});
@@ -0,0 +1,39 @@
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) : "";
}
/** Only confirmed monitoring units opt into integer ticks; small generic units retain their scale. */
export function chartUsesIntegerTicks(axisName: string | undefined): boolean {
const normalized = axisName?.replace(/\s+/g, "").replace(/(/g, "(").replace(/)/g, ")").replace(/³/g, "3").toLowerCase();
return /^(?:(?:压力|压力水头|水头)\(m\)|(?:流量|管流)\(m3\/h\))$/.test(normalized ?? "");
}
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number, integerTicks = false) {
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;
const bounds = { min: min - padding, max: max + padding };
if (!integerTicks) return bounds;
const target = Math.max(1, (bounds.max - bounds.min) / 4);
const magnitude = 10 ** Math.floor(Math.log10(target));
const interval = ([1, 2, 5, 10].find((step) => step * magnitude >= target) ?? 10) * magnitude;
return { min: Math.floor(bounds.min / interval) * interval, max: Math.ceil(bounds.max / interval) * interval, interval };
}
/** 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");
}
@@ -11,6 +11,19 @@ const block = {
} }
}; };
describe("untrusted answer components", () => { 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", () => { it("keeps explicit UI phases and rejects invalid phase values", () => {
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined(); expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process"); expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
+4 -2
View File
@@ -56,8 +56,10 @@ export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
if (!definition?.props.safeParse(node.props).success) return null; if (!definition?.props.safeParse(node.props).success) return null;
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null; if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
if (node.type === "Chart") { if (node.type === "Chart") {
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> }; const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array<string | null> }> };
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null; 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") { if (node.type === "Table") {
const data = node.props as { columns: unknown[]; rows: unknown[][] }; const data = node.props as { columns: unknown[]; rows: unknown[][] };
+244 -122
View File
@@ -1,23 +1,30 @@
import { useLayoutEffect, useRef, useState } from "react"; import { memo, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
import { motion, useAnimationControls, useReducedMotion } from "motion/react"; import { motion, useAnimationControls, useReducedMotion } from "motion/react";
import ReactECharts from "echarts-for-react"; import { BarChart3, X, Database, FileText, SlidersHorizontal, Table2, TrendingUp } from "lucide-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 { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
import { InteractiveDataTable } from "./interactive-data-table"; import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart";
import { agentEase } from "../components/agent-motion"; import { agentEase } from "../components/agent-motion";
import type { SafeChartSeries } from "../chart-data";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { AnswerInteractionContext } from "./answer-ui-interaction-context";
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
export type AnswerChartProps = { export type AnswerChartProps = {
title: string; title: string;
chart_type: "line" | "bar"; chart_type: "line" | "bar" | "time_comparison";
x_data: string[]; x_data: string[];
series: Array<{ name: string; data: number[] }>; series: SafeChartSeries[];
x_axis_name?: string;
y_axis_name?: string;
source: 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"; import type { AnswerChartState } from "./answer-view-state";
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"]; const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
@@ -29,12 +36,36 @@ function PointSelect({ label, value, labels, onChange }: {
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent> <SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
</Select>; </Select>;
} }
export function InteractiveChart(props: AnswerChartProps) { type ChartPresentation = { tile?: boolean; 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 <AnswerViewStateProvider spec={spec} registry={standaloneRegistry}><AnswerNodeStateScope id="chart"><InteractiveChart {...props} /></AnswerNodeStateScope></AnswerViewStateProvider>;
}
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
const { chartPresentation } = useContext(AnswerInteractionContext);
const tile = props.tile ?? chartPresentation === "tile";
const fitted = Boolean(props.expanded || tile);
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props); const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
const pointer = useRef(false); const pointer = useRef(false);
const plot = useRef<HTMLDivElement>(null); const plot = useRef<HTMLDivElement>(null);
const [animateInteraction, setAnimateInteraction] = useState(false); const [animateInteraction, setAnimateInteraction] = useState(false);
const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => {
const element = plot.current;
if (fitted || !element || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotWidth(element.clientWidth);
resize();
const observer = new ResizeObserver(resize);
observer.observe(element);
return () => observer.disconnect();
}, [view, fitted]);
const contentMotion = useAnimationControls(); const contentMotion = useAnimationControls();
const pendingTransition = useRef(false); const pendingTransition = useRef(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -47,7 +78,7 @@ export function InteractiveChart(props: AnswerChartProps) {
}, [contentMotion, view]); }, [contentMotion, view]);
const setView = (next: string) => { const setView = (next: string) => {
if (next === view) return; if (next === view) return;
const animate = pointer.current && !reducedMotion; const animate = pointer.current && !reducedMotion && !tile;
setAnimateInteraction(animate); setAnimateInteraction(animate);
contentMotion.stop(); contentMotion.stop();
contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 }); contentMotion.set(animate ? { opacity: 0, y: 4 } : { opacity: 1, y: 0 });
@@ -57,120 +88,79 @@ export function InteractiveChart(props: AnswerChartProps) {
const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]); const setRange = (range: [number, number]) => patch({ range, table: { query: "", sort: null, ...table, selection: [] } }, ["range"]);
const setPoint = (point: number) => patch({ point }); const setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting }); const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index)); const hiddenKey = hidden.join(",");
const labels = props.x_data.slice(range[0], range[1] + 1); const hiddenSeries = useMemo(() => new Set(hiddenKey.split(",").filter(Boolean).map(Number)), [hiddenKey]);
const visible = useMemo(() => props.series.filter((_, index) => !hiddenSeries.has(index)), [props.series, hiddenSeries]);
const [rangeStart, rangeEnd] = range;
const labels = useMemo(() => props.x_data.slice(rangeStart, rangeEnd + 1), [props.x_data, rangeStart, rangeEnd]);
const pointIndex = Math.min(range[1], Math.max(range[0], point)); const pointIndex = Math.min(range[1], Math.max(range[0], point));
const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1; const fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => { const summaries = visible.map((series) => {
const values = series.data.slice(range[0], range[1] + 1); 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: Math.min(...values), max: Math.max(...values) }; 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") => ({ const composite = props.series.some((series) => series.type === "scatter");
animation: animateInteraction && !reducedMotion, const categoryColors = visible.length === 1;
animationDuration: 180, const horizontalBars = props.x_data.some((label) => label.length > 10);
animationDurationUpdate: 180, const numberLabel = chartValueLabel;
color: colors.filter((_, index) => !hidden.includes(index)), const xAxisName = props.xAxisName ?? props.x_axis_name;
aria: { enabled: true }, const yAxisName = props.yAxisName ?? props.y_axis_name;
tooltip: { trigger: "axis", renderMode: "richText" }, // Keep tooltip functions and options stable across sizing, focus and table-only updates.
grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true }, const options = useMemo(() => {
xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } }, const option = (type: "line" | "bar") => {
yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } }, const horizontal = type === "bar" && horizontalBars;
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1), const horizontalAxisName = horizontal ? yAxisName : xAxisName;
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } })) const verticalAxisName = horizontal ? xAxisName : yAxisName;
}); const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title} const includesBars = visible.some((series) => seriesType(series) === "bar");
onPointerDownCapture={() => { pointer.current = true; }} const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
onKeyDownCapture={() => { axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
pointer.current = false; ...(tile ? { margin: 4, lineHeight: 12 } : {}),
setAnimateInteraction(false); ...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) },
contentMotion.stop(); axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
contentMotion.set({ opacity: 1, y: 0 }); const valueAxis = { type: "value", scale: !includesBars,
}}> ...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5"> ...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.title}</CardTitle> axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
<CollapsibleTrigger asChild> ...(tile ? { margin: 4, lineHeight: 12 } : {}) },
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" splitLine: { lineStyle: { color: "#e2e8f0" } } };
className="shrink-0 text-muted-foreground"> return {
<SlidersHorizontal size={14} aria-hidden /> animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
</Button> textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
</CollapsibleTrigger> color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
</CardHeader> tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
<CardContent className="space-y-3 px-3.5 pb-3.5 pt-0"> textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null} ...(composite ? { formatter: (params: unknown) => {
<Tabs value={view} onValueChange={setView}> const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
<TabsList aria-label={`${props.title}显示方式`} className="relative mb-3 grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none"> const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs" const text = typeof first?.dataIndex === "number"
style={{ width: "calc((100% - 0.5rem) / 3)" }} ? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }} // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} /> // readable and never interprets model-provided labels as HTML.
<TabsTrigger value="line"><TrendingUp size={13} />折线</TabsTrigger> const content = document.createElement("div");
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger> content.textContent = text;
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger> content.style.whiteSpace = "pre-line";
</TabsList> return content;
<div className="answer-ui-chart-series mb-2 flex flex-wrap gap-2" aria-label="显示的数据序列"> } } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline" grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
className="h-8 gap-1.5 text-[11px]" aria-pressed={!hidden.includes(index)} bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
disabled={visible.length === 1 && !hidden.includes(index)} xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index], nameLocation: "middle", nameGap: tile ? 16 : 30,
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}> ...(tile ? { nameTextStyle: { fontSize: 10, lineHeight: 12 } } : {}) },
<span className="size-2 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name} yAxis: { ...(horizontal ? { ...categoryAxis, inverse: true } : valueAxis), name: verticalAxisName,
</Button>)} ...(tile ? { nameGap: 4, nameTextStyle: { fontSize: 10, lineHeight: 12, align: "left" } } : {}) },
</div> series: visible.map((series) => ({ name: series.name, type: seriesType(series),
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null} data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5"> ? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
<div className="flex items-center gap-2"> barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
<PointSelect label="区间起点" value={range[0]} labels={props.x_data} showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
onChange={(start) => setRange([start, Math.max(start, range[1])])} /> };
<span className="text-xs text-muted-foreground">至</span> };
<PointSelect label="区间终点" value={range[1]} labels={props.x_data} return { line: option("line"), bar: option("bar") };
onChange={(end) => setRange([Math.min(range[0], end), end])} /> }, [animateInteraction, categoryColors, composite, fitted, hiddenSeries, horizontalBars, labels, numberLabel, plotWidth,
</div> props.chart_type, rangeStart, rangeEnd, reducedMotion, tile, view, visible, xAxisName, yAxisName]);
<Button type="button" variant="ghost" size="sm" disabled={fullRange} const pointDetails = view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent>
<motion.div data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className="rounded-md focus-visible:outline-2 focus-visible:outline-ring"
onKeyDown={(event) => {
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);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
if (event.componentType === "series" && typeof event.dataIndex === "number") {
patch({ point: range[0] + event.dataIndex, inspecting: true });
}
} }} />
</div>
</TabsContent>)}
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => 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}`))} />
</TabsContent>
</motion.div>
</Tabs>
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{item.first} → {item.last}</span>
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</span>
</div>)}
</div>
</div> : null}
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false} initial={pointer.current && !reducedMotion ? { opacity: 0, y: 4 } : false}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}> animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
<div className="mb-2 flex items-center justify-between gap-2"> <div className="mb-2 flex items-center justify-between gap-2">
@@ -183,13 +173,145 @@ export function InteractiveChart(props: AnswerChartProps) {
<dl className="my-2 space-y-1.5 text-xs"> <dl className="my-2 space-y-1.5 text-xs">
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3"> {visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
<dt className="min-w-0 text-muted-foreground">{series.name}</dt> <dt className="min-w-0 text-muted-foreground">{series.name}</dt>
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd> <dd className="min-w-0 text-right font-medium tabular-nums">{numberLabel(series.data[pointIndex])}
{series.sample_times?.[pointIndex] ? <span className="block break-words text-[10px] font-normal text-muted-foreground">{chartSampleTime(series.sample_times[pointIndex])}</span> : null}
</dd>
</div>)} </div>)}
</dl> </dl>
</motion.section> : null} </motion.section> : null;
const metadata = <>
{!props.embedded && !tile && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
</div>
<div className="space-y-2">
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
<span className="tabular-nums">{numberLabel(item.first)} → {numberLabel(item.last)}</span>
<span className="text-muted-foreground">最小 {numberLabel(item.min)} · 最大 {numberLabel(item.max)}</span>
</div>)}
</div>
</div> : null}
{pointDetails}
<p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground"> <p className="flex items-start gap-1.5 border-t pt-2.5 text-[11px] leading-5 text-muted-foreground">
<Database size={12} className="mt-1 shrink-0" /><span>{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列<br />来源:{props.source}</span> <Database size={12} className="mt-1 shrink-0" /><span className="min-w-0 break-words [overflow-wrap:anywhere]">{fullRange ? labels.length : `${labels.length} / ${props.x_data.length}`} 个数据点 · {visible.length} 组序列{props.embedded ? "" : <><br />来源:{props.source}</>}</span>
</p> </p>
</>;
const rangeControls = <div className="space-y-2">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
<span className="text-xs text-muted-foreground">至</span>
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
onChange={(end) => setRange([Math.min(range[0], end), end])} />
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</div>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${fitted ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} data-chart-tile={tile || undefined} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
{!props.embedded ? <CardHeader className={`shrink-0 flex-row items-center justify-between gap-2 space-y-0 ${tile ? "px-2 py-1.5" : "px-3.5 py-2.5"}`}>
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent>
{() => <InteractiveChart {...props} expanded tile={false} />}
</FloatingWindow> : null}
{tile ? <Popover><PopoverTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="查看图表来源" title="查看图表来源"><FileText size={14} aria-hidden /></Button>
</PopoverTrigger><PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto text-xs leading-5">{props.source}</PopoverContent></Popover> : <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className="shrink-0 text-muted-foreground"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger>}
</div>
</CardHeader> : null}
<CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
</TabsList>
{tile ? <>
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
</Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
</Popover>
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
</Popover> : null}
</> : props.embedded ? <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger> : null}
</div>
{!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
</div> : null}
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${fitted ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
onKeyDown={(event) => {
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);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
<ResponsiveChart option={options[type]} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div>
</div>
</TabsContent>)}
<TabsContent value="table" className={fitted ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => 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}`))} />
</TabsContent>
</motion.div>
<div aria-label="图表图例" className={fitted ? `col-start-1 ${tile ? "row-start-3 min-h-8 max-h-14" : `row-start-4 min-h-0 ${props.embedded ? "max-h-56" : "max-h-40"}`} min-w-0 overflow-y-auto border-t border-border ${tile ? "pt-1" : "pt-2"}` : "border-t border-border pt-3"}>
<div className={`answer-ui-chart-series flex flex-wrap ${tile ? "gap-1" : "mb-2 gap-2"}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto ${tile ? "min-h-6 px-2 py-0.5" : "min-h-8"} min-w-0 max-w-full justify-start gap-1.5 whitespace-normal text-left text-[11px] after:inset-0`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion && !tile); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
</Button>)}
</div>
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${fitted ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${fitted ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
<span className="min-w-0 break-words">{label}</span>
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
</div>)}
</div> : null}
{props.expanded && !tile ? metadata : null}
</div>
{!fitted ? metadata : null}
</Tabs>
</CardContent> </CardContent>
</Card></Collapsible>; </Card></Collapsible>;
} });
@@ -1,17 +1,19 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react"; import { ArrowDown, ArrowUp } from "lucide-react";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
import { useAnswerNodeState } from "./answer-view-state-provider"; import { useAnswerNodeState } from "./answer-view-state-provider";
import type { AnswerTableState } from "./answer-view-state"; 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[][]; title: string; columns: string[]; rows: string[][];
state?: Omit<AnswerTableState, "type">; state?: Omit<AnswerTableState, "type">;
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void; onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
expanded?: boolean;
}) { }) {
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows }); const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
const state = controlledState ?? localState; const state = controlledState ?? localState;
@@ -34,19 +36,22 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs" <Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
onChange={(event) => { patch({ query: event.currentTarget.value }); }} /> onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span> <span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
{!expanded ? <FloatingWindow title={`${title} · 表格`} triggerLabel={`放大查看表格 ${title}`}>
{() => <InteractiveDataTable title={title} columns={columns} rows={rows} state={state} onStateChange={patch} expanded />}
</FloatingWindow> : null}
</div> </div>
<div role="region" aria-label={title} tabIndex={0} <div role="region" aria-label={title} tabIndex={0}
className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring"> className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
<Table> <Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption> <TableCaption className="caption-top font-medium">{title}</TableCaption>
<TableHeader className="bg-muted/50"><TableRow> <TableHeader className="bg-muted/50"><TableRow>
{columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap" {columns.map((column, index) => <TableHead key={index} scope="col" className="whitespace-nowrap p-0"
aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}> aria-sort={sort?.column === index ? sort.descending ? "descending" : "ascending" : "none"}>
<Button size="sm" variant="ghost" className="h-9 gap-1 px-1 text-xs" aria-label={`按${column}排序`} <Button variant="ghost" className="h-10 w-full justify-start gap-1 rounded-none px-4 text-xs focus-visible:ring-inset focus-visible:ring-offset-0" aria-label={`按${column}排序`}
onClick={() => patch({ sort: sort?.column === index onClick={() => patch({ sort: sort?.column === index
? sort.descending ? null : { column: index, descending: true } ? sort.descending ? null : { column: index, descending: true }
: { column: index, descending: false } })}> : { column: index, descending: false } })}>
{column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} /> : <ArrowUp size={12} /> : <ArrowUpDown size={12} />} {column}{sort?.column === index ? sort.descending ? <ArrowDown size={12} aria-hidden /> : <ArrowUp size={12} aria-hidden /> : null}
</Button> </Button>
</TableHead>)} </TableHead>)}
</TableRow></TableHeader> </TableRow></TableHeader>
@@ -0,0 +1,130 @@
import { act, cleanup, render } from "@testing-library/react";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { ResponsiveChart } from "./responsive-chart";
const engine = vi.hoisted(() => ({ resize: vi.fn() }));
vi.mock("echarts-for-react", async () => {
const React = await import("react");
return { default: React.forwardRef(function MockChart({ onChartReady }: { onChartReady?: (instance: typeof engine) => void }, ref) {
React.useImperativeHandle(ref, () => ({ getEchartsInstance: () => engine }), []);
React.useLayoutEffect(() => { onChartReady?.(engine); }, [onChartReady]);
return <div data-testid="chart-engine" />;
}) };
});
const observers: ResizeObserverCallback[] = [];
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] });
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; });
vi.stubGlobal("cancelAnimationFrame", (id: number) => { frames.delete(id); });
vi.stubGlobal("ResizeObserver", class {
constructor(callback: ResizeObserverCallback) { observers.push(callback); }
observe() {}
disconnect() {}
});
});
afterEach(() => {
cleanup();
engine.resize.mockReset();
observers.length = 0;
frames.clear();
vi.unstubAllGlobals();
vi.useRealTimers();
});
function fixture(resizing: boolean, count = 1) {
const observerStart = observers.length;
const view = render(<div data-floating-window="" data-floating-window-resizing={resizing ? "true" : undefined}>
{Array.from({ length: count }, (_, index) => <ResponsiveChart key={index} option={{}} height="100%" onPointSelect={() => undefined} />)}
</div>);
const shell = view.container.firstElementChild as HTMLElement;
const size = { width: 320, height: 200 };
for (const host of shell.children) Object.defineProperties(host, { clientWidth: { get: () => size.width }, clientHeight: { get: () => size.height } });
const notify = (index = 0) => observers[observerStart + index]([], {} as ResizeObserver);
const paint = () => { const pending = [...frames.values()]; frames.clear(); pending.forEach((callback) => callback(performance.now())); };
engine.resize.mockClear();
return { view, shell, size, notify, paint };
}
it("coalesces active resizes to the latest actual dimensions with a 64ms interval", () => {
const chart = fixture(true);
const sizes: number[] = [];
engine.resize.mockImplementation(() => { sizes.push(chart.size.width); });
chart.notify();
chart.notify();
expect(frames.size).toBe(1);
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.size.width = 400;
chart.notify();
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
chart.size.width = 520;
chart.notify();
act(() => vi.advanceTimersByTime(48));
chart.paint();
expect(sizes).toEqual([320, 520]);
expect(engine.resize).toHaveBeenLastCalledWith({ width: "auto", height: "auto", animation: { duration: 0 } });
});
it("spaces charts in the same window from the last completed draw", () => {
const chart = fixture(true, 2);
engine.resize.mockImplementation(() => { vi.advanceTimersByTime(70); });
chart.notify(0);
chart.notify(1);
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
act(() => vi.advanceTimersByTime(64));
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("does not delay a different window behind another window's draw", () => {
const first = fixture(true);
const second = fixture(true);
first.notify();
second.notify();
first.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("flushes the final geometry on release without waiting for the remaining interval", async () => {
const chart = fixture(true);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
expect(vi.getTimerCount()).toBe(1);
await act(async () => { delete chart.shell.dataset.floatingWindowResizing; await Promise.resolve(); });
expect(vi.getTimerCount()).toBe(0);
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
});
it("keeps normal layout changes immediate instead of applying pointer throttling", () => {
const chart = fixture(false);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(2);
expect(vi.getTimerCount()).toBe(0);
});
it("cancels scheduled drawing on unmount", () => {
const chart = fixture(true);
chart.notify();
chart.paint();
act(() => vi.advanceTimersByTime(16));
chart.notify();
chart.paint();
chart.view.unmount();
act(() => vi.advanceTimersByTime(100));
chart.paint();
expect(engine.resize).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
@@ -0,0 +1,55 @@
import { useLayoutEffect, useRef, type ComponentProps } from "react";
import ReactECharts from "echarts-for-react";
const resizedWindows = new WeakMap<HTMLElement, number>();
/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */
export function ResponsiveChart({ option, height, onPointSelect }: {
option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%";
onPointSelect: (index: number) => void;
}) {
const host = useRef<HTMLDivElement>(null);
const chart = useRef<ReactECharts>(null);
const ready = useRef(false);
useLayoutEffect(() => {
const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return;
const shell = element.closest<HTMLElement>("[data-floating-window]");
let frame = 0;
let timer = 0;
const resize = () => {
frame = 0;
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
// Space expensive drawings in one window instead of redrawing every chart together.
const remaining = 64 - (performance.now() - (shell ? resizedWindows.get(shell) ?? -Infinity : -Infinity));
if (shell?.dataset.floatingWindowResizing === "true" && remaining > 0) {
timer = window.setTimeout(() => { timer = 0; frame = requestAnimationFrame(resize); }, remaining);
return;
}
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
if (shell) resizedWindows.set(shell, performance.now());
};
const schedule = () => { if (!frame && !timer) frame = requestAnimationFrame(resize); };
const observer = new ResizeObserver(schedule);
const settled = new MutationObserver(() => {
if (shell?.dataset.floatingWindowResizing === "true") return;
clearTimeout(timer);
timer = 0;
schedule();
});
observer.observe(element);
if (shell) settled.observe(shell, { attributes: true, attributeFilter: ["data-floating-window-resizing"] });
return () => { clearTimeout(timer); cancelAnimationFrame(frame); observer.disconnect(); settled.disconnect(); };
}, []);
return <div ref={host} style={{ height }}>
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
style={{ height: "100%" }} onChartReady={(instance) => {
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);
} }} />
</div>;
}
+4 -28
View File
@@ -233,35 +233,11 @@ describe("Agent API client sessions", () => {
}); });
}); });
it("falls back when a candidate is not the agent service", async () => { it("uses the configured Agent address once and propagates its error", async () => {
const fetchMock = vi const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({ message: "not found" }), { status: 404 }))
.mockResolvedValueOnce(
new Response(
JSON.stringify({
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
}),
{ status: 200 }
)
);
vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("fetch", fetchMock);
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({ expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
schema_version: "agent-ui-registry@1",
chart_grammars: [],
components: [],
actions: []
});
expect(fetchMock).toHaveBeenNthCalledWith(
1,
"http://not-agent.local/api/v1/ui-registry",
undefined
);
expect(fetchMock).toHaveBeenNthCalledWith(2, "http://agent.local/api/v1/ui-registry", undefined);
}); });
it("sends title update payloads using backend field names", async () => { it("sends title update payloads using backend field names", async () => {
+13 -49
View File
@@ -121,6 +121,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update" | "todo_update"
| "permission_request" | "permission_request"
| "permission_response" | "permission_response"
| "run_status"
| "interaction_request" | "interaction_request"
| "question_request" | "question_request"
| "question_response" | "question_response"
@@ -131,6 +132,7 @@ export type AgentSessionStreamDataEventType =
| "ui_envelope" | "ui_envelope"
| "session_title" | "session_title"
| "done" | "done"
| "aborted"
| "error" | "error"
| "auth_required" | "auth_required"
| "credential_refresh_required" | "credential_refresh_required"
@@ -218,24 +220,16 @@ export type AgentApiClientOptions = {
getProjectId?: () => Promise<string | undefined>; getProjectId?: () => Promise<string | undefined>;
}; };
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const API_PATH = "/api/v1"; const API_PATH = "/api/v1";
export function createAgentApiClient( export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS, baseUrl: string = env.TJWATER_AGENT_URL,
options: AgentApiClientOptions = {} options: AgentApiClientOptions = {}
): AgentApiClient { ): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, "")); const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const request = (path: string, init?: RequestInit) => const request = (path: string, init?: RequestInit) =>
fetchWithFallback( fetchAgent(
candidates, normalizedBaseUrl,
activeBaseUrl,
setActiveBaseUrl,
path, path,
init, init,
options.getAccessToken, options.getAccessToken,
@@ -263,7 +257,7 @@ export function createAgentApiClient(
return token; return token;
} : undefined; } : undefined;
const answerStateRequest = async (path: string, init?: RequestInit) => { const answerStateRequest = async (path: string, init?: RequestInit) => {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, path, init, const response = await fetchAgent(normalizedBaseUrl, path, init,
checkedAnswerStateToken, options.getProjectId); checkedAnswerStateToken, options.getProjectId);
// An identity change while awaiting the network also invalidates the result. // An identity change while awaiting the network also invalidates the result.
await checkedAnswerStateToken?.(); await checkedAnswerStateToken?.();
@@ -273,7 +267,7 @@ export function createAgentApiClient(
return { return {
...createInteractionRequestApi(answerStateRequest), ...createInteractionRequestApi(answerStateRequest),
async getHealth(signal) { async getHealth(signal) {
const response = await fetchWithFallback(candidates, activeBaseUrl, setActiveBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, ""); const response = await fetchAgent(normalizedBaseUrl, "/health", { signal, cache: "no-store" }, undefined, undefined, "");
if (!response.ok) throw new Error("Agent 服务暂不可用"); if (!response.ok) throw new Error("Agent 服务暂不可用");
const data: unknown = await response.json(); const data: unknown = await response.json();
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪"); if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
@@ -577,19 +571,14 @@ export function createAgentApiClient(
}; };
} }
async function fetchWithFallback( async function fetchAgent(
baseUrls: string[], baseUrl: string,
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
path: string, path: string,
init?: RequestInit, init?: RequestInit,
getAccessToken?: AccessTokenProvider, getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined>, getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH prefix = API_PATH
) { ) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.(); const accessToken = await getAccessToken?.();
const projectId = await getProjectId?.(); const projectId = await getProjectId?.();
const hasContextHeaders = Boolean(accessToken || projectId); const hasContextHeaders = Boolean(accessToken || projectId);
@@ -598,34 +587,7 @@ async function fetchWithFallback(
if (projectId) headers?.set("X-Project-Id", projectId); if (projectId) headers?.set("X-Project-Id", projectId);
const requestInit = hasContextHeaders ? { ...init, headers } : init; const requestInit = hasContextHeaders ? { ...init, headers } : init;
for (const baseUrl of orderedBaseUrls) { return fetch(`${baseUrl}${prefix}${path}`, requestInit);
try {
const response = await fetch(`${baseUrl}${prefix}${path}`, requestInit);
if (response.ok) {
setActiveBaseUrl(baseUrl);
return response;
}
lastResponse = response;
if (shouldFallbackOnHttpStatus(response.status) && baseUrl !== orderedBaseUrls[orderedBaseUrls.length - 1]) {
await response.body?.cancel();
continue;
}
return response;
} catch (error) {
lastError = error;
}
}
if (lastResponse) {
return lastResponse;
}
throw lastError instanceof Error ? lastError : new Error("Agent API unavailable");
}
function shouldFallbackOnHttpStatus(status: number) {
return status === 404 || status === 405 || status === 502 || status === 503 || status === 504;
} }
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
@@ -837,6 +799,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" || value === "execution_plan" ||
value === "permission_request" || value === "permission_request" ||
value === "permission_response" || value === "permission_response" ||
value === "run_status" ||
value === "interaction_request" || value === "interaction_request" ||
value === "question_request" || value === "question_request" ||
value === "question_response" || value === "question_response" ||
@@ -847,6 +810,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "ui_envelope" || value === "ui_envelope" ||
value === "session_title" || value === "session_title" ||
value === "done" || value === "done" ||
value === "aborted" ||
value === "error" || value === "error" ||
value === "credential_refresh_required" || value === "credential_refresh_required" ||
value === "credential_refreshed" || value === "credential_refreshed" ||
+10
View File
@@ -56,4 +56,14 @@ describe("chart data normalization", () => {
expect(data?.xData).toHaveLength(5); expect(data?.xData).toHaveLength(5);
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]); 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"] });
});
}); });
+17 -6
View File
@@ -1,3 +1,7 @@
import { createChartSampleTimeSchema } from "./presentation/command-schema";
const sampleTimeSchema = createChartSampleTimeSchema();
export type SafeChartSpec = { export type SafeChartSpec = {
title?: string; title?: string;
chartType: "line" | "bar"; chartType: "line" | "bar";
@@ -7,8 +11,9 @@ export type SafeChartSpec = {
export type SafeChartSeries = { export type SafeChartSeries = {
name: string; name: string;
data: number[]; data: Array<number | null>;
type?: "line" | "bar"; type?: "line" | "bar" | "scatter";
sample_times?: Array<string | null>;
}; };
export type SafeChartData = { export type SafeChartData = {
@@ -34,6 +39,7 @@ type RawChartSeries = {
points?: unknown; points?: unknown;
values?: unknown; values?: unknown;
type?: unknown; type?: unknown;
sample_times?: unknown;
}; };
export function parseChartSpec(value: unknown): SafeChartSpec { export function parseChartSpec(value: unknown): SafeChartSpec {
@@ -75,12 +81,12 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
.map((point, index) => { .map((point, index) => {
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`); const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
if (!parsed) { if (!parsed) {
labelsFromPoints[index] = xData[index] ?? `${index + 1}`;
return null; return null;
} }
labelsFromPoints[index] = parsed.label; labelsFromPoints[index] = parsed.label;
return parsed.value; return parsed.value;
}) });
.filter((item): item is number => item !== null);
if (!data.length) { if (!data.length) {
return null; return null;
@@ -93,7 +99,11 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
return { return {
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`, name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
data, 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)); .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 clippedXData = xData.slice(0, maxPoints);
const clippedSeries = normalizedSeries.map((series) => ({ const clippedSeries = normalizedSeries.map((series) => ({
...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 return clippedXData.length > 0 && clippedSeries.length > 0
@@ -557,7 +557,6 @@ export function AgentCommandPanel({
{centeredComposer && !promptReference ? ( {centeredComposer && !promptReference ? (
<ConversationEmptyState className="agent-chat-empty-state"> <ConversationEmptyState className="agent-chat-empty-state">
<h2 className="text-2xl font-medium tracking-tight text-foreground">有什么可以帮你?</h2> <h2 className="text-2xl font-medium tracking-tight text-foreground">有什么可以帮你?</h2>
<p className="text-xs leading-6 text-muted-foreground">询问供水问题,或从最近的任务继续。</p>
</ConversationEmptyState> </ConversationEmptyState>
) : null} ) : null}
<motion.div <motion.div
@@ -1067,6 +1066,11 @@ function BackendMessage({
<AgentUserMessageText content={message.content} /> <AgentUserMessageText content={message.content} />
) )
) : null} ) : null}
{message.role === "assistant" && message.runError && message.content !== `错误:${message.runError}` ? (
<p role="status" className="whitespace-pre-wrap break-words text-sm text-destructive">
错误:{message.runError}
</p>
) : null}
{message.role === "assistant" && processComplete && hasProcessError ? ( {message.role === "assistant" && processComplete && hasProcessError ? (
<p role="status" className="text-xs text-[var(--status-warning-foreground)]"> <p role="status" className="text-xs text-[var(--status-warning-foreground)]">
部分查询或工具未完成,可展开处理过程查看原因。 部分查询或工具未完成,可展开处理过程查看原因。
@@ -1,9 +1,10 @@
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react"; import { Ban, CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
import { StatusIcon, type StatusTone } from "@/shared/ui/status"; import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import type { AgentChatProgress } from "../types"; import type { AgentChatProgress } from "../types";
const states = { const states = {
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 }, completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
cancelled: { label: "已中止", tone: "neutral", Icon: Ban },
error: { label: "失败", tone: "danger", Icon: XCircle }, error: { label: "失败", tone: "danger", Icon: XCircle },
running: { label: "进行中", tone: "info", Icon: LoaderCircle } running: { label: "进行中", tone: "info", Icon: LoaderCircle }
} satisfies Record< } satisfies Record<
@@ -1,9 +1,10 @@
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react"; import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react"; import { AnimatePresence, MotionConfig, motion } from "motion/react";
import type { ReactNode } from "react"; import { memo, type ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { formatChartDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
import { StatusIcon } from "@/shared/ui/status"; import { StatusIcon } from "@/shared/ui/status";
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data"; import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
import type { AgentUiResult } from "../types"; import type { AgentUiResult } from "../types";
import type { UIEnvelope } from "../ui-envelope"; import type { UIEnvelope } from "../ui-envelope";
import type { parseFrontendChart } from "../frontend-action/chart"; import type { parseFrontendChart } from "../frontend-action/chart";
@@ -71,185 +72,35 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
); );
} }
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) { export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false, plotHeight, tile = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />; return <SafeChartEnvelope embedded={embedded} expanded={expanded} plotHeight={plotHeight} tile={tile} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
} ...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
});
function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) { function SafeChartEnvelope({ envelope, embedded = false, expanded = false, plotHeight, tile = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
const spec = parseChartSpec(envelope.spec); const parsed = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data, 100); const raw = normalizeSafeChartData(envelope.data, 100);
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
x_data: raw?.xData ?? [], series: raw?.series ?? [] });
const spec = { ...parsed, title: displayed.title, xAxisName: displayed.x_axis_name };
const data = raw ? { ...raw, xData: displayed.x_data, series: displayed.series } : null;
if (!data || data.series.length === 0 || data.xData.length === 0) { if (!data || data.series.length === 0 || data.xData.length === 0) {
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />; return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
} }
return ( return <StandaloneInteractiveChart
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}> title={spec.title ?? "Agent 图表"}
<div className={cn("mb-3 flex gap-3", embedded ? "min-w-0 flex-col" : "items-start justify-between")}> chart_type={spec.chartType}
{!embedded ? <div className="min-w-0"> x_data={data.xData}
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}> series={data.series.slice(0, 8)}
{spec.title ?? "Agent 图表"} source="来源见关联依据。"
</p> embedded={embedded}
<p className="mt-1 text-xs text-[var(--ink-muted)]"> expanded={expanded} plotHeight={plotHeight} tile={tile}
{spec.chartType === "bar" ? "柱状图" : "折线图"} xAxisName={spec.xAxisName}
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""} yAxisName={spec.yAxisName}
</p> />;
</div> : null}
<div className={cn("flex flex-wrap gap-1.5", embedded ? "min-w-0 justify-start" : "shrink-0 justify-end")}>
{data.series.slice(0, 3).map((series, index) => (
<span key={series.name} className={cn("flex items-center gap-1 text-xs", embedded ? "min-w-0 text-muted-foreground" : "text-slate-600")}>
<span className={cn("h-2 w-2 shrink-0 rounded-full", chartColorClass(index))} />
{embedded ? <span className="min-w-0 break-words [overflow-wrap:anywhere]">{series.name}</span> : series.name}
</span>
))}
</div>
</div>
<SafeChartSvg spec={spec} data={data} />
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
</div>
);
}
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 (
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
{ticks.map((tick) => {
const y = yFor(tick);
return (
<g key={tick}>
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
{formatChartNumber(tick)}
</text>
</g>
);
})}
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
{data.xData.map((label, index) => (
<text
key={`${label}-${index}`}
x={xFor(index)}
y={height - 12}
textAnchor="middle"
className="fill-slate-500 text-xs"
>
{shortLabel(label)}
</text>
))}
{data.series.slice(0, 4).map((series, seriesIndex) =>
(series.type ?? spec.chartType) === "bar" ? (
<BarSeries
key={series.name}
series={series}
seriesIndex={seriesIndex}
seriesCount={Math.min(data.series.length, 4)}
xFor={xFor}
yFor={yFor}
baselineY={yFor(0)}
/>
) : (
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
)
)}
</svg>
);
}
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 (
<g>
<polyline
points={points}
fill="none"
stroke={stroke}
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
className="agent-chart-line"
/>
{series.data.map((value, index) => (
<circle
key={`${series.name}-${index}`}
cx={xFor(index)}
cy={yFor(value)}
r="2.8"
fill={stroke}
className="agent-chart-point"
style={{ animationDelay: `${80 + index * 18}ms` }}
/>
))}
</g>
);
}
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 (
<g>
{series.data.map((value, index) => {
const y = yFor(value);
return (
<rect
key={`${series.name}-${index}`}
x={xFor(index) - barWidth / 2 + offset}
y={Math.min(y, baselineY)}
width={barWidth}
height={Math.max(1, Math.abs(baselineY - y))}
rx="2"
fill={fill}
className="agent-chart-bar"
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
/>
);
})}
</g>
);
} }
function RegisteredComponentEnvelope({ function RegisteredComponentEnvelope({
@@ -360,7 +211,7 @@ function getEnvelopeIcon(envelope: UIEnvelope) {
function getEnvelopeTitle(envelope: UIEnvelope) { function getEnvelopeTitle(envelope: UIEnvelope) {
if (envelope.kind === "chart") { if (envelope.kind === "chart") {
const spec = parseChartSpec(envelope.spec); const spec = parseChartSpec(envelope.spec);
return spec.title ?? "Agent 图表"; return formatChartDisplayText(spec.title ?? "Agent 图表");
} }
if (envelope.kind === "registered_component") { if (envelope.kind === "registered_component") {
@@ -377,35 +228,6 @@ function getSurfaceLabel(surface: UIEnvelope["surface"]) {
return "地图叠加"; 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<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null; return typeof value === "object" && value !== null;
} }
@@ -10,4 +10,14 @@ describe("frontend chart boundary", () => {
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull(); expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).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();
});
}); });
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) { export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null, const inbox = useSWR(sessionId ? ["agent-message-inbox", sessionId] : null,
([, id]) => client.listAgentMessages(id), { revalidateOnFocus: true, shouldRetryOnError: false }); ([, id]) => client.listAgentMessages(id), { revalidateOnMount: false, revalidateOnFocus: true, shouldRetryOnError: false });
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const mutate = inbox.mutate; const mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]); const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
const controller = new AbortController(); const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined; let timer: ReturnType<typeof setTimeout> | undefined;
let retryDelay = 1500; let retryDelay = 1500;
let initialized = false;
// Prefer the initial stream notification, but do not wait indefinitely for its first frame.
const initialLoadTimer = setTimeout(() => { initialized = true; refreshRef.current(); }, 1500);
function subscribe() { function subscribe() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } }) void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); }) .catch(() => {
// A failed subscription must not prevent the initial list from loading.
if (!initialized && !controller.signal.aborted) { initialized = true; clearTimeout(initialLoadTimer); refreshRef.current(); }
retryDelay = Math.min(retryDelay * 2, 30_000);
})
.finally(() => { .finally(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay); if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
}); });
} }
subscribe(); subscribe();
return () => { controller.abort(); clearTimeout(timer); }; return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
}, [client, sessionId]); }, [client, sessionId]);
return { return {
messages: inbox.data?.items ?? [], messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor), hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading, loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
error: inbox.error ? "任务消息暂时无法加载" : null, error: inbox.error ? "任务消息暂时无法加载" : null,
refresh, refresh,
loadingMore, loadingMore,
+2
View File
@@ -55,6 +55,7 @@ export {
cancelRunningTodos, cancelRunningTodos,
completeRunningProgress, completeRunningProgress,
finalizeAssistantMessageAfterAbort, finalizeAssistantMessageAfterAbort,
finalizeAssistantMessageAfterError,
toTodoUpdate, toTodoUpdate,
upsertPermission, upsertPermission,
upsertProgress, upsertProgress,
@@ -115,3 +116,4 @@ export { readInteractionReply } from "./api/interaction-requests";
export type { InteractionRequestsController } from "./hooks/use-interaction-requests"; export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command"; export { presentationCommandSchema, presentationOverlayDataSchema, PRESENTATION_ACTIONS, type PresentationCommand } from "./presentation/command";
export { chartPublicationError, safeChartPublicationInput } from "./presentation/command-schema";
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { z } from "zod";
import cases from "../../../../tests/fixtures/chart-publication-cases.json";
import examples from "../../../../tests/fixtures/unified-presentation.jsonl?raw";
import { createViewSchemas, safeChartInput, safeChartPublicationInput } from "./command-schema";
describe("shared chart publication conformance", () => {
it.each(cases)("$name", ({ chart, accepted }) => {
expect(safeChartPublicationInput.safeParse(chart).success).toBe(accepted);
for (const publication of [false, true]) {
const schema = createViewSchemas(z, publication, true).command;
expect(schema.safeParse({ action: "open_chart", chart_id: "comparison", evidence_refs: ["dev:synthetic"], chart }).success).toBe(accepted);
}
});
it("keeps historical mixed charts structurally readable", () => {
expect(safeChartInput.safeParse(cases.find((item) => item.name === "old mixed charts are read only")!.chart).success).toBe(true);
});
it("accepts every exported command example through the publication schema", () => {
const schema = createViewSchemas(z, true).command;
for (const record of examples.trim().split("\n").map((line) => JSON.parse(line)))
if (record.record_type === "presentation") expect(schema.safeParse(record.command).success).toBe(true);
});
});
@@ -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<number | null>, sample_times: Array<string | null>) => ({
title: "压力", chart_type: "line", x_data: ["12:00"], series: [{ name: "测点", data, sample_times }]
});
const block = (value: ReturnType<typeof chart>) => ({
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<number | null>, Array<string | null>, 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);
});
@@ -1,21 +1,87 @@
import { z } from "zod"; 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])$/
);
}
type PublicationChart = {
chart_type: string;
x_data: string[];
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
};
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
export function chartPublicationError(chart: PublicationChart): string | null {
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
const labels = chart.x_data;
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
const offsets = [0];
for (let index = 1; index < seconds.length; index++) {
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
offsets.push(offsets[index - 1] + step);
}
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
const current = new Set<number>();
const historical: number[] = [];
for (const series of chart.series) {
const times = series.sample_times;
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
const bases = new Set<number>();
for (let index = 0; index < times.length; index++) {
const at = times[index];
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
if (at === null) continue;
const local = new Date(Date.parse(at) + 8 * 3600_000);
const base = local.getTime() - offsets[index] * 1000;
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
return "sample_times 超出同期对比可表达的日期范围";
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
bases.add(base);
}
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
else historical.push(...bases);
}
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
const start = [...current][0];
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
}
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */ /** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
export function createViewSchemas(zod: typeof z = z, publication = false) { export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
const z = zod; const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128); const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const chartSchema = z const chartSchema = z
.object({ .object({
title: z.string().max(200), title: z.string().max(200),
chart_type: z.enum(["line", "bar"]), chart_type: z.enum(["line", "bar", "time_comparison"]),
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), x_data: z.array(z.string().max(128)).min(1).max(100),
series: z series: z
.array( .array(
z z
.object({ .object({
name: z.string().max(128), 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() .strict()
) )
@@ -24,9 +90,15 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
}) })
.strict() .strict()
.refine( .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" "series must align with x_data"
); ).superRefine((value, ctx) => {
if (!validateCharts) return;
const message = chartPublicationError(value);
if (message) ctx.addIssue({ code: "custom", message });
});
const position = z const position = z
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)]) .tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite()) .rest(z.number().finite())
@@ -200,8 +272,9 @@ export const {
overlayDataSchema, overlayDataSchema,
variants: viewCommandVariants variants: viewCommandVariants
} = createViewSchemas(); } = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) => export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
createViewSchemas(z, options.publication).command; export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>; type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand = export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }> | Exclude<Command, { action: "set_overlay" }>
+3 -1
View File
@@ -73,6 +73,8 @@ describe("agent session state", () => {
requestId: "permission-1", requestId: "permission-1",
status: "approved_once" status: "approved_once"
}); });
expect(applyPermissionResponse(permissions, { request_id: "permission-1", status: "error", error: "native unavailable" })?.[0])
.toMatchObject({ status: "error", error: "native unavailable" });
}); });
it("dedupes question requests by tool call id", () => { it("dedupes question requests by tool call id", () => {
@@ -152,7 +154,7 @@ describe("agent session state", () => {
const next = finalizeAssistantMessageAfterAbort(message); const next = finalizeAssistantMessageAfterAbort(message);
expect(next.content).toBe("请求已中止。"); expect(next.content).toBe("请求已中止。");
expect(next.progress?.[0].status).toBe("completed"); expect(next.progress?.[0].status).toBe("cancelled");
expect(next.permissions?.[0].status).toBe("aborted"); expect(next.permissions?.[0].status).toBe("aborted");
}); });
}); });
+21 -6
View File
@@ -47,7 +47,10 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
return next; return next;
} }
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) { export function completeRunningProgress(
progress: AgentChatProgress[] | undefined,
status: "completed" | "error" | "cancelled" = "completed"
) {
return progress?.map((item) => { return progress?.map((item) => {
if (item.status !== "running") { if (item.status !== "running") {
return item; return item;
@@ -56,7 +59,7 @@ export function completeRunningProgress(progress: AgentChatProgress[] | undefine
const endedAt = Date.now(); const endedAt = Date.now();
return { return {
...item, ...item,
status: "completed" as const, status,
endedAt, endedAt,
elapsedMs: undefined, elapsedMs: undefined,
elapsedSnapshotAt: undefined, elapsedSnapshotAt: undefined,
@@ -154,9 +157,9 @@ export function applyPermissionResponse(
permission.requestId === requestId permission.requestId === requestId
? { ? {
...permission, ...permission,
status: toPermissionStatus(readString(payload.reply)), status: payload.status === "error" ? "error" as const : toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(), repliedAt: Date.now(),
error: undefined error: payload.status === "error" ? readString(payload.error) : undefined
} }
: permission : permission
); );
@@ -235,7 +238,7 @@ export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefi
} }
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage { export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
const progress = completeRunningProgress(message.progress); const progress = completeRunningProgress(message.progress, "cancelled");
const todos = cancelRunningTodos(message.todos); const todos = cancelRunningTodos(message.todos);
return { return {
@@ -266,6 +269,18 @@ export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): A
}; };
} }
export function finalizeAssistantMessageAfterError(message: AgentChatMessage, reason: string): AgentChatMessage {
const settled = finalizeAssistantMessageAfterAbort(message);
return {
...settled,
content: message.content || `错误:${reason}`,
runError: reason,
progress: settled.progress?.map((item, index) => message.progress?.[index].status === "running"
? { ...item, status: "error", detail: item.detail ? `${item.detail}\n${reason}` : reason }
: item)
};
}
function toPermissionStatus(reply: string | undefined): AgentPermissionStatus { function toPermissionStatus(reply: string | undefined): AgentPermissionStatus {
if (reply === "always") { if (reply === "always") {
return "approved_always"; return "approved_always";
@@ -340,7 +355,7 @@ function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
} }
function readProgressStatus(value: unknown): AgentChatProgress["status"] { function readProgressStatus(value: unknown): AgentChatProgress["status"] {
return value === "completed" || value === "error" ? value : "running"; return value === "completed" || value === "error" || value === "cancelled" ? value : "running";
} }
function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined { function readTodoStatus(value: unknown): AgentTodoItem["status"] | undefined {
+2 -1
View File
@@ -2,6 +2,7 @@ export type AgentChatMessage = {
id: string; id: string;
role: "user" | "assistant"; role: "user" | "assistant";
content: string; content: string;
runError?: string;
answerParts?: import("./answer-ui/contract").AnswerUiPart[]; answerParts?: import("./answer-ui/contract").AnswerUiPart[];
finalAnswer?: { content: string; uiBlockIds: string[] }; finalAnswer?: { content: string; uiBlockIds: string[] };
progress?: AgentChatProgress[]; progress?: AgentChatProgress[];
@@ -24,7 +25,7 @@ export type AgentChatProgress = {
tool?: { name: string; callId?: string; input?: Record<string, unknown> }; tool?: { name: string; callId?: string; input?: Record<string, unknown> };
id: string; id: string;
phase: string; phase: string;
status: "running" | "completed" | "error"; status: "running" | "completed" | "error" | "cancelled";
title: string; title: string;
detail?: string; detail?: string;
startedAt?: number; startedAt?: number;
@@ -2,13 +2,13 @@ import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react"; import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
@@ -105,18 +105,18 @@ export function BaseLayersControl({
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)] shadow-xs"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", : "text-slate-700",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
@@ -159,22 +159,22 @@ export function BaseLayersControl({
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex h-[84px] w-[84px] flex-col p-1 text-center transition duration-150", "flex h-[84px] w-[84px] flex-col p-1 text-center text-slate-800",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)] hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)]",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
<span <span
data-base-layer-label="true" data-base-layer-label="true"
className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none text-slate-800" className="block h-4 shrink-0 truncate px-0.5 pt-1 text-xs font-semibold leading-none"
> >
{option.label} {option.label}
</span> </span>
@@ -185,9 +185,10 @@ export function BaseLayersControl({
<div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}> <div className={cn("absolute bottom-0 right-0 grid h-[96px] w-[96px] place-items-center p-1.5", MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME)}>
<button <button
type="button" type="button"
data-map-option=""
aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`} aria-label={`${title}:当前 ${activeLayer?.label ?? ""}`}
title={`${title}:当前 ${activeLayer?.label ?? ""}`} title={`${title}:当前 ${activeLayer?.label ?? ""}`}
className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs transition hover:border-[var(--palette-brand)] hover:text-[var(--action-blue-hover)]", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, MAP_SELECTION_CARD_FOCUS_CLASS_NAME)} className={cn("grid h-[84px] w-[84px] place-items-center p-1 text-slate-700 shadow-xs", MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME, mapOptionInteractionVariants())}
> >
<span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}> <span className={cn("relative h-full w-full overflow-hidden border border-white shadow-inner", MAP_ICON_CELL_RADIUS_CLASS_NAME)}>
{activeLayer ? ( {activeLayer ? (
@@ -195,7 +196,7 @@ export function BaseLayersControl({
) : ( ) : (
<Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" /> <Map size={18} aria-hidden="true" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
)} )}
<span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none text-slate-800"> <span className="surface-reading absolute bottom-1 left-1 right-1 rounded px-1 py-0.5 text-xs font-semibold leading-none">
{activeLayer?.label ?? title} {activeLayer?.label ?? title}
</span> </span>
</span> </span>
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,14 +80,15 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
return ( return (
<button <button
type="button" type="button"
data-map-option=""
aria-pressed={active} aria-pressed={active}
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-14 flex-col items-center justify-center gap-1 text-xs font-semibold transition duration-150", "flex h-14 flex-col items-center justify-center gap-1 border border-transparent text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]", active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<Icon size={17} aria-hidden="true" /> <Icon size={17} aria-hidden="true" />
@@ -109,13 +111,14 @@ export function MapActionChip({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700 transition duration-150 hover:border-[var(--surface-border)] hover:text-[var(--action-blue-hover)]", "flex h-10 items-center justify-center gap-1.5 text-xs font-semibold text-slate-700",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
disabled && "cursor-not-allowed opacity-55 hover:border-white/70 hover:text-slate-700" mapOptionInteractionVariants()
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -157,23 +160,20 @@ export function MapActionRow({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
onClick={onClick} onClick={onClick}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left", "flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
isPrimary isPrimary
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25 hover:bg-[var(--action-blue-hover)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/25"
: strong : strong
? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]" ? "border-[var(--surface-border)] bg-[var(--action-soft)]/95 text-[var(--action-blue-pressed)]"
: "surface-control border-transparent text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]", : "surface-control border-transparent text-slate-700",
isDanger && "hover:border-red-100 hover:text-red-700",
selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]", selected && !isPrimary && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-pressed)]",
mapOptionInteractionVariants({ state: isDanger ? "danger" : isPrimary ? "primary" : selected || strong ? "selected" : "default" }),
muted && "opacity-70", muted && "opacity-70",
disabled &&
(isPrimary
? "cursor-not-allowed opacity-55 hover:border-[var(--action-blue)] hover:bg-[var(--action-blue-hover)] hover:text-white"
: "cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"),
disabled && "shadow-none" disabled && "shadow-none"
)} )}
> >
@@ -185,8 +185,8 @@ export function MapActionRow({
? "bg-[var(--surface-reading)]/16 text-white" ? "bg-[var(--surface-reading)]/16 text-white"
: selected || strong : selected || strong
? "bg-[var(--action-blue)] text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-control)] text-[var(--ink-muted)]", : "bg-[var(--surface-control)] text-inherit",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
@@ -7,6 +7,7 @@ import {
TooltipTrigger TooltipTrigger
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME, MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -46,27 +47,26 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-map-option=""
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
onClick={item.onClick} onClick={item.onClick}
className={cn( className={cn(
"flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold transition duration-150", "flex min-h-10 items-center gap-2 border px-2.5 py-2 text-left text-xs font-semibold",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.active item.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-700 hover:border-[var(--surface-border)] hover:bg-[var(--surface-reading)] hover:text-[var(--action-blue-hover)]", : "text-slate-700",
!item.active && MAP_READABLE_SURFACE_CLASS_NAME, !item.active && MAP_READABLE_SURFACE_CLASS_NAME,
isDanger && "hover:border-red-100 hover:text-red-700", mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-7 w-7 shrink-0 place-items-center", "grid h-7 w-7 shrink-0 place-items-center text-inherit transition-[background-color] duration-150 motion-reduce:transition-none",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]", item.active ? "bg-[var(--surface-reading)]/16" : "bg-[var(--surface-control)]",
isDanger && "bg-red-50 text-red-600" isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
@@ -100,6 +100,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<TooltipTrigger asChild> <TooltipTrigger asChild>
<button <button
type="button" type="button"
data-map-option=""
aria-label={item.label} aria-label={item.label}
aria-pressed={item.active} aria-pressed={item.active}
disabled={item.disabled} disabled={item.disabled}
@@ -109,9 +110,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active item.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "hover:bg-[var(--action-soft)] hover:text-[var(--action-blue-hover)]", : undefined,
isDanger && "hover:bg-red-50 hover:text-red-700", mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
)} )}
> >
<Icon size={16} aria-hidden="true" /> <Icon size={16} aria-hidden="true" />
@@ -1,6 +1,7 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react"; import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,23 +80,24 @@ export function MapLayerControl({
<button <button
key={item.id} key={item.id}
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
aria-pressed={item.visible} aria-pressed={item.visible}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm transition duration-150", "flex min-h-12 w-full items-center gap-3 border px-2.5 text-left text-sm",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "surface-control border-[var(--surface-border)] text-slate-900" ? "surface-control border-[var(--surface-border)] text-slate-900"
: "surface-well border-transparent text-[var(--ink-muted)] hover:border-[var(--surface-divider)] hover:bg-[var(--surface-reading)]", : "surface-well border-transparent text-[var(--ink-muted)]",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent" mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)} )}
> >
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center", "grid h-8 w-8 shrink-0 place-items-center",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]" item.visible ? "bg-[var(--action-blue)] text-white" : "bg-[var(--surface-control)] text-inherit"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -4,12 +4,12 @@ import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control"; import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control"; import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME, MAP_READABLE_SURFACE_STRONG_CLASS_NAME
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
} from "./map-control-styles"; } from "./map-control-styles";
export type MapLayerPanelProps = { export type MapLayerPanelProps = {
@@ -46,17 +46,10 @@ type LayerVisibilitySectionProps = {
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) { function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
const groups = groupLayerItems(items); const groups = groupLayerItems(items);
const [openGroupIds, setOpenGroupIds] = useState( const [groupOpenStates, setGroupOpenStates] = useState<Record<string, boolean>>({});
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
);
const handleGroupToggle = (groupId: string, open: boolean) => { const handleGroupToggle = (groupId: string, open: boolean) => {
setOpenGroupIds((current) => { setGroupOpenStates((current) => ({ ...current, [groupId]: open }));
const next = new Set(current);
if (open) next.add(groupId);
else next.delete(groupId);
return next;
});
}; };
return ( return (
@@ -64,18 +57,18 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
{groups.map((group) => ( {groups.map((group) => (
<details <details
key={group.id} key={group.id}
open={openGroupIds.has(group.id)} open={groupOpenStates[group.id] ?? group.defaultOpen}
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)} onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
className="surface-well group/layer overflow-hidden rounded-xl border" className="surface-well group/layer overflow-hidden rounded-xl border"
> >
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-2 px-2.5 py-1.5 text-left marker:content-none active:scale-[0.99]"> <summary data-map-option="" className={cn("flex min-h-11 cursor-pointer list-none items-center gap-2 border border-transparent px-2.5 py-1.5 text-left text-slate-700 marker:content-none", mapOptionInteractionVariants())}>
<ChevronRight <ChevronRight
size={14} size={14}
aria-hidden="true" aria-hidden="true"
className="shrink-0 text-[var(--ink-muted)] transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90" className="shrink-0 text-inherit transition-transform duration-150 ease-out motion-reduce:transition-none group-open/layer:rotate-90"
/> />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-slate-700">{group.label}</span> <span className="block truncate text-xs font-semibold">{group.label}</span>
{group.description ? ( {group.description ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]"> <span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description} {group.description}
@@ -176,18 +169,19 @@ function LayerVisibilityRow({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
disabled={disabled} disabled={disabled}
aria-pressed={item.visible} aria-pressed={item.visible}
aria-label={`${item.label}${meta ? `,${meta}` : ""}`} aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
title={meta} title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)} onClick={() => onToggleLayer(item.id, !item.visible)}
className={cn( className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left transition-[background-color,border-color,color,box-shadow,transform] duration-150 ease-out motion-reduce:transition-none active:scale-[0.98] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1", "flex min-h-12 w-full items-center gap-2.5 border px-2 py-1.5 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
item.visible item.visible
? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5" ? "border-[var(--surface-border)]/90 bg-[var(--surface-reading)] text-slate-950 shadow-xs shadow-slate-950/5"
: "border-transparent bg-[var(--surface-control)]/55 text-slate-600 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/75", : "border-transparent bg-[var(--surface-control)]/55 text-slate-600",
disabled && "cursor-not-allowed opacity-55 active:scale-100" mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)} )}
> >
<LayerVisual visual={item.visual} visible={item.visible} /> <LayerVisual visual={item.visual} visible={item.visible} />
@@ -202,7 +196,7 @@ function LayerVisibilityRow({
<span <span
className={cn( className={cn(
"grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none", "grid h-8 w-8 shrink-0 place-items-center rounded-lg transition-[background-color,color,opacity] duration-150 motion-reduce:transition-none",
item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-[var(--ink-muted)]" item.visible ? "bg-[var(--action-soft)] text-[var(--action-blue-hover)]" : "bg-transparent text-inherit"
)} )}
> >
{item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />} {item.locked ? <Lock size={15} aria-hidden="true" /> : <Icon size={15} aria-hidden="true" />}
@@ -304,18 +298,18 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
<button <button
key={option.id} key={option.id}
type="button" type="button"
data-map-option=""
disabled={option.disabled} disabled={option.disabled}
aria-pressed={active} aria-pressed={active}
onClick={() => onSelectLayer(option.id)} onClick={() => onSelectLayer(option.id)}
className={cn( className={cn(
"flex min-h-[76px] flex-col p-1 text-left transition duration-150", "flex min-h-[76px] flex-col p-1 text-left",
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME, active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)]" ? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40", : "text-slate-700",
option.disabled && "cursor-not-allowed opacity-50" mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)} )}
> >
<BaseLayerSwatch option={option} active={active} /> <BaseLayerSwatch option={option} active={active} />
@@ -1,4 +1,4 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction"; import { cva } from "class-variance-authority";
export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl"; export const MAP_MAJOR_PANEL_RADIUS_CLASS_NAME = "rounded-2xl";
@@ -28,5 +28,27 @@ export const MAP_READABLE_SURFACE_CLASS_NAME =
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME = export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"surface-control border"; "surface-control border";
export const MAP_CONTROL_HOVER_CLASS_NAME = export const mapOptionInteractionVariants = cva(
buttonInteractionVariants({ tone: "neutral" }); "map-option-interaction outline-hidden transition-colors duration-150 motion-reduce:transition-none focus-visible:outline-1 focus-visible:outline-solid focus-visible:outline-[var(--action-focus-ring)] focus-visible:-outline-offset-1 disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-55 data-[disabled]:pointer-events-none data-[disabled]:opacity-55",
{
variants: {
state: {
default: "[@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)] focus-visible:border-[var(--surface-border)] focus-visible:bg-[var(--surface-reading)] focus-visible:text-[var(--action-blue-hover)] data-[highlighted]:border-[var(--surface-border)] data-[highlighted]:bg-[var(--surface-reading)] data-[highlighted]:text-[var(--action-blue-hover)]",
selected: "[@media(hover:hover)]:hover:border-[var(--action-border-hover)] focus-visible:border-[var(--action-border-hover)] data-[highlighted]:border-[var(--action-border-hover)]",
primary: "[@media(hover:hover)]:hover:border-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-blue-hover)] [@media(hover:hover)]:hover:text-white focus-visible:border-[var(--action-blue-hover)] focus-visible:bg-[var(--action-blue-hover)] focus-visible:text-white data-[highlighted]:border-[var(--action-blue-hover)] data-[highlighted]:bg-[var(--action-blue-hover)] data-[highlighted]:text-white",
danger: "[@media(hover:hover)]:hover:border-[var(--status-danger-border)] [@media(hover:hover)]:hover:bg-[var(--status-danger-soft)] [@media(hover:hover)]:hover:text-[var(--status-danger-foreground)] focus-visible:border-[var(--status-danger-border)] focus-visible:bg-[var(--status-danger-soft)] focus-visible:text-[var(--status-danger-foreground)] data-[highlighted]:border-[var(--status-danger-border)] data-[highlighted]:bg-[var(--status-danger-soft)] data-[highlighted]:text-[var(--status-danger-foreground)]"
}
},
defaultVariants: { state: "default" }
}
);
export const MAP_CONTROL_HOVER_CLASS_NAME = mapOptionInteractionVariants();
export const MAP_ACTION_ROW_INTERACTION_CLASS_NAME = [
MAP_COMPACT_RADIUS_CLASS_NAME,
"surface-control border border-transparent text-slate-700",
// Radix menu items receive focus while the pointer highlights a row.
"focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
mapOptionInteractionVariants()
].join(" ");
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import { Copy, Ruler, Trash2 } from "lucide-react"; import { Copy, Ruler, Trash2 } from "lucide-react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME, MAP_READABLE_RADIUS_CLASS_NAME,
@@ -91,16 +92,17 @@ export function MapMeasurePanel({
<button <button
key={mode.id} key={mode.id}
type="button" type="button"
data-map-option=""
aria-pressed={mode.id === activeModeId} aria-pressed={mode.id === activeModeId}
disabled={mode.disabled} disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)} onClick={() => onSelectMode?.(mode.id)}
className={cn( className={cn(
"flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "flex h-10 min-w-0 touch-manipulation items-center justify-center gap-1.5 border border-transparent px-2 text-xs font-semibold",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
mode.id === activeModeId mode.id === activeModeId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]", : "text-slate-600",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100" mapOptionInteractionVariants({ state: mode.id === activeModeId ? "selected" : "default" })
)} )}
> >
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" /> <mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
@@ -134,14 +136,16 @@ export function MapMeasurePanel({
<button <button
key={unit.id} key={unit.id}
type="button" type="button"
data-map-option=""
aria-pressed={unit.id === activeUnitId} aria-pressed={unit.id === activeUnitId}
onClick={() => onSelectUnit?.(unit.id)} onClick={() => onSelectUnit?.(unit.id)}
className={cn( className={cn(
"h-10 min-w-10 touch-manipulation px-2 text-xs font-semibold transition-[color,background-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-95 motion-reduce:transition-none", "h-10 min-w-10 touch-manipulation border border-transparent px-2 text-xs font-semibold",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
unit.id === activeUnitId unit.id === activeUnitId
? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? "bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]"
: "text-slate-600 [@media(hover:hover)]:hover:bg-[var(--surface-reading)]/85 [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" : "text-slate-600",
mapOptionInteractionVariants({ state: unit.id === activeUnitId ? "selected" : "default" })
)} )}
> >
{unit.label} {unit.label}
@@ -228,28 +232,27 @@ function MeasureActionButton({
return ( return (
<button <button
type="button" type="button"
data-map-option=""
title={action.description} title={action.description}
data-state={active ? "active" : undefined} data-state={active ? "active" : undefined}
disabled={action.disabled} disabled={action.disabled}
onClick={action.onClick} onClick={action.onClick}
className={cn( className={cn(
"flex w-full touch-manipulation items-center gap-2 font-semibold transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--action-focus-offset)] active:scale-[0.98] motion-reduce:transition-none", "flex w-full touch-manipulation items-center gap-2 font-semibold",
primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs", primary ? "h-12 justify-start border px-2.5 text-sm" : "h-10 justify-center border text-xs",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
primary primary
? active ? active
? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)] [@media(hover:hover)]:hover:border-[var(--palette-brand)] [@media(hover:hover)]:hover:bg-[var(--action-soft)]/70" ? "border-[var(--palette-brand)] bg-[var(--surface-reading)] text-slate-950 shadow-[0_3px_10px_rgba(37,99,235,0.14)] ring-1 ring-inset ring-[var(--surface-border)]"
: action.variant === "primary" : "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-xs"
? "border-[var(--surface-divider)] bg-[var(--surface-reading)]/90 text-slate-900 shadow-xs [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:bg-[var(--surface-reading)]"
: "border-slate-800 bg-slate-800 text-white shadow-xs [@media(hover:hover)]:hover:border-slate-900 [@media(hover:hover)]:hover:bg-slate-900"
: cn( : cn(
MAP_READABLE_SURFACE_CLASS_NAME, MAP_READABLE_SURFACE_CLASS_NAME,
danger danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50" ? "text-[var(--status-danger-foreground)]"
: "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]" : "text-slate-700"
), ),
action.disabled && mapOptionInteractionVariants({ state: danger ? "danger" : active ? "selected" : primary ? "primary" : "default" }),
"cursor-not-allowed opacity-45 shadow-none active:scale-100 [@media(hover:hover)]:hover:border-[var(--surface-divider)] [@media(hover:hover)]:hover:bg-transparent [@media(hover:hover)]:hover:text-slate-700" action.disabled && "shadow-none"
)} )}
> >
{primary ? ( {primary ? (
@@ -258,7 +261,7 @@ function MeasureActionButton({
"grid h-8 w-8 shrink-0 place-items-center rounded-lg", "grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active active
? "bg-[var(--action-blue)] text-white" ? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-reading)] text-slate-700 ring-1 ring-inset ring-[var(--surface-divider)]" : "bg-[var(--surface-reading)]/16 text-white"
)} )}
> >
<Icon size={15} strokeWidth={2.2} aria-hidden="true" /> <Icon size={15} strokeWidth={2.2} aria-hidden="true" />
@@ -269,7 +272,7 @@ function MeasureActionButton({
{primary ? ( {primary ? (
<span className="min-w-0 flex-1 text-left"> <span className="min-w-0 flex-1 text-left">
<span className="block truncate text-sm font-semibold leading-4">{action.label}</span> <span className="block truncate text-sm font-semibold leading-4">{action.label}</span>
<span className="mt-0.5 block truncate text-[10px] font-medium leading-3 text-[var(--ink-muted)]"> <span className={cn("mt-0.5 block truncate text-[10px] font-medium leading-3", active ? "text-[var(--ink-muted)]" : "text-white/85")}>
{action.description} {action.description}
</span> </span>
</span> </span>
@@ -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<MapNoticeTone>(["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 });
});
@@ -1,46 +1,30 @@
import type { ComponentProps } from "react"; import { toast } from "sonner";
import { toast, Toaster } from "sonner";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation"; import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types"; import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
const DEFAULT_POSITION: MapNoticePosition = "top-center"; const DEFAULT_POSITION: MapNoticePosition = "top-center";
const pendingNotices = new Map<string | number, () => void>();
const positionClassNames: Record<MapNoticePosition, string> = { let nextNoticeId = 0;
"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<MapNoticePosition, SonnerPosition> = {
"top-center": "top-center",
"top-right": "top-right",
"bottom-left": "bottom-left",
"bottom-right": "bottom-right"
};
export function showMapNotice(options: MapNoticeOptions) { export function showMapNotice(options: MapNoticeOptions) {
const id = options.id ?? `map-notice:${++nextNoticeId}`;
const tone = options.tone ?? "info"; const tone = options.tone ?? "info";
const position = options.position ?? DEFAULT_POSITION; const position = options.position ?? DEFAULT_POSITION;
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity); const hasAction = Boolean(options.actionLabel && options.onAction);
const dismissible = options.dismissible ?? tone !== "loading"; // 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(
() => <MapNoticeContent {...options} tone={tone} />, () => <MapNoticeContent {...options} tone={tone} />,
{ {
id: options.id, id, duration, position, dismissible,
duration,
position: sonnerPositions[position],
dismissible,
cancel: dismissible cancel: dismissible
? { ? { label: <MapNoticeCloseIcon />, onClick: (event) => event.stopPropagation() }
label: <MapNoticeCloseIcon />,
onClick: (event) => event.stopPropagation()
}
: undefined, : undefined,
classNames: { classNames: {
cancelButton: cn( cancelButton: cn(
@@ -48,11 +32,22 @@ export function showMapNotice(options: MapNoticeOptions) {
MAP_ICON_CELL_RADIUS_CLASS_NAME 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) { export function dismissMapNotice(id?: string | number) {
if (id === undefined) pendingNotices.clear();
else pendingNotices.delete(id);
toast.dismiss(id); toast.dismiss(id);
} }
@@ -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 type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -32,16 +32,16 @@ export function MapNoticeContent({
return ( return (
<MapNoticeFrame tone={tone}> <MapNoticeFrame tone={tone}>
<div data-slot="map-notice-summary" className="flex items-start gap-3 py-3.5 pl-4 pr-12">
<MapNoticeIcon tone={tone} icon={Icon} /> <MapNoticeIcon tone={tone} icon={Icon} />
<MapNoticeBody <MapNoticeBody title={title} message={message} />
title={title} </div>
message={message} {actionLabel && onAction ? (
action={ <div data-slot="map-notice-actions" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pb-3 pl-14 pr-3">
actionLabel && onAction ? ( <span className="text-xs leading-5 text-[var(--ink-muted)]">保留至手动关闭</span>
<MapNoticeAction label={actionLabel} onClick={onAction} /> <MapNoticeAction label={actionLabel} onClick={onAction} />
) : null </div>
} ) : null}
/>
</MapNoticeFrame> </MapNoticeFrame>
); );
} }
@@ -59,8 +59,11 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
return ( return (
<div <div
role={tone === "error" || tone === "warning" ? "alert" : "status"} role={tone === "error" || tone === "warning" ? "alert" : "status"}
// Sonner restores its saved focus on blur. Persistent action buttons must
// let the user move directly to another workbench control instead.
onFocus={(event) => event.stopPropagation()}
className={cn( 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, MAP_READABLE_RADIUS_CLASS_NAME,
)} )}
> >
@@ -90,23 +93,20 @@ function MapNoticeIcon({
function MapNoticeBody({ function MapNoticeBody({
title, title,
message, message
action
}: { }: {
title?: string; title?: string;
message: string; message: string;
action?: ReactNode;
}) { }) {
return ( return (
<span className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{title ? ( {title ? (
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span> <span className="block break-words text-sm font-semibold leading-5 text-slate-950">{title}</span>
) : null} ) : null}
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}> <span className={cn("block break-words text-sm leading-6 text-slate-600", title && "mt-1")}>
{message} {message}
</span> </span>
{action} </div>
</span>
); );
} }
@@ -118,11 +118,12 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
size="sm" size="sm"
variant="outline" variant="outline"
className={cn( 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 MAP_COMPACT_RADIUS_CLASS_NAME
)} )}
> >
{label} {label}
<ArrowUpRight size={14} aria-hidden="true" />
</Button> </Button>
); );
} }
@@ -6,6 +6,7 @@ export type MapNoticeOptions = {
title?: string; title?: string;
message: string; message: string;
tone?: MapNoticeTone; tone?: MapNoticeTone;
/** Actionable notices always remain until manually dismissed. */
duration?: number; duration?: number;
position?: MapNoticePosition; position?: MapNoticePosition;
actionLabel?: string; actionLabel?: string;
@@ -0,0 +1,52 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("keeps initial healthy sources quiet and announces recovery once", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
expect(showMapNotice).toHaveBeenCalledTimes(2);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledOnce();
});
it("updates a degraded source in place and only removes it when unmounted", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenCalledTimes(3);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("source");
});
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender(<MapErrorNotice id="new" message="另一项错误" />);
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
unmount();
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
});
it("keeps loading visible while its title or message changes", () => {
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
});
+25 -8
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react"; import { useEffect, useRef, type CSSProperties } from "react";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions"; import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types"; import type { MapNoticeTone } from "./notice-types";
@@ -8,14 +8,20 @@ export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notic
export function MapToaster() { export function MapToaster() {
return ( return (
<Toaster <Toaster
className="map-notice-stack"
style={{ fontFamily: "inherit", "--width": "min(420px, calc(100vw - 24px))" } as CSSProperties}
containerAriaLabel="系统通知"
closeButton={false} closeButton={false}
expand expand
visibleToasts={4} visibleToasts={4}
position="top-center" position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
swipeDirections={[]}
toastOptions={{ toastOptions={{
unstyled: true, unstyled: true,
classNames: { classNames: {
toast: "group pointer-events-none", toast: "group pointer-events-auto",
closeButton: closeButton:
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700" "pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
} }
@@ -25,6 +31,7 @@ export function MapToaster() {
} }
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) { export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useNoticeCleanup(id);
useEffect(() => { useEffect(() => {
showMapNotice({ showMapNotice({
id, id,
@@ -33,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
message, message,
duration: Infinity duration: Infinity
}); });
return () => dismissMapNotice(id);
}, [id, message]); }, [id, message]);
return null; return null;
@@ -49,6 +54,7 @@ export function MapLoadingNotice({
title?: string; title?: string;
id?: string | number; id?: string | number;
}) { }) {
useNoticeCleanup(id);
useEffect(() => { useEffect(() => {
showMapNotice({ showMapNotice({
id, id,
@@ -58,8 +64,6 @@ export function MapLoadingNotice({
duration: Infinity, duration: Infinity,
dismissible: false dismissible: false
}); });
return () => dismissMapNotice(id);
}, [id, message, title]); }, [id, message, title]);
return null; return null;
@@ -84,7 +88,16 @@ export function MapSourceStatusNotice({
actionLabel, actionLabel,
onAction onAction
}: MapSourceStatusNoticeProps) { }: MapSourceStatusNoticeProps) {
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
useNoticeCleanup(id);
useEffect(() => { useEffect(() => {
const last = previous.current;
previous.current = { id, status };
// Initial healthy sources and repeated tile-load messages stay quiet.
// Success is announced when the same source recovers from a problem.
if (status === "online" && !(actionLabel && onAction)
&& (!last || last.id !== id || last.status === "online")) return;
showMapNotice({ showMapNotice({
id, id,
tone: getSourceStatusTone(status), tone: getSourceStatusTone(status),
@@ -94,13 +107,17 @@ export function MapSourceStatusNotice({
actionLabel, actionLabel,
onAction onAction
}); });
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]); }, [actionLabel, id, message, onAction, sourceName, status]);
return null; return null;
} }
function useNoticeCleanup(id: string | number) {
// Content updates reuse the toast id. Dismissing during an update races
// Sonner's asynchronous removal against the newly published content.
useEffect(() => () => dismissMapNotice(id), [id]);
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone { function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") { if (status === "online") {
return "success"; return "success";
+3 -2
View File
@@ -8,6 +8,7 @@ import {
} from "@/shared/ui/tooltip"; } from "@/shared/ui/tooltip";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
@@ -70,9 +71,9 @@ export function MapToolbar({
"relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700", "relative grid h-10 w-10 shrink-0 place-items-center border border-transparent text-slate-700",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tool.active tool.active
? "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]" ? cn("border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-[var(--action-shadow-primary)]", mapOptionInteractionVariants({ state: "selected" }))
: MAP_CONTROL_HOVER_CLASS_NAME, : MAP_CONTROL_HOVER_CLASS_NAME,
tool.disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)] hover:ring-0" tool.disabled && "text-[var(--ink-muted)] opacity-45"
)} )}
> >
<Icon size={20} strokeWidth={2.15} aria-hidden="true" /> <Icon size={20} strokeWidth={2.15} aria-hidden="true" />
+3 -2
View File
@@ -10,6 +10,7 @@ import {
} from "./camera-actions"; } from "./camera-actions";
import { import {
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME MAP_CONTROL_SURFACE_CLASS_NAME
@@ -158,9 +159,9 @@ function ControlButton({
"grid h-10 w-10 place-items-center border border-transparent", "grid h-10 w-10 place-items-center border border-transparent",
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
"bg-transparent text-slate-700", "bg-transparent text-slate-700",
MAP_CONTROL_HOVER_CLASS_NAME, pressed ? mapOptionInteractionVariants({ state: "selected" }) : MAP_CONTROL_HOVER_CLASS_NAME,
pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]", pressed && "bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
disabled && "text-[var(--ink-muted)] opacity-45 hover:bg-transparent hover:text-[var(--ink-muted)]" disabled && "text-[var(--ink-muted)] opacity-45"
)} )}
> >
{Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content} {Icon ? <Icon size={18} strokeWidth={2.2} aria-hidden="true" /> : content}
+2
View File
@@ -29,6 +29,8 @@ export {
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel"; export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend"; export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export { export {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_HOVER_CLASS_NAME, MAP_CONTROL_HOVER_CLASS_NAME,
MAP_CONTROL_RADIUS_CLASS_NAME, MAP_CONTROL_RADIUS_CLASS_NAME,
@@ -1,13 +1,7 @@
import { getWorkbenchAccessToken } from "./request-context"; import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources"; import type { WaterNetworkSourceId } from "../map/sources";
import {
createWorkbenchHeaders,
findOwnedWorkbenchProject,
getServerError
} from "./project-client";
const gisAttributeGroupSchema = z.object({ const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1), dataset_key: z.string().min(1),
@@ -43,7 +37,6 @@ const sourceGisFeaturePropertiesSchema = z.object({
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>; export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties( export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId, elementType: WaterNetworkSourceId,
@@ -52,26 +45,15 @@ export async function fetchElementGisProperties(
getAccessToken?: WorkbenchAccess, getAccessToken?: WorkbenchAccess,
signal?: AbortSignal signal?: AbortSignal
): Promise<GisAttributeGroup[]> { ): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [ const path = [
"/api/v1/elements", "/api/v1/elements",
encodeURIComponent(elementType), encodeURIComponent(elementType),
encodeURIComponent(elementId), encodeURIComponent(elementId),
"gis-properties" "gis-properties"
].join("/"); ].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { const payload = await requestWorkbenchJson(path, "GIS 台账属性查询", {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId), access: getAccessToken, projectId, init: { signal }
signal
}); });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "GIS 台账属性查询"));
}
const result = elementGisPropertiesSchema.parse(payload); const result = elementGisPropertiesSchema.parse(payload);
if (result.element_type !== elementType || result.element_id !== elementId) { if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致"); throw new Error("GIS 台账属性与所选元素不一致");
@@ -86,12 +68,6 @@ export async function fetchSourceGisFeatureProperties(
getAccessToken?: WorkbenchAccess, getAccessToken?: WorkbenchAccess,
signal?: AbortSignal signal?: AbortSignal
): Promise<GisAttributeGroup[]> { ): Promise<GisAttributeGroup[]> {
const ownedProject = projectId
? null
: await findOwnedWorkbenchProject(getAccessToken, signal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到 GIS 台账所属项目");
const path = [ const path = [
"/api/v1/source-gis/layers", "/api/v1/source-gis/layers",
encodeURIComponent(layerKey), encodeURIComponent(layerKey),
@@ -99,14 +75,9 @@ export async function fetchSourceGisFeatureProperties(
encodeURIComponent(String(sourceFid)), encodeURIComponent(String(sourceFid)),
"properties" "properties"
].join("/"); ].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { const payload = await requestWorkbenchJson(path, "源 GIS 属性查询", {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId), access: getAccessToken, projectId, init: { signal }
signal
}); });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "源 GIS 属性查询"));
}
const result = sourceGisFeaturePropertiesSchema.parse(payload); const result = sourceGisFeaturePropertiesSchema.parse(payload);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) { if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
throw new Error("源 GIS 属性与所选要素不一致"); throw new Error("源 GIS 属性与所选要素不一致");
@@ -21,6 +21,7 @@ function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent", const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) }; ...(generation === undefined ? {} : { execution_generation: generation }) };
return { return {
execution_scope: "production",
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint", project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null, lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2, status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => { describe("operational task contract", () => {
it.each(["event_investigation", "external_work_order", "scheduled_flow"])(
"loads %s summaries and details with the Server execution scope", (taskType) => {
const detail = { ...task(), task_type: taskType, execution_scope: "production" };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(parseOperationalTaskSummary(summary)).toHaveProperty("execution_scope", "production");
expect(parseOperationalTask(detail)).toHaveProperty("execution_scope", "production");
}
);
it.each([undefined, null, "", 1])("rejects an invalid execution scope %j", (scope) => {
const detail = { ...task(), execution_scope: scope };
const {
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
actions: _actions, permissions: _permissions, ...summary
} = detail;
expect(() => parseOperationalTaskSummary(summary)).toThrow();
expect(() => parseOperationalTask(detail)).toThrow();
});
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => { describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 }; const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
@@ -170,6 +193,7 @@ describe("operational task contract", () => {
it("keeps unknown fields and invalid generations rejected", () => { it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow(); expect(() => parseOperationalTask(task(0))).toThrow();
const value = task(); const value = task();
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow(); expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow(); expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow(); expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
@@ -1,9 +1,8 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report"; import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation"; import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]); const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
const taskPrioritySchema = z.enum(["normal", "urgent"]); const taskPrioritySchema = z.enum(["normal", "urgent"]);
@@ -50,6 +49,8 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable() next_cursor: z.string().min(1).nullable()
}).strict(); }).strict();
const taskSummarySchema = z.object({ const taskSummarySchema = z.object({
execution_scope: z.string().min(1),
handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(), project_id: z.string().uuid(),
task_id: z.string().uuid(), task_id: z.string().uuid(),
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]), task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
@@ -430,16 +431,9 @@ export type CreateOperationalTaskInput = {
agent_session_id?: string; agent_session_id?: string;
}; };
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) { async function operationalTaskRequest(path: string, getAccessToken?: WorkbenchAccess, init?: RequestInit) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined); return requestWorkbenchJson(`/api/v1${path}`, "工况任务请求", { access: getAccessToken, init });
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
if (init?.body) headers.set("Content-Type", "application/json");
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
return payload;
} }
export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) { export async function fetchOperationalTask(taskId: string, getAccessToken?: WorkbenchAccess, signal?: AbortSignal) {
@@ -1,8 +1,7 @@
import { getWorkbenchAccessToken } from "./request-context"; import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env"; import { type PlaceSearchConfiguration } from "./project-client";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10; export const MAX_PLACE_SEARCH_RESULTS = 10;
@@ -71,21 +70,8 @@ async function requestPlaceData(
getAccessToken?: WorkbenchAccess, getAccessToken?: WorkbenchAccess,
signal?: AbortSignal signal?: AbortSignal
): Promise<unknown> { ): Promise<unknown> {
const requestSignal = signal return requestWorkbenchJson(`/api/v1/${path}`, "地名搜索", {
? AbortSignal.any([signal, AbortSignal.timeout(20_000)]) access: getAccessToken, projectId, timeoutMs: 20_000,
: AbortSignal.timeout(20_000); init: { method: "POST", body: JSON.stringify(body), signal }
const ownedProject = projectId ? null : await findOwnedWorkbenchProject(getAccessToken, requestSignal);
const accessToken = ownedProject?.accessToken ?? await getWorkbenchAccessToken(getAccessToken);
requestSignal.throwIfAborted();
const resolvedProjectId = projectId ?? ownedProject?.project.project_id;
if (!resolvedProjectId) throw new Error("未找到地名搜索所属项目");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/${path}`, {
method: "POST",
headers: createWorkbenchHeaders(accessToken, resolvedProjectId, true),
body: JSON.stringify(body),
signal: requestSignal
}); });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "地名搜索"));
return payload;
} }
@@ -9,6 +9,77 @@ import {
} from "./project-client"; } from "./project-client";
describe("workbench project client", () => { describe("workbench project client", () => {
it("explains project-list timeouts using the Server readiness result", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
}, { status: 503 });
throw new DOMException("signal timed out", "TimeoutError");
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("读取授权项目超时(已等待 15 秒)");
expect(fetchMock).toHaveBeenCalledTimes(2);
const probe = fetchMock.mock.calls.find(([url]) => new URL(String(url)).pathname === "/ready");
expect(probe).toBeDefined();
expect(new Headers(probe?.[1]?.headers).has("Authorization")).toBe(false);
});
it("identifies metadata unavailability instead of exposing the raw abort message", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
}, { status: 503 });
throw new DOMException("signal timed out", "TimeoutError");
}));
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("项目元数据库暂不可用");
});
it("names backend startup and an unavailable listener separately", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "not_ready", checks: { startup: "not_ready", metadata_database: "not_checked", mcp_transport: "not_ready" }
}, { status: 503 });
throw new DOMException("signal timed out", "TimeoutError");
});
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("后端服务正在启动");
fetchMock.mockRejectedValue(new DOMException("signal timed out", "TimeoutError"));
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("后端服务未响应");
});
it("preserves caller cancellation without probing readiness or presenting a timeout", async () => {
const controller = new AbortController();
controller.abort();
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("retries a timed-out lookup after the backend recovers without keeping its cached failure", async () => {
let available = false;
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
if (new URL(String(input)).pathname === "/ready") return Response.json({
status: "ready", checks: { startup: "ready", metadata_database: "ready", mcp_transport: "ready" }
});
if (!available) throw new DOMException("signal timed out", "TimeoutError");
return Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
total: 1, limit: 1000, offset: 0 });
}));
const provider = async () => "token";
await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("后端当前已就绪");
available = true;
await expect(findOwnedWorkbenchProject(provider)).resolves.toMatchObject({ project: { project_id: "supply" } });
});
it("keeps authorization failures distinct from service availability", async () => {
const fetchMock = vi.fn(async () => Response.json({ detail: "当前账号没有项目访问权限" }, { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("当前账号没有项目访问权限");
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => { it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => {
const clock = vi.spyOn(Date, "now").mockReturnValue(100); const clock = vi.spyOn(Date, "now").mockReturnValue(100);
const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 })); const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }));
+15 -8
View File
@@ -5,6 +5,7 @@ import { env } from "@/shared/config/env";
import type { NetworkViewParams } from "../map/camera"; import type { NetworkViewParams } from "../map/camera";
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config"; import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context"; import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
import { PROJECT_REQUEST_TIMEOUT_MS, withProjectRequest } from "./project-request";
const projectSchema = z.object({ const projectSchema = z.object({
project_id: z.string().min(1), project_id: z.string().min(1),
@@ -117,11 +118,11 @@ export async function findOwnedWorkbenchProject(
if (existing && existing.expiresAt > Date.now()) { if (existing && existing.expiresAt > Date.now()) {
return { project: await awaitProject(existing.promise, signal), accessToken }; return { project: await awaitProject(existing.promise, signal), accessToken };
} }
const loadProject = async () => { const loadProject = () => withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken), headers: createWorkbenchHeaders(accessToken),
// One caller cancelling must not cancel other consumers of this lookup. // One caller cancelling must not cancel other consumers of this lookup.
signal: getAccessToken ? AbortSignal.timeout(15_000) : signal signal: requestSignal
}); });
const payload: unknown = await response.json().catch(() => null); const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询")); if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
@@ -131,7 +132,7 @@ export async function findOwnedWorkbenchProject(
); );
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`); if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return project; return project;
}; }, getAccessToken ? undefined : signal);
const pending = loadProject(); const pending = loadProject();
if (getAccessToken) { if (getAccessToken) {
projectContextPromises.set(getAccessToken, { promise: pending, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS }); projectContextPromises.set(getAccessToken, { promise: pending, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
@@ -183,7 +184,7 @@ export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvide
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal); if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
let cache = projectListCaches.get(getAccessToken); let cache = projectListCaches.get(getAccessToken);
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); } if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)), const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS)),
PROJECT_CACHE_TTL_MS, { force }); PROJECT_CACHE_TTL_MS, { force });
if (!signal) return pending; if (!signal) return pending;
signal.throwIfAborted(); signal.throwIfAborted();
@@ -200,12 +201,14 @@ async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signa
const projects: WorkbenchProject[] = []; const projects: WorkbenchProject[] = [];
let offset = 0; let offset = 0;
while (true) { while (true) {
const page = await withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
headers: createWorkbenchHeaders(token), signal headers: createWorkbenchHeaders(token), signal: requestSignal
}); });
const payload: unknown = await response.json().catch(() => null); const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询")); if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const page = projectPageSchema.parse(payload); return projectPageSchema.parse(payload);
}, signal);
projects.push(...page.items.filter((project) => project.status === "active")); projects.push(...page.items.filter((project) => project.status === "active"));
offset += page.items.length; offset += page.items.length;
if (offset >= page.total) break; if (offset >= page.total) break;
@@ -236,9 +239,10 @@ async function fetchProjectMetadata(
headers: Headers, headers: Headers,
signal?: AbortSignal signal?: AbortSignal
) { ) {
return withProjectRequest(SERVER_API_BASE_URL, "读取项目配置", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
headers, headers,
signal signal: requestSignal
}); });
const payload: unknown = await response.json().catch(() => null); const payload: unknown = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
@@ -249,6 +253,7 @@ async function fetchProjectMetadata(
throw new Error("项目元数据与授权项目不一致"); throw new Error("项目元数据与授权项目不一致");
} }
return metadata; return metadata;
}, signal);
} }
async function fetchAccessContext( async function fetchAccessContext(
@@ -256,9 +261,10 @@ async function fetchAccessContext(
headers: Headers, headers: Headers,
signal?: AbortSignal signal?: AbortSignal
) { ) {
return withProjectRequest(SERVER_API_BASE_URL, "读取项目访问权限", async (requestSignal) => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
headers, headers,
signal signal: requestSignal
}); });
const payload: unknown = await response.json().catch(() => null); const payload: unknown = await response.json().catch(() => null);
if (!response.ok) { if (!response.ok) {
@@ -269,6 +275,7 @@ async function fetchAccessContext(
throw new Error("访问上下文与授权项目不一致"); throw new Error("访问上下文与授权项目不一致");
} }
return accessContext; return accessContext;
}, signal);
} }
export function createWorkbenchHeaders( export function createWorkbenchHeaders(
@@ -0,0 +1,70 @@
import { z } from "zod";
export const PROJECT_REQUEST_TIMEOUT_MS = 15_000;
const READINESS_TIMEOUT_MS = 3_000;
const readinessSchema = z.object({
status: z.enum(["ready", "not_ready"]),
checks: z.object({
startup: z.string(),
metadata_database: z.string(),
mcp_transport: z.string()
})
});
const isTimeout = (error: unknown) => Boolean(error && typeof error === "object"
&& "name" in error && error.name === "TimeoutError");
async function describeServerReadiness(baseUrl: string): Promise<string> {
try {
const response = await fetch(`${baseUrl}/ready`, {
signal: AbortSignal.timeout(READINESS_TIMEOUT_MS),
headers: { Accept: "application/json" },
cache: "no-store"
});
const parsed = readinessSchema.safeParse(await response.json());
if (parsed.success) {
const { status, checks } = parsed.data;
if (checks.startup === "not_ready") {
return "后端服务正在启动,尚未准备好处理项目请求。请稍后重新加载。";
}
if (checks.metadata_database === "unavailable") {
return "后端项目元数据库暂不可用。请稍后重试,若持续失败,请检查后端数据库连接。";
}
if (checks.mcp_transport === "not_ready") {
return "后端查询服务尚未就绪。请稍后重新加载。";
}
if (response.ok && status === "ready") {
return "后端当前已就绪,请重新加载项目。";
}
}
return "暂时无法确认后端就绪状态。服务可能正在重启或响应缓慢,请稍后重新加载。";
} catch {
return "后端服务未响应,可能正在重启或热重载。请稍后重新加载,若持续失败,请检查后端服务。";
}
}
/** Keep caller cancellation distinct from a request deadline or unavailable backend. */
export async function withProjectRequest<T>(
baseUrl: string,
operation: string,
request: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal,
timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
): Promise<T> {
const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
try {
combined.throwIfAborted();
return await request(combined);
} catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
if (timeoutMs != null && (isTimeout(error) || (combined.aborted && isTimeout(combined.reason)))) {
const diagnosis = await describeServerReadiness(baseUrl);
throw new Error(`${operation}超时(已等待 ${timeoutMs / 1000} 秒)。${diagnosis}`, { cause: error });
}
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
}
throw error;
}
}
@@ -1,8 +1,7 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay"; import type { PipeFlowCalculation } from "../map/flow-overlay";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const realtimeLinkSchema = z.object({ const realtimeLinkSchema = z.object({
time: z.string().datetime({ offset: true }), time: z.string().datetime({ offset: true }),
@@ -11,7 +10,6 @@ const realtimeLinkSchema = z.object({
}); });
const realtimeLinksSchema = z.array(realtimeLinkSchema); const realtimeLinksSchema = z.array(realtimeLinkSchema);
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const FLOW_LOOKBACK_MS = 15 * 60 * 1000; const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
export type RealtimePipeFlowSnapshot = { export type RealtimePipeFlowSnapshot = {
@@ -24,22 +22,13 @@ export async function fetchRealtimePipeFlows(
signal?: AbortSignal, signal?: AbortSignal,
now = new Date() now = new Date()
): Promise<RealtimePipeFlowSnapshot> { ): Promise<RealtimePipeFlowSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const query = new URLSearchParams({ const query = new URLSearchParams({
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(), start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
end_time: now.toISOString() end_time: now.toISOString()
}); });
const response = await fetch( const payload = await requestWorkbenchJson(`/api/v1/timeseries/realtime/links?${query}`, "实时管道流速请求", {
`${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`, access: getAccessToken, init: { signal }
{ });
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "实时管道流速请求"));
}
const rows = realtimeLinksSchema.parse(payload); const rows = realtimeLinksSchema.parse(payload);
const latestByPipe = new Map<string, z.infer<typeof realtimeLinkSchema>>(); const latestByPipe = new Map<string, z.infer<typeof realtimeLinkSchema>>();
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { findOwnedWorkbenchProject } from "./project-client";
import { requestWorkbenchJson } from "./request-json";
vi.mock("./project-client", async (original) => ({
...await original<typeof import("./project-client")>(), findOwnedWorkbenchProject: vi.fn()
}));
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.clearAllMocks(); });
describe("project JSON transport", () => {
it("reuses resolved credentials and preserves request headers without choosing another project", async () => {
const fetcher = vi.fn().mockResolvedValue(Response.json({ ok: true }));
vi.stubGlobal("fetch", fetcher);
await expect(requestWorkbenchJson("/api/v1/probe", "查询", {
credentials: { projectId: "project-a", accessToken: "token-a" },
init: { headers: { "X-Request-Id": "request-a" } }
})).resolves.toEqual({ ok: true });
const headers = new Headers(fetcher.mock.calls[0][1].headers);
expect(headers.get("Authorization")).toBe("Bearer token-a");
expect(headers.get("X-Project-Id")).toBe("project-a");
expect(headers.get("X-Request-Id")).toBe("request-a");
expect(findOwnedWorkbenchProject).not.toHaveBeenCalled();
});
it("rejects an explicitly empty project instead of falling back to a default", async () => {
const fetcher = vi.fn(); vi.stubGlobal("fetch", fetcher);
await expect(requestWorkbenchJson("/api/v1/probe", "查询", { projectId: "" })).rejects.toThrow("所属项目不能为空");
expect(findOwnedWorkbenchProject).not.toHaveBeenCalled();
expect(fetcher).not.toHaveBeenCalled();
});
it("preserves caller cancellation before fetching and while reading the body", async () => {
const controller = new AbortController();
const reason = new DOMException("changed project", "AbortError");
const fetcher = vi.fn().mockImplementation(async () => ({ ok: true, json: async () => {
controller.abort(reason); throw reason;
} }));
vi.stubGlobal("fetch", fetcher);
const options = { credentials: { projectId: "project-a", accessToken: null }, init: { signal: controller.signal } };
await expect(requestWorkbenchJson("/api/v1/probe", "查询", options)).rejects.toBe(reason);
await expect(requestWorkbenchJson("/api/v1/probe", "查询", options)).rejects.toBe(reason);
expect(fetcher).toHaveBeenCalledTimes(1);
});
it("keeps deadlines opt-in and never retries a failed write", async () => {
const deadline = vi.spyOn(AbortSignal, "timeout");
const fetcher = vi.fn().mockResolvedValue(Response.json({ detail: "服务繁忙" }, { status: 503 }));
vi.stubGlobal("fetch", fetcher);
await expect(requestWorkbenchJson("/api/v1/probe", "提交", {
credentials: { projectId: "project-a", accessToken: null }, init: { method: "POST", body: "{}" }
})).rejects.toThrow("服务繁忙");
expect(fetcher).toHaveBeenCalledTimes(1);
expect(deadline).not.toHaveBeenCalled();
fetcher.mockResolvedValue(Response.json({}));
await requestWorkbenchJson("/api/v1/probe", "地名搜索", {
credentials: { projectId: "project-a", accessToken: null }, timeoutMs: 20_000
});
expect(deadline).toHaveBeenCalledWith(20_000);
});
});
@@ -0,0 +1,38 @@
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { withProjectRequest } from "./project-request";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
type ProjectCredentials = { projectId: string | undefined; accessToken: string | null | undefined };
/** Project-scoped JSON transport. Business validation and write retries belong to callers. */
export async function requestWorkbenchJson(path: string, operation: string, options: {
access?: WorkbenchAccess;
projectId?: string | null;
credentials?: ProjectCredentials;
init?: RequestInit;
timeoutMs?: number;
} = {}): Promise<unknown> {
const baseUrl = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
return withProjectRequest(baseUrl, operation, async (signal) => {
let credentials = options.credentials;
if (!credentials) {
if (options.projectId != null) {
if (!options.projectId) throw new Error(`${operation}所属项目不能为空`);
credentials = { projectId: options.projectId, accessToken: await getWorkbenchAccessToken(options.access) };
} else {
const { project, accessToken } = await findOwnedWorkbenchProject(options.access, signal);
credentials = { projectId: project.project_id, accessToken };
}
}
signal.throwIfAborted();
const headers = new Headers(options.init?.headers);
new Headers(createWorkbenchHeaders(credentials.accessToken, credentials.projectId)).forEach((value, key) => headers.set(key, value));
if (options.init?.body != null && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
const response = await fetch(`${baseUrl}${path}`, { ...options.init, headers, signal });
const payload: unknown = await response.json().catch(() => null);
signal.throwIfAborted();
if (!response.ok) throw new Error(getServerError(payload, response.status, operation));
return payload;
}, options.init?.signal ?? undefined, options.timeoutMs ?? null);
}
+3 -10
View File
@@ -1,8 +1,8 @@
import { requestWorkbenchJson } from "./request-json";
import type { Feature, FeatureCollection, Point } from "geojson"; import type { Feature, FeatureCollection, Point } from "geojson";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context"; import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { z } from "zod"; import { z } from "zod";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client"; import { findOwnedWorkbenchProject } from "./project-client";
const scadaDeviceSchema = z.object({ const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1), device_id: z.string().trim().min(1),
@@ -68,7 +68,6 @@ export type ScadaSnapshot = {
telemetryStatus: "ready" | "empty" | "unavailable"; telemetryStatus: "ready" | "empty" | "unavailable";
}; };
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const SCADA_LOOKBACK_MS = 15 * 60 * 1000; const SCADA_LOOKBACK_MS = 15 * 60 * 1000;
const SCADA_READING_BATCH_SIZE = 100; const SCADA_READING_BATCH_SIZE = 100;
@@ -244,11 +243,5 @@ async function requestJson(
projectId: string | undefined, projectId: string | undefined,
signal: AbortSignal | undefined signal: AbortSignal | undefined
) { ) {
const headers = createWorkbenchHeaders(accessToken, projectId); return requestWorkbenchJson(path, "SCADA API请求", { credentials: { accessToken, projectId }, init: { signal } });
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "SCADA API请求"));
}
return payload;
} }
@@ -1,6 +1,6 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import { env } from "@/shared/config/env"; import { findOwnedWorkbenchProject } from "./project-client";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context"; import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
const ruleSchema = z.object({ const ruleSchema = z.object({
@@ -21,6 +21,22 @@ const eventSchema = z.object({
detail: z.string(), task_id: z.string().nullable(), category: z.string().optional(), detail: z.string(), task_id: z.string().nullable(), category: z.string().optional(),
change: z.enum(["raised", "resolved"]).optional(), resolved_at: z.string().optional() change: z.enum(["raised", "resolved"]).optional(), resolved_at: z.string().optional()
}); });
const monitoringWeightsSchema = z.object({
burst_detection: z.number(), pressure: z.number(), outlet_flow: z.number()
});
const monitoringConfigSchema = z.object({
rule_version: z.string(), decision_policy: z.enum(["weighted", "all_signals"]),
pressure_device_ids: z.array(z.string()), outlet_flow_device_ids: z.array(z.string()),
weights: monitoringWeightsSchema, alert_threshold: z.number()
});
const calibrationSchema = z.object({
version: z.string().optional(), diagnostic_policy: z.string().optional(),
status: z.enum(["ready", "insufficient_data"]),
knowledge_cutoff: z.string(), history_start: z.string(),
pressure: z.record(z.string(), z.object({ threshold_percent: z.number() })),
outlet_flow: z.record(z.string(), z.object({ threshold_percent: z.number() })),
burst_detection: z.object({ threshold: z.number().optional() }).optional()
});
const monitoringSchema = z.object({ const monitoringSchema = z.object({
monitoring_id: z.string().uuid(), monitoring_id: z.string().uuid(),
period_start: z.string(), period_end: z.string(), knowledge_cutoff: z.string(), period_start: z.string(), period_end: z.string(), knowledge_cutoff: z.string(),
@@ -29,7 +45,19 @@ const monitoringSchema = z.object({
detail: z.string(), task_id: z.string().uuid().nullable(), detail: z.string(), task_id: z.string().uuid().nullable(),
pressure: z.record(z.string(), z.unknown()), pressure: z.record(z.string(), z.unknown()),
outlet_flow: z.record(z.string(), z.unknown()), outlet_flow: z.record(z.string(), z.unknown()),
burst_detection: z.record(z.string(), z.unknown()) burst_detection: z.record(z.string(), z.unknown()),
anomaly_signals: z.array(z.object({
event_id: z.string(), rule_id: z.string(), device_ids: z.array(z.string()), detail: z.string()
})).optional(),
weighted: z.object({
score: z.number(), threshold: z.number(),
weights: monitoringWeightsSchema
}).passthrough().nullable().optional(),
incident: z.object({
incident_id: z.string().uuid(), status: z.enum(["active", "resolved"]),
action: z.enum(["raised", "ongoing", "resolved"]),
started_at: z.string(), last_observed_at: z.string(), recovery_count: z.number().int()
}).nullable().optional()
}).passthrough(); }).passthrough();
export const scadaReplaySchema = z.object({ export const scadaReplaySchema = z.object({
replay_id: z.string().uuid(), project_id: z.string().uuid(), revision: z.number().int(), replay_id: z.string().uuid(), project_id: z.string().uuid(), revision: z.number().int(),
@@ -39,6 +67,8 @@ export const scadaReplaySchema = z.object({
step_pending: z.boolean().default(false), step_pending: z.boolean().default(false),
monitoring_count: z.number().int().default(0), monitoring_count: z.number().int().default(0),
last_monitoring: monitoringSchema.nullable().default(null), last_monitoring: monitoringSchema.nullable().default(null),
monitoring_config: monitoringConfigSchema.nullable().optional(),
monitoring_calibration: calibrationSchema.nullable().optional(),
delivered_until: z.string().nullable(), processed_until: z.string().nullable(), delivered_until: z.string().nullable(), processed_until: z.string().nullable(),
processed_count: z.number(), anomaly_count: z.number(), processed_count: z.number(), anomaly_count: z.number(),
devices: z.array(z.object({ device_id: z.string(), measurement_unit: z.string(), device_type: z.string() })), devices: z.array(z.object({ device_id: z.string(), measurement_unit: z.string(), device_type: z.string() })),
@@ -49,8 +79,11 @@ const capabilitiesSchema = z.object({
available: z.boolean(), can_write: z.boolean(), reason: z.string().nullable(), current: scadaReplaySchema.nullable(), available: z.boolean(), can_write: z.boolean(), reason: z.string().nullable(), current: scadaReplaySchema.nullable(),
monitoring_interval_seconds: z.number().int().min(60).max(31_536_000).nullable().default(null), monitoring_interval_seconds: z.number().int().min(60).max(31_536_000).nullable().default(null),
burst_monitoring_ready: z.boolean().default(false), burst_monitoring_ready: z.boolean().default(false),
burst_monitoring_reason: z.string().nullable().default(null) burst_monitoring_reason: z.string().nullable().default(null),
monitoring_config: monitoringConfigSchema.nullable().optional()
}); });
export type ScadaMonitoringConfig = z.infer<typeof monitoringConfigSchema>;
export type ScadaMonitoringCalibration = z.infer<typeof calibrationSchema>;
export type ScadaReplay = z.infer<typeof scadaReplaySchema>; export type ScadaReplay = z.infer<typeof scadaReplaySchema>;
export type ScadaReplayRule = z.infer<typeof ruleSchema>; export type ScadaReplayRule = z.infer<typeof ruleSchema>;
export type ScadaReplayEvent = z.infer<typeof eventSchema>; export type ScadaReplayEvent = z.infer<typeof eventSchema>;
@@ -85,18 +118,20 @@ export class ScadaReplayClient {
request_id: crypto.randomUUID(), expected_revision: replay.revision, action request_id: crypto.randomUUID(), expected_revision: replay.revision, action
})); }));
} }
async monitorAt(replay: ScadaReplay, atTime: string) {
return scadaReplaySchema.parse(await this.request(`/${replay.replay_id}/commands`, {
request_id: crypto.randomUUID(), expected_revision: replay.revision,
action: "monitor", at_time: atTime
}));
}
private async request(path: string, body?: unknown, signal?: AbortSignal) { private async request(path: string, body?: unknown, signal?: AbortSignal) {
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id) this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
.catch((error) => { this.projectId = null; throw error; }); .catch((error) => { this.projectId = null; throw error; });
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]); const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
const headers = createWorkbenchHeaders(token, projectId); return requestWorkbenchJson(`/api/v1/scada-replays${path}`, "SCADA 回放", {
if (body !== undefined) headers.set("Content-Type", "application/json"); credentials: { projectId, accessToken: token },
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, { init: { method: body === undefined ? "GET" : "POST", signal,
method: body === undefined ? "GET" : "POST", headers, signal, ...(body === undefined ? {} : { body: JSON.stringify(body) }) }
...(body === undefined ? {} : { body: JSON.stringify(body) })
}); });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "SCADA 回放"));
return payload;
} }
} }
@@ -1,7 +1,6 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const analysisHistoryItemSchema = z.object({ const analysisHistoryItemSchema = z.object({
run_id: z.string().trim().min(1), run_id: z.string().trim().min(1),
@@ -41,7 +40,6 @@ export type WorkbenchAnalysisHistoryPage = {
nextOffset: number | null; nextOffset: number | null;
}; };
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchAnalysisHistory( export async function fetchWorkbenchAnalysisHistory(
options: { options: {
@@ -59,25 +57,13 @@ export async function fetchWorkbenchAnalysisHistory(
if (!Number.isInteger(offset) || offset < 0) { if (!Number.isInteger(offset) || offset < 0) {
throw new Error("分析成果历史偏移量无效"); throw new Error("分析成果历史偏移量无效");
} }
const { project, accessToken } = await findOwnedWorkbenchProject(
options.getAccessToken,
options.signal
);
const search = new URLSearchParams({ const search = new URLSearchParams({
limit: String(limit), limit: String(limit),
offset: String(offset) offset: String(offset)
}); });
const response = await fetch( const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs?${search}`, "分析成果历史请求", {
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`, access: options.getAccessToken, init: { signal: options.signal }
{ });
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal: options.signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析成果历史请求"));
}
const page = analysisHistoryPageSchema.parse(payload); const page = analysisHistoryPageSchema.parse(payload);
return { return {
items: page.items.map((item) => ({ items: page.items.map((item) => ({
@@ -1,7 +1,7 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson"; import type { FeatureCollection, Geometry, Position } from "geojson";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { formatDateTime } from "@/shared/utils/date-time"; import { formatDateTime } from "@/shared/utils/date-time";
import type { import type {
ScheduledConditionRecord, ScheduledConditionRecord,
@@ -10,7 +10,6 @@ import type {
WorkbenchScenario WorkbenchScenario
} from "../types"; } from "../types";
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model"; import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
// Keep legacy analysis values parseable while old servers are rolling forward; // Keep legacy analysis values parseable while old servers are rolling forward;
// they are filtered before the operational workbench model is created. // they are filtered before the operational workbench model is created.
@@ -159,7 +158,6 @@ export type WorkbenchAnalysisProjection = {
simulationOverlay: FeatureCollection<Geometry> | null; simulationOverlay: FeatureCollection<Geometry> | null;
}; };
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot { export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
const snapshot = runtimeSnapshotSchema.parse(payload); const snapshot = runtimeSnapshotSchema.parse(payload);
@@ -264,18 +262,9 @@ export async function fetchWorkbenchAnalysisRun(
): Promise<WorkbenchAnalysisProjection> { ): Promise<WorkbenchAnalysisProjection> {
const normalizedRunId = runId.trim(); const normalizedRunId = runId.trim();
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空"); if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal); const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
const response = await fetch( access: getAccessToken, init: { signal }
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, });
{
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}
);
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "分析运行成果请求"));
}
const analysis = runtimeLatestAnalysisSchema.parse(payload); const analysis = runtimeLatestAnalysisSchema.parse(payload);
if (analysis.run_id !== normalizedRunId) { if (analysis.run_id !== normalizedRunId) {
throw new Error("分析运行成果与请求 ID 不一致"); throw new Error("分析运行成果与请求 ID 不一致");
@@ -1,3 +1,4 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod"; import { z } from "zod";
import type { WorkbenchAccess } from "./request-context"; import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env"; import { env } from "@/shared/config/env";
@@ -63,13 +64,9 @@ export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: WorkbenchAccess, getAccessToken?: WorkbenchAccess,
signal?: AbortSignal signal?: AbortSignal
): Promise<WorkbenchSyncSnapshot> { ): Promise<WorkbenchSyncSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal); const payload = await requestWorkbenchJson("/api/v1/workbench/sync-snapshot", "工作台同步快照请求", {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, { access: getAccessToken, init: { signal }
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
}); });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
const envelope = snapshotEnvelopeSchema.parse(payload); const envelope = snapshotEnvelopeSchema.parse(payload);
return { return {
eventCursor: envelope.event_cursor, eventCursor: envelope.event_cursor,
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
}); });
const taskBase = { const taskBase = {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111", project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222", task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation", task_type: "event_investigation",
@@ -130,6 +131,48 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender }; return { ...props, rerender };
} }
it.each(["failed", "unsupported"] as const)("retries an approved %s action using its current version", async (status) => {
let finish!: () => void;
const onExecute = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
const task: OperationalTask = { ...taskBase,
permissions: { ...taskBase.permissions, can_execute_work_orders: true },
actions: [{ ...taskBase.actions[0], approval_status: "approved", execution_status: status }]
};
const props = taskPanel(task, { onExecute });
const section = screen.getByRole("region", { name: "当前需处理" });
expect(section).toHaveTextContent("待重试");
const retry = within(section).getByRole("button", { name: "重试执行" });
fireEvent.click(retry);
expect(retry).toBeDisabled();
fireEvent.click(retry);
expect(onExecute).toHaveBeenCalledExactlyOnceWith(task.actions[0].action_id, task.actions[0].version);
expect(props.onDecision).not.toHaveBeenCalled();
finish();
await waitFor(() => expect(retry).toBeEnabled());
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
actions: [{ ...task.actions[0], version: 4, execution_status: "submitted" }]
}} />);
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "核对执行回执" })).toBeVisible();
});
it("keeps retry disabled without work-order execution permission", () => {
const props = taskPanel({ ...taskBase, actions: [{ ...taskBase.actions[0],
approval_status: "approved", execution_status: "unsupported"
}] });
const retry = screen.getByRole("button", { name: "重试执行" });
expect(retry).toBeDisabled();
fireEvent.click(retry);
expect(props.onExecute).not.toHaveBeenCalled();
});
it.each(["pending", "rejected"] as const)("does not retry a %s action", (approval) => {
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_execute_work_orders: true },
actions: [{ ...taskBase.actions[0], approval_status: approval, execution_status: "failed" }]
});
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
});
it("keeps fixed question answers in the report and focuses them from a data gap", () => { it("keeps fixed question answers in the report and focuses them from a data gap", () => {
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human", const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id, questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
@@ -1,3 +1,4 @@
import { getReportPresentationState, snapshotRound } from "../utils/report-presentation-state";
import { InvestigationReportView } from "./investigation-report-view"; import { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time"; import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
import { InvestigationTaskReport } from "./investigation-task-report"; import { InvestigationTaskReport } from "./investigation-task-report";
@@ -22,6 +23,7 @@ import { Checkbox } from "@/shared/ui/checkbox";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure"; import { Disclosure } from "@/shared/ui/disclosure";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status"; import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status";
import type { import type {
OperationalTask, OperationalTask,
@@ -32,6 +34,7 @@ import type {
OperationalTaskDataSnapshot OperationalTaskDataSnapshot
} from "../api/operational-task-client"; } from "../api/operational-task-client";
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client"; import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
import { isRetryableInvestigationAction } from "../utils/investigation-presentation";
type ExecutionNode = { type ExecutionNode = {
id: string; id: string;
@@ -108,18 +111,8 @@ export function AgentExecutionPlanPanel({
/>; />;
const plan = task.latest_plan?.plan; const plan = task.latest_plan?.plan;
const report = task.latest_report?.report; const report = task.latest_report?.report;
const planGeneration = task.latest_plan?.execution_generation; const planState = snapshotRound(task.latest_plan?.execution_generation, task.execution_generation);
const planState = planGeneration == null const { round: reportState } = getReportPresentationState(task, task.latest_report);
? "historical"
: planGeneration === task.execution_generation
? "current"
: "previous";
const reportGeneration = task.latest_report?.execution_generation;
const reportState = reportGeneration == null
? "historical"
: reportGeneration === task.execution_generation
? "current"
: "previous";
const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2" const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
? activePlanGraph(plan).visible : null; ? activePlanGraph(plan).visible : null;
const visiblePlanNodes = plan && visiblePlanNodeIds const visiblePlanNodes = plan && visiblePlanNodeIds
@@ -542,7 +535,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select> <select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select>
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} /> <Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} /> <Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} /> <DateTimeField label="预约时间" value={toLocalDateTime(draft.scheduled_at)} onChange={(value) => setDraft({ ...draft, scheduled_at: value ? new Date(value).toISOString() : null })} disabled={submitting !== null} />
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div> <div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
</div> : null} </div> : null}
{action.evidence_refs.length > 0 ? ( {action.evidence_refs.length > 0 ? (
@@ -567,6 +560,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
<div className="mt-3 flex flex-wrap justify-end gap-2"> <div className="mt-3 flex flex-wrap justify-end gap-2">
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null} {action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null} {action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
{isRetryableInvestigationAction(action) ? <Button size="sm" disabled={!canExecute || submitting !== null || stale} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}重试执行</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null} {action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</div> </div>
</div> </div>
@@ -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(<FeatureLocationPanel commands={commands} controllerState={state}
detailFeature={{ id: "PRESSURE-17", layer: "scada", title: "压力监测点", subtitle: "", properties: {} }}
onUseSelectedFeature={onUseSelectedFeature} />);
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(<FeatureLocationPanel {...props} enabled={false} />);
await user.type(screen.getByLabelText("要素 ID(可多个)"), "P-17");
const action = () => screen.getByRole("button", { name: "定位并高亮" });
expect(action()).toBeDisabled();
rerender(<FeatureLocationPanel {...props} enabled />);
expect(action()).toBeEnabled();
rerender(<FeatureLocationPanel {...props} controllerState={{ ...state, pending: true }} enabled />);
expect(screen.getByRole("button", { name: "正在定位要素" })).toBeDisabled();
expect(screen.getByRole("button", { name: "仅高亮" })).toBeDisabled();
expect(screen.getByRole("button", { name: "缩放到要素" })).toBeDisabled();
expect(commands.locateAndHighlight).not.toHaveBeenCalled();
});
});
@@ -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<WorkbenchMapCommands, "highlight" | "zoomToFeature" | "locateAndHighlight" | "clearHighlight">;
controllerState: Pick<WorkbenchMapControllerState, "pending" | "targets" | "targetResult" | "errorCode">;
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<FeatureLocationDraft>({ 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 (
<div className="space-y-3">
<FieldSelect label="业务源" value={sourceId} contentClassName={contentClassName}
onValueChange={(nextSourceId) => updateDraft({ sourceId: nextSourceId as AvailableWaterNetworkSourceId, featureId: "" })}
options={SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => ({ value: layer.id, label: layer.label }))}
/>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label htmlFor={inputId} className="text-xs font-medium text-[var(--ink-muted)]">要素 ID(可多个)</label>
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{featureIds.length} / {MAX_MAP_FEATURE_IDS}</span>
</div>
<Textarea id={inputId} value={featureId} rows={3} placeholder="粘贴要素 ID,支持多个"
onChange={(event) => updateDraft({ sourceId, featureId: event.target.value })} aria-describedby={helpId} aria-invalid={invalidIds}
className="min-h-24 resize-y border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
/>
<p id={helpId} className="text-[11px] leading-5 text-[var(--ink-muted)]">逗号、空格或换行分隔,自动去重。</p>
</div>
{invalidIds ? <p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
<Button type="button" variant="secondary" className="w-full text-xs" disabled={!canUseSelected}
onClick={() => {
if (!detailFeature || !canUseSelected) return;
updateDraft({ sourceId: detailFeature.layer as AvailableWaterNetworkSourceId, featureId: detailFeature.id });
onUseSelectedFeature?.();
}}>
<MousePointer2 aria-hidden="true" />使用当前地图选中
</Button>
<div className="space-y-2 border-t border-[var(--surface-divider)] pt-3">
<Button type="button" className="w-full text-xs" disabled={!canLocate}
loading={controllerState.pending} loadingLabel="正在定位要素"
onClick={() => void commands.locateAndHighlight(target)}>
<LocateFixed aria-hidden="true" />定位并高亮
</Button>
<div className="grid grid-cols-2 gap-2">
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.highlight(target)}>
<Crosshair aria-hidden="true" />仅高亮
</Button>
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.zoomToFeature(target)}>
<ZoomIn aria-hidden="true" />缩放到要素
</Button>
</div>
</div>
{!enabled ? <p role="status" className="text-xs text-[var(--ink-muted)]">等待地图加载后即可定位</p> : null}
{showError && controllerState.errorCode && FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode] ? (
<p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">{FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode]}</p>
) : null}
{result ? <p role="status" className="break-words text-xs leading-5 text-[var(--ink-muted)]">
本次命中 {result.featureIds.length} 个{result.missingIds.length ? `,未找到 ${result.missingIds.length} 个:${result.missingIds.join("、")}` : ",全部找到"}
</p> : null}
<div className="flex items-center justify-between gap-2 border-t border-[var(--surface-divider)] pt-2">
<p className="text-[11px] text-[var(--ink-muted)]">已高亮 {controllerState.targets.length} 个要素</p>
<Button type="button" variant="ghost" className="shrink-0 text-xs" onClick={() => commands.clearHighlight()}>
<Trash2 aria-hidden="true" />清除高亮
</Button>
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react"; import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react"; import { useId, useLayoutEffect, useRef } from "react";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
@@ -8,7 +9,7 @@ import { MAX_PLACE_SEARCH_RESULTS, type PlaceSearchItem } from "../api/place-sea
const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]"; const RESULT_VIEWPORT_CLASS_NAME = "h-72 min-h-0 max-h-72 overflow-y-auto overscroll-contain pr-1 [scrollbar-gutter:stable] [scrollbar-width:thin]";
function PlaceSummary({ item, index, annotation = item.district }: { item: PlaceSearchItem; index?: number; annotation?: string }) { function PlaceSummary({ item, index, annotation = item.district, interactive = false }: { item: PlaceSearchItem; index?: number; annotation?: string; interactive?: boolean }) {
return ( return (
<> <>
{index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]"> {index !== undefined ? <span aria-hidden="true" className="w-5 shrink-0 self-start text-center text-[11px] font-normal leading-5 tabular-nums text-[var(--ink-muted)]">
@@ -16,7 +17,7 @@ function PlaceSummary({ item, index, annotation = item.district }: { item: Place
</span> : null} </span> : null}
<span className="min-w-0 flex-1 space-y-1"> <span className="min-w-0 flex-1 space-y-1">
<span className="flex items-start justify-between gap-2 leading-5"> <span className="flex items-start justify-between gap-2 leading-5">
<span className="min-w-0 flex-1 break-words font-semibold text-[var(--ink)]">{item.name}</span> <span className={cn("min-w-0 flex-1 break-words font-semibold", !interactive && "text-[var(--ink)]")}>{item.name}</span>
<span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span> <span aria-label={index === undefined && item.category ? `地点类型:${item.category}` : undefined} className="max-w-[40%] shrink-0 break-words text-right text-[11px] font-normal text-[var(--ink-muted)]">{annotation}</span>
</span> </span>
<span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span> <span className="block break-words text-[11px] font-normal leading-4 text-[var(--ink-muted)]">{item.address}</span>
@@ -123,21 +124,22 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}> {!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
{results.items.map((item, index) => ( {results.items.map((item, index) => (
<li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0"> <li key={item.id} className="border-b border-[var(--surface-divider)] last:border-b-0">
<Button type="button" variant="ghost" aria-pressed={result?.id === item.id} data-selection-tone="soft" <button type="button" aria-pressed={result?.id === item.id}
aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled} aria-label={`定位 ${item.name} ${item.address}`} disabled={pending || !enabled}
className={cn("h-auto min-h-16 w-full justify-start gap-3 rounded-lg border-0 whitespace-normal px-1 py-3 text-left text-xs focus-visible:ring-inset focus-visible:ring-offset-0 [@media(hover:hover)]:hover:bg-[var(--surface-control)] [@media(hover:hover)]:hover:text-[var(--ink)]", className={cn("flex min-h-16 w-full items-center justify-start gap-3 rounded-xl border border-transparent whitespace-normal px-1 py-3 text-left text-xs text-[var(--ink)]",
result?.id === item.id && "bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] [&_span]:text-[var(--action-blue-hover)] [@media(hover:hover)]:hover:bg-[var(--action-selection-soft)]")} mapOptionInteractionVariants({ state: result?.id === item.id ? "selected" : "default" }),
result?.id === item.id && "border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)]")}
onClick={(event) => { onClick={(event) => {
geocoding.select(item.id); geocoding.select(item.id);
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus()); if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
}}> }}>
<PlaceSummary item={item} index={index} /> <PlaceSummary item={item} index={index} interactive />
</Button> </button>
</li> </li>
))} ))}
</ul> : null} </ul> : null}
{result && selectionCollapsed ? ( {result && selectionCollapsed ? (
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}> <section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden", mapResult && "h-auto shrink-0")}>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]"> <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
<div className={mapResult ? "space-y-2" : "space-y-3"}> <div className={mapResult ? "space-y-2" : "space-y-3"}>
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}> <div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
@@ -0,0 +1,94 @@
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Check, FileText, X } from "lucide-react";
import { AgentChartView } from "@/features/agent";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { InvestigationChartGroup as ChartGroup } from "../utils/investigation-chart-groups";
export function InvestigationChartGroup({ group, reviewing, onCloseChart, references, tracked = true, tile = false }: {
group: ChartGroup;
tracked?: boolean;
tile?: boolean;
reviewing: boolean;
onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode;
}) {
const card = useRef<HTMLDivElement>(null);
const [plotHeight, setPlotHeight] = useState(220);
useLayoutEffect(() => {
if (tile) return;
const viewport = card.current?.closest<HTMLElement>("[data-investigation-content]");
if (!viewport || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotHeight(Math.max(120, Math.min(220, viewport.clientHeight - 24)));
resize();
const observer = new ResizeObserver(resize);
observer.observe(viewport);
return () => observer.disconnect();
}, [tile]);
const [selectedId, setSelectedId] = useState(group.items[0].command.chart_id);
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
const multiple = group.items.length > 1;
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
<span>监测对象</span><span className="tabular-nums">{group.items.length} 个</span>
</div>
<div className={compact ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-2 @min-[560px]:grid-cols-1"}>
{group.items.map((item) => <Button key={item.command.chart_id} type="button" variant="outline" size="sm"
aria-label={`切换对象 ${item.label}`} aria-pressed={selected.command.chart_id === item.command.chart_id}
className={`h-auto ${compact ? "min-h-8 gap-1.5 px-2 py-1" : "min-h-10 gap-2 px-3 py-2"} min-w-0 justify-start whitespace-normal text-left font-normal after:inset-0 aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-accent-foreground`}
onClick={() => setSelectedId(item.command.chart_id)}>
<span className={`size-2 shrink-0 rounded-full ${selected.command.chart_id === item.command.chart_id ? "bg-primary" : "bg-muted-foreground/50"}`} aria-hidden />
<span className="min-w-0 flex-1 break-words tabular-nums">{item.label}</span>
{selected.command.chart_id === item.command.chart_id ? <Check size={14} aria-hidden /> : null}
</Button>)}
</div>
</aside>;
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
<SelectContent className="workbench-shadcn-surface z-[90]">{group.items.map((item) => <SelectItem key={item.command.chart_id} value={item.command.chart_id}>{item.label}</SelectItem>)}</SelectContent>
</Select>
</div> : null}
<div className={`grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1 gap-2 ${multiple ? "@min-[420px]:grid-cols-[minmax(0,1fr)_128px]" : ""}`}>
<div className="flex min-h-0 min-w-0 flex-col"><AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded tile /></div>
{multiple ? objectButtons(true) : null}
</div>
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
<AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded={expanded} tile={false} plotHeight={plotHeight} />
</div>
{multiple ? objectButtons() : null}
</div>
{selected.command.evidence_refs.length ? <div className={expanded ? "max-h-[20%] shrink-0 overflow-auto" : undefined}>{references(selected.command.evidence_refs)}</div> : null}
</div>;
return <Card ref={card} data-investigation-chart={tracked ? group.items[0].command.chart_id : undefined} data-chart-group-tile={tile || undefined}
role="group" aria-label={title} className={`relative min-w-0 border-border shadow-none ${tile ? "flex h-full min-h-0 flex-col overflow-hidden" : ""}`}>
{tracked ? group.items.slice(1).map((item) => <span key={item.command.chart_id} data-investigation-chart={item.command.chart_id} className="pointer-events-none absolute inset-0" />) : null}
<CardHeader className={`flex-row items-start justify-between gap-2 space-y-0 ${tile ? "shrink-0 px-2 py-1.5" : "p-3 pb-2"}`}>
<div className="min-w-0">
<h3 className={`break-words text-xs font-semibold [overflow-wrap:anywhere] ${tile ? "leading-4" : "leading-5"}`}>{title}</h3>
{!tile ? <p className="mt-1 text-[11px] text-muted-foreground">{multiple ? `${group.items.length} 个对象 · 点击切换 · ` : ""}{selected.command.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : selected.command.chart.chart_type === "bar" ? "柱状图" : "折线图"} · {selected.command.chart.x_data.length} 个{selected.command.chart.chart_type === "bar" ? "类别" : "时间点"}</p> : null}
</div>
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
</Popover> : null}
<FloatingWindow title={title} fillContent>{() => content(true)}</FloatingWindow>
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
</div>
</CardHeader>
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
{content(tile, tile)}
</CardContent>
</Card>;
}
@@ -0,0 +1,118 @@
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { DragDropProvider, DragOverlay, type DragEndEvent } from "@dnd-kit/react";
import { Columns2, GripVertical, LayoutGrid, LocateFixed, RotateCcw, Square } from "lucide-react";
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
import { useChartWindowLayout } from "../hooks/use-chart-window-layout";
import type { InvestigationChartGroup } from "../utils/investigation-chart-groups";
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle, reviewing, onCloseChart, references }: {
groups: InvestigationChartGroup[];
blocks: AnswerUiBlock[];
/** Transient live intent, never derived from restored chart data or history. */
autoOpen: boolean;
taskTitle: string;
reviewing: boolean;
onCloseChart: (id: string) => void;
references: (ids: string[]) => ReactNode;
}) {
const entries = useMemo<ManagedChart[]>(() => [
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
], [groups, blocks]);
const titles = useMemo(() => new Map(entries.map(({ id, title }) => [id, title])), [entries]);
const arrangement = useChartWindowLayout(entries.map(({ id }) => id));
const { layout, slots, columns } = arrangement;
const scrollRef = useRef<HTMLDivElement>(null);
const [located, setLocated] = useState<{ id: string } | null>(null);
const [announcement, setAnnouncement] = useState("");
const cards = useRef(new Map<string, HTMLElement>());
const pendingLocation = useRef<string | null>(null);
const helpId = useId();
useEffect(() => {
if (!located) return;
const timer = window.setTimeout(() => setLocated(null), 1600);
return () => window.clearTimeout(timer);
}, [located]);
const move = (id: string, target: number) => {
arrangement.move(id, target);
setAnnouncement(`${titles.get(id)},已移至位置 ${target + 1}`);
};
const drop = (event: DragEndEvent) => {
const { source, target } = event.operation;
if (!event.canceled && source && target && typeof target.data.slot === "number") move(String(source.id), target.data.slot);
};
const count = entries.length;
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
initialSize={{ width: 1120, height: 760 }} fillContent
// A session gets one automatic request. Updates never revoke a user's dismissal.
openRequestKey={autoOpen ? "live-charts" : undefined}>
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
<div className="shrink-0 space-y-2 border-b border-border pb-2">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
</div>
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
<Select value="" onValueChange={(id) => { setLocated({ id }); pendingLocation.current = id; }} disabled={!count}>
<SelectTrigger aria-label="快速定位图表" className="h-8 w-auto min-w-0 max-w-72 flex-[1_1_160px] gap-2 border-transparent bg-transparent px-2 text-xs shadow-none [--field-focus-border:transparent] hover:bg-background/70 focus-visible:ring-1 focus-visible:ring-border [&>span]:flex-1 [&>span]:truncate [&>span]:text-left"><LocateFixed size={14} className="shrink-0" aria-hidden /><SelectValue placeholder="快速定位图表" /></SelectTrigger>
<SelectContent position="popper" className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
onCloseAutoFocus={(event) => {
const id = pendingLocation.current;
pendingLocation.current = null;
const card = id ? cards.current.get(id) : undefined;
if (card) {
event.preventDefault();
card.scrollIntoView({ block: "start", inline: "nearest", behavior: "auto" });
card.focus({ preventScroll: true });
}
}}>
{slots.map((id, index) => id ? <SelectItem key={id} value={id} className="whitespace-normal text-xs [&>span:last-child]:break-words">位置 {index + 1} · {titles.get(id)}</SelectItem> : null)}
</SelectContent>
</Select>
<div className="ml-auto flex shrink-0 items-center gap-1">
<div role="group" aria-label="图表排列方式" className="flex items-center gap-1">
{layouts.map(({ id, label, icon: Icon }) => <Button key={id} type="button" variant={layout === id ? "secondary" : "ghost"} size="icon-sm"
aria-label={label} title={label} aria-pressed={layout === id} onClick={() => arrangement.setLayout(id)}><Icon size={14} aria-hidden /></Button>)}
</div>
<span className="inline-flex border-l border-border pl-1">
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs" aria-label="重置图表排列" disabled={!count}
onClick={() => { arrangement.reset(); scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); setAnnouncement("已恢复默认图表位置"); }}><RotateCcw size={14} aria-hidden />重置</Button>
</span>
</div>
</div>
</div>
<p id={helpId} className="sr-only">拖动整条位置栏到目标位置可交换图表。向下滚动查看全部图表,也可快速定位。</p>
<p role="status" aria-live="polite" className="sr-only">{announcement}</p>
<DragDropProvider onDragEnd={drop}>
<div ref={scrollRef} tabIndex={0} aria-label="图表滚动区" className="min-h-0 flex-1 overflow-auto overscroll-contain p-0.5" style={{ containerType: "size", scrollbarGutter: "stable" }} data-chart-manager-scroll="">
{count ? <div data-chart-layout={layout} className={`isolate grid min-h-full min-w-0 content-start gap-3 ${layout === "four" ? "grid-cols-2 auto-rows-[max(256px,calc((100cqh-12px)/2))]" : layout === "two" ? "grid-cols-2 auto-rows-[max(256px,100cqh)]" : "grid-cols-1 auto-rows-[max(256px,100cqh)]"}`}>
{slots.map((id, index) => <ChartDropSlot key={index} index={index} columns={columns} occupied={Boolean(id)} />)}
{entries.map((entry) => {
const index = slots.indexOf(entry.id);
return <ManagedChartSlot key={entry.id} id={entry.id} title={entry.title} index={index} columns={columns}
located={located?.id === entry.id} helpId={helpId}
register={(id, node) => { if (node) cards.current.set(id, node); else cards.current.delete(id); }}>
{entry.group ? <ChartGroup group={entry.group} tile tracked={false} reviewing={reviewing} onCloseChart={onCloseChart} references={references} /> : <AnswerUiView block={entry.block} chartPresentation="tile" disabled={reviewing} />}
</ManagedChartSlot>;
})}
</div> : <p role="status" className="py-8 text-center text-sm text-muted-foreground">当前没有图表,新的图表将在这里更新。</p>}
</div>
<DragOverlay dropAnimation={null} style={{ position: "absolute", zIndex: 100 }}>
{(source) => <div data-chart-drag-preview="" className="workbench-shadcn-surface flex max-w-80 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground shadow-xs"><GripVertical size={14} className="shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 break-words">{String(source.data.title)}</span></div>}
</DragOverlay>
</DragDropProvider>
</section>}
</FloatingWindow>
</span>;
}
@@ -0,0 +1,44 @@
import { useDraggable, useDroppable } from "@dnd-kit/react";
import { pointerIntersection } from "@dnd-kit/collision";
import { GripVertical } from "lucide-react";
import type { CSSProperties, ReactNode } from "react";
function chartSlotPosition(index: number, columns: number): CSSProperties {
return { gridRow: Math.floor(index / columns) + 1, gridColumn: index % columns + 1 };
}
export function ChartDropSlot({ index, columns, occupied }: { index: number; columns: number; occupied: boolean }) {
const { ref, isDropTarget } = useDroppable({ id: `slot:${index}`, type: "slot", accept: "chart", data: { slot: index }, collisionDetector: pointerIntersection });
return <div ref={ref} data-chart-slot={index} style={chartSlotPosition(index, columns)}
className={`pointer-events-none relative flex min-h-0 min-w-0 items-center justify-center rounded-xl border border-dashed ${isDropTarget ? "z-10 border-primary/40 bg-primary/5 ring-1 ring-primary/15 ring-inset" : occupied ? "border-transparent" : "border-border bg-muted/20"}`}>
{!occupied ? <p className="text-center text-xs leading-6 text-muted-foreground"><span className="block tabular-nums">位置 {index + 1}</span>拖动图表到这里</p> : null}
</div>;
}
/** The chart stays under the same grid parent when its CSS position changes. */
export function ManagedChartSlot({ id, title, index, columns, located, helpId, register, children }: {
id: string;
title: string;
index: number;
columns: number;
located: boolean;
helpId: string;
register: (id: string, element: HTMLElement | null) => void;
children: ReactNode;
}) {
const { ref, handleRef, isDragSource } = useDraggable({ id, type: "chart", data: { title } });
return <article ref={(node) => { ref(node); register(id, node); }} tabIndex={-1}
data-managed-chart={id} data-chart-title={title} data-slot-index={index} data-chart-located={located} data-chart-dragging={isDragSource}
aria-label={`图表窗口 ${title}`} style={chartSlotPosition(index, columns)}
className="group/chart-slot relative z-0 flex min-h-0 min-w-0 flex-col rounded-xl outline-hidden">
<div className={`flex h-9 shrink-0 items-center rounded-lg ${located ? "bg-primary/10" : ""}`}>
<button ref={handleRef} type="button" data-slot="button" className={`flex h-full w-full touch-none select-none items-center justify-start gap-1 rounded-lg border border-transparent px-2 text-xs font-normal outline-hidden focus-visible:outline-1 focus-visible:outline-ring focus-visible:-outline-offset-1 active:cursor-grabbing ${isDragSource ? "cursor-grabbing bg-muted text-foreground" : "cursor-grab bg-transparent text-muted-foreground [@media(hover:hover)]:hover:bg-muted/70 [@media(hover:hover)]:hover:text-foreground"}`}
aria-label={`拖动图表 ${title}`} aria-describedby={helpId}>
<GripVertical size={14} aria-hidden /><span className="tabular-nums">位置 {index + 1}</span>
{located ? <span aria-hidden="true" className="ml-auto shrink-0 text-xs font-medium text-primary">已定位</span> : null}
</button>
</div>
<div className="min-h-0 min-w-0 flex-1">{children}</div>
<div aria-hidden="true" data-chart-hover-highlight="" className={`pointer-events-none absolute inset-0 z-10 rounded-xl bg-primary/[0.04] ${isDragSource ? "opacity-100" : "opacity-0 [@media(hover:hover)]:group-hover/chart-slot:opacity-100"}`} />
</article>;
}
@@ -1,3 +1,4 @@
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview"; import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
import { cleanup, render, screen } from "@testing-library/react"; import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
@@ -18,8 +19,7 @@ describe("investigation Dev case selection", () => {
mapReady mapReady
charts={0} charts={0}
overlay={null} overlay={null}
activity={[]} onSample={(id = "combined") => createInvestigationDevCase(id)}
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
onPreview={onPreview} onPreview={onPreview}
onClear={vi.fn()} onClear={vi.fn()}
/> />
@@ -29,7 +29,7 @@ describe("investigation Dev case selection", () => {
await user.click(cases); await user.click(cases);
await user.click(screen.getByRole("option", { name: "候选管段对比" })); await user.click(screen.getByRole("option", { name: "候选管段对比" }));
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain( expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
"DEMO-PIPE-01" INVESTIGATION_DEV_NETWORK.pipeIds[0]
); );
const execute = screen.getByRole("button", { name: "执行本地预览" }); const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled(); expect(execute).toBeEnabled();
@@ -39,10 +39,10 @@ describe("investigation Dev case selection", () => {
expect.any(String) expect.any(String)
); );
expect(onPreview).toHaveBeenCalledWith( expect(onPreview).toHaveBeenCalledWith(
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])), parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table")),
"候选管段对比" "候选管段对比"
); );
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板"); expect(screen.getByRole("status")).toHaveTextContent("本地预览完成");
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument(); expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "载入合成样例" })); await user.click(screen.getByRole("button", { name: "载入合成样例" }));
expect(cases).toHaveTextContent("候选管段对比"); expect(cases).toHaveTextContent("候选管段对比");
@@ -57,7 +57,6 @@ describe("investigation Dev case selection", () => {
mapReady={false} mapReady={false}
charts={0} charts={0}
overlay={null} overlay={null}
activity={[]}
onSample={vi.fn()} onSample={vi.fn()}
onPreview={onPreview} onPreview={onPreview}
onClear={vi.fn()} onClear={vi.fn()}
@@ -65,7 +64,7 @@ describe("investigation Dev case selection", () => {
); );
const input = screen.getByLabelText("调查展示 JSONL"); const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input); await user.click(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1])); await user.paste(createInvestigationDevCase("data_only"));
await user.click(screen.getByRole("button", { name: "校验 JSONL" })); await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
const execute = screen.getByRole("button", { name: "执行本地预览" }); const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled(); expect(execute).toBeEnabled();
@@ -75,7 +74,7 @@ describe("investigation Dev case selection", () => {
expect.any(String) expect.any(String)
); );
await user.clear(input); await user.clear(input);
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1])); await user.paste(createInvestigationDevCase("impact_area"));
await user.click(screen.getByRole("button", { name: "校验 JSONL" })); await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
expect(execute).toBeEnabled(); expect(execute).toBeEnabled();
await user.click(execute); await user.click(execute);
@@ -93,15 +92,14 @@ describe("investigation Dev case selection", () => {
mapReady mapReady
charts={0} charts={0}
overlay={null} overlay={null}
activity={[]} onSample={(caseId = "combined") => createInvestigationDevCase(caseId)}
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
onPreview={onPreview} onPreview={onPreview}
onClear={vi.fn()} onClear={vi.fn()}
/> />
); );
const input = screen.getByLabelText("调查展示 JSONL"); const input = screen.getByLabelText("调查展示 JSONL");
await user.click(input); await user.click(input);
await user.paste(createInvestigationDevCase(id, [121.48, 31.1])); await user.paste(createInvestigationDevCase(id));
const execute = screen.getByRole("button", { name: "执行本地预览" }); const execute = screen.getByRole("button", { name: "执行本地预览" });
expect(execute).toBeEnabled(); expect(execute).toBeEnabled();
await user.click(execute); await user.click(execute);
@@ -111,7 +109,7 @@ describe("investigation Dev case selection", () => {
expect(onPreview).not.toHaveBeenCalled(); expect(onPreview).not.toHaveBeenCalled();
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument(); expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
await user.clear(input); await user.clear(input);
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1])); await user.paste(createInvestigationDevCase("data_only"));
await user.click(execute); await user.click(execute);
expect(onPreview).toHaveBeenCalledWith( expect(onPreview).toHaveBeenCalledWith(
expect.objectContaining({ commands: [] }), expect.objectContaining({ commands: [] }),
@@ -10,12 +10,10 @@ import {
} from "../utils/investigation-dev-cases"; } from "../utils/investigation-dev-cases";
import type { OperationalTask } from "../api/operational-task-client"; import type { OperationalTask } from "../api/operational-task-client";
import { import {
investigationActionLabels,
investigationReportJsonl, investigationReportJsonl,
investigationReportUiSummary, investigationReportUiSummary,
parseInvestigationDevJsonl, parseInvestigationDevJsonl,
type InvestigationDevPreview, type InvestigationDevPreview
type InvestigationPresentationActivity
} from "../utils/investigation-dev-preview"; } from "../utils/investigation-dev-preview";
export type InvestigationDevToolsProps = { export type InvestigationDevToolsProps = {
@@ -23,7 +21,6 @@ export type InvestigationDevToolsProps = {
mapReady: boolean; mapReady: boolean;
charts: number; charts: number;
overlay: string | null; overlay: string | null;
activity: InvestigationPresentationActivity[];
onSample: (caseId?: InvestigationDevCaseId) => string; onSample: (caseId?: InvestigationDevCaseId) => string;
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>; onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
onClear: () => void; onClear: () => void;
@@ -34,7 +31,6 @@ export function InvestigationDevTools({
mapReady, mapReady,
charts, charts,
overlay, overlay,
activity,
onSample, onSample,
onPreview, onPreview,
onClear onClear
@@ -43,11 +39,9 @@ export function InvestigationDevTools({
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>(); const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null); const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [running, setRunning] = useState(false); const [running, setRunning] = useState(false);
const [selectedRecord, setSelectedRecord] = useState(0);
const previewActive = useRef(false); const previewActive = useRef(false);
const inputId = useId(); const inputId = useId();
const summary = useMemo(() => investigationReportUiSummary(task), [task]); const summary = useMemo(() => investigationReportUiSummary(task), [task]);
@@ -56,10 +50,8 @@ export function InvestigationDevTools({
setInput(value); setInput(value);
setCaseId(nextCaseId); setCaseId(nextCaseId);
setChecked(null); setChecked(null);
setPreview(null);
setMessage(null); setMessage(null);
setError(null); setError(null);
setSelectedRecord(0);
} }
function clearLoadedPreview() { function clearLoadedPreview() {
if (!previewActive.current) return; if (!previewActive.current) return;
@@ -100,7 +92,6 @@ export function InvestigationDevTools({
} }
setRunning(true); setRunning(true);
setError(null); setError(null);
setPreview(parsed);
previewActive.current = true; previewActive.current = true;
try { try {
await onPreview(parsed, selectedCase?.label ?? "JSONL"); await onPreview(parsed, selectedCase?.label ?? "JSONL");
@@ -174,7 +165,7 @@ export function InvestigationDevTools({
<div id="investigation-dev-lab" className="min-w-0 space-y-3"> <div id="investigation-dev-lab" className="min-w-0 space-y-3">
<h3 className="text-xs font-semibold">01 准备数据</h3> <h3 className="text-xs font-semibold">01 准备数据</h3>
<p className="text-[11px] leading-5 text-muted-foreground"> <p className="text-[11px] leading-5 text-muted-foreground">
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。 仅在当前浏览器预览,不写入任务或触发调查。空间位置与要素 ID 以 tjwater_v2 为基准;读数与方案为合成样例,不代表现场事实。
</p> </p>
<FieldSelect <FieldSelect
label="JSONL 测试案例" label="JSONL 测试案例"
@@ -197,7 +188,7 @@ export function InvestigationDevTools({
</p> </p>
) : null} ) : null}
<p className="text-[11px] leading-5 text-muted-foreground"> <p className="text-[11px] leading-5 text-muted-foreground">
切换即载入,执行时自动校验;已执行的本地预览会清除。 切换即载入,执行时自动校验;已执行的本地预览会清除。定位和高亮请使用“地图调试”页签的业务源与要素 ID。
</p> </p>
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Button <Button
@@ -296,7 +287,6 @@ export function InvestigationDevTools({
onClick={() => { onClick={() => {
onClear(); onClear();
previewActive.current = false; previewActive.current = false;
setPreview(null);
setMessage("已清除当前浏览器的临时展示。"); setMessage("已清除当前浏览器的临时展示。");
setError(null); setError(null);
}} }}
@@ -319,92 +309,8 @@ export function InvestigationDevTools({
{error} {error}
</p> </p>
) : null} ) : null}
{preview ? (
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
<p className="text-xs text-muted-foreground">
业务结果已在展示面板打开,可切换动作历史回看。
</p>
<Disclosure label="原始数据检查">
{preview.records.length ? (
<div className="max-h-48 overflow-auto">
<table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
<thead>
<tr className="border-b">
<th className="py-1 pr-3 font-medium">类型</th>
<th className="py-1 pr-3 font-medium">对象</th>
<th className="py-1 pr-3 font-medium">来源</th>
<th className="py-1 text-right font-medium">证据</th>
</tr>
</thead>
<tbody>
{preview.records.map((record, index) => (
<tr key={index} className="border-b last:border-0">
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
<td className="py-1.5 pr-3">
<button
type="button"
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
aria-label={`查看第 ${index + 1} 条调查数据`}
aria-pressed={selectedRecord === index}
onClick={() => setSelectedRecord(index)}
>
{String(
record.element_id ??
record.device_id ??
record.action_id ??
record.item ??
`记录 ${index + 1}`
)}
</button>
</td>
<td className="py-1.5 pr-3">{String(record.origin)}</td>
<td className="py-1.5 text-right tabular-nums">
{(record.source_evidence_refs as string[]).length}
</td>
</tr>
))}
</tbody>
</table>
<pre
aria-label="调查数据详情"
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
>
{JSON.stringify(preview.records[selectedRecord], null, 2)}
</pre>
</div> </div>
) : null} ) : null}
</Disclosure>
</section>
) : null}
</div>
) : null}
{activity.length ? (
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">最近展示动作</h3>
<ol
aria-label="动作识别记录"
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
>
{activity.slice(-3).map((item) => (
<li key={`${item.source}-${item.id}`} className="break-words">
<span className="text-muted-foreground">
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
</span>
{investigationActionLabels[item.action]} ·{" "}
{item.status === "completed"
? "已应用"
: item.status === "error"
? "未完成"
: "展示中"}
{item.message ? (
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
) : null}
</li>
))}
</ol>
</section>
) : null}
</div> </div>
); );
} }
@@ -1,7 +1,7 @@
import { useId, useState } from "react"; import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react"; import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent"; import type { AnswerUiBlock } from "@/features/agent";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card"; import { Card, CardContent, CardHeader } from "@/shared/ui/card";
@@ -18,6 +18,9 @@ import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/investigation-presentation"; import type { InvestigationOverlay } from "../api/investigation-presentation";
import type { InvestigationChart } from "../hooks/use-investigation-presentation"; import type { InvestigationChart } from "../hooks/use-investigation-presentation";
import { InvestigationChartManager } from "./investigation-chart-manager";
import { InvestigationChartGroup } from "./investigation-chart-group";
import { groupInvestigationCharts } from "../utils/investigation-chart-groups";
import { PresentationHistorySwitcher } from "./presentation-history-switcher"; import { PresentationHistorySwitcher } from "./presentation-history-switcher";
import { PresentationBusinessResults } from "./presentation-business-results"; import { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history"; import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -34,6 +37,8 @@ type Props = {
task: OperationalTask | null; task: OperationalTask | null;
localPreview?: boolean; localPreview?: boolean;
charts: InvestigationChart[]; charts: InvestigationChart[];
chartWindowAutoOpen?: boolean;
chartWindowScope?: string;
overlay: InvestigationOverlay | null; overlay: InvestigationOverlay | null;
cameraPaused: boolean; cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error"; status: "connecting" | "live" | "history" | "disconnected" | "error";
@@ -66,6 +71,8 @@ export function InvestigationPresentationPanel({
onLocateTargets, onLocateTargets,
localPreview = false, localPreview = false,
charts, charts,
chartWindowAutoOpen = false,
chartWindowScope,
overlay, overlay,
cameraPaused, cameraPaused,
status, status,
@@ -78,6 +85,7 @@ export function InvestigationPresentationPanel({
onFitOverlay, onFitOverlay,
onToggleLayer onToggleLayer
}: Props) { }: Props) {
const chartGroups = groupInvestigationCharts(charts);
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [keyboardAction, setKeyboardAction] = useState(false); const [keyboardAction, setKeyboardAction] = useState(false);
const reducedMotion = useReducedMotion(); const reducedMotion = useReducedMotion();
@@ -104,7 +112,7 @@ export function InvestigationPresentationPanel({
return ( return (
<motion.aside <motion.aside
layout={immediate ? false : "size"} layout={immediate || localPreview ? false : "size"}
{...presentationFade(immediate)} {...presentationFade(immediate)}
onKeyDownCapture={() => setKeyboardAction(true)} onKeyDownCapture={() => setKeyboardAction(true)}
onPointerDownCapture={() => setKeyboardAction(false)} onPointerDownCapture={() => setKeyboardAction(false)}
@@ -112,18 +120,18 @@ export function InvestigationPresentationPanel({
data-testid="investigation-presentation-panel" data-testid="investigation-presentation-panel"
data-presentation-scope={scope} data-presentation-scope={scope}
data-investigation-collapsed={collapsed} data-investigation-collapsed={collapsed}
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`} className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg ${localPreview ? `${collapsed ? "h-28" : "h-[min(480px,55dvh)]"} [&>[data-investigation-content]]:flex-1` : ""}`}
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }} style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
> >
<motion.header <motion.header
layout={immediate ? false : "position"} layout={immediate || localPreview ? false : "position"}
transition={presentationTransition(immediate)} transition={presentationTransition(immediate)}
className="shrink-0 border-b border-border px-3 py-2.5" className={`@container shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
> >
<div className="flex items-start justify-between gap-2"> <div className={localPreview ? "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-x-2 gap-y-1" : "flex items-start justify-between gap-2"}>
<div className="min-w-0 space-y-1"> <div className={localPreview ? "contents" : "min-w-0 space-y-1"}>
<h2 className="text-sm font-semibold leading-6">{title}</h2> <h2 className="text-sm font-semibold leading-6">{title}</h2>
<div className="flex flex-wrap items-center gap-2"> <div className={`flex flex-wrap items-center gap-2 ${localPreview ? "col-span-2 row-start-2" : ""}`}>
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium"> <Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
{reviewing {reviewing
? "历史回看" ? "历史回看"
@@ -134,7 +142,7 @@ export function InvestigationPresentationPanel({
: statusLabels[status]} : statusLabels[status]}
</Badge> </Badge>
<span className="text-[11px] text-muted-foreground tabular-nums"> <span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length} 图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span> </span>
{reviewing && pending > 0 ? ( {reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground"> <span role="status" className="text-[11px] text-muted-foreground">
@@ -143,26 +151,27 @@ export function InvestigationPresentationPanel({
) : null} ) : null}
</div> </div>
</div> </div>
<div className={`flex shrink-0 items-center gap-1 ${localPreview ? "col-start-2 row-start-1" : ""}`}>
<InvestigationChartManager key={chartWindowScope ?? JSON.stringify([scope, task?.task_id, task?.execution_generation])}
groups={chartGroups} blocks={blocks} autoOpen={chartWindowAutoOpen} taskTitle={task?.title ?? title}
reviewing={reviewing} onCloseChart={onCloseChart} references={references} />
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
className="shrink-0" className={`shrink-0 ${localPreview ? "col-start-2 row-start-1" : ""}`}
aria-label={collapsed ? "展开展示" : "收起展示"} aria-label={collapsed ? "展开展示" : "收起展示"}
title={collapsed ? "展开展示" : "收起展示"} title={collapsed ? "展开展示" : "收起展示"}
aria-expanded={!collapsed} aria-expanded={!collapsed}
aria-controls={contentId} aria-controls={contentId}
onClick={() => setCollapsed(!collapsed)} onClick={() => setCollapsed(!collapsed)}
> >
<motion.span <motion.span className="inline-flex" animate={{ rotate: collapsed ? 180 : 0 }} transition={presentationTransition(immediate)}>
className="inline-flex"
animate={{ rotate: collapsed ? 180 : 0 }}
transition={presentationTransition(immediate)}
>
<ChevronDown size={16} aria-hidden /> <ChevronDown size={16} aria-hidden />
</motion.span> </motion.span>
</Button> </Button>
</div> </div>
</div>
<p <p
className={ className={
history?.entries.length history?.entries.length
@@ -180,12 +189,12 @@ export function InvestigationPresentationPanel({
? "已载入所选调查保存的展示结果" ? "已载入所选调查保存的展示结果"
: "跟随所选调查,展示地图操作与结果"} : "跟随所选调查,展示地图操作与结果"}
</p> </p>
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null} {history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null}
</motion.header> </motion.header>
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence initial={false} mode="popLayout">
{!collapsed ? ( {!collapsed ? (
<PresentationViewport <PresentationViewport
key={history?.selectedId ?? "current"} key="presentation-content"
immediate={immediate} immediate={immediate}
id={contentId} id={contentId}
> >
@@ -243,7 +252,7 @@ export function InvestigationPresentationPanel({
</div> </div>
</section> </section>
) : null} ) : null}
<AnimatePresence initial={false} mode="popLayout"> <AnimatePresence key={chartWindowScope} initial={false} mode="popLayout">
{overlay ? ( {overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}> <PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none"> <Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
@@ -292,42 +301,10 @@ export function InvestigationPresentationPanel({
</Card> </Card>
</PresentationItem> </PresentationItem>
) : null} ) : null}
{charts.map((item) => ( {chartGroups.map((group) => (
<PresentationItem key={item.chart_id} immediate={immediate}> <PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
<Card <InvestigationChartGroup group={group} reviewing={reviewing ?? false}
data-investigation-chart={item.chart_id} onCloseChart={onCloseChart} references={references} />
role="group"
aria-label={item.chart.title}
className="border-border shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
<div className="min-w-0">
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem> </PresentationItem>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -1,6 +1,7 @@
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent"; import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client"; import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report"; import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
import { formatInvestigationText } from "../utils/investigation-text";
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: { export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
task: Pick<OperationalTask, "evidence">; task: Pick<OperationalTask, "evidence">;
@@ -56,7 +57,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
{block ? <AnswerUiView block={block} submitMode="draft" {block ? <AnswerUiView block={block} submitMode="draft"
onSubmit={(text) => quote({ title: "补充资料", text })} onSubmit={(text) => quote({ title: "补充资料", text })}
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} /> onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>} : <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{formatInvestigationText(item.block.fallback_text)}</p>}
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground"> {remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")} 引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
</p> : null} </p> : null}
@@ -1,3 +1,4 @@
import { getReportPresentationState } from "../utils/report-presentation-state";
import { FileText } from "lucide-react"; import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status"; import { StatusBadge } from "@/shared/ui/status";
import { formatDateTime } from "@/shared/utils/date-time"; import { formatDateTime } from "@/shared/utils/date-time";
@@ -7,9 +8,7 @@ import type { OperationalTask } from "../api/operational-task-client";
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) { export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
const snapshot = task.latest_report; const snapshot = task.latest_report;
const report = snapshot?.report; const report = snapshot?.report;
const insufficient = report?.outcome === "insufficient_data"; const { insufficient, current, ended } = getReportPresentationState(task, snapshot);
const current = snapshot?.execution_generation === task.execution_generation;
const ended = current && task.lifecycle_status === "completed";
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论" const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败" : task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消" : task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
@@ -1,3 +1,4 @@
import { getReportPresentationState } from "../utils/report-presentation-state";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions"; import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react"; import { History, LoaderCircle, MessageSquare } from "lucide-react";
@@ -18,7 +19,8 @@ import type {
import { import {
investigationDiagram, investigationDiagram,
investigationStatus, investigationStatus,
isPendingInvestigationAction isPendingInvestigationAction,
isRetryableInvestigationAction
} from "../utils/investigation-presentation"; } from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view"; import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card"; import { InvestigationExpandCard } from "./investigation-expand-card";
@@ -178,22 +180,13 @@ export function InvestigationTaskReport({
// A current final report owns its archived graph. During investigation, a // A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior // progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress. // round's final report must never hide the new round's progress.
const currentFinalReport = snapshot?.execution_generation === task.execution_generation const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
&& report?.stage === "final";
const diagram = live const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot) ? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram; : selection.diagram;
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running"); const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
const previousRound = Boolean(
snapshot && snapshot.execution_generation !== task.execution_generation
);
const olderRevision = Boolean(
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
);
const latestAvailable = !live; const latestAvailable = !live;
const insufficient = report?.outcome === "insufficient_data"; const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
const pendingActions = task.actions.filter(isPendingInvestigationAction); const pendingActions = task.actions.filter(isPendingInvestigationAction);
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action)); const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id)); const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
@@ -433,6 +426,8 @@ export function InvestigationTaskReport({
{actionTitle(action)} ·{" "} {actionTitle(action)} ·{" "}
{action.approval_status === "pending" {action.approval_status === "pending"
? "待审核" ? "待审核"
: isRetryableInvestigationAction(action)
? "待重试"
: action.execution_status === "not_submitted" : action.execution_status === "not_submitted"
? "待执行" ? "待执行"
: "待核对回执"} : "待核对回执"}
@@ -138,8 +138,6 @@ function createCommands() {
clearValueLabel: vi.fn(), clearValueLabel: vi.fn(),
applyLayerGroupStyle: vi.fn(), applyLayerGroupStyle: vi.fn(),
resetLayerGroupStyle: vi.fn(), resetLayerGroupStyle: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearScadaAnalysis: vi.fn(),
resetAll: vi.fn() resetAll: vi.fn()
} satisfies WorkbenchMapCommands; } satisfies WorkbenchMapCommands;
} }
@@ -163,7 +161,6 @@ function createControllerState(
invalid: 0 invalid: 0
}, },
styledGroups: [], styledGroups: [],
scadaAnalysis: null,
...patch ...patch
}; };
} }
@@ -1,11 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react"; import { RotateCcw, X } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Checkbox } from "@/shared/ui/checkbox"; import { Checkbox } from "@/shared/ui/checkbox";
import { Disclosure } from "@/shared/ui/disclosure"; import { Disclosure } from "@/shared/ui/disclosure";
import { FieldSelect } from "@/shared/ui/field-select"; import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Textarea } from "@/shared/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { import {
LAYER_GROUP_GEOMETRIES, LAYER_GROUP_GEOMETRIES,
@@ -15,18 +14,20 @@ import {
import { MAP_STYLE_TOKENS } from "../map/map-colors"; import { MAP_STYLE_TOKENS } from "../map/map-colors";
import { getNumericFeatureProperties } from "../map/value-label"; import { getNumericFeatureProperties } from "../map/value-label";
import type { import type {
FeatureBatchTarget,
WorkbenchMapCommands, WorkbenchMapCommands,
WorkbenchMapControllerState WorkbenchMapControllerState
} from "../map/workbench-map-controller"; } from "../map/workbench-map-controller";
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
import { import {
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
SUPPLY_LAYER_CATALOG, SUPPLY_LAYER_CATALOG,
type AvailableWaterNetworkSourceId type AvailableWaterNetworkSourceId
} from "../map/sources"; } from "../map/sources";
import type { DetailFeature } from "../types"; import type { DetailFeature } from "../types";
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools"; import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
import { FeatureLocationPanel } from "./feature-location-panel";
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
import type { ScadaAlertIntensity } from "./scada-alert-atmosphere";
import { NoticeDevTools } from "./notice-dev-tools";
type MapDevPanelProps = { type MapDevPanelProps = {
commands: WorkbenchMapCommands; commands: WorkbenchMapCommands;
@@ -37,6 +38,11 @@ type MapDevPanelProps = {
investigationFollow?: boolean; investigationFollow?: boolean;
onInvestigationFollowChange?: (enabled: boolean) => void; onInvestigationFollowChange?: (enabled: boolean) => void;
investigationTools?: InvestigationDevToolsProps; investigationTools?: InvestigationDevToolsProps;
onPreviewScadaAlert?: () => void;
onEndScadaAlertPreview?: () => void;
scadaAlertPreviewActive?: boolean;
scadaAlertIntensity?: ScadaAlertIntensity;
onScadaAlertIntensityChange?: (intensity: ScadaAlertIntensity) => void;
}; };
const GROUP_LABELS = Object.fromEntries( const GROUP_LABELS = Object.fromEntries(
@@ -93,13 +99,7 @@ const LABEL_PRESETS: Record<
}; };
const ERROR_LABELS: Record<string, string> = { const ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪", ...FEATURE_LOCATION_ERROR_LABELS,
FEATURE_NOT_FOUND: "目标要素不存在",
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
WFS_UNAVAILABLE: "WFS 查询暂不可用",
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
FLOW_UNAVAILABLE: "水流图层初始化失败", FLOW_UNAVAILABLE: "水流图层初始化失败",
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效", INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
INVALID_STYLE_PATCH: "参数不符合受控范围" INVALID_STYLE_PATCH: "参数不符合受控范围"
@@ -113,13 +113,21 @@ export function MapDevPanel({
onClose, onClose,
investigationFollow = true, investigationFollow = true,
onInvestigationFollowChange, onInvestigationFollowChange,
investigationTools investigationTools,
onPreviewScadaAlert,
onEndScadaAlertPreview,
scadaAlertPreviewActive = false,
scadaAlertIntensity = "standard",
onScadaAlertIntensityChange
}: MapDevPanelProps) { }: MapDevPanelProps) {
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map"); const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes"); const [locationDraft, setLocationDraft] = useState<{ sourceId: AvailableWaterNetworkSourceId; featureId: string }>({
const [featureId, setFeatureId] = useState("440681"); sourceId: "pipes", featureId: INVESTIGATION_DEV_NETWORK.pipeIds[0]
});
const { sourceId, featureId } = locationDraft;
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const [property, setProperty] = useState("diameter"); const [property, setProperty] = useState("diameter");
const [flowPipeId, setFlowPipeId] = useState("440681"); const [flowPipeId, setFlowPipeId] = useState<string>(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
const [flowVelocity, setFlowVelocity] = useState("-1"); const [flowVelocity, setFlowVelocity] = useState("-1");
const [precision, setPrecision] = useState(0); const [precision, setPrecision] = useState(0);
const [unit, setUnit] = useState("mm"); const [unit, setUnit] = useState("mm");
@@ -148,10 +156,6 @@ export function MapDevPanel({
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [onClose]); }, [onClose]);
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
const target: FeatureBatchTarget = { sourceId, featureIds };
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
const geometry = LAYER_GROUP_GEOMETRIES[groupId]; const geometry = LAYER_GROUP_GEOMETRIES[groupId];
const activeLabel = controllerState.valueLabels[groupId]; const activeLabel = controllerState.valueLabels[groupId];
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0; const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
@@ -171,14 +175,6 @@ export function MapDevPanel({
function useSelectedFeature() { function useSelectedFeature() {
if (!detailFeature) return; if (!detailFeature) return;
if (
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
detailFeature.layer as AvailableWaterNetworkSourceId
)
) {
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
}
setFeatureId(detailFeature.id);
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id); if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) { if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
const nextGroupId = detailFeature.layer as LayerGroupId; const nextGroupId = detailFeature.layer as LayerGroupId;
@@ -246,7 +242,7 @@ export function MapDevPanel({
return ( return (
<aside <aside
aria-label="地图开发面板" aria-label="地图开发面板"
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex" className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-[80] hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
data-testid="map-dev-panel" data-testid="map-dev-panel"
> >
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3"> <div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
@@ -256,7 +252,7 @@ export function MapDevPanel({
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span> <span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
</h2> </h2>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground"> <p className="mt-1 text-[11px] leading-4 text-muted-foreground">
调查联动验证与地图工具 · 仅当前页面生效 调查、通知与地图工具 · 仅当前页面生效
</p> </p>
</div> </div>
<Button <Button
@@ -272,12 +268,27 @@ export function MapDevPanel({
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col"> <Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-border px-4 py-2"> <div className="shrink-0 border-b border-border px-4 py-2">
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2"> <TabsList aria-label="开发测试类型" className="grid w-full grid-cols-3">
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger> <TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
<TabsTrigger value="notices">通知测试</TabsTrigger>
<TabsTrigger value="map">地图调试</TabsTrigger> <TabsTrigger value="map">地图调试</TabsTrigger>
</TabsList> </TabsList>
</div> </div>
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]"> <TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{onPreviewScadaAlert ? <section aria-label="爆管视觉警告测试" className="mb-4 space-y-2 border-b border-border pb-4">
<h3 className="text-xs font-semibold">全屏爆管视觉警告</h3>
<p className="text-[11px] leading-5 text-muted-foreground">本地持续预览,可模拟事件处理结束。</p>
{onScadaAlertIntensityChange ? <div role="group" aria-label="告警灯效强度" className="flex gap-2">
{([ ["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"] ] as const).map(([value, label]) =>
<Button key={value} type="button" size="sm" variant="outline" aria-pressed={scadaAlertIntensity === value}
onClick={() => onScadaAlertIntensityChange(value)}>{label}</Button>)}
</div> : null}
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={onPreviewScadaAlert}>测试爆管告警效果</Button>
<Button type="button" size="sm" variant="ghost" disabled={!scadaAlertPreviewActive}
onClick={onEndScadaAlertPreview}>模拟事件处理结束</Button>
</div>
</section> : null}
{onInvestigationFollowChange ? ( {onInvestigationFollowChange ? (
<section aria-label="调查联动" className="space-y-4"> <section aria-label="调查联动" className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
@@ -288,6 +299,9 @@ export function MapDevPanel({
</section> </section>
) : null} ) : null}
</TabsContent> </TabsContent>
<TabsContent value="notices" hidden={activeTab !== "notices"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
<NoticeDevTools />
</TabsContent>
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]"> <TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
{controllerState.errorCode ? ( {controllerState.errorCode ? (
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]"> <div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
@@ -297,100 +311,14 @@ export function MapDevPanel({
<PanelSection <PanelSection
title="要素定位" title="要素定位"
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。" description="按业务源与要素 ID 定位,也可从地图工具的定位面板使用。"
initiallyOpen initiallyOpen
status={ status={controllerState.pending ? "执行中" : undefined}
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
}
> >
<div className="space-y-3"> <FeatureLocationPanel commands={commands} controllerState={controllerState}
<FieldSelect detailFeature={detailFeature} value={locationDraft} onValueChange={setLocationDraft}
contentClassName="workbench-shadcn-surface" onUseSelectedFeature={useSelectedFeature} showError={false}
label="业务源" contentClassName="workbench-shadcn-surface" />
value={sourceId}
onValueChange={(value) => {
setSourceId(value as AvailableWaterNetworkSourceId);
setFeatureId("");
}}
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
value: id,
label: GROUP_LABELS[id]
}))}
/>
<Field label="要素 ID(可多个)">
<Textarea
value={featureId}
onChange={(event) => setFeatureId(event.target.value)}
rows={3}
placeholder="例如:440681, 440682&#10;440683"
aria-describedby="map-dev-feature-ids-help"
aria-invalid={invalidIds}
className="resize-y text-xs"
/>
</Field>
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
</p>
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
</div>
<Button
type="button"
disabled={!detailFeature}
onClick={useSelectedFeature}
className="w-full text-xs"
variant="outline"
>
使用当前地图选中
</Button>
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.highlight(target)}
variant="outline"
className="w-full text-xs"
>
<Crosshair size={14} />
仅高亮
</Button>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.zoomToFeature(target)}
variant="outline"
className="w-full text-xs"
>
<ZoomIn size={14} />
缩放到要素
</Button>
</div>
<Button
type="button"
disabled={!canLocate}
onClick={() => void commands.locateAndHighlight(target)}
className="w-full text-xs"
>
定位并高亮
</Button>
<Button
type="button"
onClick={() => commands.clearHighlight()}
className="w-full text-xs"
variant="outline"
>
清除高亮
</Button>
{controllerState.targets.length ? (
<StatusText>
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
</StatusText>
) : null}
{controllerState.targetResult ? (
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
本次命中 {controllerState.targetResult.featureIds.length} 个
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
</StatusText>
) : null}
</PanelSection> </PanelSection>
<PanelSection <PanelSection
@@ -0,0 +1,28 @@
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import { NoticeDevTools } from "./notice-dev-tools";
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("previews every tone, completes loading in place and clears only its own notices", async () => {
render(<NoticeDevTools />);
const user = userEvent.setup();
for (const label of ["信息", "成功", "警告", "错误", "加载"]) {
await user.click(screen.getByRole("button", { name: `测试${label}通知` }));
}
expect(vi.mocked(showMapNotice).mock.calls.map(([options]) => options.tone)).toEqual(["info", "success", "warning", "error", "loading"]);
await user.click(screen.getByRole("button", { name: "模拟加载完成" }));
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "dev-notice:loading", tone: "success" }));
await user.click(screen.getByRole("button", { name: "测试报告通知" }));
const report = vi.mocked(showMapNotice).mock.lastCall![0];
expect(report.actionLabel).toBe("查看报告");
await user.click(screen.getByRole("button", { name: "连续 6 条通知" }));
expect(vi.mocked(showMapNotice).mock.calls.slice(-6).map(([options]) => options.id)).toEqual(
Array.from({ length: 6 }, (_, index) => `dev-notice:batch-${index}`)
);
await user.click(screen.getByRole("button", { name: "清除测试通知" }));
expect(vi.mocked(dismissMapNotice).mock.calls.every(([id]) => typeof id === "string" && id.startsWith("dev-notice:"))).toBe(true);
});
@@ -0,0 +1,87 @@
import { useState } from "react";
import { dismissMapNotice, showMapNotice, type MapNoticeOptions } from "@/features/map/core";
import { Button } from "@/shared/ui/button";
const samples = [
{ key: "info", label: "信息通知", tone: "info", title: "地图视图已更新", message: "已切换到当前项目的管网视图。", lifetime: "3.2 秒后关闭" },
{ key: "success", label: "成功通知", tone: "success", title: "操作已完成", message: "当前管网视图已导出,可以继续分析。", lifetime: "3.2 秒后关闭" },
{ key: "warning", label: "警告通知", tone: "warning", title: "部分监测数据延迟", message: "最新读数尚未到达,请稍后查看。", lifetime: "手动关闭" },
{ key: "error", label: "错误通知", tone: "error", title: "管网数据加载失败", message: "暂时无法连接数据源,请检查连接后重试。", lifetime: "手动关闭" },
{ key: "loading", label: "加载通知", tone: "loading", title: "正在加载管网", message: "正在准备图层和监测数据,请稍候。", lifetime: "等待完成" }
] satisfies (MapNoticeOptions & { key: string; label: string; lifetime: string })[];
const extraKeys = ["report", "task", "long", ...Array.from({ length: 6 }, (_, index) => `batch-${index}`)];
const noticeId = (key: string) => `dev-notice:${key}`;
export function NoticeDevTools() {
const [feedback, setFeedback] = useState("");
const [loading, setLoading] = useState(false);
function showLinked(key: string, target: "报告" | "任务", message?: string) {
showMapNotice({
id: noticeId(key),
tone: target === "报告" ? "success" : "warning",
title: `${target === "报告" ? "调查报告已更新" : "调查任务待处理"} · 测试`,
message: message ?? (target === "报告"
? "城南片区压力异常调查报告已更新,可查看分析结论与处置建议。"
: "新收到一项管网异常调查任务,请查看现场情况与待办事项。"),
actionLabel: `查看${target}`,
onAction: () => setFeedback(`已触发“查看${target}”测试操作,通知仍保留。`)
});
}
function clearSamples() {
[...samples.map((sample) => sample.key), ...extraKeys].forEach((key) => dismissMapNotice(noticeId(key)));
setLoading(false);
setFeedback("已清除测试通知。");
}
return (
<section aria-label="通知测试" className="space-y-5">
<p className="text-xs leading-5 text-muted-foreground">
本地样例,仅当前页面生效。报告和任务入口只记录点击反馈,不创建真实业务数据。
</p>
<div className="space-y-2">
<h3 className="text-xs font-semibold">通知类型</h3>
<div className="grid grid-cols-2 gap-2">
{samples.map(({ key, label, lifetime, ...options }) => (
<Button key={key} type="button" variant="outline" aria-label={`测试${label}`}
className="h-auto min-h-14 flex-col items-start gap-1 px-3 py-2 text-xs"
onClick={() => {
showMapNotice({ ...options, id: noticeId(key), title: `${options.title} · 测试` });
if (key === "loading") setLoading(true);
}}>
<span>{label}</span>
<span className="font-normal text-muted-foreground">{lifetime}</span>
</Button>
))}
</div>
<Button type="button" variant="ghost" size="sm" disabled={!loading}
onClick={() => {
showMapNotice({ id: noticeId("loading"), tone: "success", title: "管网加载完成 · 测试", message: "图层和监测数据已准备就绪。" });
setLoading(false);
}}>模拟加载完成</Button>
</div>
<div className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">报告与任务入口</h3>
<p className="text-xs leading-5 text-muted-foreground">带操作入口的通知保留至手动关闭,点击查看后继续保留。</p>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("report", "报告")}>测试报告通知</Button>
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("task", "任务")}>测试任务通知</Button>
</div>
</div>
<div className="space-y-2 border-t border-border pt-4">
<h3 className="text-xs font-semibold">布局与堆叠</h3>
<div className="flex flex-wrap gap-2">
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("long", "报告",
"城南供水片区夜间压力持续下降,多处监测点出现关联异常。调查报告已整理受影响管段、关键监测读数、候选原因和后续核查建议,请结合现场情况查看并安排处置。关联编号:SCADA-PRESSURE-20261107-REPORT-000128。")}>测试长文本通知</Button>
<Button type="button" size="sm" variant="outline" onClick={() => {
Array.from({ length: 6 }, (_, index) => showLinked(`batch-${index}`, index % 2 ? "任务" : "报告", `第 ${index + 1} 条测试通知。手动关闭当前通知后,可继续查看其余通知。`));
}}>连续 6 条通知</Button>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={clearSamples}>清除测试通知</Button>
{feedback ? <p role="status" className="text-xs leading-5 text-muted-foreground">{feedback}</p> : null}
</section>
);
}
@@ -1,5 +1,6 @@
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent"; import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
import { Card, CardContent, CardHeader } from "@/shared/ui/card"; import { Card, CardContent, CardHeader } from "@/shared/ui/card";
import { FloatingWindow } from "@/shared/ui/floating-window";
import type { InvestigationMapTarget } from "../api/investigation-report"; import type { InvestigationMapTarget } from "../api/investigation-report";
type Props = { type Props = {
@@ -38,10 +39,21 @@ export function PresentationBusinessResults({
))} ))}
{!blocks.length && records.length ? ( {!blocks.length && records.length ? (
<Card className="border-border shadow-none"> <Card className="border-border shadow-none">
<CardHeader className="p-3 pb-2"> <CardHeader className="flex-row items-center justify-between gap-2 space-y-0 p-3 pb-2">
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3> <h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
<FloatingWindow title="调查数据摘要" triggerLabel="放大查看表格 调查数据摘要">{() => <PresentationRecordTable records={records} />}</FloatingWindow>
</CardHeader> </CardHeader>
<CardContent className="overflow-x-auto px-3 pb-3 pt-0"> <CardContent className="overflow-x-auto px-3 pb-3 pt-0">
<PresentationRecordTable records={records} />
</CardContent>
</Card>
) : null}
</section>
);
}
function PresentationRecordTable({ records }: { records: Array<Record<string, unknown>> }) {
return (
<table className="w-full text-left text-[11px] leading-5"> <table className="w-full text-left text-[11px] leading-5">
<caption className="sr-only">调查数据摘要</caption> <caption className="sr-only">调查数据摘要</caption>
<thead> <thead>
@@ -69,9 +81,5 @@ export function PresentationBusinessResults({
))} ))}
</tbody> </tbody>
</table> </table>
</CardContent>
</Card>
) : null}
</section>
); );
} }
@@ -58,3 +58,24 @@ it("keeps the final historical result selectable when current has been cleared",
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ })); await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
expect(onSelect).toHaveBeenCalledWith(history[1].id); expect(onSelect).toHaveBeenCalledWith(history[1].id);
}); });
it("offers playback, speed, progress and keeps the latest snapshot pinned until returning to current", async () => {
const user = userEvent.setup();
const history = entries();
const onSelect = vi.fn();
const playback = { playing: true, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
render(<PresentationHistorySwitcher entries={history} currentEntryId={history[1].id} selectedId={history[1].id} onSelect={onSelect} playback={playback} />);
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/2");
await user.click(screen.getByRole("button", { name: "暂停历史播放" }));
expect(playback.onToggle).toHaveBeenCalledTimes(1);
const progress = screen.getByRole("slider", { name: "历史播放进度" });
progress.focus();
await user.keyboard("{ArrowLeft}");
// jsdom does not implement the browser's native range keyboard default.
await user.click(screen.getByRole("combobox", { name: "历史播放速度" }));
await user.click(screen.getByRole("option", { name: /^2×$/ }));
expect(playback.onSpeedChange).toHaveBeenCalledWith(2);
await user.click(screen.getByRole("button", { name: "回到当前" }));
expect(onSelect).toHaveBeenCalledWith(null);
});
@@ -1,4 +1,4 @@
import { ChevronLeft, ChevronRight, Radio } from "lucide-react"; import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
import type { PresentationHistoryControls } from "../utils/presentation-view-history"; import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -9,18 +9,22 @@ export function PresentationHistorySwitcher({
entries, entries,
currentEntryId, currentEntryId,
selectedId, selectedId,
onSelect onSelect,
playback
}: PresentationHistoryControls) { }: PresentationHistoryControls) {
const currentEntry = entries.find((entry) => entry.id === currentEntryId); const currentEntry = entries.find((entry) => entry.id === currentEntryId);
const available = entries.filter( const available = entries.filter(
(entry) => entry.status !== "running" && entry.id !== currentEntryId (entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
); );
const index = const index =
selectedId === null selectedId === null
? available.length ? available.length
: available.findIndex((entry) => entry.id === selectedId); : available.findIndex((entry) => entry.id === selectedId);
const playable = entries.filter((entry) => entry.status === "completed");
const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId));
return ( return (
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史"> <div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
<div className="flex min-w-0 items-center gap-1">
<Button <Button
type="button" type="button"
variant="ghost" variant="ghost"
@@ -56,7 +60,7 @@ export function PresentationHistorySwitcher({
.slice() .slice()
.reverse() .reverse()
.map((entry, position) => .map((entry, position) =>
entry.id === currentEntryId ? null : ( entry.id === currentEntryId && entry.id !== selectedId ? null : (
<SelectItem <SelectItem
key={entry.id} key={entry.id}
value={String(entry.id)} value={String(entry.id)}
@@ -93,5 +97,29 @@ export function PresentationHistorySwitcher({
</Button> </Button>
) : null} ) : null}
</div> </div>
{playback ? (
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
<Button type="button" variant="outline" size="icon-sm" className="size-8 shrink-0"
aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
disabled={!playable.length} onClick={playback.onToggle}>
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
</Button>
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
min={0} max={Math.max(0, playable.length - 1)} step={1} value={selectedId === null ? Math.max(0, playable.length - 1) : progress}
disabled={playable.length < 2}
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
</span>
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
<SelectTrigger aria-label="历史播放速度" className="h-8 w-[68px] shrink-0 gap-1 px-2 text-xs"><SelectValue /></SelectTrigger>
<SelectContent position="popper" className="workbench-shadcn-surface z-[90]">
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
</SelectContent>
</Select>
</div>
) : null}
</div>
); );
} }
@@ -14,7 +14,7 @@ export function ProjectStartupScreen({ state, error, onRetry }: {
<StartupScreen <StartupScreen
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }} brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"} title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"} description={error ? "项目加载已中断,具体原因如下。" : "正在准备你的项目工作台,完成后自动进入。"}
progressLabel="项目加载进度" progressLabel="项目加载进度"
steps={[ steps={[
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete", { id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
@@ -0,0 +1,40 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { ScadaAlertAtmosphere } from "./scada-alert-atmosphere";
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
const preview: ScadaAlertAtmosphereState = { id: "preview-1", projectId: "project-1", preview: true, alert: null };
afterEach(() => { cleanup(); vi.useRealTimers(); });
it("shows a local preview with a dismiss button and no investigation action", () => {
const onDismiss = vi.fn(), onOpen = vi.fn();
render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.getByRole("status")).toHaveTextContent("爆管告警效果预览");
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
const close = screen.getByRole("button", { name: "关闭告警灯效" });
expect(close).toHaveTextContent("关闭灯效");
fireEvent.click(close);
expect(onDismiss).toHaveBeenCalledWith("preview-1");
expect(onOpen).not.toHaveBeenCalled();
});
it("opens the actual linked investigation while retaining the visual warning", () => {
const onDismiss = vi.fn(), onOpen = vi.fn();
const alert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常", description: "已启动调查", time: "08:00" };
render(<ScadaAlertAtmosphere atmosphere={{ ...preview, id: alert.id, preview: false, alert }} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.getByRole("status")).toHaveTextContent("爆管风险调查中");
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
expect(onDismiss).not.toHaveBeenCalled();
expect(onOpen).toHaveBeenCalledWith(alert);
});
it("keeps the warning for a long-running event until its state is removed", () => {
vi.useFakeTimers();
const onDismiss = vi.fn(), onOpen = vi.fn();
const { rerender } = render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
act(() => vi.advanceTimersByTime(24 * 60 * 60_000));
expect(onDismiss).not.toHaveBeenCalled();
expect(screen.getByRole("status")).toBeVisible();
rerender(<ScadaAlertAtmosphere atmosphere={null} onDismiss={onDismiss} onOpen={onOpen} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
});
@@ -0,0 +1,41 @@
import { createPortal } from "react-dom";
import { ArrowUpRight, TriangleAlert, X } from "lucide-react";
import { Button } from "@/shared/ui/button";
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
import type { WorkbenchAlert } from "../types";
export type ScadaAlertIntensity = "soft" | "standard" | "strong";
/** Event-lifetime viewport warning with an independently dismissible visual layer. */
export function ScadaAlertAtmosphere({ atmosphere, onDismiss, onOpen, intensity = "standard" }: {
atmosphere: ScadaAlertAtmosphereState | null;
onDismiss: (id: string) => void;
onOpen: (alert: WorkbenchAlert) => void;
intensity?: ScadaAlertIntensity;
}) {
if (!atmosphere) return null;
const alert = atmosphere.alert;
return createPortal(
<div className="scada-alert-atmosphere status-tone-danger"
data-testid="scada-alert-atmosphere" data-alert-id={atmosphere.id} data-preview={atmosphere.preview} data-intensity={intensity}>
<div className="scada-alert-atmosphere-frame" aria-hidden="true" />
<div className="scada-alert-atmosphere-light" aria-hidden="true" />
<div role="status" aria-live="polite" className="scada-alert-atmosphere-notice glass-transient items-center gap-3 rounded-xl border p-3 text-foreground">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[var(--status-soft)] text-[var(--status-foreground)]">
<TriangleAlert size={19} aria-hidden="true" />
</span>
<div className="min-w-0 flex-1 space-y-0.5">
<p className="text-[13px] leading-5 font-semibold">{atmosphere.preview ? "爆管告警效果预览"
: (atmosphere.count ?? 1) > 1 ? `${atmosphere.count} 起爆管风险事件处理中` : "爆管风险调查中"}</p>
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">{atmosphere.preview ? "持续预览 · 可在 Dev 面板模拟结束" : "持续至事件处理结束"}</p>
</div>
{alert?.conditionId ? <Button type="button" size="sm" variant="ghost" className="scada-alert-atmosphere-action h-9 shrink-0 gap-1 px-2 text-xs"
onClick={() => onOpen(alert)}>
查看调查<ArrowUpRight size={14} aria-hidden="true" />
</Button> : null}
<Button type="button" size="sm" variant="outline" className="scada-alert-atmosphere-close h-9 shrink-0 gap-1.5 px-2.5 text-xs" aria-label="关闭告警灯效" title="关闭当前视觉警告"
onClick={() => onDismiss(atmosphere.id)}><X size={14} aria-hidden="true" />关闭灯效</Button>
</div>
</div>, document.body
);
}
@@ -48,6 +48,8 @@ import {
import { waterNetworkToolbarItems } from "../map/toolbar-config"; import { waterNetworkToolbarItems } from "../map/toolbar-config";
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding"; import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
import { GeocodingPanel } from "./geocoding-panel"; import { GeocodingPanel } from "./geocoding-panel";
import { FeatureLocationPanel, type FeatureLocationPanelProps } from "./feature-location-panel";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more"; export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
export type ExportViewPreset = "current" | "4k"; export type ExportViewPreset = "current" | "4k";
@@ -98,6 +100,7 @@ const DRAW_TOOL_OPTIONS: Array<{
export type ToolbarPanelProps = { export type ToolbarPanelProps = {
geocoding: WorkbenchGeocoding; geocoding: WorkbenchGeocoding;
featureLocation: FeatureLocationPanelProps;
activeToolId: ToolbarToolId | null; activeToolId: ToolbarToolId | null;
activeMeasureModeId: WorkbenchMeasureMode; activeMeasureModeId: WorkbenchMeasureMode;
activeMeasureUnitId: WorkbenchMeasureUnit; activeMeasureUnitId: WorkbenchMeasureUnit;
@@ -140,6 +143,7 @@ export type ToolbarPanelProps = {
export function ToolbarPanel({ export function ToolbarPanel({
geocoding, geocoding,
featureLocation,
activeToolId, activeToolId,
activeMeasureModeId, activeMeasureModeId,
activeMeasureUnitId, activeMeasureUnitId,
@@ -203,6 +207,7 @@ export function ToolbarPanel({
); );
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId); const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
const [locationMode, setLocationMode] = useState("address");
const panelVisible = Boolean(activeToolId); const panelVisible = Boolean(activeToolId);
useEffect(() => { useEffect(() => {
@@ -232,12 +237,28 @@ export function ToolbarPanel({
if (renderedToolId === "geocoding") { if (renderedToolId === "geocoding") {
return ( return (
<MapControlPanel <MapControlPanel
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon} key="geocoding" title="定位" description={locationMode === "features" ? "按图层和要素 ID 定位" : geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`} widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
contentClassName="flex min-h-0 flex-1 flex-col" contentClassName="flex min-h-0 flex-1 flex-col"
visible={panelVisible} visible={panelVisible}
> >
<Tabs value={locationMode} onValueChange={(value) => {
if (value === "features" && geocoding.picking) geocoding.togglePicking();
setLocationMode(value);
}} className="flex min-h-0 flex-1 flex-col gap-3">
<TabsList aria-label="定位方式" className="surface-control grid shrink-0 grid-cols-2">
<TabsTrigger value="address">地址地名</TabsTrigger>
<TabsTrigger value="features">管网要素</TabsTrigger>
</TabsList>
<TabsContent value="address" forceMount hidden={locationMode !== "address"}
className="flex min-h-0 flex-1 flex-col overflow-hidden">
<GeocodingPanel geocoding={geocoding} /> <GeocodingPanel geocoding={geocoding} />
</TabsContent>
<TabsContent value="features" forceMount hidden={locationMode !== "features"}
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-0.5">
<FeatureLocationPanel {...featureLocation} />
</TabsContent>
</Tabs>
</MapControlPanel> </MapControlPanel>
); );
} }
@@ -5,7 +5,6 @@ import {
Bot, Bot,
CheckCircle2, CheckCircle2,
ChevronDown, ChevronDown,
ChevronRight,
Columns2, Columns2,
FileText, FileText,
Keyboard, Keyboard,
@@ -17,10 +16,12 @@ import {
UserRound, UserRound,
type LucideIcon type LucideIcon
} from "lucide-react"; } from "lucide-react";
import { useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { AnimatePresence } from "motion/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ProjectSwitchDialog } from "./project-switch-dialog"; import { ProjectSwitchDialog } from "./project-switch-dialog";
import { import {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -54,8 +55,14 @@ const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900" "acrylic-panel border p-2 text-slate-900"
); );
const menuReadableRowClassName = const alertReadableRowClassName =
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950"; "surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
const alertActionClassName = cn(
MAP_COMPACT_RADIUS_CLASS_NAME,
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
mapOptionInteractionVariants()
);
export function AnalysisViewMenu({ export function AnalysisViewMenu({
open, open,
onOpenChange, onOpenChange,
@@ -123,6 +130,7 @@ export function AlertMenu({
compact = false, compact = false,
conditionFeedVisible, conditionFeedVisible,
onSelectAlert, onSelectAlert,
onRemoveAlert,
onToggleConditionFeed, onToggleConditionFeed,
onAnalyzeAlertsWithAgent onAnalyzeAlertsWithAgent
}: { }: {
@@ -133,14 +141,33 @@ export function AlertMenu({
compact?: boolean; compact?: boolean;
conditionFeedVisible: boolean; conditionFeedVisible: boolean;
onSelectAlert: (alert: WorkbenchAlert) => void; onSelectAlert: (alert: WorkbenchAlert) => void;
onRemoveAlert?: (alert: WorkbenchAlert) => void;
onToggleConditionFeed: () => void; onToggleConditionFeed: () => void;
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) { }) {
const reduceMotion = useReducedMotion();
const [keyboardInteraction, setKeyboardInteraction] = useState(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction;
const hasAlerts = alerts.length > 0; const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning"); const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral"; const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
const latestAlertTime = alerts[0]?.time; const latestAlertTime = alerts[0]?.time;
useEffect(() => {
// Radix can preserve :focus-visible through pointer-driven focus transfers.
// Track actual input, including Tab from controls outside this portaled menu.
const handleKeyboard = () => setKeyboardInteraction(true);
const handlePointer = () => setKeyboardInteraction(false);
document.addEventListener("keydown", handleKeyboard, true);
document.addEventListener("pointerdown", handlePointer, true);
document.addEventListener("pointermove", handlePointer, true);
return () => {
document.removeEventListener("keydown", handleKeyboard, true);
document.removeEventListener("pointerdown", handlePointer, true);
document.removeEventListener("pointermove", handlePointer, true);
};
}, []);
return ( return (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}> <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -149,6 +176,7 @@ export function AlertMenu({
className={cn( className={cn(
"group relative px-2", "group relative px-2",
headerControlButtonClassName, headerControlButtonClassName,
!keyboardInteraction && "focus-visible:ring-0 focus-visible:ring-offset-0",
hasAlerts && !compact && hasAlerts && !compact &&
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"), (hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact && hasAlerts && !compact &&
@@ -197,36 +225,68 @@ export function AlertMenu({
tone={hasAlerts ? alertSummaryTone : "success"} tone={hasAlerts ? alertSummaryTone : "success"}
/> />
<MenuSectionLabel>待处理事项</MenuSectionLabel> <MenuSectionLabel>待处理事项</MenuSectionLabel>
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1"> <div className="scheduled-feed-scroll relative max-h-[min(52dvh,420px)] overflow-x-hidden overflow-y-auto pr-1">
<AnimatePresence initial={false} mode="popLayout" custom={skipRemovalMotion}>
{hasAlerts ? alerts.map((alert) => { {hasAlerts ? alerts.map((alert) => {
const tone = alert.severity === "warning" ? "warning" : "danger"; const tone = alert.severity === "warning" ? "warning" : "danger";
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
return ( return (
<DropdownMenuItem <motion.div
key={alert.id} key={alert.id}
data-alert-id={alert.id}
layout="position"
initial={false}
animate={{ opacity: 1, x: 0 }}
exit="removed"
variants={{ removed: (skip: boolean) => ({
opacity: 0, x: skip ? 0 : 12, pointerEvents: "none",
transition: { duration: skip ? 0 : 0.18, ease: [0.4, 0, 1, 1] }
}) }}
transition={{ duration: skipRemovalMotion ? 0 : 0.18, ease: [0.4, 0, 1, 1] }}
className={cn( className={cn(
toneClassName, tone === "warning" ? "status-tone-warning" : "status-tone-danger",
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]", "relative my-1 min-h-[76px] border border-transparent px-3 py-2 transition-colors hover:border-[var(--action-border-hover)] hover:bg-[var(--action-selection-soft)] focus-within:border-[var(--action-border-hover)] focus-within:bg-[var(--action-selection-soft)]",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName alertReadableRowClassName
)}
>
<DropdownMenuItem
asChild
className={cn(
"absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]",
keyboardInteraction && "focus-visible:ring-2"
)} )}
onSelect={() => onSelectAlert(alert)} onSelect={() => onSelectAlert(alert)}
> >
<span className="min-w-0"> <button
<span className="flex min-w-0 items-center gap-2"> type="button"
<StatusDot tone={tone} size="md" /> aria-label={`打开通知:${alert.title}`}
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span> />
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-[var(--ink-muted)] tabular-nums">{alert.time}</span>
</span>
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</span>
</span>
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-[var(--surface-reading)] text-[var(--ink-muted)] transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
<ChevronRight size={14} />
</span>
</DropdownMenuItem> </DropdownMenuItem>
<div className="pointer-events-none relative">
<span className="flex min-w-0 items-center gap-2 pr-10 text-xs text-[var(--ink-muted)]">
<StatusDot tone={tone} size="md" />
<span className="font-mono tabular-nums">{alert.time}</span>
</span>
<p className="mt-1 break-words pr-10 text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
<p className="mt-0.5 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
</div>
{onRemoveAlert ? (
<DropdownMenuItem
aria-label={`移除通知:${alert.title}`}
title="移除通知"
className={cn(alertActionClassName, "absolute right-1 top-1 z-10")}
onSelect={(event) => {
event.preventDefault();
onRemoveAlert(alert);
}}
>
<X size={14} aria-hidden="true" />
</DropdownMenuItem>
) : null}
</motion.div>
); );
}) : ( }) : (
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}> <div key="empty-alert-queue" className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
<div className="min-w-0"> <div className="min-w-0">
<SemanticStatusIcon tone="success" size="md" className="mx-auto"> <SemanticStatusIcon tone="success" size="md" className="mx-auto">
<CheckCircle2 size={16} /> <CheckCircle2 size={16} />
@@ -236,6 +296,7 @@ export function AlertMenu({
</div> </div>
</div> </div>
)} )}
</AnimatePresence>
</div> </div>
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2"> <div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2">
<MenuPlainButton <MenuPlainButton
@@ -339,8 +400,8 @@ export function UserMenu({
onSelect={onLogout ? () => void onLogout() : undefined} onSelect={onLogout ? () => void onLogout() : undefined}
className={cn( className={cn(
"px-2 py-2", "px-2 py-2",
!onLogout && "text-[var(--ink-muted)]", MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME !onLogout && "text-[var(--ink-muted)]"
)} )}
> >
<LogOut size={15} aria-hidden="true" /> <LogOut size={15} aria-hidden="true" />
@@ -424,7 +485,7 @@ function MenuPlainButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-800 hover:border-[var(--surface-border)] hover:bg-[var(--action-soft)]/55 focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" : cn(MAP_ACTION_ROW_INTERACTION_CLASS_NAME, "focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]")
)} )}
> >
<span className="truncate text-xs font-semibold leading-4">{label}</span> <span className="truncate text-xs font-semibold leading-4">{label}</span>
@@ -454,7 +515,7 @@ function MenuPrimaryButton({
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
disabled disabled
? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]" ? "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-[var(--ink-muted)]"
: "border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20 hover:bg-[var(--action-blue-hover)] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-[var(--action-focus-ring)]" : cn("border-[var(--surface-border)] bg-[var(--action-blue)] text-white shadow-lg shadow-[color:var(--action-blue)]/20", mapOptionInteractionVariants({ state: "primary" }))
)} )}
> >
<Icon size={14} aria-hidden="true" /> <Icon size={14} aria-hidden="true" />
@@ -512,9 +573,8 @@ function MenuAction({
return ( return (
<DropdownMenuItem <DropdownMenuItem
className={cn( className={cn(
"my-0.5 items-center border border-transparent px-2 py-2 transition", "my-0.5 items-center px-2 py-2 transition-colors",
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_ACTION_ROW_INTERACTION_CLASS_NAME
menuReadableRowClassName
)} )}
onSelect={onSelect} onSelect={onSelect}
> >
@@ -524,13 +584,13 @@ function MenuAction({
MAP_ICON_CELL_RADIUS_CLASS_NAME, MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]", tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
tone === "warning" && "status-icon status-tone-warning", tone === "warning" && "status-icon status-tone-warning",
tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)] text-slate-600" tone === "default" && "border-[var(--surface-divider)] bg-[var(--surface-reading)]"
)} )}
> >
<Icon size={15} aria-hidden="true" /> <Icon size={15} aria-hidden="true" />
</span> </span>
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm font-semibold leading-5 text-slate-900">{label}</span> <span className="block text-sm font-semibold leading-5">{label}</span>
<span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span> <span className="mt-0.5 block truncate text-xs text-[var(--ink-muted)]">{description}</span>
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>

Some files were not shown because too many files have changed in this diff Show More