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
143 changed files with 6223 additions and 1408 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` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
+43 -8
View File
@@ -20,13 +20,31 @@ Dev 分为“调查测试”“通知测试”和“地图调试”三个页签
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。 Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
调查与本地预览在展示分析图表时自动打开可拖动、缩放和最大化的独立窗口,覆盖 `open_chart` 指令和结构化界面中的 `Chart`。自动打开不抢走键盘焦点;关闭窗口仅收起放大视图,卡片中的图表和放大入口继续保留。同一图表内容未变化时,其他地图动作不会使已关闭的窗口再次弹出;图表内容更新或进入、退出历史回看时可再次自动打开。回放窗口随所选快照更新,保留已有窗口的位置和尺寸,期间收到的实时数据不替换历史图表。关闭图表指令或切换任务时移除对应窗口;普通对话图表仍由用户手动放大。 ## 图表和窗口管理
调查、本地预览与对话展示面板提供“图表窗口”入口,在一个可拖动、缩放、最大化的总窗口中集中查看所有子图表,包括 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 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
@@ -37,8 +55,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 案例 | 核对内容 | | 案例 | 核对内容 |
| --- | --- | | --- | --- |
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 | | 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 |
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 | | 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 |
| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 |
| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 |
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 | | 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 | | 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
| 合成影响范围 | 当前地图中心附近的合成 Polygon | | 合成影响范围 | 当前地图中心附近的合成 Polygon |
@@ -49,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)。
@@ -62,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 写入接口。
@@ -75,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 任务入口继续执行证据归属和快照校验。
## 已有案例与示例迁移 ## 已有案例与示例迁移
@@ -97,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,并在各仓库独立运行测试。
## 展示动作历史 ## 展示动作历史
+4
View File
@@ -194,6 +194,10 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
每个业务区最多一个醒目的主操作。按钮覆盖默认、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 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
+3
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",
+71
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
@@ -470,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'}
@@ -825,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==}
@@ -4567,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
@@ -4860,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': {}
+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);
} }
} }
@@ -7,10 +7,10 @@ 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;
submitMode?: "send" | "draft"; submitMode?: "send" | "draft";
chartWindowKey?: string;
}; };
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true }); export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
@@ -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,
@@ -103,17 +103,19 @@ 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(withAnswerDisplayTimes(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,
chartWindowKey,
submitMode = "send" submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) { }: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState(); const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
@@ -132,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, chartWindowKey }}> <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}
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
describe("monitoring chart comparison", () => { describe("monitoring chart comparison", () => {
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => { it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
@@ -17,6 +17,39 @@ describe("monitoring chart comparison", () => {
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 }); 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", () => { 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"] }, 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: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
@@ -9,13 +9,24 @@ export function chartSampleTime(time: string | null | undefined): string {
return time ? formatDisplayTimestamp(time) : ""; return time ? formatDisplayTimestamp(time) : "";
} }
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) { /** 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)) const values = series.flatMap((item) => item.data.slice(start, end + 1))
.filter((value): value is number => value !== null && Number.isFinite(value)); .filter((value): value is number => value !== null && Number.isFinite(value));
if (!values.length) return {}; if (!values.length) return {};
const min = Math.min(...values), max = Math.max(...values); const min = Math.min(...values), max = Math.max(...values);
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08; const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
return { min: min - padding, max: max + padding }; 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. */ /** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
+141 -105
View File
@@ -1,6 +1,6 @@
import { memo, useContext, 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 { BarChart3, X, Database, SlidersHorizontal, Table2, TrendingUp } from "lucide-react"; import { BarChart3, X, Database, FileText, SlidersHorizontal, Table2, TrendingUp } from "lucide-react";
import { FloatingWindow } from "@/shared/ui/floating-window"; 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";
@@ -11,8 +11,9 @@ import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart"; import { ResponsiveChart } from "./responsive-chart";
import { agentEase } from "../components/agent-motion"; import { agentEase } from "../components/agent-motion";
import type { SafeChartSeries } from "../chart-data"; import type { SafeChartSeries } from "../chart-data";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { AnswerInteractionContext } from "./answer-ui-interaction-context"; 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;
@@ -35,7 +36,7 @@ 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>;
} }
type ChartPresentation = { expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string }; type ChartPresentation = { tile?: boolean; expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
const standaloneRegistry = {}; const standaloneRegistry = {};
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */ /** Legacy chart envelopes use the same controls and chart engine as structured answers. */
@@ -47,7 +48,9 @@ export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresen
} }
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) { export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
const { chartWindowKey } = useContext(AnswerInteractionContext); 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);
@@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const [plotWidth, setPlotWidth] = useState(360); const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => { useLayoutEffect(() => {
const element = plot.current; const element = plot.current;
if (props.expanded || !element || typeof ResizeObserver === "undefined") return; if (fitted || !element || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotWidth(element.clientWidth); const resize = () => setPlotWidth(element.clientWidth);
resize(); resize();
const observer = new ResizeObserver(resize); const observer = new ResizeObserver(resize);
observer.observe(element); observer.observe(element);
return () => observer.disconnect(); return () => observer.disconnect();
}, [view, props.expanded]); }, [view, fitted]);
const contentMotion = useAnimationControls(); const contentMotion = useAnimationControls();
const pendingTransition = useRef(false); const pendingTransition = useRef(false);
useLayoutEffect(() => { useLayoutEffect(() => {
@@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
}, [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 });
@@ -85,8 +88,11 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
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) => {
@@ -98,29 +104,38 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const categoryColors = visible.length === 1; const categoryColors = visible.length === 1;
const horizontalBars = props.x_data.some((label) => label.length > 10); const horizontalBars = props.x_data.some((label) => label.length > 10);
const numberLabel = chartValueLabel; const numberLabel = chartValueLabel;
const xAxisName = props.xAxisName ?? props.x_axis_name;
const yAxisName = props.yAxisName ?? props.y_axis_name;
// Keep tooltip functions and options stable across sizing, focus and table-only updates.
const options = useMemo(() => {
const option = (type: "line" | "bar") => { const option = (type: "line" | "bar") => {
const horizontal = type === "bar" && horizontalBars; const horizontal = type === "bar" && horizontalBars;
const horizontalAxisName = horizontal ? yAxisName : xAxisName;
const verticalAxisName = horizontal ? xAxisName : yAxisName;
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type; const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
const includesBars = visible.some((series) => seriesType(series) === "bar"); const includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar", const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto", axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) }, ...(tile ? { margin: 4, lineHeight: 12 } : {}),
...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) },
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } }; axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
const valueAxis = { type: "value", scale: !includesBars, const valueAxis = { type: "value", scale: !includesBars,
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}), ...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel }, ...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
...(tile ? { margin: 4, lineHeight: 12 } : {}) },
splitLine: { lineStyle: { color: "#e2e8f0" } } }; splitLine: { lineStyle: { color: "#e2e8f0" } } };
return { return {
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180, animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' }, textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true }, color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true, tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 }, textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
...(composite ? { formatter: (params: unknown) => { ...(composite ? { formatter: (params: unknown) => {
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined; const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible; const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
const text = typeof first?.dataIndex === "number" const text = typeof first?.dataIndex === "number"
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : ""; ? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK // ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML. // readable and never interprets model-provided labels as HTML.
const content = document.createElement("div"); const content = document.createElement("div");
@@ -128,31 +143,24 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
content.style.whiteSpace = "pre-line"; content.style.whiteSpace = "pre-line";
return content; return content;
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } }, } } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true }, grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name, bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
nameLocation: "middle", nameGap: 30 }, xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name }, nameLocation: "middle", nameGap: tile ? 16 : 30,
...(tile ? { nameTextStyle: { fontSize: 10, lineHeight: 12 } } : {}) },
yAxis: { ...(horizontal ? { ...categoryAxis, inverse: true } : valueAxis), name: verticalAxisName,
...(tile ? { nameGap: 4, nameTextStyle: { fontSize: 10, lineHeight: 12, align: "left" } } : {}) },
series: visible.map((series) => ({ name: series.name, type: seriesType(series), series: visible.map((series) => ({ name: series.name, type: seriesType(series),
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value), ? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false, barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } })) showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
}; };
}; };
const metadata = <> return { line: option("line"), bar: option("bar") };
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要"> }, [animateInteraction, categoryColors, composite, fitted, hiddenSeries, horizontalBars, labels, numberLabel, plotWidth,
<div className="mb-2 flex flex-wrap items-center justify-between gap-1"> props.chart_type, rangeStart, rangeEnd, reducedMotion, tile, view, visible, xAxisName, yAxisName]);
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span> const pointDetails = view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
</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}
{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">
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</dd> </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 className="min-w-0 break-words [overflow-wrap:anywhere]">{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>
</>; </>;
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title} const rangeControls = <div className="space-y-2">
onPointerDownCapture={() => { pointer.current = true; }}
onKeyDownCapture={() => {
pointer.current = false;
setAnimateInteraction(false);
contentMotion.stop();
contentMotion.set({ opacity: 1, y: 0 });
}}>
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 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.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
<div className="flex shrink-0 items-center gap-1">
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
{() => <InteractiveChart {...props} expanded />}
</FloatingWindow> : null}
<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>
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} 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={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined}>
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3"} relative 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`}>
<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"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
</TabsList>
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined}>
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : ""}`} aria-label="显示的数据序列">
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
className={`h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
disabled={visible.length === 1 && !hidden.includes(index)}
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); 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 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 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 ? metadata : null}
</aside>
<div className={props.expanded ? "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}
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data} <PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} /> onChange={(start) => setRange([start, Math.max(start, range[1])])} />
@@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</div> </div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange} <Button type="button" variant="ghost" size="sm" disabled={fullRange}
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button> onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
</CollapsibleContent> </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> </div>
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}> </CardHeader> : null}
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}> <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} <div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${props.expanded ? (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`} 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) => { onKeyDown={(event) => {
if (event.target !== event.currentTarget) return; if (event.target !== event.currentTarget) return;
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) { if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
@@ -259,13 +275,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
if (event.key === "Escape") setInspecting(false); if (event.key === "Escape") setInspecting(false);
}}> }}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span> <span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: props.expanded ? "100%" : props.plotHeight ?? 220, <div role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}> minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} /> <ResponsiveChart option={options[type]} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div> </div>
</div> </div>
</TabsContent>)} </TabsContent>)}
<TabsContent value="table" className={props.expanded ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}> <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)]} <InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => { rows={labels.map((label, index) => [label, ...visible.map((series) => {
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]); const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
@@ -274,7 +290,27 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} /> state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
</TabsContent> </TabsContent>
</motion.div> </motion.div>
{!props.expanded ? metadata : null} <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> </Tabs>
</CardContent> </CardContent>
</Card></Collapsible>; </Card></Collapsible>;
@@ -1,5 +1,5 @@
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 { FloatingWindow } from "@/shared/ui/floating-window";
@@ -45,13 +45,13 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<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);
});
@@ -1,7 +1,9 @@
import { useLayoutEffect, useRef, type ComponentProps } from "react"; import { useLayoutEffect, useRef, type ComponentProps } from "react";
import ReactECharts from "echarts-for-react"; import ReactECharts from "echarts-for-react";
/** Resize the existing drawing at most once per frame without replacing its options. */ const resizedWindows = new WeakMap<HTMLElement, number>();
/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */
export function ResponsiveChart({ option, height, onPointSelect }: { export function ResponsiveChart({ option, height, onPointSelect }: {
option: ComponentProps<typeof ReactECharts>["option"]; option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%"; height: number | "100%";
@@ -13,16 +15,32 @@ export function ResponsiveChart({ option, height, onPointSelect }: {
useLayoutEffect(() => { useLayoutEffect(() => {
const element = host.current; const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return; if (!element || typeof ResizeObserver === "undefined") return;
const shell = element.closest<HTMLElement>("[data-floating-window]");
let frame = 0; let frame = 0;
const observer = new ResizeObserver(() => { let timer = 0;
cancelAnimationFrame(frame); const resize = () => {
frame = requestAnimationFrame(() => { frame = 0;
if (!ready.current || !element.clientWidth || !element.clientHeight) return; 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 } }); 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); observer.observe(element);
return () => { cancelAnimationFrame(frame); observer.disconnect(); }; 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 }}> return <div ref={host} style={{ height }}>
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false} <ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
+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" ||
@@ -1066,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<
@@ -72,13 +72,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
); );
} }
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: 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} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type, 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.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 } }} />; ...(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, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: 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 parsed = parseChartSpec(envelope.spec); const parsed = parseChartSpec(envelope.spec);
const raw = normalizeSafeChartData(envelope.data, 100); const raw = normalizeSafeChartData(envelope.data, 100);
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName, const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
@@ -97,7 +97,7 @@ function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { e
series={data.series.slice(0, 8)} series={data.series.slice(0, 8)}
source="来源见关联依据。" source="来源见关联依据。"
embedded={embedded} embedded={embedded}
expanded={expanded} expanded={expanded} plotHeight={plotHeight} tile={tile}
xAxisName={spec.xAxisName} xAxisName={spec.xAxisName}
yAxisName={spec.yAxisName} yAxisName={spec.yAxisName}
/>; />;
@@ -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);
});
});
@@ -7,8 +7,63 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
); );
} }
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);
@@ -39,7 +94,11 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
(!series.sample_times || series.sample_times.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.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())
@@ -213,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>
+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,7 @@ 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(), 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(),
@@ -431,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;
} }
@@ -48,18 +48,19 @@ export async function withProjectRequest<T>(
baseUrl: string, baseUrl: string,
operation: string, operation: string,
request: (signal: AbortSignal) => Promise<T>, request: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal signal?: AbortSignal,
timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
): Promise<T> { ): Promise<T> {
const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS); const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline; const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
try { try {
combined.throwIfAborted(); combined.throwIfAborted();
return await request(combined); return await request(combined);
} catch (error) { } catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason; if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
if (isTimeout(error) || (combined.aborted && isTimeout(combined.reason))) { if (timeoutMs != null && (isTimeout(error) || (combined.aborted && isTimeout(combined.reason)))) {
const diagnosis = await describeServerReadiness(baseUrl); const diagnosis = await describeServerReadiness(baseUrl);
throw new Error(`${operation}超时(已等待 15 秒)。${diagnosis}`, { cause: error }); throw new Error(`${operation}超时(已等待 ${timeoutMs / 1000} 秒)。${diagnosis}`, { cause: error });
} }
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) { if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error }); throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: 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({
@@ -128,14 +128,10 @@ export class ScadaReplayClient {
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",
@@ -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";
@@ -110,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
@@ -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,16 +124,17 @@ 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}
@@ -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,8 +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 { formatChartDisplayText } from "@/shared/utils/date-time-text";
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";
@@ -19,7 +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 { FloatingWindow } from "@/shared/ui/floating-window"; 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";
@@ -36,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";
@@ -68,6 +71,8 @@ export function InvestigationPresentationPanel({
onLocateTargets, onLocateTargets,
localPreview = false, localPreview = false,
charts, charts,
chartWindowAutoOpen = false,
chartWindowScope,
overlay, overlay,
cameraPaused, cameraPaused,
status, status,
@@ -80,13 +85,13 @@ 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();
const immediate = Boolean(reducedMotion) || keyboardAction; const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId(); const contentId = useId();
const reviewing = history?.selectedId != null; const reviewing = history?.selectedId != null;
const chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]);
const selected = history?.entries.find((entry) => entry.id === history.selectedId); const selected = history?.entries.find((entry) => entry.id === history.selectedId);
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0; const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示"; const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
@@ -121,7 +126,7 @@ export function InvestigationPresentationPanel({
<motion.header <motion.header
layout={immediate || localPreview ? false : "position"} layout={immediate || localPreview ? false : "position"}
transition={presentationTransition(immediate)} transition={presentationTransition(immediate)}
className={`shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "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={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={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={localPreview ? "contents" : "min-w-0 space-y-1"}> <div className={localPreview ? "contents" : "min-w-0 space-y-1"}>
@@ -137,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">
@@ -146,6 +151,10 @@ 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"
@@ -162,6 +171,7 @@ export function InvestigationPresentationPanel({
</motion.span> </motion.span>
</Button> </Button>
</div> </div>
</div>
<p <p
className={ className={
history?.entries.length history?.entries.length
@@ -242,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">
@@ -291,51 +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={formatChartDisplayText(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]">
{formatChartDisplayText(item.chart.title)}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
{item.chart.x_data.length} 个
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
<AgentChartView chart={item.chart} embedded expanded />
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
{references(item.evidence_refs)}
</CardContent>
</Card>
</PresentationItem> </PresentationItem>
))} ))}
</AnimatePresence> </AnimatePresence>
@@ -343,7 +312,6 @@ export function InvestigationPresentationPanel({
blocks={blocks} blocks={blocks}
records={records} records={records}
reviewing={reviewing} reviewing={reviewing}
chartWindowKey={chartWindowKey}
onLocateTargets={onLocateTargets} onLocateTargets={onLocateTargets}
/> />
{!charts.length && {!charts.length &&
@@ -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";
@@ -179,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));
@@ -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
}; };
} }
@@ -7,7 +7,6 @@ type Props = {
blocks: AnswerUiBlock[]; blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>; records: Array<Record<string, unknown>>;
reviewing: boolean; reviewing: boolean;
chartWindowKey?: string;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>; onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
}; };
@@ -15,7 +14,6 @@ export function PresentationBusinessResults({
blocks, blocks,
records, records,
reviewing, reviewing,
chartWindowKey,
onLocateTargets onLocateTargets
}: Props) { }: Props) {
if (!blocks.length && !records.length) return null; if (!blocks.length && !records.length) return null;
@@ -26,7 +24,6 @@ export function PresentationBusinessResults({
key={block.block_id} key={block.block_id}
block={block} block={block}
disabled={reviewing} disabled={reviewing}
chartWindowKey={chartWindowKey}
onLocate={ onLocate={
onLocateTargets && !reviewing onLocateTargets && !reviewing
? (target) => ? (target) =>
@@ -16,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, motion, useReducedMotion } 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,
@@ -53,11 +55,12 @@ 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( const alertActionClassName = cn(
MAP_COMPACT_RADIUS_CLASS_NAME, MAP_COMPACT_RADIUS_CLASS_NAME,
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95" "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({
@@ -143,13 +146,28 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>; onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) { }) {
const reduceMotion = useReducedMotion(); const reduceMotion = useReducedMotion();
const keyboardInteraction = useRef(false); const [keyboardInteraction, setKeyboardInteraction] = useState(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current; 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>
@@ -158,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,10 +216,7 @@ export function AlertMenu({
)} )}
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)} <DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<AlertQueueSummary <AlertQueueSummary
count={alerts.length} count={alerts.length}
latestAlertTime={latestAlertTime} latestAlertTime={latestAlertTime}
@@ -230,12 +246,15 @@ export function AlertMenu({
tone === "warning" ? "status-tone-warning" : "status-tone-danger", tone === "warning" ? "status-tone-warning" : "status-tone-danger",
"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)]", "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 <DropdownMenuItem
asChild asChild
className="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-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]" 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)}
> >
<button <button
@@ -381,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" />
@@ -466,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>
@@ -496,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" />
@@ -554,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}
> >
@@ -566,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>
@@ -1,9 +1,12 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction"; import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
export const headerControlButtonClassName = export const headerControlButtonClassName =
cn( cn(
buttonInteractionVariants({ tone: "neutral" }), buttonInteractionVariants({ tone: "neutral" }),
mapOptionInteractionVariants(),
"data-[state=open]:border-[var(--surface-border)] data-[state=open]:bg-[var(--surface-reading)] data-[state=open]:text-[var(--action-blue-hover)]",
"inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']" "inline-flex h-8 items-center gap-2 whitespace-nowrap rounded-full border border-transparent bg-transparent text-sm font-semibold after:absolute after:-inset-1 after:content-['']"
); );
@@ -11,6 +14,6 @@ export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0"; "h-10 w-10 justify-center px-0";
export const headerControlIconClassName = export const headerControlIconClassName =
"header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-[var(--ink-muted)] transition-colors group-hover:bg-[var(--action-soft)] group-hover:text-[var(--action-blue-hover)] group-data-[state=open]:bg-[var(--action-soft)] group-data-[state=open]:text-[var(--action-blue-hover)]"; "header-control-icon grid h-5 w-5 shrink-0 place-items-center rounded-full bg-[var(--surface-control)]/80 text-inherit";
export const compactHeaderTextClassName = "hidden min-[1800px]:inline"; export const compactHeaderTextClassName = "hidden min-[1800px]:inline";
@@ -1,4 +1,4 @@
import { useState } from "react"; import { useState, useSyncExternalStore } from "react";
import { import {
Box, Box,
CalendarClock, CalendarClock,
@@ -14,6 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core"; import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn"; import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import type { import type {
WorkbenchAlert, WorkbenchAlert,
WorkbenchAlertDataSource, WorkbenchAlertDataSource,
@@ -21,6 +22,7 @@ import type {
} from "../types"; } from "../types";
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model"; import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus"; import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
import { WorkbenchWindowMenu } from "./workbench-window-menu";
import { import {
compactHeaderTextClassName, compactHeaderTextClassName,
headerControlButtonClassName, headerControlButtonClassName,
@@ -63,7 +65,7 @@ export type WorkbenchTopBarProps = {
onLogout?: () => Promise<void>; onLogout?: () => Promise<void>;
}; };
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user"; type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user" | "windows";
export function WorkbenchTopBar({ export function WorkbenchTopBar({
dataTime, dataTime,
@@ -101,12 +103,13 @@ export function WorkbenchTopBar({
onLogout onLogout
}: WorkbenchTopBarProps) { }: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null); const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const hasWindows = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries).length > 0;
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => { const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null); setOpenMenu(open ? menuId : null);
}; };
return ( return (
<header className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5"> <header data-floating-window-inset-top="" className="workbench-top-bar acrylic-navigation pointer-events-auto absolute left-0 right-0 top-0 z-30 flex h-14 select-none items-center justify-between gap-2 border-b px-2 sm:gap-3 sm:px-4 md:px-5">
<div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0"> <div className="flex min-w-0 items-center gap-2 sm:gap-2.5 lg:shrink-0">
<div <div
className={cn( className={cn(
@@ -116,7 +119,7 @@ export function WorkbenchTopBar({
> >
<Droplets size={18} aria-hidden="true" /> <Droplets size={18} aria-hidden="true" />
</div> </div>
<div className="min-w-0"> <div className={cn("min-w-0", hasWindows && "max-[399px]:sr-only")}>
<h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950"> <h1 aria-label="供水管网智慧运维系统" className="truncate text-sm font-semibold leading-5 text-slate-950">
<span className="sm:hidden">供水</span> <span className="sm:hidden">供水</span>
<span className="hidden sm:inline">供水管网智慧运维系统</span> <span className="hidden sm:inline">供水管网智慧运维系统</span>
@@ -172,6 +175,7 @@ export function WorkbenchTopBar({
</div> </div>
<div className="flex shrink-0 items-center gap-2"> <div className="flex shrink-0 items-center gap-2">
<WorkbenchWindowMenu open={openMenu === "windows"} onOpenChange={createMenuOpenChange("windows")} />
<button <button
type="button" type="button"
aria-label="项目分析成果" aria-label="项目分析成果"
@@ -0,0 +1,58 @@
import { useRef, useSyncExternalStore } from "react";
import { AppWindow, Check, PanelsTopLeft, X } from "lucide-react";
import { mapOptionInteractionVariants, MAP_ACTION_ROW_INTERACTION_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/shared/ui/dropdown-menu";
import { headerControlButtonClassName, headerControlIconClassName } from "./workbench-top-bar-styles";
export function WorkbenchWindowMenu({ open, onOpenChange }: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const entries = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries);
const trigger = useRef<HTMLButtonElement>(null);
const afterClose = useRef<(() => void) | null>(null);
if (!entries.length) return null;
return <DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
<button ref={trigger} type="button" aria-label={`窗口管理,当前 ${entries.length} 个窗口`} title="窗口管理"
className={cn("group relative px-1.5 sm:px-2", headerControlButtonClassName)}>
<span className={headerControlIconClassName}><PanelsTopLeft size={14} aria-hidden /></span>
<span className="hidden text-xs xl:inline">窗口</span>
<span aria-hidden className="absolute -right-0.5 -top-0.5 grid min-w-3.5 place-items-center rounded-full bg-[var(--action-blue)] px-0.5 text-[9px] leading-3.5 text-white tabular-nums xl:static xl:min-w-4 xl:px-1 xl:text-xs xl:leading-4">{entries.length}</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" aria-label="已打开的窗口" aria-labelledby={undefined}
className={cn("acrylic-panel z-[90] w-80 max-w-[calc(100vw-24px)] border p-2 text-slate-900", MAP_MAJOR_PANEL_RADIUS_CLASS_NAME)}
onCloseAutoFocus={(event) => {
const focus = afterClose.current;
afterClose.current = null;
if (focus) { event.preventDefault(); focus(); }
}}>
<DropdownMenuLabel className="flex items-center justify-between px-2 py-1.5 text-xs text-[var(--ink-muted)]">
<span>已打开的窗口</span><span className="tabular-nums">{entries.length} 个</span>
</DropdownMenuLabel>
{[...entries].reverse().map((entry, index) => <DropdownMenuItem key={entry.id}
aria-label={`定位窗口 ${entry.title}`} aria-current={index === 0 ? "true" : undefined}
className={cn("min-h-10 items-start px-2 py-2.5 text-xs", index === 0
? cn("rounded-xl border border-[var(--action-selection-border)] bg-[var(--action-selection-soft)] text-[var(--action-blue-hover)] focus:bg-[var(--action-selection-soft)] focus:text-[var(--action-blue-hover)]", mapOptionInteractionVariants({ state: "selected" }))
: MAP_ACTION_ROW_INTERACTION_CLASS_NAME)}
onSelect={() => {
afterClose.current = () => entry.focus();
onOpenChange(false);
}}>
<AppWindow size={15} className="mt-0.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 break-words leading-5">{entry.title}</span>
{index === 0 ? <Check size={14} className="mt-0.5 shrink-0 text-[var(--action-blue)]" aria-hidden /> : null}
</DropdownMenuItem>)}
<DropdownMenuSeparator className="my-1.5 bg-[var(--surface-divider)]/70" />
<DropdownMenuItem className={cn("min-h-10 px-2 text-xs", MAP_ACTION_ROW_INTERACTION_CLASS_NAME)} onSelect={() => {
const fallback = trigger.current?.closest("header")?.querySelector<HTMLButtonElement>('button[aria-label="打开用户菜单"]');
afterClose.current = () => fallback?.focus({ preventScroll: true });
onOpenChange(false);
[...entries].reverse().forEach((entry) => entry.close());
}}><X size={15} aria-hidden />关闭全部窗口</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>;
}
@@ -1,73 +0,0 @@
import type { MapAnnotation } from "../types";
export const mapAnnotations: MapAnnotation[] = [
{
id: "burst",
label: "演示爆管位置",
coordinate: [121.5215, 30.9084],
kind: "burst"
},
{
id: "dn600",
label: "演示 DN600",
coordinate: [121.512, 30.9102],
kind: "pipe-label"
},
{
id: "east-gate",
label: "演示片区",
coordinate: [121.545, 30.951],
kind: "district"
},
{
id: "impact",
label: "演示影响范围 约 1.82 km²",
coordinate: [121.567, 30.896],
kind: "tooltip"
}
];
export const impactAreaPolygon = {
type: "FeatureCollection" as const,
features: [
{
type: "Feature" as const,
properties: {
id: "impact-area",
label: "演示影响范围",
area: "1.82 km²"
},
geometry: {
type: "Polygon" as const,
coordinates: [
[
[121.5205, 30.9084],
[121.548, 30.928],
[121.604, 30.918],
[121.626, 30.886],
[121.584, 30.862],
[121.538, 30.873],
[121.506, 30.895],
[121.5205, 30.9084]
]
]
}
}
]
};
export const annotationPoints = {
type: "FeatureCollection" as const,
features: mapAnnotations.map((annotation) => ({
type: "Feature" as const,
properties: {
id: annotation.id,
label: annotation.label,
kind: annotation.kind
},
geometry: {
type: "Point" as const,
coordinates: annotation.coordinate
}
}))
};
@@ -0,0 +1,25 @@
import { expect, it, vi } from "vitest";
import type { AgentQuestionRequest } from "@/features/agent";
import { createAgentInteractionReplies } from "./agent-interaction-replies";
import type { AgentSessionRuntime } from "./agent-session-runtime";
import { WorkbenchSessionResources } from "./workbench-session-resources";
it("keeps a reply bound to its original session when the user switches conversations", async () => {
let resolve: () => void = () => {};
const request = vi.fn(() => new Promise<void>((done) => { resolve = done; }));
const original = { sessionId: "original", client: { replyQuestion: request }, questionOverrides: {} } as unknown as AgentSessionRuntime;
const next = { sessionId: "next", questionOverrides: {} } as AgentSessionRuntime;
const resources = new WorkbenchSessionResources(); resources.activeAgent = original;
const setters = { permission: vi.fn(), question: vi.fn() };
const replies = createAgentInteractionReplies(resources, setters);
const question = { sessionId: "original", requestId: "question" } as AgentQuestionRequest;
const submitted = replies.replyQuestion(question, [["A"]]);
expect(original.questionOverrides.question.status).toBe("submitting");
resources.activeAgent = next; setters.question.mockClear();
resolve(); await submitted;
expect(original.questionOverrides.question).toEqual({ status: "answered", answers: [["A"]] });
expect(next.questionOverrides).toEqual({});
expect(setters.question).not.toHaveBeenCalled();
await replies.replyQuestion(question, [["B"]]);
expect(request).toHaveBeenCalledTimes(1);
});
@@ -0,0 +1,60 @@
import type { AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest } from "@/features/agent";
import { showMapNotice } from "@/features/map/core";
import { toPermissionStatus, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
import type { AgentSessionRuntime } from "./agent-session-runtime";
import type { WorkbenchSessionResources } from "./workbench-session-resources";
export function createAgentInteractionReplies(resources: WorkbenchSessionResources, setters: {
permission: (value: Record<string, PermissionOverride>) => void;
question: (value: Record<string, QuestionOverride>) => void;
}) {
const permissionUpdate = (target: AgentSessionRuntime, id: string, value: PermissionOverride) => {
target.permissionOverrides = { ...target.permissionOverrides, [id]: value };
if (resources.activeAgent === target) setters.permission(target.permissionOverrides);
resources.notify();
};
const questionUpdate = (target: AgentSessionRuntime, id: string, value: QuestionOverride) => {
target.questionOverrides = { ...target.questionOverrides, [id]: value };
if (resources.activeAgent === target) setters.question(target.questionOverrides);
resources.notify();
};
const submit = async <T extends PermissionOverride | QuestionOverride>(
request: { sessionId: string; requestId: string },
update: (target: AgentSessionRuntime, id: string, value: T | { status: "submitting" } | { status: "error"; error: string }) => void,
execute: (target: AgentSessionRuntime) => Promise<unknown>,
success: T,
failure: { id: string; title: string; message: string }
) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== request.sessionId) return;
const apply = (value: T | { status: "submitting" } | { status: "error"; error: string }) => update(target, request.requestId, value);
apply({ status: "submitting" });
try {
await execute(target);
apply(success);
} catch (error) {
const message = error instanceof Error ? error.message : failure.message;
apply({ status: "error", error: message });
showMapNotice({ id: failure.id, tone: "error", title: failure.title, message });
}
};
const questionFailure = { id: "agent-question-status", title: "Agent 问题提交失败", message: "问题回复提交失败。" };
return {
replyPermission: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => submit<PermissionOverride>(
permission, permissionUpdate,
(target) => target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply }),
{ status: toPermissionStatus(reply) },
{ id: "agent-permission-status", title: "Agent 权限提交失败", message: "权限请求提交失败。" }
),
replyQuestion: (question: AgentQuestionRequest, answers: string[][]) => submit<QuestionOverride>(
question, questionUpdate,
(target) => target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers }),
{ status: "answered", answers }, questionFailure
),
rejectQuestion: (question: AgentQuestionRequest) => submit<QuestionOverride>(
question, questionUpdate,
(target) => target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId }),
{ status: "rejected" }, { ...questionFailure, message: "问题跳过失败。" }
)
};
}
@@ -125,3 +125,76 @@ it("keeps a malformed persisted data payload from crashing the conversation", ()
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。"); expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
} }
}); });
it("restores an aborted run's terminal state from canonical history", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
{ type: "text", text: "已核查的部分结果" },
{ type: "data-progress", data: { id: "query", title: "查询", status: "running" } },
{ type: "data-run_status", data: { status: "aborted", message: "请求已中断" } }
] }]);
expect(toAgentChatMessages(messages, {}, {})[0]).toMatchObject({ content: "已核查的部分结果", progress: [{ status: "cancelled" }] });
});
describe("terminal tool progress", () => {
const parts = [
{ type: "text", text: "已核查的部分结果" },
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query", input: { id: 12 } } } },
{ type: "data-todo_update", data: { session_id: "s", todos: [{ id: "todo", content: "核查", status: "in_progress" }] } },
{ type: "data-permission_request", data: { request_id: "permission", session_id: "s", permission: "read", patterns: [] } },
{ type: "data-question_request", data: { request_id: "question", session_id: "s", questions: [{ question: "继续吗?", options: [] }] } }
];
const initial = () => toAgentUiMessages([{ id: "answer", role: "assistant", parts }]);
it.each(["done", "error", "aborted"] as const)("settles running tool rows after resumed %s", (type) => {
const messages = applySessionStreamEvent(initial(), { type, data: { message: "模型请求失败" } });
const answer = toAgentChatMessages(messages, {}, {})[0];
expect(answer.progress?.[0]).toMatchObject({ status: type === "done" ? "completed" : type === "aborted" ? "cancelled" : "error", tool: { name: "query", input: { id: 12 } } });
expect(answer.todos?.todos[0].status).toBe("cancelled");
expect(answer.content).toBe("已核查的部分结果");
if (type === "error") expect(answer.runError).toBe("模型请求失败");
});
it("preserves the reason when reopening a failed answer with partial structured output", () => {
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [...parts,
{ type: "data-final_answer", data: { content: "已核查的部分结果" } },
{ type: "data-run_status", data: { status: "error", message: "模型请求失败" } }
] }]);
const answer = toAgentChatMessages(messages, {}, {})[0];
expect(answer).toMatchObject({ content: "已核查的部分结果", runError: "模型请求失败", progress: [{ status: "error", detail: "模型请求失败" }] });
});
it("does not let an old reply override revive stopped cards", () => {
const messages = applySessionStreamEvent(initial(), { type: "run_status", data: { status: "aborted" } });
const answer = toAgentChatMessages(messages, { permission: { status: "error", error: "late HTTP response" } },
{ question: { status: "submitting" } })[0];
expect(answer.permissions?.[0].status).toBe("aborted");
expect(answer.questions?.[0].status).toBe("rejected");
const stopped = applySessionStreamEvent(messages, { type: "error", data: { message: "请求已中断" } });
expect(toAgentChatMessages(stopped, {}, {})[0].runError).toBeUndefined();
expect(toAgentChatMessages(stopped, {}, {})[0].progress?.[0].status).toBe("cancelled");
});
it("settles legacy history using the session status while leaving active tools running", () => {
for (const status of ["completed", "aborted", "error", "waiting_input", "running"] as const) {
const messages = toAgentUiMessages(initial(), { runStatus: status, isStreaming: status === "running" });
expect(toAgentChatMessages(messages, {}, {})[0].progress?.[0].status)
.toBe(status === "running" ? "running" : status === "error" ? "error" : status === "aborted" ? "cancelled" : "completed");
}
const state = applySessionStreamEvent(initial(), { type: "state", sessionId: "s", isStreaming: false, messages: initial() });
expect(toAgentChatMessages(state, {}, {})[0].progress?.[0].status).toBe("completed");
});
it("keeps a saved failure authoritative when a legacy session defaults to completed", () => {
const saved = applySessionStreamEvent(initial(), { type: "error", data: { message: "历史错误" } });
const answer = toAgentChatMessages(toAgentUiMessages(saved, { runStatus: "completed", isStreaming: false }), {}, {})[0];
expect(answer.runError).toBe("历史错误");
});
it("routes historical question replies to the owning assistant without changing the current turn", () => {
const messages = [...initial(), ...toAgentUiMessages([{ id: "current", role: "assistant", parts: [{ type: "text", text: "新一轮" }] }])];
const updated = applySessionStreamEvent(messages, { type: "question_response", data: { request_id: "question", rejected: true } });
const answers = toAgentChatMessages(updated, {}, {});
expect(answers[0].questions?.[0].status).toBe("rejected");
expect(updated[1]).toEqual(messages[1]);
});
});
@@ -2,6 +2,10 @@ import type { UIMessage } from "ai";
import type { Dispatch, SetStateAction } from "react"; import type { Dispatch, SetStateAction } from "react";
import { import {
parseAnswerUiBlock, parseAnswerUiBlock,
completeRunningProgress,
cancelRunningTodos,
finalizeAssistantMessageAfterAbort,
finalizeAssistantMessageAfterError,
readInteractionReply, readInteractionReply,
getFinalAnswerTranscript, getFinalAnswerTranscript,
type AnswerUiPart, type AnswerUiPart,
@@ -17,7 +21,8 @@ import {
type AgentPermissionStatus, type AgentPermissionStatus,
type AgentQuestionRequest, type AgentQuestionRequest,
type AgentSessionStreamEvent, type AgentSessionStreamEvent,
type AgentStreamRenderState type AgentStreamRenderState,
type AgentRunStatus
} from "@/features/agent"; } from "@/features/agent";
type AgentUiDataParts = { type AgentUiDataParts = {
@@ -26,6 +31,7 @@ type AgentUiDataParts = {
task_context: Record<string, unknown>; task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>; execution_plan: Record<string, unknown>;
progress: Record<string, unknown>; progress: Record<string, unknown>;
run_status: Record<string, unknown>;
final_answer: Record<string, unknown>; final_answer: Record<string, unknown>;
credential_refresh_required: Record<string, unknown>; credential_refresh_required: Record<string, unknown>;
credential_refreshed: Record<string, unknown>; credential_refreshed: Record<string, unknown>;
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
...(answerParts ? { answerParts } : {}) ...(answerParts ? { answerParts } : {})
}; };
let runState: Record<string, unknown> | undefined;
for (const part of message.parts) { for (const part of message.parts) {
if (!isAgentDataPart(part)) { if (!isAgentDataPart(part)) {
continue; continue;
} }
if (part.type === "data-interaction_reply" && message.role === "user") { if (part.type === "data-run_status") {
runState = part.data;
} else if (part.type === "data-interaction_reply" && message.role === "user") {
const reply = readInteractionReply(part.data, message.id); const reply = readInteractionReply(part.data, message.id);
if (reply) next = { ...next, interactionReply: reply }; if (reply) next = { ...next, interactionReply: reply };
} else if (part.type === "data-task_context") { } else if (part.type === "data-task_context") {
@@ -193,23 +202,44 @@ export function toAgentChatMessages(
if (next.finalAnswer) { if (next.finalAnswer) {
next.content = getFinalAnswerTranscript(next); next.content = getFinalAnswerTranscript(next);
} }
if (runState?.status === "aborted") {
next = finalizeAssistantMessageAfterAbort(next);
} else if (runState?.status === "error") {
next = finalizeAssistantMessageAfterError(next, getDataString(runState, "message") ?? "Agent response failed");
} else if (runState?.status === "completed" || runState?.status === "waiting_input") {
next = { ...next, progress: completeRunningProgress(next.progress),
...(runState.status === "completed" ? { todos: cancelRunningTodos(next.todos) } : {}) };
}
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)]; return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
}); });
} }
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] { export function toAgentUiMessages(
return messages.flatMap((message) => { messages: unknown[],
session?: { runStatus?: AgentRunStatus; isStreaming?: boolean }
): AgentUiMessage[] {
const normalized = messages.flatMap((message) => {
if (isAgentUiMessage(message)) { if (isAgentUiMessage(message)) {
return [message]; return [message];
} }
const legacyMessage = toAgentUiMessageFromLegacy(message); const legacyMessage = toAgentUiMessageFromLegacy(message);
return legacyMessage ? [legacyMessage] : []; return legacyMessage ? [legacyMessage] : [];
}); });
if (!session) return normalized;
const lastAssistant = normalized.reduce((last, message, index) => message.role === "assistant" ? index : last, -1);
return normalized.map((message, index) => {
if (message.role !== "assistant" || message.parts.some((part) => part.type === "data-run_status")) return message;
const status = index === lastAssistant
? session.runStatus ?? (session.isStreaming === false ? "completed" : undefined)
: "completed";
if (status === "completed" && !message.parts.some((part) => part.type === "data-progress" || part.type === "data-todo_update")) return message;
return status && status !== "running" ? upsertUiDataPart(message, "run_status", { id: "run-status", status }) : message;
});
} }
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] { export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
if (event.type === "state") { if (event.type === "state") {
return toAgentUiMessages(event.messages); return toAgentUiMessages(event.messages, event);
} }
if (event.type === "user_input") { if (event.type === "user_input") {
@@ -226,15 +256,34 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages; return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
} }
if (event.type === "done" || event.type === "error" || event.type === "aborted") {
return updateLastAssistantUiMessage(messages, (message) => {
const previous = [...message.parts].reverse().find((part) => part.type === "data-run_status");
const data = previous && isAgentDataPart(previous) ? previous.data : {};
return upsertUiDataPart(message, "run_status", {
...data, id: "run-status",
status: data.status === "aborted" || event.type === "aborted" || event.data.status === "aborted" ? "aborted"
: event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed",
...(event.type === "error" || event.type === "aborted" ? { message: getDataString(event.data, "message") ?? getDataString(data, "message") ?? "Agent stream failed" } : {})
});
});
}
if (event.type === "permission_response" || event.type === "question_response") {
const requestType = event.type === "permission_response" ? "data-permission_request" : "data-question_request";
return messages.map((message) => message.role === "assistant" && message.parts.some((part) =>
part.type === requestType && isAgentDataPart(part) && part.data.request_id === event.data.request_id)
? upsertUiDataPart(message, event.type, event.data) : message);
}
if ( if (
event.type === "run_status" ||
event.type === "final_answer" || event.type === "final_answer" ||
event.type === "progress" || event.type === "progress" ||
event.type === "execution_plan" || event.type === "execution_plan" ||
event.type === "todo_update" || event.type === "todo_update" ||
event.type === "permission_request" || event.type === "permission_request" ||
event.type === "permission_response" ||
event.type === "question_request" || event.type === "question_request" ||
event.type === "question_response" ||
event.type === "answer_ui" || event.type === "answer_ui" ||
event.type === "ui_envelope" || event.type === "ui_envelope" ||
event.type === "session_title" event.type === "session_title"
@@ -244,13 +293,6 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
); );
} }
if (event.type === "error") {
const message = getDataString(event.data, "message") ?? "Agent stream failed";
return updateLastAssistantUiMessage(messages, (item) =>
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
);
}
return messages; return messages;
} }
@@ -478,7 +520,7 @@ function applyPermissionOverrides(
...message, ...message,
permissions: message.permissions.map((permission) => { permissions: message.permissions.map((permission) => {
const override = permissionOverrides[permission.requestId]; const override = permissionOverrides[permission.requestId];
return override return override && ["pending", "submitting", "error"].includes(permission.status)
? { ? {
...permission, ...permission,
status: override.status, status: override.status,
@@ -502,7 +544,7 @@ function applyQuestionOverrides(
...message, ...message,
questions: message.questions.map((question) => { questions: message.questions.map((question) => {
const override = questionOverrides[question.requestId]; const override = questionOverrides[question.requestId];
return override return override && ["checking", "pending", "submitting", "error"].includes(question.status)
? { ? {
...question, ...question,
status: override.status, status: override.status,
@@ -3,11 +3,42 @@ import type { ChatTransport, UIMessageChunk } from "ai";
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent"; import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources"; import { WorkbenchSessionResources } from "./workbench-session-resources";
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime"; import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
import type { AgentUiMessage } from "./agent-session-message-state"; import { toAgentChatMessages, type AgentUiMessage } from "./agent-session-message-state";
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] }); const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
describe("independent conversation runtimes", () => { describe("independent conversation runtimes", () => {
it.each(["completed", "error", "aborted"] as const)("settles direct tool progress when the response is %s without a final progress event", async (status) => {
const resources = new WorkbenchSessionResources();
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, {
sendMessages: async ({ abortSignal }) => new ReadableStream<UIMessageChunk>({ start(stream) {
controller = stream;
abortSignal?.addEventListener("abort", () => stream.error(new DOMException("Aborted", "AbortError")), { once: true });
} }),
reconnectToStream: async () => null,
});
resources.activeAgent = runtime;
try {
const request = runtime.chat.sendMessage({ text: "核查工具状态" });
await vi.waitFor(() => expect(controller).toBeDefined());
controller.enqueue({ type: "start", messageId: "answer" });
controller.enqueue({ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running" } });
await vi.waitFor(() => expect(toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1)?.progress?.[0].status).toBe("running"));
if (status === "aborted") await runtime.chat.stop();
else {
if (status === "error") controller.enqueue({ type: "error", errorText: "工具执行失败" });
controller.enqueue({ type: "finish", finishReason: status === "error" ? "error" : "stop" });
controller.close();
}
await request;
const answer = toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1);
expect(answer?.progress?.[0].status).toBe(status === "aborted" ? "cancelled" : status);
expect(runtime.runStatus).toBe(status);
if (status === "error") expect(answer?.runError).toBe("工具执行失败");
} finally { resources.dispose(); }
});
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => { it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
const resources = new WorkbenchSessionResources(); const resources = new WorkbenchSessionResources();
@@ -141,7 +172,9 @@ describe("independent conversation runtimes", () => {
}); });
resumeAgentRuntime(resources, runtime); resumeAgentRuntime(resources, runtime);
await vi.waitFor(() => expect(runtime.controller).toBeUndefined()); await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]); expect(toAgentChatMessages(runtime.chat.messages, {}, {})).toMatchObject([
{ id: "a", content: "a" }, { id: "b", content: "new" }, { id: "c", content: "c" }
]);
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({ expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }), status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
})); }));
@@ -91,7 +91,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
const active = () => resources.activeAgent === runtime; const active = () => resources.activeAgent === runtime;
const notify = () => queueMicrotask(() => resources.notify()); const notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat<AgentUiMessage>({ runtime.chat = new Chat<AgentUiMessage>({
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []), id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? [], loaded),
onData: (part) => { onData: (part) => {
if (resources.disposed) return; if (resources.disposed) return;
const data = part as AgentDataPart; const data = part as AgentDataPart;
@@ -99,6 +99,10 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return; if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
if (sessionId) runtime.sessionId = sessionId; if (sessionId) runtime.sessionId = sessionId;
if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input"; if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
if (data.type === "data-run_status") {
const status = data.data.status;
if (status === "completed" || status === "error" || status === "aborted" || status === "waiting_input") runtime.runStatus = status;
}
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title; if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
if (data.type === "data-credential_refresh_required") { if (data.type === "data-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data); void refreshRuntimeCredential(runtime, data.data);
@@ -118,10 +122,16 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (resources.disposed) return; if (resources.disposed) return;
runtime.loadedAt = Date.now(); runtime.loadedAt = Date.now();
runtime.updatedAt = Date.now(); runtime.updatedAt = Date.now();
runtime.runStatus = isAbort ? "aborted" : isError runtime.runStatus = isAbort || runtime.runStatus === "aborted" ? "aborted" : isError
? isPendingInteractionError(runtime.error) ? "waiting_input" : "error" ? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed"; : runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId; const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
if (!recovering) {
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, {
type: runtime.runStatus === "error" || runtime.runStatus === "aborted" ? "error" : "done",
data: { status: runtime.runStatus, reason: runtime.runStatus, ...(runtime.error ? { message: runtime.error } : {}) }
});
}
if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); } if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
else if (!recovering) notifyBackgroundCompletion(runtime); else if (!recovering) notifyBackgroundCompletion(runtime);
if (recovering) { if (recovering) {
@@ -173,7 +183,7 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id"); const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
if (sessionId && sessionId !== runtime.sessionId) return; if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") { if (event.type === "state") {
const tail = toAgentUiMessages(event.messages); const tail = toAgentUiMessages(event.messages, event);
const prior = runtime.chat.messages; const prior = runtime.chat.messages;
// Start filling a disconnected interval before reconciling the live tail. // Start filling a disconnected interval before reconciling the live tail.
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) { if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
@@ -191,11 +201,16 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data); if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data); else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event); runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
if (event.type === "run_status") {
const status = event.data.status;
if (status === "aborted" || status === "error" || status === "completed" || status === "waiting_input") runtime.runStatus = status;
}
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title; if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
if (event.type === "done" || event.type === "error") { if (event.type === "done" || event.type === "error" || event.type === "aborted") {
terminal = true; terminal = true;
runtime.resumed = false; runtime.resumed = false;
runtime.runStatus = event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed"; runtime.runStatus = event.type === "aborted" || event.data.status === "aborted" || runtime.runStatus === "aborted" ? "aborted"
: event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
runtime.updatedAt = Date.now(); runtime.updatedAt = Date.now();
} }
} }
@@ -0,0 +1,43 @@
import { useLayoutEffect, useState } from "react";
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartLayout, type ChartSlot } from "../utils/chart-window-slots";
/** Keep chart identities in fixed positions throughout the scrolling grid. */
export function useChartWindowLayout(ids: string[]) {
const [container, setContainer] = useState<HTMLElement | null>(null);
const [narrow, setNarrow] = useState(window.innerWidth < 760);
const [preferredLayout, setPreferredLayout] = useState<ChartLayout>("four");
const layout = narrow ? "one" : preferredLayout;
const columns = columnsForLayout(layout);
const [slots, setSlots] = useState<ChartSlot[]>([]);
const available = reconcileChartSlots(slots, ids, columns);
if (available !== slots) setSlots(available);
useLayoutEffect(() => {
if (!container) return;
const measure = () => setNarrow(container.clientWidth < 720);
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [container]);
return {
containerRef: setContainer,
layout,
preferredLayout,
narrow,
setLayout: setPreferredLayout,
slots: available,
columns,
move: (id: string, target: number) => {
const source = available.indexOf(id);
const next = swapChartSlots(available, source, target);
if (next !== available) {
setSlots(next);
}
},
reset: () => {
setSlots(reconcileChartSlots([], ids, columns));
}
};
}
@@ -95,3 +95,59 @@ it("advances saved snapshots on its timer, ignores new results and stops when th
act(() => vi.advanceTimersByTime(3000)); act(() => vi.advanceTimersByTime(3000));
expect(result.current.records).toEqual([{ value: 3 }]); expect(result.current.records).toEqual([{ value: 3 }]);
}); });
it("requests only the live task manager and forgets automatic intent across task switches", async () => {
vi.useFakeTimers();
const task = { task_id: "task-a", title: "运行中调查", execution_generation: 1, status: "running", evidence: [] } as unknown as import("../api/operational-task-client").OperationalTask;
const command = { action: "open_chart" as const, chart_id: "pressure", evidence_refs: ["test"], chart: {
title: "压力", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "压力", data: [20] }]
} };
const panel = document.createElement("div");
panel.dataset.presentationScope = "investigation";
const marker = document.createElement("div");
marker.dataset.investigationChart = "pressure";
marker.getBoundingClientRect = () => ({ height: 10 }) as DOMRect;
panel.append(marker);
document.body.append(panel);
const initial = { ...options, scope: "investigation" as const, task, enabled: true };
const { result, rerender } = renderHook((next) => useInvestigationPresentation(next), { initialProps: initial });
const execute = async (origin: "live" | "preview" | "chat") => {
await act(async () => {
const pending = result.current.executeCommand(command, new AbortController().signal, origin);
await vi.advanceTimersByTimeAsync(700);
await pending;
});
};
try {
expect(result.current.chartWindowAutoOpen).toBe(false);
await execute("preview");
expect(result.current.chartWindowAutoOpen).toBe(false);
await execute("chat");
expect(result.current.chartWindowAutoOpen).toBe(false);
panel.dataset.investigationCollapsed = "true";
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
panel.dataset.investigationCollapsed = "false";
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(true);
rerender({ ...initial, task: { ...task, task_id: "task-b" } });
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender(initial);
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender({ ...initial, enabled: false });
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender({ ...initial, task: { ...task, status: "completed", lifecycle_status: "completed" } });
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
rerender(initial);
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "历史结果" }], commands: [] }));
act(() => result.current.history.onSelect(result.current.history.entries[0].id));
await execute("live");
expect(result.current.chartWindowAutoOpen).toBe(false);
act(() => result.current.history.onSelect(null));
expect(result.current.chartWindowAutoOpen).toBe(false);
} finally {
panel.remove();
}
});
@@ -114,6 +114,9 @@ async function waitForChart(
} }
export function useInvestigationPresentation(options: Options) { export function useInvestigationPresentation(options: Options) {
const windowScope = JSON.stringify([options.scope, options.scopeId, options.projectId, options.task?.task_id, options.task?.execution_generation]);
const [chartWindow, setChartWindow] = useState({ scope: windowScope, requested: false });
if (chartWindow.scope !== windowScope) setChartWindow({ scope: windowScope, requested: false });
const valuesRef = useRef(options); const valuesRef = useRef(options);
valuesRef.current = options; valuesRef.current = options;
const storeRef = useRef(new PresentationViewHistory()); const storeRef = useRef(new PresentationViewHistory());
@@ -155,6 +158,14 @@ export function useInvestigationPresentation(options: Options) {
: "live", : "live",
[] []
); );
const requestChartWindow = useCallback((origin: InvestigationPresentationActivity["source"]) => {
const current = valuesRef.current;
if (storeRef.current.selectedId !== null || current.scope === "chat" || current.scope === "preview") return;
if (origin !== "live" || !current.enabled || current.task?.status !== "running") return;
const panel = document.querySelector<HTMLElement>(`[data-presentation-scope="${current.scope ?? "investigation"}"]`);
if (panel?.dataset.investigationCollapsed === "true") return;
setChartWindow({ scope: JSON.stringify([current.scope, current.scopeId, current.projectId, current.task?.task_id, current.task?.execution_generation]), requested: true });
}, []);
const update = useCallback( const update = useCallback(
(updater: (view: PresentationView) => PresentationView) => { (updater: (view: PresentationView) => PresentationView) => {
storeRef.current.update(updater(storeRef.current.current)); storeRef.current.update(updater(storeRef.current.current));
@@ -289,6 +300,7 @@ export function useInvestigationPresentation(options: Options) {
}) })
); );
signal.throwIfAborted(); signal.throwIfAborted();
if (command.action === "open_chart") requestChartWindow(origin);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry); if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish(); publish();
setActivity((current) => setActivity((current) =>
@@ -327,7 +339,7 @@ export function useInvestigationPresentation(options: Options) {
throw cause; throw cause;
} }
}, },
[scene, publish, update] [scene, publish, update, requestChartWindow]
); );
useEffect(() => { useEffect(() => {
@@ -555,6 +567,8 @@ export function useInvestigationPresentation(options: Options) {
const visible = storeRef.current.visible; const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null; const reviewing = storeRef.current.selectedId !== null;
return { return {
chartWindowAutoOpen: chartWindow.scope === windowScope && chartWindow.requested,
chartWindowScope: windowScope,
charts: visible.charts, charts: visible.charts,
blocks: visible.blocks, blocks: visible.blocks,
records: visible.records, records: visible.records,
@@ -9,6 +9,7 @@ import {
function task(taskType: OperationalTask["task_type"]): OperationalTask { function task(taskType: OperationalTask["task_type"]): OperationalTask {
return { return {
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: taskType, task_type: taskType,
@@ -79,7 +79,7 @@ export function useOperationalTasks(
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0); const [refreshGeneration, setRefreshGeneration] = useState(0);
const [eventRetryGeneration, setEventRetryGeneration] = useState(0); const enqueueEvents = useRef<(events: WorkbenchEvent[]) => void>(() => {});
const eventRetryMsRef = useRef(1_000); const eventRetryMsRef = useRef(1_000);
const forceRefreshRef = useRef(false); const forceRefreshRef = useRef(false);
const detailResetGenerationRef = useRef(sync.detailResetGeneration); const detailResetGenerationRef = useRef(sync.detailResetGeneration);
@@ -131,50 +131,76 @@ export function useOperationalTasks(
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]); }, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
useEffect(() => { useEffect(() => {
const events = syncEvents;
if (events.length === 0) return;
const controller = new AbortController(); const controller = new AbortController();
let retryTimer: number | undefined; let retryTimer: number | undefined;
const lastSequence = events.at(-1)!.event_seq; let pendingEvents: WorkbenchEvent[] = [];
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events); let appliedSequence = 0;
if (deletedTaskIds.size > 0) { let processing = false;
const removeDeletedTasks = (deletedTaskIds: Set<string>) => {
if (deletedTaskIds.size === 0) return;
tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id)); tasksRef.current = tasksRef.current.filter((task) => !deletedTaskIds.has(task.task_id));
setTasks(tasksRef.current); setTasks(tasksRef.current);
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id))); setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
} };
void Promise.all([ const load = async () => {
if (processing || controller.signal.aborted) return;
processing = true;
window.clearTimeout(retryTimer);
try {
while (pendingEvents.length > 0 && !controller.signal.aborted) {
const events = pendingEvents;
const lastSequence = events.at(-1)!.event_seq;
const { taskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
const [nextNotifications, changedTasks] = await Promise.all([
notificationsChanged notificationsChanged
? fetchOperationalTaskNotifications(getAccessToken, controller.signal) ? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
: Promise.resolve(null), : Promise.resolve(null),
Promise.all([...taskIds].map((taskId) => Promise.all([...taskIds].map((taskId) =>
fetchOperationalTask(taskId, getAccessToken, controller.signal) fetchOperationalTask(taskId, getAccessToken, controller.signal)
)) ))
]).then(([nextNotifications, changedTasks]) => { ]);
if (controller.signal.aborted) return; if (controller.signal.aborted) return;
if (nextNotifications) setNotifications(nextNotifications); // A deletion received during this read must not be undone by its response.
if (changedTasks.length > 0) { const { deletedTaskIds } = collectOperationalTaskEventChanges(pendingEvents);
const changed = new Map(changedTasks.map((task) => [task.task_id, task])); if (nextNotifications) setNotifications(nextNotifications.filter((item) => !deletedTaskIds.has(item.task_id)));
const visibleTasks = changedTasks.filter((task) => !deletedTaskIds.has(task.task_id));
if (visibleTasks.length > 0) {
const changed = new Map(visibleTasks.map((task) => [task.task_id, task]));
tasksRef.current = [ tasksRef.current = [
...changedTasks, ...visibleTasks,
...tasksRef.current.filter((task) => !changed.has(task.task_id)) ...tasksRef.current.filter((task) => !changed.has(task.task_id))
]; ];
setTasks(tasksRef.current); setTasks(tasksRef.current);
} }
appliedSequence = lastSequence;
pendingEvents = pendingEvents.filter((event) => event.event_seq > appliedSequence);
acknowledgeEventsThrough(lastSequence); acknowledgeEventsThrough(lastSequence);
eventRetryMsRef.current = 1_000; eventRetryMsRef.current = 1_000;
setError(null); setError(null);
}).catch((nextError) => { }
} catch (nextError) {
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。"); setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current); retryTimer = window.setTimeout(() => void load(), eventRetryMsRef.current);
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000); eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
} }
}); } finally { processing = false; }
};
enqueueEvents.current = (events) => {
pendingEvents = events.filter((event) => event.event_seq > appliedSequence);
removeDeletedTasks(collectOperationalTaskEventChanges(pendingEvents).deletedTaskIds);
void load();
};
return () => { return () => {
controller.abort(); controller.abort();
if (retryTimer !== undefined) window.clearTimeout(retryTimer); enqueueEvents.current = () => {};
window.clearTimeout(retryTimer);
}; };
}, [acknowledgeEventsThrough, eventRetryGeneration, getAccessToken, syncEvents]); }, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration]);
useEffect(() => {
enqueueEvents.current(syncEvents);
}, [acknowledgeEventsThrough, getAccessToken, sync.detailResetGeneration, syncEvents]);
const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task), const conditions = useMemo(() => tasks.map((task) => ({ ...toOperationalTaskCondition(task),
hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated" hasNewReport: notifications.some((item) => item.task_id === task.task_id && item.kind === "report_updated"
@@ -1,3 +1,4 @@
import { createAgentInteractionReplies } from "./agent-interaction-replies";
import { getWorkbenchAccessToken, getWorkbenchTokenProvider, getWorkbenchProject } from "../api/request-context"; import { getWorkbenchAccessToken, getWorkbenchTokenProvider, getWorkbenchProject } from "../api/request-context";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useChat } from "@ai-sdk/react"; import { useChat } from "@ai-sdk/react";
@@ -33,9 +34,6 @@ import {
type AgentEventTriggerContext, type AgentEventTriggerContext,
type AgentModelOption, type AgentModelOption,
type AgentInputDelivery, type AgentInputDelivery,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent, type AgentSessionStreamEvent,
type AgentSessionPurpose, type AgentSessionPurpose,
type AgentStreamRenderState, type AgentStreamRenderState,
@@ -52,7 +50,6 @@ import {
readBodyString, readBodyString,
toAgentChatMessages, toAgentChatMessages,
toAgentUiMessages, toAgentUiMessages,
toPermissionStatus,
type AgentDataPart, type AgentDataPart,
type AgentUiMessage, type AgentUiMessage,
type PermissionOverride, type PermissionOverride,
@@ -200,7 +197,7 @@ export function useWorkbenchAgent({
try { try {
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 }); const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
if (loaded && !resources.disposed && target.client === scopeClient) { if (loaded && !resources.disposed && target.client === scopeClient) {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages)); target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
resources.notify(); resources.notify();
} }
} catch { /* A failed reconciliation leaves the accepted answer intact. */ } } catch { /* A failed reconciliation leaves the accepted answer intact. */ }
@@ -534,12 +531,15 @@ export function useWorkbenchAgent({
() => clientRef.current.listResponseInputs(sessionId!), () => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false } { refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
); );
const handledRecovery = useRef({ scopeKey, version: 0 });
useEffect(() => { useEffect(() => {
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
handledRecovery.current = { scopeKey, version: recoveryVersion };
if (!recoveryVersion) return; if (!recoveryVersion) return;
void refreshBootstrap(); void refreshBootstrap();
void refreshInteractionRequests(); void refreshInteractionRequests();
void refreshPendingInputs(); void refreshPendingInputs();
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]); }, [scopeKey, recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => { const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return; if (!sessionIdRef.current) return;
try { try {
@@ -681,9 +681,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成"); setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now(); runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "error") { } else if (event.type === "aborted" || event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 后端请求失败"); setStatusLabel(event.type === "aborted" || event.data.status === "aborted" ? "Agent 已停止" : "Agent 后端请求失败");
runtimeRef.current!.loadedAt = Date.now(); runtimeRef.current!.loadedAt = Date.now();
} }
@@ -732,7 +732,7 @@ export function useWorkbenchAgent({
if (generation !== loadingGenerationRef.current) return false; if (generation !== loadingGenerationRef.current) return false;
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded); if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
else { else {
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages)); target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title; target.topicId = loaded.topicId; target.loadedAt = Date.now(); target.runStatus = loaded.runStatus; target.title = loaded.title; target.topicId = loaded.topicId; target.loadedAt = Date.now(); target.runStatus = loaded.runStatus;
target.error = undefined; target.error = undefined;
target.messagePage ??= loaded.messagePage; target.messagePage ??= loaded.messagePage;
@@ -827,7 +827,7 @@ export function useWorkbenchAgent({
); );
} else { } else {
target.client = projectClient; target.client = projectClient;
target.chat.messages = toAgentUiMessages(loaded.messages); target.chat.messages = toAgentUiMessages(loaded.messages, loaded);
target.title = loaded.title; target.title = loaded.title;
target.purpose = "task_execution"; target.purpose = "task_execution";
target.taskId = normalizedTaskId; target.taskId = normalizedTaskId;
@@ -877,7 +877,7 @@ export function useWorkbenchAgent({
refreshing = false; refreshing = false;
return; return;
} }
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages)); target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.title = loaded.title; target.title = loaded.title;
target.updatedAt = loaded.updatedAt; target.updatedAt = loaded.updatedAt;
target.loadedAt = Date.now(); target.loadedAt = Date.now();
@@ -1045,7 +1045,7 @@ export function useWorkbenchAgent({
target.purpose = "task_collaboration"; target.purpose = "task_collaboration";
target.taskId = binding.taskId; target.taskId = binding.taskId;
target.taskExecutionHistory = undefined; target.taskExecutionHistory = undefined;
target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages)); target.chat.messages = mergeHistoryMessages(target.chat.messages, toAgentUiMessages(loaded.messages, loaded));
target.messagePage = loaded.messagePage; target.messagePage = loaded.messagePage;
target.loadedAt = Date.now(); target.loadedAt = Date.now();
target.updatedAt = loaded.updatedAt; target.updatedAt = loaded.updatedAt;
@@ -1139,62 +1139,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState); markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]); }, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => { const { replyPermission, replyQuestion, rejectQuestion } = useMemo(() => createAgentInteractionReplies(resources, {
const target = resources.activeAgent; permission: setPermissionOverrides, question: setQuestionOverrides
if (!target || target.sessionId !== permission.sessionId) return; }), [resources]);
const update = (value: PermissionOverride) => {
target.permissionOverrides = { ...target.permissionOverrides, [permission.requestId]: value };
if (resources.activeAgent === target) setPermissionOverrides(target.permissionOverrides);
resources.notify();
};
update({ status: "submitting" });
try {
await target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply });
update({ status: toPermissionStatus(reply) });
} catch (error) {
const message = error instanceof Error ? error.message : "权限请求提交失败。";
update({ status: "error", error: message });
showMapNotice({ id: "agent-permission-status", tone: "error", title: "Agent 权限提交失败", message });
}
}, [resources]);
const replyQuestion = useCallback(async (question: AgentQuestionRequest, answers: string[][]) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== question.sessionId) return;
const update = (value: QuestionOverride) => {
target.questionOverrides = { ...target.questionOverrides, [question.requestId]: value };
if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
resources.notify();
};
update({ status: "submitting" });
try {
await target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers });
update({ status: "answered", answers });
} catch (error) {
const message = error instanceof Error ? error.message : "问题回复提交失败。";
update({ status: "error", error: message });
showMapNotice({ id: "agent-question-status", tone: "error", title: "Agent 问题提交失败", message });
}
}, [resources]);
const rejectQuestion = useCallback(async (question: AgentQuestionRequest) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== question.sessionId) return;
const update = (value: QuestionOverride) => {
target.questionOverrides = { ...target.questionOverrides, [question.requestId]: value };
if (resources.activeAgent === target) setQuestionOverrides(target.questionOverrides);
resources.notify();
};
update({ status: "submitting" });
try {
await target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId });
update({ status: "rejected" });
} catch (error) {
const message = error instanceof Error ? error.message : "问题跳过失败。";
update({ status: "error", error: message });
showMapNotice({ id: "agent-question-status", tone: "error", title: "Agent 问题提交失败", message });
}
}, [resources]);
return { return {
apiClient: clientRef.current, apiClient: clientRef.current,
@@ -0,0 +1,298 @@
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { SWRConfig } from "swr";
const harness = vi.hoisted(() => ({
api: null as any,
recovery: 0,
flow: vi.fn(),
task: vi.fn(),
check: vi.fn()
}));
vi.mock("@/features/agent", async (original) => ({
...(await original<any>()),
createAgentApiClient: () => harness.api
}));
vi.mock("@/features/agent/hooks/use-agent-connection", () => ({
useAgentConnection: () => ({
connectionStatus: "online",
recoveryVersion: harness.recovery,
recheck: harness.check
})
}));
vi.mock("@/features/workbench/api/realtime-flow-client", () => ({
fetchRealtimePipeFlows: harness.flow
}));
vi.mock("@/features/workbench/api/operational-task-client", async (original) => ({
...(await original<any>()),
fetchOperationalTask: harness.task,
fetchOperationalTaskNotifications: async () => []
}));
import { useWorkbenchRuntimeData } from "@/features/workbench/hooks/use-workbench-runtime-data";
import { useWorkbenchAgent } from "@/features/workbench/hooks/use-workbench-agent";
import { useOperationalTasks } from "@/features/workbench/hooks/use-operational-tasks";
import { WorkbenchSessionProvider } from "@/features/workbench/hooks/workbench-session-provider";
import { useAgentMessageInbox } from "@/features/agent/hooks/use-agent-message-inbox";
const access: any = {
project: { project_id: "p", gs_workspace: "tjwater_next", status: "active" },
getAccessToken: async () => "test"
};
const sync: any = {
snapshot: null,
events: [],
status: "live",
error: null,
detailResetGeneration: 0,
acknowledgeEventsThrough: vi.fn(),
refresh: vi.fn(),
subscribePresentationEvents: () => () => {}
};
afterEach(() => {
cleanup();
vi.useRealTimers();
vi.restoreAllMocks();
harness.flow.mockReset();
harness.task.mockReset();
harness.recovery = 0;
});
function wrapperFactory() {
const cache = new Map();
return ({ children }: any) => (
<WorkbenchSessionProvider owner={access}>
<SWRConfig
value={{ provider: () => cache, revalidateOnFocus: false, shouldRetryOnError: false }}
>
{children}
</SWRConfig>
</WorkbenchSessionProvider>
);
}
it("keeps one flow request and one polling timer when visibility changes during a request", async () => {
vi.useFakeTimers();
let visibility = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility as any);
const done: Array<() => void> = [];
harness.flow.mockImplementation(
() => new Promise((resolve) => done.push(() => resolve({ records: [], observedAt: null })))
);
renderHook(() => useWorkbenchRuntimeData(access, sync));
expect(harness.flow).toHaveBeenCalledTimes(1);
await act(async () => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(harness.flow).toHaveBeenCalledTimes(1);
await act(async () => {
done[0]();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(15000);
});
expect(harness.flow).toHaveBeenCalledTimes(2);
await act(async () => {
done[1]();
await vi.advanceTimersByTimeAsync(15000);
});
expect(harness.flow).toHaveBeenCalledTimes(3);
});
it("queues new task events without cancelling or repeating the current detail request", async () => {
const resolve: Array<(value: unknown) => void> = [];
harness.task.mockImplementation(() => new Promise((done) => resolve.push(done)));
const event = (seq: number, id: string) => ({
event_seq: seq,
topic: "operational.progress.changed",
aggregate_type: "operational_task",
aggregate_id: id,
metadata: { task_id: id }
});
const first = event(1, "task-a");
const { result, rerender, unmount } = renderHook(
({ events }) => useOperationalTasks(access, { ...sync, events }),
{ initialProps: { events: [first] } }
);
rerender({ events: [first, event(2, "task-b")] });
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a"]);
expect(harness.task.mock.calls[0][2].aborted).toBe(false);
await act(async () => {
resolve[0]({
task_id: "task-a",
title: "A",
trigger: { kind: "manual" },
source: { kind: "operator" },
task_type: "event_investigation",
evidence: [],
updated_at: "2026-11-09T00:00:00Z"
});
});
expect(harness.task.mock.calls.map((call) => call[0])).toEqual(["task-a", "task-b"]);
await act(async () => {
resolve[1]({
task_id: "task-b",
title: "B",
trigger: { kind: "manual" },
source: { kind: "operator" },
task_type: "event_investigation",
evidence: [],
updated_at: "2026-11-09T00:00:00Z"
});
});
expect(result.current.tasks.map((task) => task.task_id).sort()).toEqual(["task-a", "task-b"]);
expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(2);
unmount();
});
it("refreshes once per health recovery and uses normal loads on session changes", async () => {
const api: any = {
getModels: vi.fn(async () => ({ models: [], defaultModel: "" })),
getUiRegistry: vi.fn(async () => ({})),
getFrontendActionRegistry: vi.fn(async () => ({})),
listSessionPage: vi.fn(async () => ({ sessions: [], hasMore: false, nextCursor: null })),
listTopics: vi.fn(async () => []),
listInteractionRequests: vi.fn(async () => ({ items: [] })),
listResponseInputs: vi.fn(async () => []),
listAgentMessages: vi.fn(async () => ({ items: [], next_cursor: null })),
streamAgentMessages: vi.fn(
(_id: any, options: any) =>
new Promise<void>((resolve) =>
options.signal.addEventListener("abort", () => resolve(), { once: true })
)
),
loadSession: vi.fn(async (id: string) => ({
id,
sessionId: id,
title: id,
purpose: "ordinary_chat",
messages: [],
createdAt: 1,
updatedAt: 1,
runStatus: "completed",
isStreaming: false,
isTitleManuallyEdited: false
}))
};
harness.api = api;
const { result, rerender } = renderHook(
() =>
useWorkbenchAgent({
getAccessToken: access,
onUiEnvelope: () => {},
onFrontendAction: async () => ({})
}),
{ wrapper: wrapperFactory() }
);
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(1));
await act(async () => {
await result.current.loadHistorySession("a");
});
await waitFor(() =>
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "a").length).toBe(1)
);
harness.recovery = 1;
rerender();
await waitFor(() => expect(api.getModels).toHaveBeenCalledTimes(2));
const before = api.getModels.mock.calls.length;
await act(async () => {
await result.current.loadHistorySession("b");
});
await waitFor(() => expect(api.getModels.mock.calls.length).toBe(before));
await waitFor(() =>
expect(api.listInteractionRequests.mock.calls.filter((c: any) => c[0] === "b").length).toBe(1)
);
const pending = api.listResponseInputs.mock.calls.filter((c: any) => c[0] === "b").length;
expect(pending).toBe(1);
});
it("loads the inbox once when the stream is ready and refreshes on later notifications", async () => {
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
const stream = vi.fn((_id: any, options: any) => {
options.onMessage();
return new Promise<void>((resolve) =>
options.signal.addEventListener("abort", () => resolve(), { once: true })
);
});
const api = { listAgentMessages: list, streamAgentMessages: stream } as any;
renderHook(() => useAgentMessageInbox(api, "s"), { wrapper: wrapperFactory() });
await waitFor(() => expect(list).toHaveBeenCalledTimes(1));
await act(async () => {
stream.mock.calls[0][1].onMessage();
});
await waitFor(() => expect(list).toHaveBeenCalledTimes(2));
});
it("keeps newer task changes and does not revive a task deleted during a read", async () => {
const complete: Array<(value: unknown) => void> = [];
harness.task.mockImplementation(() => new Promise((resolve) => complete.push(resolve)));
const event = (seq: number, deleted = false) => ({
event_seq: seq,
topic: "operational.task.changed",
aggregate_type: "operational_task",
aggregate_id: "a",
metadata: { task_id: "a", ...(deleted ? { change: "deleted" } : {}) }
});
const a = event(1);
const { result, rerender } = renderHook(
({ events }) => useOperationalTasks(access, { ...sync, events }),
{
initialProps: { events: [a] }
}
);
rerender({ events: [a, event(2)] });
const task = {
task_id: "a",
title: "A",
trigger: { kind: "manual" },
source: { kind: "operator" },
task_type: "event_investigation",
evidence: [],
updated_at: "2026-11-09T00:00:00Z"
};
await act(async () => {
complete[0](task);
});
expect(harness.task).toHaveBeenCalledTimes(2);
rerender({ events: [a, event(2), event(3, true)] });
expect(result.current.tasks).toEqual([]);
await act(async () => {
complete[1](task);
});
await waitFor(() => expect(sync.acknowledgeEventsThrough).toHaveBeenLastCalledWith(3));
expect(result.current.tasks).toEqual([]);
expect(harness.task).toHaveBeenCalledTimes(2);
});
it("loads the initial inbox even when its stream cannot connect", async () => {
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
const api = {
listAgentMessages: list,
streamAgentMessages: vi.fn(async () => {
throw new Error("offline");
})
} as any;
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
wrapper: wrapperFactory()
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(list).toHaveBeenCalledTimes(1);
});
it("loads the inbox when its stream remains pending without a first frame", async () => {
vi.useFakeTimers();
const list = vi.fn(async () => ({ items: [], next_cursor: null }));
const api = {
listAgentMessages: list,
streamAgentMessages: vi.fn(
(_id: string, options: { signal: AbortSignal }) =>
new Promise<void>((resolve) => {
options.signal.addEventListener("abort", () => resolve(), { once: true });
})
)
} as any;
const { result } = renderHook(() => useAgentMessageInbox(api, "s"), {
wrapper: wrapperFactory()
});
expect(list).not.toHaveBeenCalled();
await act(async () => {
await vi.advanceTimersByTimeAsync(1500);
});
expect(list).toHaveBeenCalledTimes(1);
expect(result.current.loading).toBe(false);
});
@@ -70,7 +70,6 @@ type UseWorkbenchMapOptions = {
onSelectFeature: (feature: DetailFeature) => void; onSelectFeature: (feature: DetailFeature) => void;
selectedFeature: DetailFeature | null; selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null; simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null; networkView?: NetworkViewParams | null;
getAccessToken?: WorkbenchAccess; getAccessToken?: WorkbenchAccess;
geoserverWorkspace?: string; geoserverWorkspace?: string;
@@ -121,8 +120,6 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
...Object.fromEntries( ...Object.fromEntries(
GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"]) GEOSERVER_WATER_NETWORK_SOURCE_IDS.map((sourceId) => [sourceId, "geoserver-mvt"])
), ),
[SIMULATION_SOURCE_IDS.impactArea]: "annotation-source",
[SIMULATION_SOURCE_IDS.annotations]: "annotation-source",
[SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source" [SIMULATION_SOURCE_IDS.backendAnalysis]: "annotation-source"
}; };
@@ -142,7 +139,6 @@ export function useWorkbenchMap({
onSelectFeature, onSelectFeature,
selectedFeature, selectedFeature,
simulationOverlay, simulationOverlay,
allowDemoSimulation,
networkView, networkView,
geoserverWorkspace, geoserverWorkspace,
getAccessToken getAccessToken
@@ -150,11 +146,9 @@ export function useWorkbenchMap({
const mapRef = useRef<MapLibreMap | null>(null); const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible); const impactVisibleRef = useRef(impactVisible);
const simulationOverlayRef = useRef(simulationOverlay); const simulationOverlayRef = useRef(simulationOverlay);
const allowDemoSimulationRef = useRef(allowDemoSimulation);
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW); const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
const fittedNetworkViewRef = useRef<string | null>(null); const fittedNetworkViewRef = useRef<string | null>(null);
simulationOverlayRef.current = simulationOverlay; simulationOverlayRef.current = simulationOverlay;
allowDemoSimulationRef.current = allowDemoSimulation;
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW; networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
const [mapReady, setMapReady] = useState(false); const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null); const [mapError, setMapError] = useState<string | null>(null);
@@ -315,10 +309,8 @@ export function useWorkbenchMap({
const source = sources[sourceId]; const source = sources[sourceId];
if (source) map.addSource(sourceId, source); if (source) map.addSource(sourceId, source);
}); });
map.addSource(SIMULATION_SOURCE_IDS.impactArea, simulationSources.impactArea);
map.addSource(SIMULATION_SOURCE_IDS.annotations, simulationSources.annotations);
map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis); map.addSource(SIMULATION_SOURCE_IDS.backendAnalysis, simulationSources.backendAnalysis);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current); syncSimulationSourceData(map, simulationOverlayRef.current);
await registerSupplyAssetImages(map, scadaAbortController.signal); await registerSupplyAssetImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return; if (scadaAbortController.signal.aborted) return;
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal); const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
@@ -327,16 +319,13 @@ export function useWorkbenchMap({
scadaRegistration.status === "unavailable" scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers ? scadaFallbackBusinessLayers
: scadaBusinessLayers; : scadaBusinessLayers;
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds( filterWaterNetworkLayersBySourceIds(
waterNetworkBusinessLayers, waterNetworkBusinessLayers,
REQUIRED_GEOSERVER_SOURCE_IDS REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer)); ).forEach((layer) => map.addLayer(layer));
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer)); activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers simulationAnnotationLayers
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill") .filter((layer) => layer.type !== "symbol")
.forEach((layer) => map.addLayer(layer)); .forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds( filterWaterNetworkLayersBySourceIds(
waterNetworkInteractionLayers, waterNetworkInteractionLayers,
@@ -484,8 +473,8 @@ export function useWorkbenchMap({
useEffect(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || !mapReady) return; if (!map || !mapReady) return;
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation); syncSimulationSourceData(map, simulationOverlay);
}, [allowDemoSimulation, mapReady, simulationOverlay]); }, [mapReady, simulationOverlay]);
function fitToNetworkBounds() { function fitToNetworkBounds() {
const map = mapRef.current; const map = mapRef.current;
@@ -541,23 +530,12 @@ function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSna
function syncSimulationSourceData( function syncSimulationSourceData(
map: MapLibreMap, map: MapLibreMap,
backendOverlay: FeatureCollection<Geometry> | null, backendOverlay: FeatureCollection<Geometry> | null
allowDemoSimulation: boolean
) { ) {
const useBackendOverlay = Boolean(backendOverlay?.features.length); const useBackendOverlay = Boolean(backendOverlay?.features.length);
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData( (map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION useBackendOverlay ? backendOverlay! : EMPTY_SIMULATION_COLLECTION
); );
(map.getSource(SIMULATION_SOURCE_IDS.impactArea) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.impactArea.data
);
(map.getSource(SIMULATION_SOURCE_IDS.annotations) as GeoJSONSource | undefined)?.setData(
useBackendOverlay || !allowDemoSimulation
? EMPTY_SIMULATION_COLLECTION
: simulationSources.annotations.data
);
} }
function syncDataDependentGeoServerLayers( function syncDataDependentGeoServerLayers(
@@ -177,12 +177,15 @@ export function useWorkbenchRuntimeData(
const controller = new AbortController(); const controller = new AbortController();
let timeoutId: number | undefined; let timeoutId: number | undefined;
let refreshing = false;
const refreshRealtimePipeFlows = async () => { const refreshRealtimePipeFlows = async () => {
if (controller.signal.aborted || refreshing) return;
if (document.visibilityState === "hidden") { if (document.visibilityState === "hidden") {
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS); timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
return; return;
} }
try { try {
refreshing = true;
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal); const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null); setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
@@ -190,6 +193,7 @@ export function useWorkbenchRuntimeData(
} catch { } catch {
if (!controller.signal.aborted) setRealtimePipeFlows(null); if (!controller.signal.aborted) setRealtimePipeFlows(null);
} finally { } finally {
refreshing = false;
if (!controller.signal.aborted) { if (!controller.signal.aborted) {
timeoutId = window.setTimeout( timeoutId = window.setTimeout(
refreshRealtimePipeFlows, refreshRealtimePipeFlows,
+45 -29
View File
@@ -99,6 +99,7 @@ import {
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera"; import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view"; import { exportMapViewImage } from "./map/export-view";
import { setMapLayersVisibility } from "./map/layer-visibility"; import { setMapLayersVisibility } from "./map/layer-visibility";
import { MAP_STYLE_TOKENS } from "./map/map-colors";
import { import {
BASE_LAYER_OPTIONS, BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY, INITIAL_LAYER_VISIBILITY,
@@ -298,9 +299,6 @@ function MapWorkbenchContent({
const artifactActive = artifactWorkspace.activeArtifact !== null; const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null; const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length); const activeArtifactHasSpatialView = Boolean(activeSpatialOverlay?.features.length);
const allowDemoSimulation =
(!getWorkbenchProject(getAccessToken) || getWorkbenchProject(getAccessToken)?.gs_workspace === "tjwater_next")
&& (artifactWorkspace.activeArtifact === null || artifactWorkspace.activeArtifact.origin === "demo");
const latestAnalysisAvailable = Boolean( const latestAnalysisAvailable = Boolean(
latestAnalysisArtifact?.blocks.some( latestAnalysisArtifact?.blocks.some(
(block) => (block) =>
@@ -355,11 +353,6 @@ function MapWorkbenchContent({
useEffect(() => { useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView); setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) =>
current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView }
);
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]); }, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() { function openAgentWorkspaceForViewport() {
@@ -458,7 +451,6 @@ function MapWorkbenchContent({
onSelectFeature: handleSelectFeature, onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature, selectedFeature: detailFeature,
simulationOverlay: activeSpatialOverlay, simulationOverlay: activeSpatialOverlay,
allowDemoSimulation,
networkView: projectPresentation.networkView, networkView: projectPresentation.networkView,
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace, geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
getAccessToken getAccessToken
@@ -690,23 +682,55 @@ function MapWorkbenchContent({
[activeToolId, mapReady, toggleToolbarTool] [activeToolId, mapReady, toggleToolbarTool]
); );
const layerControlItems = useMemo( const overlayLayers = [
() => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts), { scope: "investigation", description: "Agent 调查", surface: presentation },
[availableSourceIds, layerVisibility, sourceFeatureCounts] { scope: "chat", description: "Agent 对话", surface: chatPresentation },
{ scope: "preview", description: "本地预览", surface: previewPresentation }
].flatMap(({ scope, description, surface }) => surface.overlay ? [{
id: `presentation:${scope}:${surface.overlay.overlay_id}`,
title: surface.overlay.title,
description: `${description} · ${surface.overlay.mode === "heatmap" ? "热力分布" : "空间范围"}`,
featureCount: surface.overlay.data.features.length,
color: MAP_STYLE_TOKENS.state.risk as string,
visible: surface.overlay.visible,
disabled: surface.history.selectedId !== null,
onToggle: (visible: boolean) => {
if (surface.overlay?.visible !== visible) surface.toggleOverlay();
}
}] : []);
if (activeArtifactHasSpatialView && artifactWorkspace.activeArtifact) {
overlayLayers.push({
id: `artifact:${artifactWorkspace.activeArtifact.id}`,
title: artifactWorkspace.activeArtifact.title,
description: "分析成果 · 空间范围",
featureCount: activeSpatialOverlay!.features.length,
color: MAP_STYLE_TOKENS.state.agent,
visible: impactVisible,
disabled: false,
onToggle: setImpactVisible
});
}
const layerControlItems = createLayerControlItems(
layerVisibility, availableSourceIds, sourceFeatureCounts, overlayLayers
); );
const visibleLegendItems = useMemo( const visibleLegendItems = useMemo(
() => createVisibleMapLegendItems(layerControlItems), () => createVisibleMapLegendItems(layerControlItems),
[layerControlItems] [layerControlItems]
); );
function handleToggleLayer(layerControlId: string, visible: boolean) { function handleToggleLayer(layerControlId: string, visible: boolean) {
if (layerControlId === "simulation") {
setImpactVisible(visible);
return;
}
const overlay = overlayLayers.find((item) => item.id === layerControlId);
if (overlay) {
if (!overlay.disabled) overlay.onToggle(visible);
return;
}
presentationLayerChangesRef.current.delete(layerControlId); presentationLayerChangesRef.current.delete(layerControlId);
const map = mapRef.current; const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible })); setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
if (layerControlId === "simulation") {
setImpactVisible(visible);
}
if (!map || !mapReady) { if (!map || !mapReady) {
return; return;
} }
@@ -932,19 +956,15 @@ function MapWorkbenchContent({
} }
async function handleStartNewAgentSession() { async function handleStartNewAgentSession() {
mapController.clearScadaAnalysis();
await agent.startNewSession(); await agent.startNewSession();
} }
async function handleLoadAgentHistorySession(sessionId: string) { async function handleLoadAgentHistorySession(sessionId: string) {
const changesSession = sessionId !== agent.sessionId;
const loaded = await agent.loadHistorySession(sessionId); const loaded = await agent.loadHistorySession(sessionId);
if (!loaded) throw new Error("会话打开已取消"); if (!loaded) throw new Error("会话打开已取消");
if (changesSession) mapController.clearScadaAnalysis();
} }
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) { async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
mapController.clearScadaAnalysis();
const id = await agent.openTaskCollaboration(taskId, discussion); const id = await agent.openTaskCollaboration(taskId, discussion);
if (discussion?.reference && id) agent.setContentReference(discussion.reference); if (discussion?.reference && id) agent.setContentReference(discussion.reference);
readingWorkspace?.close(); readingWorkspace?.close();
@@ -956,7 +976,6 @@ function MapWorkbenchContent({
target: TaskExecutionHistoryTarget, target: TaskExecutionHistoryTarget,
knownRunId?: string knownRunId?: string
) { ) {
mapController.clearScadaAnalysis();
let runId = knownRunId; let runId = knownRunId;
if (!runId) { if (!runId) {
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken); const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
@@ -975,12 +994,8 @@ function MapWorkbenchContent({
} }
async function handleDeleteAgentHistorySession(sessionId: string) { async function handleDeleteAgentHistorySession(sessionId: string) {
const clearsActiveAnalysis = sessionId === agent.sessionId;
const deleted = await agent.deleteHistorySession(sessionId); const deleted = await agent.deleteHistorySession(sessionId);
if (!deleted) throw new Error("会话删除失败,请重试"); if (!deleted) throw new Error("会话删除失败,请重试");
if (deleted && clearsActiveAnalysis) {
mapController.clearScadaAnalysis();
}
} }
async function handleAgentMapAction( async function handleAgentMapAction(
@@ -1079,7 +1094,7 @@ function MapWorkbenchContent({
measurement.clear(); measurement.clear();
drawing.clear(); drawing.clear();
geocoding.clear(); geocoding.clear();
handleToggleLayer("simulation", false); setImpactVisible(false);
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" }); showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
} }
@@ -1462,6 +1477,7 @@ function MapWorkbenchContent({
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview } { scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel ].filter((item) => item.visible).map(({ scope, surface, task }) => <InvestigationPresentationPanel
key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused} key={scope} scope={scope} embedded blocks={surface.blocks} records={surface.records} evidence={surface.evidence} history={surface.history} onLocateTargets={surface.locateTargets} task={task} localPreview={scope === "preview"} charts={surface.charts} overlay={surface.overlay} cameraPaused={surface.cameraPaused}
chartWindowAutoOpen={surface.chartWindowAutoOpen} chartWindowScope={surface.chartWindowScope}
status={scope === "chat" ? "live" : surface.status} error={surface.error} status={scope === "chat" ? "live" : surface.status} error={surface.error}
width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))} width={Math.min(380, Math.max(180, businessWorkspaceWidth - (rightPanelOpen ? (rightPanelExpanded ? (viewportWidth >= 1536 ? 960 : 880) : 432) + 64 : 64) - 24))}
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer} relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
@@ -1471,10 +1487,10 @@ function MapWorkbenchContent({
</div> </div>
{!devPanelOpen ? ( {!devPanelOpen ? (
<> <>
<div className="pointer-events-auto absolute right-3 top-10 hidden lg:block"> <div className="pointer-events-auto absolute right-3 top-10 z-50 hidden lg:block">
<MapToolbar items={toolbarItems} /> <MapToolbar items={toolbarItems} />
</div> </div>
<div className="pointer-events-auto absolute right-16 top-10 hidden lg:block"> <div className="pointer-events-auto absolute right-16 top-10 z-50 hidden lg:block">
<ToolbarPanel {...toolbarPanelProps} /> <ToolbarPanel {...toolbarPanelProps} />
</div> </div>
</> </>
@@ -1570,7 +1586,7 @@ function MapWorkbenchContent({
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? ( {mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
<div <div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden" className="absolute left-3 right-3 z-50 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{ style={{
bottom: mobileOverlayLayout.mapControlBottom, bottom: mobileOverlayLayout.mapControlBottom,
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)` "--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
+1 -133
View File
@@ -1,54 +1,18 @@
import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl"; import type { GeoJSONSourceSpecification, StyleSpecification } from "maplibre-gl";
import { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
export const SIMULATION_SOURCE_IDS = { export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations",
backendAnalysis: "simulation-backend-analysis" backendAnalysis: "simulation-backend-analysis"
} as const; } as const;
const EMPTY_FEATURE_COLLECTION = {
type: "FeatureCollection" as const,
features: []
};
export const simulationSources = { export const simulationSources = {
impactArea: {
type: "geojson",
data: impactAreaPolygon
} satisfies GeoJSONSourceSpecification,
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification,
backendAnalysis: { backendAnalysis: {
type: "geojson", type: "geojson",
data: EMPTY_FEATURE_COLLECTION data: { type: "FeatureCollection", features: [] }
} satisfies GeoJSONSourceSpecification } satisfies GeoJSONSourceSpecification
}; };
export const simulationAnnotationLayers: StyleSpecification["layers"] = [ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
{
id: "simulation-impact-fill",
type: "fill",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"fill-color": MAP_STYLE_TOKENS.state.risk,
"fill-opacity": MAP_STYLE_TOKENS.opacity.area
}
},
{
id: "simulation-impact-outline",
type: "line",
source: SIMULATION_SOURCE_IDS.impactArea,
paint: {
"line-color": MAP_STYLE_TOKENS.state.risk,
"line-width": 1.5,
"line-dasharray": [2, 2],
"line-opacity": 0.72
}
},
{ {
id: "simulation-backend-analysis-fill", id: "simulation-backend-analysis-fill",
type: "fill", type: "fill",
@@ -82,83 +46,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"circle-stroke-width": 2 "circle-stroke-width": 2
} }
}, },
{
id: "simulation-burst-halo",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-radius": 18,
"circle-stroke-color": MAP_STYLE_TOKENS.state.incident,
"circle-stroke-width": 3,
"circle-opacity": 0.88
}
},
{
id: "simulation-burst-point",
type: "circle",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
paint: {
"circle-color": MAP_STYLE_TOKENS.state.incident,
"circle-radius": 7,
"circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing,
"circle-stroke-width": 3
}
},
{
id: "simulation-pipe-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "pipe-label"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.inverse,
"text-halo-color": MAP_STYLE_TOKENS.state.agent,
"text-halo-width": 9
}
},
{
id: "simulation-impact-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "tooltip"],
layout: {
"text-field": ["get", "label"],
"text-size": 14,
"text-font": ["Noto Sans Regular"],
"text-offset": [0, 0],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.state.risk,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 4
}
},
{
id: "simulation-district-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "district"],
layout: {
"text-field": ["get", "label"],
"text-size": 13,
"text-font": ["Noto Sans Regular"],
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.secondary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3
}
},
{ {
id: "simulation-backend-analysis-label", id: "simulation-backend-analysis-label",
type: "symbol", type: "symbol",
@@ -176,25 +63,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing, "text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 3 "text-halo-width": 3
} }
},
{
id: "simulation-burst-label",
type: "symbol",
source: SIMULATION_SOURCE_IDS.annotations,
filter: ["==", ["get", "kind"], "burst"],
layout: {
"text-field": "演示爆管位置\nDN600 给水管线\n压力:0.18 MPa\n时间:09:00",
"text-size": 12,
"text-font": ["Noto Sans Regular"],
"text-offset": [5.2, -3.4],
"text-anchor": "left",
"text-allow-overlap": true
},
paint: {
"text-color": MAP_STYLE_TOKENS.label.primary,
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"text-halo-width": 5
}
} }
]; ];
@@ -54,8 +54,7 @@ describe("workbench supply layer controls", () => {
"scada-flow", "scada-flow",
"water_quality_monitoring_points", "water_quality_monitoring_points",
"fx_20200423_pipes", "fx_20200423_pipes",
"fx_20200811_dn100_pipes", "fx_20200811_dn100_pipes"
"simulation"
]) ])
); );
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({ expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
@@ -104,12 +103,30 @@ describe("workbench supply layer controls", () => {
"valves", "valves",
"reservoirs", "reservoirs",
"scada-pressure", "scada-pressure",
"scada-flow", "scada-flow"
"simulation"
]); ]);
}); });
it("defines map-matched symbols for model, source GIS, and analysis layers", () => { it("lists actual presentation overlays with their visibility and removes cleared entries", () => {
const overlays = [
{ id: "presentation:investigation:area", title: "爆管影响范围", description: "Agent 调查 · 空间范围", featureCount: 1, visible: true },
{ id: "presentation:chat:area", title: "投诉点热力", description: "Agent 对话 · 热力分布", featureCount: 8, visible: false }
];
const items = createLayerControlItems(INITIAL_LAYER_VISIBILITY, undefined, {}, overlays);
expect(items.filter((item) => item.group === "analysis")).toMatchObject([
{ id: overlays[0].id, label: "爆管影响范围", visible: true, featureCount: 1 },
{ id: overlays[1].id, label: "投诉点热力", visible: false, featureCount: 8 }
]);
expect(createVisibleMapLegendItems(items).filter((item) => item.group === "analysis")).toMatchObject([
{ label: "爆管影响范围", layerControlId: overlays[0].id, shape: "area" }
]);
const cleared = createLayerControlItems(INITIAL_LAYER_VISIBILITY);
expect(cleared.filter((item) => item.group === "analysis")).toEqual([]);
expect(createVisibleMapLegendItems(cleared).filter((item) => item.group === "analysis")).toEqual([]);
});
it("defines map-matched symbols for model and source GIS layers", () => {
expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([ expect(MAP_LEGEND_ITEMS.map((item) => item.id)).toEqual([
"major-pipe", "major-pipe",
"medium-pipe", "medium-pipe",
@@ -134,8 +151,7 @@ describe("workbench supply layer controls", () => {
"source-hydrant", "source-hydrant",
"source-connection", "source-connection",
"source-customer-meter", "source-customer-meter",
"source-non-residential-meter", "source-non-residential-meter"
"impact-area"
]); ]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({ expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
imageSrc: "/map/valve.png" imageSrc: "/map/valve.png"
@@ -186,11 +202,6 @@ describe("workbench supply layer controls", () => {
).toMatchObject({ ).toMatchObject({
imageSrc: "/map/gis-non-residential-meter.svg" imageSrc: "/map/gis-non-residential-meter.svg"
}); });
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "impact-area")).toMatchObject({
groupLabel: "分析覆盖",
layerControlId: "simulation",
shape: "area"
});
}); });
it("keeps the legend synchronized with visible layer controls", () => { it("keeps the legend synchronized with visible layer controls", () => {
@@ -222,8 +233,7 @@ describe("workbench supply layer controls", () => {
const reducedItems = createLayerControlItems({ const reducedItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY, ...INITIAL_LAYER_VISIBILITY,
valves: false, valves: false
simulation: false
}); });
const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id); const reducedLegendIds = createVisibleMapLegendItems(reducedItems).map((item) => item.id);
@@ -15,19 +15,6 @@ import {
import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada"; import { SCADA_ICON_PATHS, SCADA_LAYER_CONTROL_ITEMS, SCADA_LAYER_GROUPS } from "./scada";
import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons"; import { SUPPLY_ASSET_ICON_PATHS } from "./supply-icons";
export const WORKBENCH_LAYER_GROUPS: Record<string, string[]> = {
simulation: [
"simulation-impact-fill",
"simulation-impact-outline",
"simulation-burst-halo",
"simulation-burst-point",
"simulation-pipe-label",
"simulation-impact-label",
"simulation-district-label",
"simulation-burst-label"
]
};
export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = { export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
...Object.fromEntries( ...Object.fromEntries(
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [ SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
@@ -35,8 +22,17 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
layer.initialVisible layer.initialVisible
]) ])
), ),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])), ...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true]))
simulation: false };
export type AnalysisOverlayLayer = {
id: string;
title: string;
description: string;
visible: boolean;
disabled?: boolean;
featureCount: number;
color?: string;
}; };
const SOURCE_CONTROL_LABELS = Object.fromEntries( const SOURCE_CONTROL_LABELS = Object.fromEntries(
@@ -421,25 +417,27 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter, imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
layerControlId: "fx_20200423_non_residential_meters", layerControlId: "fx_20200423_non_residential_meters",
...SOURCE_FACILITY_LEGEND_GROUP ...SOURCE_FACILITY_LEGEND_GROUP
},
{
id: "impact-area",
label: "影响范围",
description: "事故或分析结果",
color: MAP_STYLE_TOKENS.state.incident,
shape: "area",
layerControlId: "simulation",
...ANALYSIS_LEGEND_GROUP
} }
]; ];
export function createVisibleMapLegendItems( export function createVisibleMapLegendItems(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[] layerItems: readonly MapLayerControlItem[]
) { ) {
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id)); const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
return MAP_LEGEND_ITEMS.filter((item) => return [
...MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
); ),
...layerItems.filter((item) => item.group === "analysis" && item.visible).map((item) => ({
id: item.id,
label: item.label,
description: item.description,
color: item.visual?.color ?? MAP_STYLE_TOKENS.state.risk,
shape: "area" as const,
layerControlId: item.id,
...ANALYSIS_LEGEND_GROUP
}))
];
} }
export const BASE_LAYER_GROUPS = { export const BASE_LAYER_GROUPS = {
@@ -502,7 +500,8 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
export function createLayerControlItems( export function createLayerControlItems(
layerVisibility: Record<string, boolean>, layerVisibility: Record<string, boolean>,
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS, availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {} featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {},
analysisOverlays: readonly AnalysisOverlayLayer[] = []
): MapLayerControlItem[] { ): MapLayerControlItem[] {
const available = new Set<WaterNetworkSourceId>(availableSourceIds); const available = new Set<WaterNetworkSourceId>(availableSourceIds);
const catalogItems = SUPPLY_LAYER_CATALOG.filter( const catalogItems = SUPPLY_LAYER_CATALOG.filter(
@@ -560,16 +559,18 @@ export function createLayerControlItems(
groupDescription: "源数据快照与专项台账", groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false groupDefaultOpen: false
})), })),
{ ...analysisOverlays.map((overlay) => ({
id: "simulation", id: overlay.id,
label: "模拟结果", label: overlay.title,
description: "影响范围与事故标注", description: overlay.description,
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident }, featureCount: overlay.featureCount,
visible: layerVisibility.simulation, visual: { kind: "area" as const, color: overlay.color ?? MAP_STYLE_TOKENS.state.risk },
visible: overlay.visible,
disabled: overlay.disabled,
group: "analysis", group: "analysis",
groupLabel: "分析覆盖", groupLabel: "分析覆盖",
groupDescription: "随已打开的空间成果显示" groupDescription: "Agent 生成的范围与分析成果"
} }))
]; ];
} }
@@ -589,8 +590,7 @@ export function getWorkbenchLayerIds(
.map((layer) => layer.id); .map((layer) => layer.id);
} }
const layerIds = WORKBENCH_LAYER_GROUPS[groupId] ?? []; return [];
return layerIds.filter((layerId) => map.getLayer(layerId));
} }
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId { function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
+1 -14
View File
@@ -1,5 +1,5 @@
import type { Feature, FeatureCollection, Point } from "geojson"; import type { Feature, FeatureCollection, Point } from "geojson";
import type { ExpressionSpecification, Map as MapLibreMap, StyleSpecification } from "maplibre-gl"; import type { ExpressionSpecification, StyleSpecification } from "maplibre-gl";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis"; export const SCADA_ANALYSIS_SOURCE_ID = "agent-scada-analysis";
@@ -121,19 +121,6 @@ export const scadaAnalysisLayers: Layer[] = [
} }
]; ];
export function ensureScadaAnalysisLayers(
map: Pick<MapLibreMap, "addLayer" | "getLayer">
): void {
scadaAnalysisLayers.forEach((layer, index) => {
if (map.getLayer(layer.id)) return;
const beforeId = scadaAnalysisLayers
.slice(index + 1)
.map((candidate) => candidate.id)
.find((candidateId) => Boolean(map.getLayer(candidateId)));
map.addLayer(layer, beforeId);
});
}
export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> { export function createEmptyScadaAnalysisCollection(): FeatureCollection<Point> {
return { type: "FeatureCollection", features: [] }; return { type: "FeatureCollection", features: [] };
} }
-5
View File
@@ -2,7 +2,6 @@ import type { StyleSpecification, VectorSourceSpecification } from "maplibre-gl"
import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config"; import { GEOSERVER_WORKSPACE, MAP_URL } from "./geoserver-config";
import { MAP_STYLE_TOKENS } from "./map-colors"; import { MAP_STYLE_TOKENS } from "./map-colors";
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada"; import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = { export const WATER_NETWORK_GLOBAL_VIEW = {
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
type: "geojson", type: "geojson",
promoteId: "device_id", promoteId: "device_id",
data: { type: "FeatureCollection", features: [] } data: { type: "FeatureCollection", features: [] }
},
[SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
} }
}; };
const layers: StyleSpecification["layers"] = [ const layers: StyleSpecification["layers"] = [
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
expect(map.easeTo).not.toHaveBeenCalled(); expect(map.easeTo).not.toHaveBeenCalled();
expect(controller.getSnapshot().targets).toEqual([]); expect(controller.getSnapshot().targets).toEqual([]);
}); });
it("clears every controller overlay, preserves layer styles, and invalidates pending analysis", async () => { it("clears controller overlays and preserves layer styles", async () => {
const map = createMap(); const map = createMap();
map.getLayer.mockReturnValue({} as never); map.getLayer.mockReturnValue({} as never);
const setData = vi.fn(); const setData = vi.fn();
map.getSource.mockReturnValue({ setData }); map.getSource.mockReturnValue({ setData });
let resolveFeatures = (_value: FeatureCollection) => {};
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
const controller = new WorkbenchMapController({ const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true, getMap: () => map as unknown as MapLibreMap, isReady: () => true,
getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }), getPadding: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
fetchFeatures: (source) => source === "scada" ? pendingAnalysis : Promise.resolve(pointCollection) fetchFeatures: async () => pointCollection
}); });
await controller.highlight({ sourceId: "junctions", featureId: "J-1" }); await controller.highlight({ sourceId: "junctions", featureId: "J-1" });
controller.applyLayerGroupStyle("pipes", { color: "#123456" }); controller.applyLayerGroupStyle("pipes", { color: "#123456" });
const showingFlow = controller.setFlowVisible(true); const showingFlow = controller.setFlowVisible(true);
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
controller.clearOverlays(); controller.clearOverlays();
await showingFlow; await showingFlow;
resolveFeatures(pointCollection);
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
expect(controller.getSnapshot()).toMatchObject({ expect(controller.getSnapshot()).toMatchObject({
target: null, targets: [], targetResult: null, scadaAnalysis: null, target: null, targets: [], targetResult: null,
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"] flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
}); });
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted"); expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
expect(map.removeFeatureState).not.toHaveBeenCalled(); expect(map.removeFeatureState).not.toHaveBeenCalled();
}); });
it("renders SCADA analysis through the registered GeoJSON source", async () => {
const map = createMap();
const source = { setData: vi.fn() };
map.getSource.mockReturnValue(source);
const controller = createController(map, {
type: "FeatureCollection",
features: [
{
type: "Feature",
geometry: { type: "Point", coordinates: [120.7, 28] },
properties: { device_id: "MP01" }
}
]
});
await expect(
controller.renderScadaAnalysis([{ device_id: "MP01", level: "high" }])
).resolves.toMatchObject({
rendered_ids: ["MP01"],
fitted: true
});
expect(source.setData).toHaveBeenCalled();
expect(controller.getSnapshot()).toMatchObject({
errorCode: null,
pending: false,
scadaAnalysis: {
rendered_ids: ["MP01"],
fitted: true
}
});
});
}); });
const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] }; const pointCollection: FeatureCollection = { type: "FeatureCollection", features: [{ type: "Feature", geometry: { type: "Point", coordinates: [120.7, 28] }, properties: { id: "J-1" } }] };
@@ -1,5 +1,5 @@
import type { Feature, FeatureCollection, Geometry, Position } from "geojson"; import type { Feature, FeatureCollection, Geometry, Position } from "geojson";
import type { GeoJSONSource, Map as MapLibreMap, PaddingOptions } from "maplibre-gl"; import type { Map as MapLibreMap, PaddingOptions } from "maplibre-gl";
import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources"; import { SOURCE_LAYERS, type WaterNetworkSourceId } from "./sources";
import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style"; import { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import { import {
@@ -36,14 +36,7 @@ import {
type ValueLabelSourceId, type ValueLabelSourceId,
type ValueLabelStyle type ValueLabelStyle
} from "./value-label"; } from "./value-label";
import {
SCADA_ANALYSIS_SOURCE_ID,
createEmptyScadaAnalysisCollection,
createScadaAnalysisCollection,
ensureScadaAnalysisLayers,
type ScadaAnalysisItem,
type ScadaAnalysisRenderResult
} from "./scada-analysis";
export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string }; export type FeatureTarget = { sourceId: WaterNetworkSourceId; featureId: string };
export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] }; export type FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
| "INVALID_FEATURE_IDS" | "INVALID_FEATURE_IDS"
| "WFS_UNAVAILABLE" | "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE" | "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED" | "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE" | "FLOW_UNAVAILABLE"
| "INVALID_FLOW_CALCULATION" | "INVALID_FLOW_CALCULATION"
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
flowVisible: boolean; flowVisible: boolean;
flowSummary: PipeFlowSummary; flowSummary: PipeFlowSummary;
styledGroups: LayerGroupId[]; styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
}; };
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string }; export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
clearValueLabel: (sourceId?: ValueLabelSourceId) => void; clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void; applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void; resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void; resetAll: () => void;
}; };
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
errorCode: null, errorCode: null,
flowVisible: false, flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(), flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [], styledGroups: []
scadaAnalysis: null
}; };
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private cachedFeatures = new Map<string, Feature>(); private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState(); private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0; private flowUpdateRevision = 0;
private flowVisibilityRevision = 0; private flowVisibilityRevision = 0;
private scadaAnalysisRevision = 0;
private targetRequestRevision = 0; private targetRequestRevision = 0;
private pendingTargetSourceId: WaterNetworkSourceId | null = null; private pendingTargetSourceId: WaterNetworkSourceId | null = null;
private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null; private availableSourceIds: ReadonlySet<WaterNetworkSourceId> | null = null;
@@ -399,125 +386,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null }); this.patchState({ styledGroups: this.state.styledGroups.filter((item) => item !== groupId), errorCode: null });
} }
async renderScadaAnalysis(
items: ScadaAnalysisItem[],
signal?: AbortSignal
): Promise<ScadaAnalysisRenderResult> {
const revision = ++this.scadaAnalysisRevision;
const map = this.options.getMap();
if (!map || !this.options.isReady()) {
this.setError("MAP_NOT_READY");
throw new Error("MAP_NOT_READY");
}
this.patchState({ pending: true, errorCode: null });
let sourceCollection: FeatureCollection;
try {
sourceCollection = await this.fetchFeatures(
"scada",
items.map((item) => item.device_id),
signal
);
} catch {
if (revision === this.scadaAnalysisRevision) {
this.patchState({ pending: false, errorCode: "SCADA_API_UNAVAILABLE" });
}
throw new Error(
revision === this.scadaAnalysisRevision
? "SCADA_API_UNAVAILABLE"
: "ACTION_SUPERSEDED"
);
}
if (revision !== this.scadaAnalysisRevision) {
throw new Error("ACTION_SUPERSEDED");
}
const next = createScadaAnalysisCollection(sourceCollection, items);
if (next.collection.features.length === 0) {
this.patchState({
pending: false,
errorCode: "SCADA_FEATURES_NOT_FOUND"
});
throw new Error("SCADA_FEATURES_NOT_FOUND");
}
const source = map.getSource(
SCADA_ANALYSIS_SOURCE_ID
) as GeoJSONSource | undefined;
if (!source) {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
try {
ensureScadaAnalysisLayers(map);
source.setData(next.collection);
} catch {
this.patchState({ pending: false, errorCode: "MAP_NOT_READY" });
throw new Error("MAP_NOT_READY");
}
const result: ScadaAnalysisRenderResult = {
...next.result,
fitted: true
};
this.patchState({
pending: false,
errorCode: null,
scadaAnalysis: result
});
const coordinates = next.collection.features.map(
(feature) => feature.geometry.coordinates
);
if (coordinates.length === 1) {
map.easeTo({
center: [coordinates[0][0], coordinates[0][1]],
zoom: 18,
padding: this.options.getPadding(),
duration: this.options.reducedMotion?.() ? 0 : 600
});
} else {
const bounds = coordinates.reduce<[number, number, number, number]>(
(value, coordinate) => [
Math.min(value[0], coordinate[0]),
Math.min(value[1], coordinate[1]),
Math.max(value[2], coordinate[0]),
Math.max(value[3], coordinate[1])
],
[Infinity, Infinity, -Infinity, -Infinity]
);
map.fitBounds(
[
[bounds[0], bounds[1]],
[bounds[2], bounds[3]]
],
{
padding: this.options.getPadding(),
maxZoom: 18,
duration: this.options.reducedMotion?.() ? 0 : 600
}
);
}
return result;
}
clearScadaAnalysis() {
this.scadaAnalysisRevision += 1;
const map = this.options.getMap();
(map?.getSource(SCADA_ANALYSIS_SOURCE_ID) as GeoJSONSource | undefined)?.setData(
createEmptyScadaAnalysisCollection()
);
this.patchState({ scadaAnalysis: null, pending: false, errorCode: null });
return { cleared: true as const };
}
clearOverlays = () => { clearOverlays = () => {
this.clearHighlight(); this.clearHighlight();
this.clearValueLabel(); this.clearValueLabel();
this.clearScadaAnalysis();
this.flowVisibilityRevision += 1; this.flowVisibilityRevision += 1;
const map = this.options.getMap(); const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState); if (map) hideFlowOverlay(map, this.flowState);
this.patchState({ flowVisible: false }); this.patchState({ flowVisible: false, pending: false, errorCode: null });
}; };
resetAll = () => { resetAll = () => {
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
errorCode: null, errorCode: null,
flowVisible: false, flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(), flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [], styledGroups: []
scadaAnalysis: null
}); });
}; };
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react"; import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react"; import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
@@ -54,7 +55,7 @@ export function ScadaMonitoringNotice({ result, animationId, processing, onOpenI
<span>调查监测</span> <span>调查监测</span>
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums"> {result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
<span className="sr-only">最新监测 </span> <span className="sr-only">最新监测 </span>
{new Date(result.period_end).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })} {formatLocalMonthDayTime(result.period_end)}
</time> : <span>尚无监测结果</span>} </time> : <span>尚无监测结果</span>}
</div> </div>
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3"> <div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime as dateTime } from "@/shared/utils/date-time";
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client"; import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
export function ScadaMonitoringRules({ config, calibration, frozen }: { export function ScadaMonitoringRules({ config, calibration, frozen }: {
@@ -68,7 +69,3 @@ function thresholdLabel(ids: string[], profiles: ScadaMonitoringCalibration["pre
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`; const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range; return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
} }
function dateTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime as stamp } from "@/shared/utils/date-time";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { import {
Activity, Activity,
@@ -430,9 +431,6 @@ export function ScadaReplaySection({
); );
} }
function stamp(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
function localTime(time: number) { function localTime(time: number) {
const date = new Date(time); const date = new Date(time);
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartSlot } from "./chart-window-slots";
describe("fixed chart slots", () => {
it("maps supported layouts to grid columns", () => {
expect([columnsForLayout("one"), columnsForLayout("two"), columnsForLayout("four")]).toEqual([1, 2, 2]);
});
it("keeps chart positions when publications update in another order", () => {
const slots: ChartSlot[] = ["flow", null, "pressure", "plans"];
expect(reconcileChartSlots(slots, ["plans", "pressure", "flow"], 2)).toBe(slots);
});
it("leaves deleted positions vacant without moving surviving charts", () => {
expect(reconcileChartSlots(["pressure", "flow", "plans", null], ["pressure", "plans"], 2))
.toEqual(["pressure", null, "plans", null]);
});
it("fills the earliest vacant slots before appending a padded row", () => {
expect(reconcileChartSlots(["pressure", null, "flow", null], ["new-1", "flow", "new-2", "pressure", "new-3"], 2))
.toEqual(["pressure", "new-1", "flow", "new-2", "new-3", null]);
});
it("uses vacancies created by a deletion in the same update", () => {
expect(reconcileChartSlots(["pressure", "removed", "flow", null], ["pressure", "flow", "new"], 2))
.toEqual(["pressure", "new", "flow", null]);
});
it("preserves linear positions when the column count changes", () => {
const slots: ChartSlot[] = ["a", null, "b", "c", "d", null];
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 1)).toEqual(["a", null, "b", "c", "d"]);
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 2)).toBe(slots);
});
it("trims only empty trailing rows, retaining leading and middle vacancies", () => {
expect(reconcileChartSlots([null, null, "a", null, null, null, null, null], ["a"], 2))
.toEqual([null, null, "a", null]);
expect(reconcileChartSlots(["a", null, null, null], ["a"], 2)).toEqual(["a", null]);
expect(reconcileChartSlots(["a", null, null, null], [], 2)).toEqual([]);
});
it("builds default slots in available order and never duplicates a chart", () => {
expect(reconcileChartSlots([], ["a", "b", "a"], 2)).toEqual(["a", "b"]);
expect(reconcileChartSlots(["a", "a", "b", null], ["a", "b", "c"], 2)).toEqual(["a", "c", "b", null]);
});
it("swaps occupied slots without inserting or shifting adjacent charts", () => {
const slots: ChartSlot[] = ["a", "b", "c", "d"];
expect(swapChartSlots(slots, 0, 2)).toEqual(["c", "b", "a", "d"]);
expect(slots).toEqual(["a", "b", "c", "d"]);
});
it("swaps across rows and permits an empty destination", () => {
expect(swapChartSlots(["a", "b", "c", "d", "e", null, null, null], 0, 5))
.toEqual([null, "b", "c", "d", "e", "a", null, null]);
});
it("keeps the original array for invalid or unchanged swaps", () => {
const slots: ChartSlot[] = ["a", null, null];
for (const [source, target] of [[-1, 1], [0, 3], [3, 0], [0.5, 1], [0, Number.NaN], [0, 0], [1, 2]]) {
expect(swapChartSlots(slots, source, target)).toBe(slots);
}
});
});
@@ -0,0 +1,38 @@
export type ChartSlot = string | null;
export type ChartLayout = "one" | "two" | "four";
export function columnsForLayout(layout: ChartLayout): 1 | 2 {
return layout === "one" ? 1 : 2;
}
/** Retain fixed positions, fill vacancies, and pad the final row without compacting. */
export function reconcileChartSlots(slots: ChartSlot[], availableIds: string[], columns: 1 | 2): ChartSlot[] {
const available = new Set(availableIds);
const retained = new Set<string>();
const next = slots.map((id) => {
if (id === null || !available.has(id) || retained.has(id)) return null;
retained.add(id);
return id;
});
for (const id of available) {
if (retained.has(id)) continue;
const empty = next.indexOf(null);
if (empty === -1) next.push(id);
else next[empty] = id;
}
let last = next.length - 1;
while (last >= 0 && next[last] === null) last -= 1;
const length = Math.ceil((last + 1) / columns) * columns;
if (next.length > length) next.length = length;
while (next.length < length) next.push(null);
return next.length === slots.length && next.every((id, index) => id === slots[index]) ? slots : next;
}
/** Exchange two fixed positions, including vacant destinations; never shift neighbors. */
export function swapChartSlots(slots: ChartSlot[], sourceIndex: number, targetIndex: number): ChartSlot[] {
if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex) || sourceIndex < 0 || targetIndex < 0 ||
sourceIndex >= slots.length || targetIndex >= slots.length || slots[sourceIndex] === slots[targetIndex]) return slots;
const next = [...slots];
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
return next;
}
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { groupInvestigationCharts } from "./investigation-chart-groups";
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
const comparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16"): InvestigationChart => ({
action: "open_chart", chart_id: `chart_tc_${id}`, evidence_refs: [`evidence:${id}`],
chart: { title: `${id} ${metric}七日同时刻对照(${unit})`, chart_type: "time_comparison",
x_axis_name: "时间", y_axis_name: `${metric}(${unit})`, x_data: ["07:45", "08:00"],
series: [
{ name: `${id} 当前 ${date}`, type: "line", data: [30, 25], sample_times: [`${date}T07:45:00+08:00`, `${date}T08:00:00+08:00`] },
{ name: `${id} 2026-07-09`, type: "scatter", data: [null, 32], sample_times: [null, "2026-07-09T08:00:00+08:00"] }
] }
});
const legacyComparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16") => {
const command = comparison(id, metric, unit, date);
command.chart.title = `${id} ${metric}三小时趋势与七天同期对照(${unit})`;
command.chart.series[0].name = `${id} ${date.slice(5)}(${unit})`;
command.chart.series[1].name = "07-09";
return command;
};
describe("investigation comparison groups", () => {
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(2);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
expect(groups[1].items.map((item) => item.label)).toEqual(["542017", "486033"]);
expect(groups[0].title).toBe("压力七日同时刻对照(m)");
expect(groups[0].items[1].command).toBe(charts[1]);
expect(charts[1].chart.series[1].data).toEqual([null, 32]);
});
it("groups saved pressure and flow charts whose titles put the metric before the object ID", () => {
const charts = [["170486", "压力", "m"], ["175618", "压力", "m"], ["541061", "流量", "m³/h"], ["542017", "流量", "m³/h"]].map(([id, metric, unit]) => {
const command = comparison(id, metric, unit);
command.chart.title = `${metric}测点 ${id} 当前曲线与前七天同期对比(${unit})`;
return command;
});
const groups = groupInvestigationCharts(charts);
expect(groups.map((group) => ({ title: group.title, objects: group.items.map((item) => item.label) }))).toEqual([
{ title: "压力当前曲线与前七天同期对比(m)", objects: ["170486", "175618"] },
{ title: "流量当前曲线与前七天同期对比(m³/h)", objects: ["541061", "542017"] }
]);
});
it("recognizes the same explicit object comparison in all three saved title formats", () => {
const charts = [comparison("170486"), comparison("175618"), comparison("159673")];
charts[0].chart.title = "170486 压力三小时与历史对照(m)";
charts[1].chart.title = "测点 175618 压力三小时与历史对照(m)";
charts[2].chart.title = "压力测点 159673 三小时与历史对照(m)";
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].title).toBe("压力三小时与历史对照(m)");
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618", "159673"]);
});
it("groups saved date-only historical labels without rewriting commands, readings or evidence", () => {
const charts = [legacyComparison("170486"), legacyComparison("175618")];
charts[1].chart.series[1].data[1] = 0;
const original = structuredClone(charts);
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(1);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
groups[0].items.forEach((item, index) => {
expect(item.command).toBe(charts[index]);
expect(item.command.chart.series).toBe(charts[index].chart.series);
expect(item.command.chart.series[1].sample_times).toBe(charts[index].chart.series[1].sample_times);
expect(item.command.evidence_refs).toBe(charts[index].evidence_refs);
});
expect(charts).toEqual(original);
});
it("keeps legacy comparisons with different units, windows or current dates separate", () => {
const shifted = legacyComparison("175618");
shifted.chart.x_data = ["08:00", "08:15"];
shifted.chart.series[0].sample_times = ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"];
shifted.chart.series[1].sample_times = [null, "2026-07-09T08:15:00+08:00"];
const charts = [legacyComparison("170486"), legacyComparison("P2", "压力", "MPa"), shifted, legacyComparison("P3", "压力", "m", "2026-07-17")];
expect(groupInvestigationCharts(charts)).toHaveLength(charts.length);
});
it("leaves ambiguous or mixed-object legacy charts and unproven historical dates separate", () => {
const ambiguous = legacyComparison("P2"); ambiguous.chart.title = "压力测点历史对照";
const foreign = legacyComparison("P3"); foreign.chart.series[1].name = "P4 07-09";
const mixed = legacyComparison("P4"); mixed.chart.series.push({ ...mixed.chart.series[0], name: "P5 当前 2026-07-16" });
const missing = legacyComparison("P5"); delete missing.chart.series[1].sample_times;
const mismatched = legacyComparison("P6"); mismatched.chart.series[1].sample_times = [null, "2026-07-10T08:00:00+08:00"];
const empty = legacyComparison("P7"); empty.chart.series[1].data = [null, null]; empty.chart.series[1].sample_times = [null, null];
const sameDateInUtc8 = legacyComparison("P8"); sameDateInUtc8.chart.series[1].sample_times = [null, "2026-07-09T00:00:00Z"];
const charts = [legacyComparison("170486"), ambiguous, foreign, mixed, missing, mismatched, empty, sameDateInUtc8];
const groups = groupInvestigationCharts(charts);
expect(groups).toHaveLength(charts.length - 1);
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "P8"]);
});
it("keeps incompatible units, sampling windows, comparison dates and ordinary trends separate", () => {
const base = comparison("170486");
const window = comparison("175618"); window.chart.x_data = ["08:00", "08:15"];
const trend = comparison("159673"); trend.chart.series = [trend.chart.series[0]];
expect(groupInvestigationCharts([base, comparison("P2", "压力", "MPa"), comparison("P3", "压力", "m", "2026-07-17"), window, trend])).toHaveLength(5);
const noTimes = [comparison("P4"), comparison("P5", "压力", "m", "2026-07-17")];
for (const command of noTimes) for (const series of command.chart.series) delete series.sample_times;
expect(groupInvestigationCharts(noTimes)).toHaveLength(2);
});
it("keeps group identity stable as objects arrive or are closed and leaves ambiguous titles alone", () => {
const first = comparison("170486"), second = comparison("175618");
expect(groupInvestigationCharts([first])[0].id).toBe(groupInvestigationCharts([second, first])[0].id);
const ambiguous = comparison("159673"); ambiguous.chart.title = "压力同期对照";
expect(groupInvestigationCharts([first, ambiguous])).toHaveLength(2);
expect(groupInvestigationCharts([])).toEqual([]);
});
});
@@ -0,0 +1,64 @@
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
export type InvestigationChartGroup = {
id: string;
title: string;
items: Array<{ label: string; command: InvestigationChart }>;
};
type ChartSeries = InvestigationChart["chart"]["series"][number];
/** Saved date-only legends identify a day only when their actual observations prove it. */
function matchesHistoricalDate(series: ChartSeries) {
if (!/^(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/.test(series.name) ||
!series.sample_times || series.sample_times.length !== series.data.length) return false;
let observedDate: string | null = null;
for (let index = 0; index < series.data.length; index++) {
const at = series.sample_times[index];
if (series.data[index] === null) {
if (at !== null) return false;
continue;
}
if (typeof at !== "string") return false;
const time = Date.parse(at);
if (!Number.isFinite(time)) return false;
const date = new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
if (date.slice(5) !== series.name || observedDate !== null && observedDate !== date) return false;
observedDate = date;
}
return observedDate !== null;
}
function matchesObjectSeries(command: InvestigationChart, id: string) {
const { chart } = command;
const prefixed = (series: ChartSeries) => series.name.startsWith(`${id} `);
if (chart.series.every(prefixed)) return true;
const current = chart.series.filter((series) => series.type !== "scatter");
return current.length === 1 && prefixed(current[0]) &&
(current[0].type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)) === "line" &&
chart.series.every((series) => prefixed(series) || series.type === "scatter" && matchesHistoricalDate(series));
}
/** Group only explicitly named objects with the same comparison, axes and sampling window. */
export function groupInvestigationCharts(charts: InvestigationChart[]): InvestigationChartGroup[] {
const groups = new Map<string, InvestigationChartGroup>();
for (const command of charts) {
const { chart } = command;
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
const object = match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
const id = comparable ? JSON.stringify([
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
chart.series.filter((series) => series.type !== "scatter").map((series) => [
series.name.slice(object.id.length).trim(), series.sample_times ?? null
])
]) : command.chart_id;
let group = groups.get(id);
if (!group) {
group = { id, title: comparable ? object.title : chart.title, items: [] };
groups.set(id, group);
}
group.items.push({ label: comparable ? object.id : chart.title, command });
}
return [...groups.values()];
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases"; import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
import { groupInvestigationCharts } from "./investigation-chart-groups";
import { parseInvestigationDevJsonl } from "./investigation-dev-preview"; import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
@@ -47,9 +48,18 @@ describe("switchable investigation Dev cases", () => {
expect(nodes[0].props.rows).toHaveLength(50); expect(nodes[0].props.rows).toHaveLength(50);
expect(nodes[1].props.rows).toEqual([]); expect(nodes[1].props.rows).toEqual([]);
const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts")); const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts"));
const pressure = Object.values(charts.blocks[0].spec.elements)[0].props; const commands = charts.commands.filter((command) => command.action === "open_chart");
expect(pressure.x_data).toHaveLength(96); expect(charts.blocks).toHaveLength(0);
expect(pressure.series).toHaveLength(8); expect(commands).toHaveLength(13);
const groups = groupInvestigationCharts(commands);
expect(groups.map((group) => group.items.length)).toEqual([8, 4, 1]);
for (const { chart } of commands.slice(0, 12)) {
expect(chart.chart_type).toBe("time_comparison");
expect(chart.x_data).toHaveLength(96);
expect(chart.series).toHaveLength(8);
expect(chart.series[0].sample_times).toHaveLength(96);
expect(chart.series[1].data.filter((value) => value !== null)).toHaveLength(3);
}
}); });
it.each(["data_only", "candidates_table", "valves_table"] as const)( it.each(["data_only", "candidates_table", "valves_table"] as const)(
@@ -65,6 +65,11 @@ export const investigationDevCases = [
id: "invalid_chart", id: "invalid_chart",
label: "错误:图表数据不齐", label: "错误:图表数据不齐",
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。" expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
},
{
id: "invalid_comparison",
label: "错误:同期时刻错位",
expected: "第 1 行拒绝历史采样时间与横轴槽位不一致的复合图,整批不执行。"
} }
] as const; ] as const;
@@ -258,5 +263,14 @@ export function createInvestigationDevCase(
} }
} }
]); ]);
case "invalid_comparison":
return invalidJsonl([{ record_type: "presentation", command: {
action: "open_chart", chart_id: "dev-invalid-comparison", evidence_refs: ["dev:synthetic"],
chart: { title: "错误案例:历史点时刻错位", chart_type: "time_comparison", x_data: ["08:00", "08:15"],
series: [
{ name: "当前", type: "line", data: [32, 30], sample_times: ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"] },
{ name: "历史", type: "scatter", data: [33, null], sample_times: ["2026-07-15T08:15:00+08:00", null] }
] }
} }]);
} }
} }

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