Compare commits

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

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

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

The remaining vertical jitter came from Sonner exit offsets being applied on top of flow positioning. Let flow own geometry and retain opacity transitions; add frame-sampled pan and recovery regression coverage.
2026-11-08 03:23:00 +08:00
jiang e0fe4a5a10 fix(charts): preserve comparison types and draggable window headings
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m11s
Keep current lines and historical scatter points in a dedicated comparison chart type. Expose floating headings and use idempotent placement so StrictMode no longer masks production overlap.
2026-11-08 03:04:14 +08:00
jiang 4e3ed5098e fix(workbench): retry approved task actions after dispatch failure
Generic Container CI/CD / test-build-publish (push) Successful in 1m13s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m13s
2026-11-07 22:20:04 +08:00
jiang 8a63926284 fix(charts): simplify time labels in investigation views
Generic Container CI/CD / test-build-publish (push) Successful in 1m25s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m26s
2026-11-07 21:03:43 +08:00
154 changed files with 6795 additions and 1458 deletions
+1
View File
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
pnpm test # 运行 Vitest 单元测试
pnpm test:watch # 监听模式运行单元测试
pnpm test:browser # 运行 Playwright 浏览器测试
pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
```
## 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 内容实例,反向操作中断旧动画,避免旧退出回调隐藏重新展开的表单。
@@ -87,7 +95,7 @@ Agent 选择组件、内容顺序与受控语义,前端负责实际视觉、
| `Stack`、`Section` | 保留既有布局,不为静态分区附加动画 |
| `Text`、`Result` | 静态说明和已有结果直接阅读,不反复播放入场或宣告历史状态 |
| `Metric` | 直接展示原始数值、单位与来源 |
| `Table` | 搜索排序即时响应;无勾选列、选行提示或操作栏 |
| `Table` | 搜索排序即时响应;表头悬停与排序点击覆盖整列宽度;仅当前排序列显示升序或降序箭头,未排序时不显示;无勾选列、选行提示或操作栏 |
| `Evidence` | 正文展开收起和箭头转动使用统一过渡;保留来源、记录时间与资料编号 |
| `MapLocation` | 关联要素详情使用统一折叠;定位中的反馈及结果短时淡入 |
| `FollowUpForm` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
+43 -8
View File
@@ -20,13 +20,31 @@ 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. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;手动打开“图表窗口”,核对单图、左右、四宫格的固定槽位、拖放交换、末行空槽与纵向滚动,再检查快速定位和子图表独立放大,并从顶部窗口列表切换已打开的窗口。Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。
样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
@@ -37,8 +55,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 案例 | 核对内容 |
| --- | --- |
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 |
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 |
| 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 |
| 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 |
| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 |
| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 |
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
| 合成影响范围 | 当前地图中心附近的合成 Polygon |
@@ -49,6 +69,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
| 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 |
| 错误:不支持的动作 | 拒绝协议未支持的动作 |
| 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 |
| 错误:同期时刻错位 | 拒绝历史时间与横轴槽位不一致的复合图,整批不执行 |
切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 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"}
```
仓库内的可复制数据示例为 [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 写入接口。
@@ -75,15 +96,16 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
```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
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 动作按接收顺序执行,切换会话取消正在执行及排队动作;过期动作不会执行。任务仍只记录发布状态,不等待浏览器。查看历史回答不会重放动作。
动作 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 提升为指令 |
| 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,并在各仓库独立运行测试。
## 展示动作历史
+7 -1
View File
@@ -56,7 +56,9 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
## 2. Tailwind 定制方式
@@ -192,6 +194,10 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
每个业务区最多一个醒目的主操作。按钮覆盖默认、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` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
弹层保留标题、关闭入口、焦点管理及触发控件的焦点返回。表单弹窗显式将初始焦点放在首要业务字段,选择框获焦不自动展开。Esc 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
+3
View File
@@ -10,6 +10,7 @@
"scripts": {
"dev": "vite",
"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: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",
@@ -23,6 +24,8 @@
},
"dependencies": {
"@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",
"@json-render/core": "^0.21.0",
"@json-render/react": "^0.21.0",
+71
View File
@@ -15,6 +15,12 @@ importers:
'@ai-sdk/react':
specifier: ^4.0.9
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':
specifier: 5.3.0
version: 5.3.0
@@ -470,6 +476,27 @@ packages:
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
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':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
@@ -825,6 +852,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
'@preact/signals-core@1.14.5':
resolution: {integrity: sha512-vcRv7Ityz5JVmeiVqIluLFzT/PDS/AlhHUpw3/aUWqcVUs6bDGIPXE/6RSC4evBNWYvnniozg5Syfj33al3gkg==}
'@radix-ui/number@1.1.2':
resolution: {integrity: sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==}
@@ -4567,6 +4597,45 @@ snapshots:
'@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':
optional: true
@@ -4860,6 +4929,8 @@ snapshots:
dependencies:
playwright: 1.61.1
'@preact/signals-core@1.14.5': {}
'@radix-ui/number@1.1.2': {}
'@radix-ui/primitive@1.1.6': {}
+12 -5
View File
@@ -1,11 +1,13 @@
import process from "node:process";
import { writeFile } from "node:fs/promises";
import { readFile, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { zodToJsonSchema } from "zod-to-json-schema";
import { answerUiCatalog, ANSWER_UI_VERSION } from "../src/features/agent/answer-ui/catalog.ts";
const targets = process.argv[2]
? [resolve(process.argv[2])]
const check = process.argv.includes("--check");
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-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) {
await writeFile(target, `${JSON.stringify(contract, null, 2)}\n`);
process.stdout.write(`Exported ${contract.components.length} components to ${target}\n`);
if (check) {
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 { 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 { zodToJsonSchema } from "zod-to-json-schema";
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]
];
// 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) {
if (process.argv.includes("--check")) {
if ((await readFile(url, "utf8")) !== content)
throw new Error(`View command contract differs: ${fileURLToPath(url)}`);
} else {
await mkdir(dirname(fileURLToPath(url)), { recursive: true });
await writeFile(url, content);
}
}
@@ -17,7 +17,7 @@ describe("answer presentation time zone", () => {
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]);
expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)");
expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z");
expect(result.elements.chart.props.x_axis_name).toBe("时间(UTC+8)");
expect(result.elements.chart.props.x_axis_name).toBe("时间");
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
expect(JSON.stringify(spec)).toBe(original);
expect(withAnswerDisplayTimes(result)).toEqual(result);
@@ -36,4 +36,23 @@ describe("answer presentation time zone", () => {
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
it("removes chart timezone wording while preserving the window, data and sample instants", () => {
const samples = ["2026-07-15T23:30:00Z", "2026-07-15T23:45:00Z"];
const spec: Spec = { root: "chart", elements: {
chart: { type: "Chart", props: {
title: "回放流量:487434(m³/h,上海时间 07-16 04:45–07:45)",
x_axis_name: "上海时间", x_data: ["07:30", "07:45"],
series: [{ name: "当前回放(2026-07-16,UTC+8)", data: [420, 380], sample_times: samples }]
}, children: [] }
} };
const result = withAnswerDisplayTimes(spec);
expect(result.elements.chart.props.title).toBe("回放流量:487434(m³/h,07-16 04:45–07:45)");
expect(result.elements.chart.props.x_axis_name).toBe("时间");
expect(result.elements.chart.props.x_data).toEqual(["07:30", "07:45"]);
expect(result.elements.chart.props.series).toEqual([
{ name: "当前回放(2026-07-16)", data: [420, 380], sample_times: samples }
]);
expect(withAnswerDisplayTimes(result)).toEqual(result);
});
});
@@ -1,5 +1,5 @@
import type { Spec } from "@json-render/core";
import { formatDisplayText } from "@/shared/utils/date-time-text";
import { formatDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
@@ -23,14 +23,7 @@ export function withAnswerDisplayTimes(spec: Spec): Spec {
props.columns = columns.map(timeAxis);
}
if (node.type === "Chart") {
const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : "";
props.x_data = (props.x_data as string[]).map((label) => {
const displayed = displayCell(label, axis);
return timeAxis(axis).includes("UTC+8") ? displayed.replace(/(UTC\+8)$/, "") : displayed;
});
if (typeof props.x_axis_name === "string") props.x_axis_name = timeAxis(props.x_axis_name);
props.series = (props.series as Array<Record<string, unknown>>).map((series) => ({ ...series,
name: typeof series.name === "string" ? formatDisplayText(series.name) : series.name }));
Object.assign(props, withChartDisplayTimes(props as Parameters<typeof withChartDisplayTimes>[0]));
}
return [id, { ...node, props }];
})) };
@@ -7,10 +7,10 @@ export type AnswerUiMapTarget = {
};
export type AnswerUiInteractions = {
disabled: boolean;
chartPresentation?: "tile";
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
onSubmit?: (text: string) => Promise<void> | void;
evidenceFilter?: string;
submitMode?: "send" | "draft";
chartWindowKey?: string;
};
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.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 () => {
const locate = vi
.fn()
@@ -1,6 +1,6 @@
import { LocateButton, FollowUpForm } from "./answer-action-components";
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 {
Info,
TriangleAlert,
@@ -103,17 +103,19 @@ const registry = withAnswerNodeIdentity(baseRegistry);
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
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}>
<AnswerUiBody {...props} block={{ ...props.block, spec: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} />
<AnswerUiBody {...props} block={{ ...props.block, spec: displayedSpec }} />
</AnswerViewStateProvider>;
}
function AnswerUiBody({
block,
disabled = false,
chartPresentation,
onLocate,
onSubmit,
chartWindowKey,
submitMode = "send"
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
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 renderNodes = (ids: string[]) => ids.map((id) => <Renderer key={id} spec={{ ...block.spec, root: id }} registry={registry} />);
return (
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}>
<AnswerMotionScope className="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} />
<div ref={contentRef}>
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartPresentation }}>
<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}>
{chartPresentation !== "tile" ? <AnswerContentNav block={block} contentRef={contentRef} /> : null}
<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.overview.length ? <div className="answer-task-overview grid gap-3" aria-label="回答概览">{renderNodes(taskLayout.overview)}</div> : null}
@@ -31,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
const start = index(proposed[0], labels.length);
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
const hidden = indices(state.hidden, series.length);
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
const data = answerChartTableData(node, chart);
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
+2 -2
View File
@@ -64,7 +64,7 @@ export const answerUiCatalog = defineCatalog(schema, {
props: z
.object({
title: shortText,
chart_type: z.enum(["line", "bar"]),
chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(shortText).min(1).max(100),
@@ -82,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
})
.strict(),
description:
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
"展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
},
Evidence: {
props: z
@@ -1,5 +1,5 @@
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", () => {
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 });
});
it("uses aligned integer boundaries and steps without rounding monitoring readings", () => {
const series = [{ name: "流量", data: [2494.274, 4742.033] }];
const original = structuredClone(series);
expect(chartValueBounds(series, 0, 1, true)).toEqual({ min: 2000, max: 5000, interval: 1000 });
expect(series).toEqual(original);
});
it("keeps a narrow pressure range near its readings instead of forcing a broad zero-based axis", () => {
expect(chartValueBounds([{ name: "压力", data: [28.125, 28.875] }], 0, 1, true))
.toEqual({ min: 28, max: 29, interval: 1 });
expect(chartValueBounds([{ name: "压力", data: [9999, 28.14, 29.03, -9999] }], 1, 2, true))
.toEqual({ min: 28, max: 30, interval: 1 });
});
it("preserves negative flow and provides distinct integer ticks for zero flow", () => {
expect(chartValueBounds([{ name: "反向流量", data: [-108.8, 12.125] }], 0, 1, true))
.toEqual({ min: -150, max: 50, interval: 50 });
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1, true))
.toEqual({ min: -1, max: 1, interval: 1 });
});
it("limits integer ticks to confirmed monitoring names and units while preserving generic small ranges", () => {
for (const name of ["压力(m)", "压力 ( m )", "压力水头(m)", "水头(m)", "流量(m³/h)", "流量 ( m3 / h )", "管流(m³/h)"])
expect(chartUsesIntegerTicks(name)).toBe(true);
for (const name of [undefined, "压力(MPa)", "流量(L/s)", "比率", ""])
expect(chartUsesIntegerTicks(name)).toBe(false);
const series = [{ name: "压力(MPa)", data: [0.2131, 0.2149] }];
const bounds = chartValueBounds(series, 0, 1, chartUsesIntegerTicks("压力(MPa)"));
expect(bounds.min).toBeGreaterThan(0.21);
expect(bounds.max).toBeLessThan(0.22);
expect(bounds).not.toHaveProperty("interval");
});
it("preserves the original historical date in tooltip and marks missing values explicitly", () => {
const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] },
{ name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
@@ -9,13 +9,24 @@ export function chartSampleTime(time: string | null | undefined): string {
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))
.filter((value): value is number => value !== null && Number.isFinite(value));
if (!values.length) return {};
const min = Math.min(...values), max = Math.max(...values);
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
return { min: min - padding, max: max + padding };
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. */
+170 -134
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 { 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 { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
@@ -11,12 +11,13 @@ import { InteractiveDataTable } from "./interactive-data-table";
import { ResponsiveChart } from "./responsive-chart";
import { agentEase } from "../components/agent-motion";
import type { SafeChartSeries } from "../chart-data";
import { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { AnswerInteractionContext } from "./answer-ui-interaction-context";
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
export type AnswerChartProps = {
title: string;
chart_type: "line" | "bar";
chart_type: "line" | "bar" | "time_comparison";
x_data: string[];
series: SafeChartSeries[];
x_axis_name?: 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>
</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 = {};
/** 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) {
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 reducedMotion = useReducedMotion();
const pointer = useRef(false);
@@ -56,13 +59,13 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const [plotWidth, setPlotWidth] = useState(360);
useLayoutEffect(() => {
const element = plot.current;
if (props.expanded || !element || typeof ResizeObserver === "undefined") return;
if (fitted || !element || typeof ResizeObserver === "undefined") return;
const resize = () => setPlotWidth(element.clientWidth);
resize();
const observer = new ResizeObserver(resize);
observer.observe(element);
return () => observer.disconnect();
}, [view, props.expanded]);
}, [view, fitted]);
const contentMotion = useAnimationControls();
const pendingTransition = useRef(false);
useLayoutEffect(() => {
@@ -75,7 +78,7 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
}, [contentMotion, view]);
const setView = (next: string) => {
if (next === view) return;
const animate = pointer.current && !reducedMotion;
const animate = pointer.current && !reducedMotion && !tile;
setAnimateInteraction(animate);
contentMotion.stop();
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 setPoint = (point: number) => patch({ point });
const setInspecting = (inspecting: boolean) => patch({ inspecting });
const visible = props.series.filter((_, index) => !hidden.includes(index));
const labels = props.x_data.slice(range[0], range[1] + 1);
const hiddenKey = hidden.join(",");
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 fullRange = range[0] === 0 && range[1] === props.x_data.length - 1;
const summaries = visible.map((series) => {
@@ -98,61 +104,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
const categoryColors = visible.length === 1;
const horizontalBars = props.x_data.some((label) => label.length > 10);
const numberLabel = chartValueLabel;
const option = (type: "line" | "bar") => {
const horizontal = type === "bar" && horizontalBars;
const seriesType = (series: SafeChartSeries) => view === props.chart_type ? series.type ?? type : type;
const includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) },
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
const valueAxis = { type: "value", scale: !includesBars,
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}),
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel },
splitLine: { lineStyle: { color: "#e2e8f0" } } };
return {
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true },
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
...(composite ? { formatter: (params: unknown) => {
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
const text = typeof first?.dataIndex === "number"
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : "";
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML.
const content = document.createElement("div");
content.textContent = text;
content.style.whiteSpace = "pre-line";
return content;
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true },
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name,
nameLocation: "middle", nameGap: 30 },
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name },
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value),
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
const 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 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 includesBars = visible.some((series) => seriesType(series) === "bar");
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
...(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" } } };
const valueAxis = { type: "value", scale: !includesBars,
...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
...(tile ? { margin: 4, lineHeight: 12 } : {}) },
splitLine: { lineStyle: { color: "#e2e8f0" } } };
return {
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
...(composite ? { formatter: (params: unknown) => {
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
const text = typeof first?.dataIndex === "number"
? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
// readable and never interprets model-provided labels as HTML.
const content = document.createElement("div");
content.textContent = text;
content.style.whiteSpace = "pre-line";
return content;
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
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),
data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
};
};
};
const metadata = <>
{!props.embedded && view !== "table" ? <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}
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
return { line: option("line"), bar: option("bar") };
}, [animateInteraction, categoryColors, composite, fitted, hiddenSeries, horizontalBars, labels, numberLabel, plotWidth,
props.chart_type, rangeStart, rangeEnd, reducedMotion, tile, view, visible, xAxisName, yAxisName]);
const pointDetails = 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}
animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.18, ease: agentEase }}>
<div className="mb-2 flex items-center justify-between gap-2">
@@ -170,69 +178,26 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</dd>
</div>)}
</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">
<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>
</>;
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}
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">
const rangeControls = <div className="space-y-2">
<div className="flex items-center gap-2">
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
@@ -242,12 +207,63 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
</div>
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
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>
</CardHeader> : null}
<CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
style={{ width: "calc((100% - 0.5rem) / 3)" }}
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
</TabsList>
{tile ? <>
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
</Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
</Popover>
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
</Popover> : null}
</> : props.embedded ? <CollapsibleTrigger asChild>
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
</CollapsibleTrigger> : null}
</div>
<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}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}>
{!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
</div> : null}
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
className={`${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) => {
if (event.target !== event.currentTarget) return;
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);
}}>
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
<div role="img" aria-label={props.title} style={{ height: props.expanded ? "100%" : props.plotHeight ?? 220,
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}>
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
<div role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
<ResponsiveChart option={options[type]} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
</div>
</div>
</TabsContent>)}
<TabsContent value="table" className={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)]}
rows={labels.map((label, index) => [label, ...visible.map((series) => {
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}`))} />
</TabsContent>
</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>
</CardContent>
</Card></Collapsible>;
@@ -1,5 +1,5 @@
import { useMemo } from "react";
import { ArrowDown, ArrowUp, ArrowUpDown } from "lucide-react";
import { ArrowDown, ArrowUp } from "lucide-react";
import { Input } from "@/shared/ui/input";
import { Button } from "@/shared/ui/button";
import { FloatingWindow } from "@/shared/ui/floating-window";
@@ -45,13 +45,13 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
<Table>
<TableCaption className="caption-top font-medium">{title}</TableCaption>
<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"}>
<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
? sort.descending ? null : { column: index, descending: true }
: { 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>
</TableHead>)}
</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 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 }: {
option: ComponentProps<typeof ReactECharts>["option"];
height: number | "100%";
@@ -13,16 +15,32 @@ export function ResponsiveChart({ option, height, onPointSelect }: {
useLayoutEffect(() => {
const element = host.current;
if (!element || typeof ResizeObserver === "undefined") return;
const shell = element.closest<HTMLElement>("[data-floating-window]");
let frame = 0;
const observer = new ResizeObserver(() => {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
});
let timer = 0;
const resize = () => {
frame = 0;
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
// Space expensive drawings in one window instead of redrawing every chart together.
const remaining = 64 - (performance.now() - (shell ? resizedWindows.get(shell) ?? -Infinity : -Infinity));
if (shell?.dataset.floatingWindowResizing === "true" && remaining > 0) {
timer = window.setTimeout(() => { timer = 0; frame = requestAnimationFrame(resize); }, remaining);
return;
}
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
if (shell) resizedWindows.set(shell, performance.now());
};
const schedule = () => { if (!frame && !timer) frame = requestAnimationFrame(resize); };
const observer = new ResizeObserver(schedule);
const settled = new MutationObserver(() => {
if (shell?.dataset.floatingWindowResizing === "true") return;
clearTimeout(timer);
timer = 0;
schedule();
});
observer.observe(element);
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 }}>
<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 () => {
const fetchMock = vi
.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 }
)
);
it("uses the configured Agent address once and propagates its error", async () => {
const fetchMock = vi.fn().mockResolvedValue(Response.json({ message: "unavailable" }, { status: 503 }));
vi.stubGlobal("fetch", fetchMock);
await expect(createAgentApiClient(["http://not-agent.local", "http://agent.local"]).getUiRegistry()).resolves.toEqual({
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);
await expect(createAgentApiClient("http://agent.local/prefix/").getUiRegistry()).rejects.toThrow("unavailable");
expect(fetchMock).toHaveBeenCalledExactlyOnceWith("http://agent.local/prefix/api/v1/ui-registry", undefined);
});
it("sends title update payloads using backend field names", async () => {
+13 -49
View File
@@ -121,6 +121,7 @@ export type AgentSessionStreamDataEventType =
| "todo_update"
| "permission_request"
| "permission_response"
| "run_status"
| "interaction_request"
| "question_request"
| "question_response"
@@ -131,6 +132,7 @@ export type AgentSessionStreamDataEventType =
| "ui_envelope"
| "session_title"
| "done"
| "aborted"
| "error"
| "auth_required"
| "credential_refresh_required"
@@ -218,24 +220,16 @@ export type AgentApiClientOptions = {
getProjectId?: () => Promise<string | undefined>;
};
const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_URL.replace(/\/$/, "")];
const API_PATH = "/api/v1";
export function createAgentApiClient(
baseUrls: string | string[] = AGENT_API_BASE_URLS,
baseUrl: string = env.TJWATER_AGENT_URL,
options: AgentApiClientOptions = {}
): AgentApiClient {
const candidates = (Array.isArray(baseUrls) ? baseUrls : [baseUrls]).map((item) => item.replace(/\/$/, ""));
let activeBaseUrl = candidates[0] ?? "";
const setActiveBaseUrl = (baseUrl: string) => {
activeBaseUrl = baseUrl;
};
const normalizedBaseUrl = baseUrl.replace(/\/$/, "");
const request = (path: string, init?: RequestInit) =>
fetchWithFallback(
candidates,
activeBaseUrl,
setActiveBaseUrl,
fetchAgent(
normalizedBaseUrl,
path,
init,
options.getAccessToken,
@@ -263,7 +257,7 @@ export function createAgentApiClient(
return token;
} : undefined;
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);
// An identity change while awaiting the network also invalidates the result.
await checkedAnswerStateToken?.();
@@ -273,7 +267,7 @@ export function createAgentApiClient(
return {
...createInteractionRequestApi(answerStateRequest),
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 服务暂不可用");
const data: unknown = await response.json();
if (!isRecord(data) || data.ok !== true || data.ready !== true) throw new Error("Agent 服务尚未就绪");
@@ -577,19 +571,14 @@ export function createAgentApiClient(
};
}
async function fetchWithFallback(
baseUrls: string[],
activeBaseUrl: string,
setActiveBaseUrl: (baseUrl: string) => void,
async function fetchAgent(
baseUrl: string,
path: string,
init?: RequestInit,
getAccessToken?: AccessTokenProvider,
getProjectId?: () => Promise<string | undefined>,
prefix = API_PATH
) {
const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)];
let lastError: unknown;
let lastResponse: Response | null = null;
const accessToken = await getAccessToken?.();
const projectId = await getProjectId?.();
const hasContextHeaders = Boolean(accessToken || projectId);
@@ -598,34 +587,7 @@ async function fetchWithFallback(
if (projectId) headers?.set("X-Project-Id", projectId);
const requestInit = hasContextHeaders ? { ...init, headers } : init;
for (const baseUrl of orderedBaseUrls) {
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;
return fetch(`${baseUrl}${prefix}${path}`, requestInit);
}
function isRecord(value: unknown): value is Record<string, unknown> {
@@ -837,6 +799,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "execution_plan" ||
value === "permission_request" ||
value === "permission_response" ||
value === "run_status" ||
value === "interaction_request" ||
value === "question_request" ||
value === "question_response" ||
@@ -847,6 +810,7 @@ function isSessionStreamDataEventType(value: string): value is AgentSessionStrea
value === "ui_envelope" ||
value === "session_title" ||
value === "done" ||
value === "aborted" ||
value === "error" ||
value === "credential_refresh_required" ||
value === "credential_refreshed" ||
@@ -1066,6 +1066,11 @@ function BackendMessage({
<AgentUserMessageText content={message.content} />
)
) : 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 ? (
<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 type { AgentChatProgress } from "../types";
const states = {
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
cancelled: { label: "已中止", tone: "neutral", Icon: Ban },
error: { label: "失败", tone: "danger", Icon: XCircle },
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
} satisfies Record<
@@ -1,6 +1,7 @@
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
import { AnimatePresence, MotionConfig, motion } from "motion/react";
import { memo, type ReactNode } from "react";
import { formatChartDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
import { StatusIcon } from "@/shared/ui/status";
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
@@ -71,15 +72,19 @@ 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 }) {
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,
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} plotHeight={plotHeight} tile={tile} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
...(chart.x_axis_name ? { x_axis_name: chart.x_axis_name } : {}),
...(chart.y_axis_name ? { y_axis_name: chart.y_axis_name } : {}) }, data: { x_data: chart.x_data, series: chart.series } }} />;
});
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data, 100);
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 raw = normalizeSafeChartData(envelope.data, 100);
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
x_data: raw?.xData ?? [], series: raw?.series ?? [] });
const spec = { ...parsed, title: displayed.title, xAxisName: displayed.x_axis_name };
const data = raw ? { ...raw, xData: displayed.x_data, series: displayed.series } : null;
if (!data || data.series.length === 0 || data.xData.length === 0) {
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
@@ -92,7 +97,7 @@ function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { e
series={data.series.slice(0, 8)}
source="来源见关联依据。"
embedded={embedded}
expanded={expanded}
expanded={expanded} plotHeight={plotHeight} tile={tile}
xAxisName={spec.xAxisName}
yAxisName={spec.yAxisName}
/>;
@@ -206,7 +211,7 @@ function getEnvelopeIcon(envelope: UIEnvelope) {
function getEnvelopeTitle(envelope: UIEnvelope) {
if (envelope.kind === "chart") {
const spec = parseChartSpec(envelope.spec);
return spec.title ?? "Agent 图表";
return formatChartDisplayText(spec.title ?? "Agent 图表");
}
if (envelope.kind === "registered_component") {
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | 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 mutate = inbox.mutate;
const refresh = useCallback(() => { void mutate().catch(() => undefined); }, [mutate]);
@@ -15,20 +15,27 @@ export function useAgentMessageInbox(client: AgentApiClient, sessionId: string |
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
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() {
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { retryDelay = 1500; refreshRef.current(); } })
.catch(() => { retryDelay = Math.min(retryDelay * 2, 30_000); })
void client.streamAgentMessages(sessionId!, { signal: controller.signal, onMessage: () => { initialized = true; clearTimeout(initialLoadTimer); retryDelay = 1500; refreshRef.current(); } })
.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(() => {
if (!controller.signal.aborted) timer = setTimeout(subscribe, retryDelay);
});
}
subscribe();
return () => { controller.abort(); clearTimeout(timer); };
return () => { controller.abort(); clearTimeout(timer); clearTimeout(initialLoadTimer); };
}, [client, sessionId]);
return {
messages: inbox.data?.items ?? [],
hasMore: Boolean(inbox.data?.next_cursor),
loading: inbox.isLoading,
loading: Boolean(sessionId) && (inbox.isLoading || (!inbox.data && !inbox.error)),
error: inbox.error ? "任务消息暂时无法加载" : null,
refresh,
loadingMore,
+2
View File
@@ -55,6 +55,7 @@ export {
cancelRunningTodos,
completeRunningProgress,
finalizeAssistantMessageAfterAbort,
finalizeAssistantMessageAfterError,
toTodoUpdate,
upsertPermission,
upsertProgress,
@@ -115,3 +116,4 @@ export { readInteractionReply } from "./api/interaction-requests";
export type { InteractionRequestsController } from "./hooks/use-interaction-requests";
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,15 +7,70 @@ 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. */
export function createViewSchemas(zod: typeof z = z, publication = false) {
export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
const z = zod;
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
const chartSchema = z
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
chart_type: z.enum(["line", "bar", "time_comparison"]),
x_axis_name: z.string().max(128).optional(),
y_axis_name: z.string().max(128).optional(),
x_data: z.array(z.string().max(128)).min(1).max(100),
@@ -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.every((time, index) => time === null || series.data[index] !== null))),
"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
.tuple([z.number().finite().min(-180).max(180), z.number().finite().min(-90).max(90)])
.rest(z.number().finite())
@@ -213,8 +272,9 @@ export const {
overlayDataSchema,
variants: viewCommandVariants
} = createViewSchemas();
export const createViewCommandSchema = (options: { publication: boolean }) =>
createViewSchemas(z, options.publication).command;
export const safeChartPublicationInput = createViewSchemas(z, true).safeChartInput;
export const createViewCommandSchema = (options: { publication: boolean; validateCharts?: boolean }) =>
createViewSchemas(z, options.publication, options.validateCharts ?? options.publication).command;
type Command = z.infer<ReturnType<typeof createViewCommandSchema>>;
export type ViewCommand =
| Exclude<Command, { action: "set_overlay" }>
+3 -1
View File
@@ -73,6 +73,8 @@ describe("agent session state", () => {
requestId: "permission-1",
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", () => {
@@ -152,7 +154,7 @@ describe("agent session state", () => {
const next = finalizeAssistantMessageAfterAbort(message);
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");
});
});
+21 -6
View File
@@ -47,7 +47,10 @@ export function upsertProgress(progress: AgentChatProgress[] | undefined, data:
return next;
}
export function completeRunningProgress(progress: AgentChatProgress[] | undefined) {
export function completeRunningProgress(
progress: AgentChatProgress[] | undefined,
status: "completed" | "error" | "cancelled" = "completed"
) {
return progress?.map((item) => {
if (item.status !== "running") {
return item;
@@ -56,7 +59,7 @@ export function completeRunningProgress(progress: AgentChatProgress[] | undefine
const endedAt = Date.now();
return {
...item,
status: "completed" as const,
status,
endedAt,
elapsedMs: undefined,
elapsedSnapshotAt: undefined,
@@ -154,9 +157,9 @@ export function applyPermissionResponse(
permission.requestId === requestId
? {
...permission,
status: toPermissionStatus(readString(payload.reply)),
status: payload.status === "error" ? "error" as const : toPermissionStatus(readString(payload.reply)),
repliedAt: Date.now(),
error: undefined
error: payload.status === "error" ? readString(payload.error) : undefined
}
: permission
);
@@ -235,7 +238,7 @@ export function applyQuestionResponse(questions: AgentQuestionRequest[] | undefi
}
export function finalizeAssistantMessageAfterAbort(message: AgentChatMessage): AgentChatMessage {
const progress = completeRunningProgress(message.progress);
const progress = completeRunningProgress(message.progress, "cancelled");
const todos = cancelRunningTodos(message.todos);
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 {
if (reply === "always") {
return "approved_always";
@@ -340,7 +355,7 @@ function toToolRef(value: unknown): AgentInteractionToolRef | undefined {
}
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 {
+2 -1
View File
@@ -2,6 +2,7 @@ export type AgentChatMessage = {
id: string;
role: "user" | "assistant";
content: string;
runError?: string;
answerParts?: import("./answer-ui/contract").AnswerUiPart[];
finalAnswer?: { content: string; uiBlockIds: string[] };
progress?: AgentChatProgress[];
@@ -24,7 +25,7 @@ export type AgentChatProgress = {
tool?: { name: string; callId?: string; input?: Record<string, unknown> };
id: string;
phase: string;
status: "running" | "completed" | "error";
status: "running" | "completed" | "error" | "cancelled";
title: string;
detail?: string;
startedAt?: number;
@@ -2,13 +2,13 @@ import { Check, Map } from "lucide-react";
import { type FocusEvent, useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/shared/ui/cn";
import {
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_TOOL_PANEL_SURFACE_CLASS_NAME
} from "./map-control-styles";
@@ -105,18 +105,18 @@ export function BaseLayersControl({
<button
key={option.id}
type="button"
data-map-option=""
disabled={option.disabled}
aria-pressed={active}
onClick={() => onSelectLayer(option.id)}
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_SELECTION_CARD_FOCUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
active
? "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",
option.disabled && "cursor-not-allowed opacity-50"
: "text-slate-700",
mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)}
>
<BaseLayerSwatch option={option} active={active} />
@@ -159,22 +159,22 @@ export function BaseLayersControl({
<button
key={option.id}
type="button"
data-map-option=""
disabled={option.disabled}
aria-pressed={active}
onClick={() => onSelectLayer(option.id)}
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_SELECTION_CARD_FOCUS_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",
option.disabled && "cursor-not-allowed opacity-50"
active ? "border-[var(--action-blue)] shadow-xs" : "border-[var(--surface-divider)]",
mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)}
>
<BaseLayerSwatch option={option} active={active} />
<span
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}
</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)}>
<button
type="button"
data-map-option=""
aria-label={`${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)}>
{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" />
)}
<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}
</span>
</span>
@@ -2,6 +2,7 @@ import type { LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { cn } from "@/shared/ui/cn";
import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,14 +80,15 @@ export function MapModeButton({ icon: Icon, label, active = false, disabled = fa
return (
<button
type="button"
data-map-option=""
aria-pressed={active}
disabled={disabled}
onClick={onClick}
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,
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600 hover:text-[var(--action-blue-hover)]",
disabled && "cursor-not-allowed opacity-55 hover:text-slate-600"
active ? "bg-[var(--action-blue)] text-white" : "surface-control text-slate-600",
mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)}
>
<Icon size={17} aria-hidden="true" />
@@ -109,13 +111,14 @@ export function MapActionChip({
return (
<button
type="button"
data-map-option=""
disabled={disabled}
onClick={onClick}
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_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" />
@@ -157,23 +160,20 @@ export function MapActionRow({
return (
<button
type="button"
data-map-option=""
disabled={disabled}
onClick={onClick}
className={cn(
"flex min-h-12 w-full items-center gap-2.5 border px-2.5 py-2 text-left",
MAP_COMPACT_RADIUS_CLASS_NAME,
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
? "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)]",
isDanger && "hover:border-red-100 hover:text-red-700",
: "surface-control border-transparent text-slate-700",
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",
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"
)}
>
@@ -185,8 +185,8 @@ export function MapActionRow({
? "bg-[var(--surface-reading)]/16 text-white"
: selected || strong
? "bg-[var(--action-blue)] text-white"
: "bg-[var(--surface-control)] text-[var(--ink-muted)]",
isDanger && "bg-red-50 text-red-600"
: "bg-[var(--surface-control)] text-inherit",
isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)}
>
<Icon size={15} aria-hidden="true" />
@@ -7,6 +7,7 @@ import {
TooltipTrigger
} from "@/shared/ui/tooltip";
import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
@@ -46,27 +47,26 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<button
key={item.id}
type="button"
data-map-option=""
aria-pressed={item.active}
disabled={item.disabled}
onClick={item.onClick}
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,
item.active
? "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,
isDanger && "hover:border-red-100 hover:text-red-700",
item.disabled &&
"cursor-not-allowed opacity-55 hover:border-white/70 hover:bg-[var(--surface-reading)]/95 hover:text-slate-700"
mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
)}
>
<span
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,
item.active ? "bg-[var(--surface-reading)]/16 text-white" : "bg-[var(--surface-control)] text-[var(--ink-muted)]",
isDanger && "bg-red-50 text-red-600"
item.active ? "bg-[var(--surface-reading)]/16" : "bg-[var(--surface-control)]",
isDanger && "bg-[var(--status-danger-soft)] text-[var(--status-danger-foreground)]"
)}
>
<Icon size={15} aria-hidden="true" />
@@ -100,6 +100,7 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
<TooltipTrigger asChild>
<button
type="button"
data-map-option=""
aria-label={item.label}
aria-pressed={item.active}
disabled={item.disabled}
@@ -109,9 +110,8 @@ export function MapDrawToolbar({ items, label = "绘制工具", className = "",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
item.active
? "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)]",
isDanger && "hover:bg-red-50 hover:text-red-700",
item.disabled && "opacity-45 hover:bg-transparent hover:text-slate-600"
: undefined,
mapOptionInteractionVariants({ state: isDanger ? "danger" : item.active ? "selected" : "default" })
)}
>
<Icon size={16} aria-hidden="true" />
@@ -1,6 +1,7 @@
import { Eye, EyeOff, Layers3, Lock } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -79,23 +80,24 @@ export function MapLayerControl({
<button
key={item.id}
type="button"
data-map-option=""
disabled={disabled}
aria-pressed={item.visible}
onClick={() => onToggleLayer(item.id, !item.visible)}
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,
item.visible
? "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)]",
disabled && "cursor-not-allowed opacity-60 hover:bg-transparent"
: "surface-well border-transparent text-[var(--ink-muted)]",
mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)}
>
<span
className={cn(
"grid h-8 w-8 shrink-0 place-items-center",
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" />}
@@ -4,12 +4,12 @@ import { cn } from "@/shared/ui/cn";
import type { BaseLayerOption } from "./base-layers-control";
import type { MapLayerControlItem, MapLayerControlVisual } from "./layer-control";
import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
MAP_READABLE_SURFACE_CLASS_NAME,
MAP_READABLE_SURFACE_STRONG_CLASS_NAME,
MAP_SELECTION_CARD_FOCUS_CLASS_NAME
MAP_READABLE_SURFACE_STRONG_CLASS_NAME
} from "./map-control-styles";
export type MapLayerPanelProps = {
@@ -46,17 +46,10 @@ type LayerVisibilitySectionProps = {
function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySectionProps) {
const groups = groupLayerItems(items);
const [openGroupIds, setOpenGroupIds] = useState(
() => new Set(groups.filter((group) => group.defaultOpen).map((group) => group.id))
);
const [groupOpenStates, setGroupOpenStates] = useState<Record<string, boolean>>({});
const handleGroupToggle = (groupId: string, open: boolean) => {
setOpenGroupIds((current) => {
const next = new Set(current);
if (open) next.add(groupId);
else next.delete(groupId);
return next;
});
setGroupOpenStates((current) => ({ ...current, [groupId]: open }));
};
return (
@@ -64,18 +57,18 @@ function LayerVisibilitySection({ items, onToggleLayer }: LayerVisibilitySection
{groups.map((group) => (
<details
key={group.id}
open={openGroupIds.has(group.id)}
open={groupOpenStates[group.id] ?? group.defaultOpen}
onToggle={(event) => handleGroupToggle(group.id, event.currentTarget.open)}
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
size={14}
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="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 ? (
<span className="mt-0.5 block truncate text-[10px] leading-tight text-[var(--ink-muted)]">
{group.description}
@@ -176,18 +169,19 @@ function LayerVisibilityRow({
return (
<button
type="button"
data-map-option=""
disabled={disabled}
aria-pressed={item.visible}
aria-label={`${item.label}${meta ? `,${meta}` : ""}`}
title={meta}
onClick={() => onToggleLayer(item.id, !item.visible)}
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,
item.visible
? "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",
disabled && "cursor-not-allowed opacity-55 active:scale-100"
: "border-transparent bg-[var(--surface-control)]/55 text-slate-600",
mapOptionInteractionVariants({ state: item.visible ? "selected" : "default" })
)}
>
<LayerVisual visual={item.visual} visible={item.visible} />
@@ -202,7 +196,7 @@ function LayerVisibilityRow({
<span
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",
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" />}
@@ -304,18 +298,18 @@ function BaseLayerSection({ options, activeLayerId, onSelectLayer }: BaseLayerSe
<button
key={option.id}
type="button"
data-map-option=""
disabled={option.disabled}
aria-pressed={active}
onClick={() => onSelectLayer(option.id)}
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_SELECTION_CARD_FOCUS_CLASS_NAME,
active ? MAP_READABLE_SURFACE_STRONG_CLASS_NAME : MAP_READABLE_SURFACE_CLASS_NAME,
active
? "border-[var(--action-blue)] text-[var(--action-blue-hover)]"
: "text-slate-700 hover:border-[var(--palette-brand)] hover:bg-[var(--action-soft)]/40",
option.disabled && "cursor-not-allowed opacity-50"
: "text-slate-700",
mapOptionInteractionVariants({ state: active ? "selected" : "default" })
)}
>
<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";
@@ -28,5 +28,27 @@ export const MAP_READABLE_SURFACE_CLASS_NAME =
export const MAP_READABLE_SURFACE_STRONG_CLASS_NAME =
"surface-control border";
export const MAP_CONTROL_HOVER_CLASS_NAME =
buttonInteractionVariants({ tone: "neutral" });
export const mapOptionInteractionVariants = cva(
"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 { cn } from "@/shared/ui/cn";
import {
mapOptionInteractionVariants,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_READABLE_RADIUS_CLASS_NAME,
@@ -91,16 +92,17 @@ export function MapMeasurePanel({
<button
key={mode.id}
type="button"
data-map-option=""
aria-pressed={mode.id === activeModeId}
disabled={mode.disabled}
onClick={() => onSelectMode?.(mode.id)}
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,
mode.id === activeModeId
? "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)]",
mode.disabled && "cursor-not-allowed opacity-45 active:scale-100"
: "text-slate-600",
mapOptionInteractionVariants({ state: mode.id === activeModeId ? "selected" : "default" })
)}
>
<mode.icon size={15} strokeWidth={2} aria-hidden="true" className="shrink-0" />
@@ -134,14 +136,16 @@ export function MapMeasurePanel({
<button
key={unit.id}
type="button"
data-map-option=""
aria-pressed={unit.id === activeUnitId}
onClick={() => onSelectUnit?.(unit.id)}
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,
unit.id === activeUnitId
? "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}
@@ -228,28 +232,27 @@ function MeasureActionButton({
return (
<button
type="button"
data-map-option=""
title={action.description}
data-state={active ? "active" : undefined}
disabled={action.disabled}
onClick={action.onClick}
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",
MAP_COMPACT_RADIUS_CLASS_NAME,
primary
? 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"
: action.variant === "primary"
? "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"
? "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)]"
: "border-[var(--action-blue)] bg-[var(--action-blue)] text-white shadow-xs"
: cn(
MAP_READABLE_SURFACE_CLASS_NAME,
danger
? "text-red-600 [@media(hover:hover)]:hover:border-red-200 [@media(hover:hover)]:hover:bg-red-50"
: "text-slate-700 [@media(hover:hover)]:hover:border-[var(--surface-border)] [@media(hover:hover)]:hover:text-[var(--action-blue-hover)]"
? "text-[var(--status-danger-foreground)]"
: "text-slate-700"
),
action.disabled &&
"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"
mapOptionInteractionVariants({ state: danger ? "danger" : active ? "selected" : primary ? "primary" : "default" }),
action.disabled && "shadow-none"
)}
>
{primary ? (
@@ -258,7 +261,7 @@ function MeasureActionButton({
"grid h-8 w-8 shrink-0 place-items-center rounded-lg",
active
? "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" />
@@ -269,7 +272,7 @@ function MeasureActionButton({
{primary ? (
<span className="min-w-0 flex-1 text-left">
<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}
</span>
</span>
@@ -0,0 +1,52 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
afterEach(() => { cleanup(); vi.clearAllMocks(); });
it("keeps initial healthy sources quiet and announces recovery once", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
expect(showMapNotice).not.toHaveBeenCalled();
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
expect(showMapNotice).toHaveBeenCalledTimes(2);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledOnce();
});
it("updates a degraded source in place and only removes it when unmounted", () => {
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
expect(showMapNotice).toHaveBeenCalledTimes(3);
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("source");
});
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender(<MapErrorNotice id="new" message="另一项错误" />);
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
unmount();
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
});
it("keeps loading visible while its title or message changes", () => {
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
expect(dismissMapNotice).not.toHaveBeenCalled();
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
});
+20 -8
View File
@@ -1,4 +1,4 @@
import { useEffect, type CSSProperties } from "react";
import { useEffect, useRef, type CSSProperties } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
@@ -17,10 +17,11 @@ export function MapToaster() {
position="top-center"
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
swipeDirections={[]}
toastOptions={{
unstyled: true,
classNames: {
toast: "group pointer-events-none",
toast: "group pointer-events-auto",
closeButton:
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
}
@@ -30,6 +31,7 @@ export function MapToaster() {
}
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -38,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
message,
duration: Infinity
});
return () => dismissMapNotice(id);
}, [id, message]);
return null;
@@ -54,6 +54,7 @@ export function MapLoadingNotice({
title?: string;
id?: string | number;
}) {
useNoticeCleanup(id);
useEffect(() => {
showMapNotice({
id,
@@ -63,8 +64,6 @@ export function MapLoadingNotice({
duration: Infinity,
dismissible: false
});
return () => dismissMapNotice(id);
}, [id, message, title]);
return null;
@@ -89,7 +88,16 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
}: MapSourceStatusNoticeProps) {
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
useNoticeCleanup(id);
useEffect(() => {
const last = previous.current;
previous.current = { id, status };
// Initial healthy sources and repeated tile-load messages stay quiet.
// Success is announced when the same source recovers from a problem.
if (status === "online" && !(actionLabel && onAction)
&& (!last || last.id !== id || last.status === "online")) return;
showMapNotice({
id,
tone: getSourceStatusTone(status),
@@ -99,13 +107,17 @@ export function MapSourceStatusNotice({
actionLabel,
onAction
});
return () => dismissMapNotice(id);
}, [actionLabel, id, message, onAction, sourceName, status]);
return null;
}
function useNoticeCleanup(id: string | number) {
// Content updates reuse the toast id. Dismissing during an update races
// Sonner's asynchronous removal against the newly published content.
useEffect(() => () => dismissMapNotice(id), [id]);
}
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
if (status === "online") {
return "success";
+3 -2
View File
@@ -8,6 +8,7 @@ import {
} from "@/shared/ui/tooltip";
import {
MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_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",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
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,
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" />
+3 -2
View File
@@ -10,6 +10,7 @@ import {
} from "./camera-actions";
import {
MAP_CONTROL_HOVER_CLASS_NAME,
mapOptionInteractionVariants,
MAP_CONTROL_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_CONTROL_SURFACE_CLASS_NAME
@@ -158,9 +159,9 @@ function ControlButton({
"grid h-10 w-10 place-items-center border border-transparent",
MAP_ICON_CELL_RADIUS_CLASS_NAME,
"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)]",
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}
+2
View File
@@ -29,6 +29,8 @@ export {
export { MapLayerPanel, type MapLayerPanelProps } from "./components/layer-panel";
export { MapLegend, MapLegendList, MapLegendSwatch, type MapLegendItem } from "./components/legend";
export {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_CONTROL_HOVER_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 type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { WaterNetworkSourceId } from "../map/sources";
import {
createWorkbenchHeaders,
findOwnedWorkbenchProject,
getServerError
} from "./project-client";
const gisAttributeGroupSchema = z.object({
dataset_key: z.string().min(1),
@@ -43,7 +37,6 @@ const sourceGisFeaturePropertiesSchema = z.object({
export type GisAttributeGroup = z.infer<typeof gisAttributeGroupSchema>;
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchElementGisProperties(
elementType: WaterNetworkSourceId,
@@ -52,26 +45,15 @@ export async function fetchElementGisProperties(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): 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 = [
"/api/v1/elements",
encodeURIComponent(elementType),
encodeURIComponent(elementId),
"gis-properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
const payload = await requestWorkbenchJson(path, "GIS 台账属性查询", {
access: getAccessToken, projectId, init: { 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);
if (result.element_type !== elementType || result.element_id !== elementId) {
throw new Error("GIS 台账属性与所选元素不一致");
@@ -86,12 +68,6 @@ export async function fetchSourceGisFeatureProperties(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): 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 = [
"/api/v1/source-gis/layers",
encodeURIComponent(layerKey),
@@ -99,14 +75,9 @@ export async function fetchSourceGisFeatureProperties(
encodeURIComponent(String(sourceFid)),
"properties"
].join("/");
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, {
headers: createWorkbenchHeaders(accessToken, resolvedProjectId),
signal
const payload = await requestWorkbenchJson(path, "源 GIS 属性查询", {
access: getAccessToken, projectId, init: { 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);
if (result.layer_key !== layerKey || result.source_fid !== sourceFid) {
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",
...(generation === undefined ? {} : { execution_generation: generation }) };
return {
execution_scope: "production",
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals());
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) => {
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", () => {
expect(() => parseOperationalTask(task(0))).toThrow();
const value = task();
expect(() => parseOperationalTask({ ...value, 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, execution_generation: -1 } })).toThrow();
@@ -1,9 +1,8 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
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 taskPrioritySchema = z.enum(["normal", "urgent"]);
@@ -50,6 +49,7 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable()
}).strict();
const taskSummarySchema = z.object({
execution_scope: z.string().min(1),
handling_context: z.record(z.unknown()).optional(),
project_id: z.string().uuid(),
task_id: z.string().uuid(),
@@ -431,16 +431,9 @@ export type CreateOperationalTaskInput = {
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) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
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;
return requestWorkbenchJson(`/api/v1${path}`, "工况任务请求", { access: getAccessToken, init });
}
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 type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError, type PlaceSearchConfiguration } from "./project-client";
import { type PlaceSearchConfiguration } from "./project-client";
export const MAX_PLACE_SEARCH_RESULTS = 10;
@@ -71,21 +70,8 @@ async function requestPlaceData(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<unknown> {
const requestSignal = signal
? AbortSignal.any([signal, AbortSignal.timeout(20_000)])
: AbortSignal.timeout(20_000);
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
return requestWorkbenchJson(`/api/v1/${path}`, "地名搜索", {
access: getAccessToken, projectId, timeoutMs: 20_000,
init: { method: "POST", body: JSON.stringify(body), signal }
});
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,
operation: string,
request: (signal: AbortSignal) => Promise<T>,
signal?: AbortSignal
signal?: AbortSignal,
timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
): Promise<T> {
const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
try {
combined.throwIfAborted();
return await request(combined);
} catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
if (isTimeout(error) || (combined.aborted && isTimeout(combined.reason))) {
if (timeoutMs != null && (isTimeout(error) || (combined.aborted && isTimeout(combined.reason)))) {
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")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
@@ -1,8 +1,7 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import type { PipeFlowCalculation } from "../map/flow-overlay";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const realtimeLinkSchema = z.object({
time: z.string().datetime({ offset: true }),
@@ -11,7 +10,6 @@ const realtimeLinkSchema = z.object({
});
const realtimeLinksSchema = z.array(realtimeLinkSchema);
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const FLOW_LOOKBACK_MS = 15 * 60 * 1000;
export type RealtimePipeFlowSnapshot = {
@@ -24,22 +22,13 @@ export async function fetchRealtimePipeFlows(
signal?: AbortSignal,
now = new Date()
): Promise<RealtimePipeFlowSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const query = new URLSearchParams({
start_time: new Date(now.getTime() - FLOW_LOOKBACK_MS).toISOString(),
end_time: now.toISOString()
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/timeseries/realtime/links?${query.toString()}`,
{
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 payload = await requestWorkbenchJson(`/api/v1/timeseries/realtime/links?${query}`, "实时管道流速请求", {
access: getAccessToken, init: { signal }
});
const rows = realtimeLinksSchema.parse(payload);
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 { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { z } from "zod";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { findOwnedWorkbenchProject } from "./project-client";
const scadaDeviceSchema = z.object({
device_id: z.string().trim().min(1),
@@ -68,7 +68,6 @@ export type ScadaSnapshot = {
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_READING_BATCH_SIZE = 100;
@@ -244,11 +243,5 @@ async function requestJson(
projectId: string | undefined,
signal: AbortSignal | undefined
) {
const headers = createWorkbenchHeaders(accessToken, projectId);
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;
return requestWorkbenchJson(path, "SCADA API请求", { credentials: { accessToken, projectId }, init: { signal } });
}
@@ -1,6 +1,6 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import { findOwnedWorkbenchProject } from "./project-client";
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
const ruleSchema = z.object({
@@ -128,14 +128,10 @@ export class ScadaReplayClient {
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
.catch((error) => { this.projectId = null; throw error; });
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
const headers = createWorkbenchHeaders(token, projectId);
if (body !== undefined) headers.set("Content-Type", "application/json");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
method: body === undefined ? "GET" : "POST", headers, signal,
...(body === undefined ? {} : { body: JSON.stringify(body) })
return requestWorkbenchJson(`/api/v1/scada-replays${path}`, "SCADA 回放", {
credentials: { projectId, accessToken: token },
init: { method: body === undefined ? "GET" : "POST", signal,
...(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 type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const analysisHistoryItemSchema = z.object({
run_id: z.string().trim().min(1),
@@ -41,7 +40,6 @@ export type WorkbenchAnalysisHistoryPage = {
nextOffset: number | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchAnalysisHistory(
options: {
@@ -59,25 +57,13 @@ export async function fetchWorkbenchAnalysisHistory(
if (!Number.isInteger(offset) || offset < 0) {
throw new Error("分析成果历史偏移量无效");
}
const { project, accessToken } = await findOwnedWorkbenchProject(
options.getAccessToken,
options.signal
);
const search = new URLSearchParams({
limit: String(limit),
offset: String(offset)
});
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
{
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 payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs?${search}`, "分析成果历史请求", {
access: options.getAccessToken, init: { signal: options.signal }
});
const page = analysisHistoryPageSchema.parse(payload);
return {
items: page.items.map((item) => ({
@@ -1,7 +1,7 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { FeatureCollection, Geometry, Position } from "geojson";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
import { formatDateTime } from "@/shared/utils/date-time";
import type {
ScheduledConditionRecord,
@@ -10,7 +10,6 @@ import type {
WorkbenchScenario
} from "../types";
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;
// they are filtered before the operational workbench model is created.
@@ -159,7 +158,6 @@ export type WorkbenchAnalysisProjection = {
simulationOverlay: FeatureCollection<Geometry> | null;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
const snapshot = runtimeSnapshotSchema.parse(payload);
@@ -264,18 +262,9 @@ export async function fetchWorkbenchAnalysisRun(
): Promise<WorkbenchAnalysisProjection> {
const normalizedRunId = runId.trim();
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(
`${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 payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
access: getAccessToken, init: { signal }
});
const analysis = runtimeLatestAnalysisSchema.parse(payload);
if (analysis.run_id !== normalizedRunId) {
throw new Error("分析运行成果与请求 ID 不一致");
@@ -1,3 +1,4 @@
import { requestWorkbenchJson } from "./request-json";
import { z } from "zod";
import type { WorkbenchAccess } from "./request-context";
import { env } from "@/shared/config/env";
@@ -63,13 +64,9 @@ export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: WorkbenchAccess,
signal?: AbortSignal
): Promise<WorkbenchSyncSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
const payload = await requestWorkbenchJson("/api/v1/workbench/sync-snapshot", "工作台同步快照请求", {
access: getAccessToken, init: { signal }
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
const envelope = snapshotEnvelopeSchema.parse(payload);
return {
eventCursor: envelope.event_cursor,
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
});
const taskBase = {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: "event_investigation",
@@ -130,6 +131,48 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
return { ...props, rerender };
}
it.each(["failed", "unsupported"] as const)("retries an approved %s action using its current version", async (status) => {
let finish!: () => void;
const onExecute = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
const task: OperationalTask = { ...taskBase,
permissions: { ...taskBase.permissions, can_execute_work_orders: true },
actions: [{ ...taskBase.actions[0], approval_status: "approved", execution_status: status }]
};
const props = taskPanel(task, { onExecute });
const section = screen.getByRole("region", { name: "当前需处理" });
expect(section).toHaveTextContent("待重试");
const retry = within(section).getByRole("button", { name: "重试执行" });
fireEvent.click(retry);
expect(retry).toBeDisabled();
fireEvent.click(retry);
expect(onExecute).toHaveBeenCalledExactlyOnceWith(task.actions[0].action_id, task.actions[0].version);
expect(props.onDecision).not.toHaveBeenCalled();
finish();
await waitFor(() => expect(retry).toBeEnabled());
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
actions: [{ ...task.actions[0], version: 4, execution_status: "submitted" }]
}} />);
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "核对执行回执" })).toBeVisible();
});
it("keeps retry disabled without work-order execution permission", () => {
const props = taskPanel({ ...taskBase, actions: [{ ...taskBase.actions[0],
approval_status: "approved", execution_status: "unsupported"
}] });
const retry = screen.getByRole("button", { name: "重试执行" });
expect(retry).toBeDisabled();
fireEvent.click(retry);
expect(props.onExecute).not.toHaveBeenCalled();
});
it.each(["pending", "rejected"] as const)("does not retry a %s action", (approval) => {
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_execute_work_orders: true },
actions: [{ ...taskBase.actions[0], approval_status: approval, execution_status: "failed" }]
});
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
});
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
@@ -1,3 +1,4 @@
import { getReportPresentationState, snapshotRound } from "../utils/report-presentation-state";
import { InvestigationReportView } from "./investigation-report-view";
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
import { InvestigationTaskReport } from "./investigation-task-report";
@@ -33,6 +34,7 @@ import type {
OperationalTaskDataSnapshot
} from "../api/operational-task-client";
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
import { isRetryableInvestigationAction } from "../utils/investigation-presentation";
type ExecutionNode = {
id: string;
@@ -109,18 +111,8 @@ export function AgentExecutionPlanPanel({
/>;
const plan = task.latest_plan?.plan;
const report = task.latest_report?.report;
const planGeneration = task.latest_plan?.execution_generation;
const planState = planGeneration == null
? "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 planState = snapshotRound(task.latest_plan?.execution_generation, task.execution_generation);
const { round: reportState } = getReportPresentationState(task, task.latest_report);
const visiblePlanNodeIds = plan && plan.schema === "execution-plan@2"
? activePlanGraph(plan).visible : null;
const visiblePlanNodes = plan && visiblePlanNodeIds
@@ -568,6 +560,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
<div className="mt-3 flex flex-wrap justify-end gap-2">
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
{isRetryableInvestigationAction(action) ? <Button size="sm" disabled={!canExecute || submitting !== null || stale} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}重试执行</Button> : null}
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
</div>
</div>
@@ -1,5 +1,6 @@
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
import { useId, useLayoutEffect, useRef } from "react";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
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]";
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 (
<>
{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 className="min-w-0 flex-1 space-y-1">
<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>
<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")}>
{results.items.map((item, index) => (
<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}
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)]",
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)]")}
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)]",
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) => {
geocoding.select(item.id);
if (event.detail === 0) window.requestAnimationFrame(() => changeSelectionRef.current?.focus());
}}>
<PlaceSummary item={item} index={index} />
</Button>
<PlaceSummary item={item} index={index} interactive />
</button>
</li>
))}
</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,7 +1,7 @@
import { useId, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react";
import type { AnswerUiBlock } from "@/features/agent";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
@@ -18,7 +18,9 @@ import type { OperationalTask } from "../api/operational-task-client";
import type { InvestigationOverlay } from "../api/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 { PresentationBusinessResults } from "./presentation-business-results";
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
@@ -35,6 +37,8 @@ type Props = {
task: OperationalTask | null;
localPreview?: boolean;
charts: InvestigationChart[];
chartWindowAutoOpen?: boolean;
chartWindowScope?: string;
overlay: InvestigationOverlay | null;
cameraPaused: boolean;
status: "connecting" | "live" | "history" | "disconnected" | "error";
@@ -67,6 +71,8 @@ export function InvestigationPresentationPanel({
onLocateTargets,
localPreview = false,
charts,
chartWindowAutoOpen = false,
chartWindowScope,
overlay,
cameraPaused,
status,
@@ -79,13 +85,13 @@ export function InvestigationPresentationPanel({
onFitOverlay,
onToggleLayer
}: Props) {
const chartGroups = groupInvestigationCharts(charts);
const [collapsed, setCollapsed] = useState(false);
const [keyboardAction, setKeyboardAction] = useState(false);
const reducedMotion = useReducedMotion();
const immediate = Boolean(reducedMotion) || keyboardAction;
const contentId = useId();
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 pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
@@ -120,7 +126,7 @@ export function InvestigationPresentationPanel({
<motion.header
layout={immediate || localPreview ? false : "position"}
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 ? "contents" : "min-w-0 space-y-1"}>
@@ -136,7 +142,7 @@ export function InvestigationPresentationPanel({
: statusLabels[status]}
</Badge>
<span className="text-[11px] text-muted-foreground tabular-nums">
图表 {charts.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
图表 {chartGroups.length} · 范围 {overlay ? 1 : 0} · 数据 {records.length}
</span>
{reviewing && pending > 0 ? (
<span role="status" className="text-[11px] text-muted-foreground">
@@ -145,6 +151,10 @@ export function InvestigationPresentationPanel({
) : null}
</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
type="button"
variant="ghost"
@@ -160,6 +170,7 @@ export function InvestigationPresentationPanel({
<ChevronDown size={16} aria-hidden />
</motion.span>
</Button>
</div>
</div>
<p
className={
@@ -241,7 +252,7 @@ export function InvestigationPresentationPanel({
</div>
</section>
) : null}
<AnimatePresence initial={false} mode="popLayout">
<AnimatePresence key={chartWindowScope} initial={false} mode="popLayout">
{overlay ? (
<PresentationItem key={`overlay-${overlay.overlay_id}`} immediate={immediate}>
<Card role="region" aria-label="调查覆盖层" className="border-border shadow-none">
@@ -290,51 +301,10 @@ export function InvestigationPresentationPanel({
</Card>
</PresentationItem>
) : null}
{charts.map((item) => (
<PresentationItem key={item.chart_id} immediate={immediate}>
<Card
data-investigation-chart={item.chart_id}
role="group"
aria-label={item.chart.title}
className="border-border shadow-none"
>
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
<div className="min-w-0">
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
{item.chart.title}
</h3>
<p className="mt-1 text-[11px] text-muted-foreground">
{item.chart.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={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={`关闭图表 ${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>
{chartGroups.map((group) => (
<PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
<InvestigationChartGroup group={group} reviewing={reviewing ?? false}
onCloseChart={onCloseChart} references={references} />
</PresentationItem>
))}
</AnimatePresence>
@@ -342,7 +312,6 @@ export function InvestigationPresentationPanel({
blocks={blocks}
records={records}
reviewing={reviewing}
chartWindowKey={chartWindowKey}
onLocateTargets={onLocateTargets}
/>
{!charts.length &&
@@ -1,3 +1,4 @@
import { getReportPresentationState } from "../utils/report-presentation-state";
import { FileText } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
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 }) {
const snapshot = task.latest_report;
const report = snapshot?.report;
const insufficient = report?.outcome === "insufficient_data";
const current = snapshot?.execution_generation === task.execution_generation;
const ended = current && task.lifecycle_status === "completed";
const { insufficient, current, ended } = getReportPresentationState(task, snapshot);
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
@@ -1,3 +1,4 @@
import { getReportPresentationState } from "../utils/report-presentation-state";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { InvestigationQuestions } from "./investigation-questions";
import { History, LoaderCircle, MessageSquare } from "lucide-react";
@@ -18,7 +19,8 @@ import type {
import {
investigationDiagram,
investigationStatus,
isPendingInvestigationAction
isPendingInvestigationAction,
isRetryableInvestigationAction
} from "../utils/investigation-presentation";
import { InvestigationDiagramView } from "./investigation-diagram-view";
import { InvestigationExpandCard } from "./investigation-expand-card";
@@ -178,22 +180,13 @@ export function InvestigationTaskReport({
// A current final report owns its archived graph. During investigation, a
// progress snapshot owns its whole graph, including an explicit null. A prior
// round's final report must never hide the new round's progress.
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
&& report?.stage === "final";
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
const diagram = live
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
: selection.diagram;
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 insufficient = report?.outcome === "insufficient_data";
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
&& task.latest_report?.report.outcome === "insufficient_data";
const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
const pendingActions = task.actions.filter(isPendingInvestigationAction);
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
@@ -433,9 +426,11 @@ export function InvestigationTaskReport({
{actionTitle(action)} ·{" "}
{action.approval_status === "pending"
? "待审核"
: action.execution_status === "not_submitted"
? "待执行"
: "待核对回执"}
: isRetryableInvestigationAction(action)
? "待重试"
: action.execution_status === "not_submitted"
? "待执行"
: "待核对回执"}
</span>
}
>
@@ -138,8 +138,6 @@ function createCommands() {
clearValueLabel: vi.fn(),
applyLayerGroupStyle: vi.fn(),
resetLayerGroupStyle: vi.fn(),
renderScadaAnalysis: vi.fn(),
clearScadaAnalysis: vi.fn(),
resetAll: vi.fn()
} satisfies WorkbenchMapCommands;
}
@@ -163,7 +161,6 @@ function createControllerState(
invalid: 0
},
styledGroups: [],
scadaAnalysis: null,
...patch
};
}
@@ -7,7 +7,6 @@ type Props = {
blocks: AnswerUiBlock[];
records: Array<Record<string, unknown>>;
reviewing: boolean;
chartWindowKey?: string;
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
};
@@ -15,7 +14,6 @@ export function PresentationBusinessResults({
blocks,
records,
reviewing,
chartWindowKey,
onLocateTargets
}: Props) {
if (!blocks.length && !records.length) return null;
@@ -26,7 +24,6 @@ export function PresentationBusinessResults({
key={block.block_id}
block={block}
disabled={reviewing}
chartWindowKey={chartWindowKey}
onLocate={
onLocateTargets && !reviewing
? (target) =>
@@ -16,10 +16,12 @@ import {
UserRound,
type LucideIcon
} 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 { ProjectSwitchDialog } from "./project-switch-dialog";
import {
mapOptionInteractionVariants,
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
MAP_COMPACT_RADIUS_CLASS_NAME,
MAP_ICON_CELL_RADIUS_CLASS_NAME,
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
@@ -53,11 +55,12 @@ const menuSurfaceClassName = cn(
MAP_MAJOR_PANEL_RADIUS_CLASS_NAME,
"acrylic-panel border p-2 text-slate-900"
);
const menuReadableRowClassName =
const alertReadableRowClassName =
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
const alertActionClassName = cn(
MAP_COMPACT_RADIUS_CLASS_NAME,
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] 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({
@@ -143,13 +146,28 @@ export function AlertMenu({
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
}) {
const reduceMotion = useReducedMotion();
const keyboardInteraction = useRef(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current;
const [keyboardInteraction, setKeyboardInteraction] = useState(false);
const skipRemovalMotion = reduceMotion === true || keyboardInteraction;
const hasAlerts = alerts.length > 0;
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
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 (
<DropdownMenu modal={false} open={open} onOpenChange={onOpenChange}>
<DropdownMenuTrigger asChild>
@@ -158,6 +176,7 @@ export function AlertMenu({
className={cn(
"group relative px-2",
headerControlButtonClassName,
!keyboardInteraction && "focus-visible:ring-0 focus-visible:ring-offset-0",
hasAlerts && !compact &&
(hasCriticalAlerts ? "status-tone-danger" : "status-tone-warning"),
hasAlerts && !compact &&
@@ -197,10 +216,7 @@ export function AlertMenu({
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
>
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
<AlertQueueSummary
count={alerts.length}
latestAlertTime={latestAlertTime}
@@ -230,12 +246,15 @@ export function AlertMenu({
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)]",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
alertReadableRowClassName
)}
>
<DropdownMenuItem
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)}
>
<button
@@ -381,8 +400,8 @@ export function UserMenu({
onSelect={onLogout ? () => void onLogout() : undefined}
className={cn(
"px-2 py-2",
!onLogout && "text-[var(--ink-muted)]",
MAP_COMPACT_RADIUS_CLASS_NAME
MAP_ACTION_ROW_INTERACTION_CLASS_NAME,
!onLogout && "text-[var(--ink-muted)]"
)}
>
<LogOut size={15} aria-hidden="true" />
@@ -466,7 +485,7 @@ function MenuPlainButton({
MAP_COMPACT_RADIUS_CLASS_NAME,
disabled
? "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>
@@ -496,7 +515,7 @@ function MenuPrimaryButton({
MAP_COMPACT_RADIUS_CLASS_NAME,
disabled
? "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" />
@@ -554,9 +573,8 @@ function MenuAction({
return (
<DropdownMenuItem
className={cn(
"my-0.5 items-center border border-transparent px-2 py-2 transition",
MAP_COMPACT_RADIUS_CLASS_NAME,
menuReadableRowClassName
"my-0.5 items-center px-2 py-2 transition-colors",
MAP_ACTION_ROW_INTERACTION_CLASS_NAME
)}
onSelect={onSelect}
>
@@ -566,13 +584,13 @@ function MenuAction({
MAP_ICON_CELL_RADIUS_CLASS_NAME,
tone === "primary" && "border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
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" />
</span>
<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>
</DropdownMenuItem>
@@ -1,9 +1,12 @@
import { buttonInteractionVariants } from "@/shared/ui/button-interaction";
import { mapOptionInteractionVariants } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
export const headerControlButtonClassName =
cn(
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-['']"
);
@@ -11,6 +14,6 @@ export const compactHeaderControlButtonClassName =
"h-10 w-10 justify-center px-0";
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";
@@ -1,4 +1,4 @@
import { useState } from "react";
import { useState, useSyncExternalStore } from "react";
import {
Box,
CalendarClock,
@@ -14,6 +14,7 @@ import {
} from "lucide-react";
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "@/features/map/core";
import { cn } from "@/shared/ui/cn";
import { floatingWindowEntries, subscribeFloatingWindows } from "@/shared/ui/floating-window-layer";
import type {
WorkbenchAlert,
WorkbenchAlertDataSource,
@@ -21,6 +22,7 @@ import type {
} from "../types";
import type { AnalysisArtifact, ArtifactRequestedView } from "../workspace/workspace-model";
import { AlertMenu, AnalysisViewMenu, UserMenu, type WorkbenchProjectSelection } from "./workbench-top-bar-menus";
import { WorkbenchWindowMenu } from "./workbench-window-menu";
import {
compactHeaderTextClassName,
headerControlButtonClassName,
@@ -63,7 +65,7 @@ export type WorkbenchTopBarProps = {
onLogout?: () => Promise<void>;
};
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user";
type HeaderMenuId = "alerts" | "analysis-view" | "compact-alerts" | "user" | "windows";
export function WorkbenchTopBar({
dataTime,
@@ -101,12 +103,13 @@ export function WorkbenchTopBar({
onLogout
}: WorkbenchTopBarProps) {
const [openMenu, setOpenMenu] = useState<HeaderMenuId | null>(null);
const hasWindows = useSyncExternalStore(subscribeFloatingWindows, floatingWindowEntries).length > 0;
const createMenuOpenChange = (menuId: HeaderMenuId) => (open: boolean) => {
setOpenMenu(open ? menuId : null);
};
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={cn(
@@ -116,7 +119,7 @@ export function WorkbenchTopBar({
>
<Droplets size={18} aria-hidden="true" />
</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">
<span className="sm:hidden">供水</span>
<span className="hidden sm:inline">供水管网智慧运维系统</span>
@@ -172,6 +175,7 @@ export function WorkbenchTopBar({
</div>
<div className="flex shrink-0 items-center gap-2">
<WorkbenchWindowMenu open={openMenu === "windows"} onOpenChange={createMenuOpenChange("windows")} />
<button
type="button"
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("这段结构化内容无法显示。");
}
});
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 {
parseAnswerUiBlock,
completeRunningProgress,
cancelRunningTodos,
finalizeAssistantMessageAfterAbort,
finalizeAssistantMessageAfterError,
readInteractionReply,
getFinalAnswerTranscript,
type AnswerUiPart,
@@ -17,7 +21,8 @@ import {
type AgentPermissionStatus,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentStreamRenderState
type AgentStreamRenderState,
type AgentRunStatus
} from "@/features/agent";
type AgentUiDataParts = {
@@ -26,6 +31,7 @@ type AgentUiDataParts = {
task_context: Record<string, unknown>;
execution_plan: Record<string, unknown>;
progress: Record<string, unknown>;
run_status: Record<string, unknown>;
final_answer: Record<string, unknown>;
credential_refresh_required: Record<string, unknown>;
credential_refreshed: Record<string, unknown>;
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
...(answerParts ? { answerParts } : {})
};
let runState: Record<string, unknown> | undefined;
for (const part of message.parts) {
if (!isAgentDataPart(part)) {
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);
if (reply) next = { ...next, interactionReply: reply };
} else if (part.type === "data-task_context") {
@@ -193,23 +202,44 @@ export function toAgentChatMessages(
if (next.finalAnswer) {
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)];
});
}
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
return messages.flatMap((message) => {
export function toAgentUiMessages(
messages: unknown[],
session?: { runStatus?: AgentRunStatus; isStreaming?: boolean }
): AgentUiMessage[] {
const normalized = messages.flatMap((message) => {
if (isAgentUiMessage(message)) {
return [message];
}
const legacyMessage = toAgentUiMessageFromLegacy(message);
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[] {
if (event.type === "state") {
return toAgentUiMessages(event.messages);
return toAgentUiMessages(event.messages, event);
}
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;
}
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 (
event.type === "run_status" ||
event.type === "final_answer" ||
event.type === "progress" ||
event.type === "execution_plan" ||
event.type === "todo_update" ||
event.type === "permission_request" ||
event.type === "permission_response" ||
event.type === "question_request" ||
event.type === "question_response" ||
event.type === "answer_ui" ||
event.type === "ui_envelope" ||
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;
}
@@ -478,7 +520,7 @@ function applyPermissionOverrides(
...message,
permissions: message.permissions.map((permission) => {
const override = permissionOverrides[permission.requestId];
return override
return override && ["pending", "submitting", "error"].includes(permission.status)
? {
...permission,
status: override.status,
@@ -502,7 +544,7 @@ function applyQuestionOverrides(
...message,
questions: message.questions.map((question) => {
const override = questionOverrides[question.requestId];
return override
return override && ["checking", "pending", "submitting", "error"].includes(question.status)
? {
...question,
status: override.status,
@@ -3,11 +3,42 @@ import type { ChatTransport, UIMessageChunk } from "ai";
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
import { WorkbenchSessionResources } from "./workbench-session-resources";
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 }] });
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 () => {
vi.useFakeTimers();
const resources = new WorkbenchSessionResources();
@@ -141,7 +172,9 @@ describe("independent conversation runtimes", () => {
});
resumeAgentRuntime(resources, runtime);
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({
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 notify = () => queueMicrotask(() => resources.notify());
runtime.chat = new Chat<AgentUiMessage>({
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? [], loaded),
onData: (part) => {
if (resources.disposed) return;
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;
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-credential_refresh_required") {
void refreshRuntimeCredential(runtime, data.data);
@@ -118,10 +122,16 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
if (resources.disposed) return;
runtime.loadedAt = 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"
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
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); }
else if (!recovering) notifyBackgroundCompletion(runtime);
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");
if (sessionId && sessionId !== runtime.sessionId) return;
if (event.type === "state") {
const tail = toAgentUiMessages(event.messages);
const tail = toAgentUiMessages(event.messages, event);
const prior = runtime.chat.messages;
// 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))) {
@@ -191,11 +201,16 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
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);
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 === "done" || event.type === "error") {
if (event.type === "done" || event.type === "error" || event.type === "aborted") {
terminal = true;
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();
}
}
@@ -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));
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) {
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);
valuesRef.current = options;
const storeRef = useRef(new PresentationViewHistory());
@@ -155,6 +158,14 @@ export function useInvestigationPresentation(options: Options) {
: "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(
(updater: (view: PresentationView) => PresentationView) => {
storeRef.current.update(updater(storeRef.current.current));
@@ -289,6 +300,7 @@ export function useInvestigationPresentation(options: Options) {
})
);
signal.throwIfAborted();
if (command.action === "open_chart") requestChartWindow(origin);
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
publish();
setActivity((current) =>
@@ -327,7 +339,7 @@ export function useInvestigationPresentation(options: Options) {
throw cause;
}
},
[scene, publish, update]
[scene, publish, update, requestChartWindow]
);
useEffect(() => {
@@ -555,6 +567,8 @@ export function useInvestigationPresentation(options: Options) {
const visible = storeRef.current.visible;
const reviewing = storeRef.current.selectedId !== null;
return {
chartWindowAutoOpen: chartWindow.scope === windowScope && chartWindow.requested,
chartWindowScope: windowScope,
charts: visible.charts,
blocks: visible.blocks,
records: visible.records,
@@ -9,6 +9,7 @@ import {
function task(taskType: OperationalTask["task_type"]): OperationalTask {
return {
execution_scope: "production",
project_id: "11111111-1111-4111-8111-111111111111",
task_id: "22222222-2222-4222-8222-222222222222",
task_type: taskType,
@@ -79,7 +79,7 @@ export function useOperationalTasks(
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [eventRetryGeneration, setEventRetryGeneration] = useState(0);
const enqueueEvents = useRef<(events: WorkbenchEvent[]) => void>(() => {});
const eventRetryMsRef = useRef(1_000);
const forceRefreshRef = useRef(false);
const detailResetGenerationRef = useRef(sync.detailResetGeneration);
@@ -131,50 +131,76 @@ export function useOperationalTasks(
}, [getAccessToken, refreshGeneration, sync.detailResetGeneration, sync.snapshot]);
useEffect(() => {
const events = syncEvents;
if (events.length === 0) return;
const controller = new AbortController();
let retryTimer: number | undefined;
const lastSequence = events.at(-1)!.event_seq;
const { taskIds, deletedTaskIds, notificationsChanged } = collectOperationalTaskEventChanges(events);
if (deletedTaskIds.size > 0) {
let pendingEvents: WorkbenchEvent[] = [];
let appliedSequence = 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));
setTasks(tasksRef.current);
setNotifications((current) => current.filter((item) => !deletedTaskIds.has(item.task_id)));
}
void Promise.all([
notificationsChanged
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
: Promise.resolve(null),
Promise.all([...taskIds].map((taskId) =>
fetchOperationalTask(taskId, getAccessToken, controller.signal)
))
]).then(([nextNotifications, changedTasks]) => {
if (controller.signal.aborted) return;
if (nextNotifications) setNotifications(nextNotifications);
if (changedTasks.length > 0) {
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
tasksRef.current = [
...changedTasks,
...tasksRef.current.filter((task) => !changed.has(task.task_id))
];
setTasks(tasksRef.current);
}
acknowledgeEventsThrough(lastSequence);
eventRetryMsRef.current = 1_000;
setError(null);
}).catch((nextError) => {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
}
});
};
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
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
: Promise.resolve(null),
Promise.all([...taskIds].map((taskId) =>
fetchOperationalTask(taskId, getAccessToken, controller.signal)
))
]);
if (controller.signal.aborted) return;
// A deletion received during this read must not be undone by its response.
const { deletedTaskIds } = collectOperationalTaskEventChanges(pendingEvents);
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 = [
...visibleTasks,
...tasksRef.current.filter((task) => !changed.has(task.task_id))
];
setTasks(tasksRef.current);
}
appliedSequence = lastSequence;
pendingEvents = pendingEvents.filter((event) => event.event_seq > appliedSequence);
acknowledgeEventsThrough(lastSequence);
eventRetryMsRef.current = 1_000;
setError(null);
}
} catch (nextError) {
if (!controller.signal.aborted) {
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
retryTimer = window.setTimeout(() => void load(), eventRetryMsRef.current);
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 () => {
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),
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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { useChat } from "@ai-sdk/react";
@@ -33,9 +34,6 @@ import {
type AgentEventTriggerContext,
type AgentModelOption,
type AgentInputDelivery,
type AgentPermissionReply,
type AgentPermissionRequest,
type AgentQuestionRequest,
type AgentSessionStreamEvent,
type AgentSessionPurpose,
type AgentStreamRenderState,
@@ -52,7 +50,6 @@ import {
readBodyString,
toAgentChatMessages,
toAgentUiMessages,
toPermissionStatus,
type AgentDataPart,
type AgentUiMessage,
type PermissionOverride,
@@ -200,7 +197,7 @@ export function useWorkbenchAgent({
try {
const loaded = await target.client.loadSession(owningSessionId, { messageLimit: 40 });
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();
}
} catch { /* A failed reconciliation leaves the accepted answer intact. */ }
@@ -534,12 +531,15 @@ export function useWorkbenchAgent({
() => clientRef.current.listResponseInputs(sessionId!),
{ refreshInterval: streaming ? 1000 : 0, shouldRetryOnError: false }
);
const handledRecovery = useRef({ scopeKey, version: 0 });
useEffect(() => {
if (handledRecovery.current.scopeKey === scopeKey && handledRecovery.current.version === recoveryVersion) return;
handledRecovery.current = { scopeKey, version: recoveryVersion };
if (!recoveryVersion) return;
void refreshBootstrap();
void refreshInteractionRequests();
void refreshPendingInputs();
}, [recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
}, [scopeKey, recoveryVersion, refreshBootstrap, refreshInteractionRequests, refreshPendingInputs]);
const updateInputDelivery = useCallback(async (id: string, delivery: AgentInputDelivery) => {
if (!sessionIdRef.current) return;
try {
@@ -681,9 +681,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel(event.data.reason === "waiting_input" ? "等待补充信息" : "Agent 分析完成");
runtimeRef.current!.loadedAt = Date.now();
} else if (event.type === "error") {
} else if (event.type === "aborted" || event.type === "error") {
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
setStatusLabel("Agent 后端请求失败");
setStatusLabel(event.type === "aborted" || event.data.status === "aborted" ? "Agent 已停止" : "Agent 后端请求失败");
runtimeRef.current!.loadedAt = Date.now();
}
@@ -732,7 +732,7 @@ export function useWorkbenchAgent({
if (generation !== loadingGenerationRef.current) return false;
if (!target) target = createAgentRuntime(resources, scopeKey, scopeClient, transport, loaded);
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.error = undefined;
target.messagePage ??= loaded.messagePage;
@@ -827,7 +827,7 @@ export function useWorkbenchAgent({
);
} else {
target.client = projectClient;
target.chat.messages = toAgentUiMessages(loaded.messages);
target.chat.messages = toAgentUiMessages(loaded.messages, loaded);
target.title = loaded.title;
target.purpose = "task_execution";
target.taskId = normalizedTaskId;
@@ -877,7 +877,7 @@ export function useWorkbenchAgent({
refreshing = false;
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.updatedAt = loaded.updatedAt;
target.loadedAt = Date.now();
@@ -1045,7 +1045,7 @@ export function useWorkbenchAgent({
target.purpose = "task_collaboration";
target.taskId = binding.taskId;
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.loadedAt = Date.now();
target.updatedAt = loaded.updatedAt;
@@ -1139,62 +1139,9 @@ export function useWorkbenchAgent({
markLastAssistantStreamDone(chatRef.current.messages, setStreamRenderState);
}, [chat, interactionRequests, refreshPendingInputs, stopSessionStreamSubscription]);
const replyPermission = useCallback(async (permission: AgentPermissionRequest, reply: AgentPermissionReply) => {
const target = resources.activeAgent;
if (!target || target.sessionId !== permission.sessionId) return;
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]);
const { replyPermission, replyQuestion, rejectQuestion } = useMemo(() => createAgentInteractionReplies(resources, {
permission: setPermissionOverrides, question: setQuestionOverrides
}), [resources]);
return {
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;
selectedFeature: DetailFeature | null;
simulationOverlay: FeatureCollection<Geometry> | null;
allowDemoSimulation: boolean;
networkView?: NetworkViewParams | null;
getAccessToken?: WorkbenchAccess;
geoserverWorkspace?: string;
@@ -121,8 +120,6 @@ const SOURCE_GROUP_BY_ID: Record<string, string> = {
...Object.fromEntries(
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"
};
@@ -142,7 +139,6 @@ export function useWorkbenchMap({
onSelectFeature,
selectedFeature,
simulationOverlay,
allowDemoSimulation,
networkView,
geoserverWorkspace,
getAccessToken
@@ -150,11 +146,9 @@ export function useWorkbenchMap({
const mapRef = useRef<MapLibreMap | null>(null);
const impactVisibleRef = useRef(impactVisible);
const simulationOverlayRef = useRef(simulationOverlay);
const allowDemoSimulationRef = useRef(allowDemoSimulation);
const networkViewRef = useRef<NetworkViewParams>(networkView ?? WATER_NETWORK_GLOBAL_VIEW);
const fittedNetworkViewRef = useRef<string | null>(null);
simulationOverlayRef.current = simulationOverlay;
allowDemoSimulationRef.current = allowDemoSimulation;
networkViewRef.current = networkView ?? WATER_NETWORK_GLOBAL_VIEW;
const [mapReady, setMapReady] = useState(false);
const [mapError, setMapError] = useState<string | null>(null);
@@ -315,10 +309,8 @@ export function useWorkbenchMap({
const source = sources[sourceId];
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);
syncSimulationSourceData(map, simulationOverlayRef.current, allowDemoSimulationRef.current);
syncSimulationSourceData(map, simulationOverlayRef.current);
await registerSupplyAssetImages(map, scadaAbortController.signal);
if (scadaAbortController.signal.aborted) return;
const scadaRegistration = await registerScadaImages(map, scadaAbortController.signal);
@@ -327,16 +319,13 @@ export function useWorkbenchMap({
scadaRegistration.status === "unavailable"
? scadaFallbackBusinessLayers
: scadaBusinessLayers;
simulationAnnotationLayers
.filter((layer) => layer.id === "simulation-impact-fill")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkBusinessLayers,
REQUIRED_GEOSERVER_SOURCE_IDS
).forEach((layer) => map.addLayer(layer));
activeScadaBusinessLayers.forEach((layer) => map.addLayer(layer));
simulationAnnotationLayers
.filter((layer) => layer.type !== "symbol" && layer.id !== "simulation-impact-fill")
.filter((layer) => layer.type !== "symbol")
.forEach((layer) => map.addLayer(layer));
filterWaterNetworkLayersBySourceIds(
waterNetworkInteractionLayers,
@@ -416,9 +405,8 @@ export function useWorkbenchMap({
updateStatusFromSourceEvent(event, "online", setSourceStatuses);
});
map.on("sourcedataabort", (event) => {
updateStatusFromSourceEvent(event, "degraded", setSourceStatuses);
});
// Moving or zooming cancels obsolete tile requests. Cancellation is part
// of navigation; source failures are reported by the error handler below.
map.on("error", (event) => {
const message = event.error?.message ?? "地图渲染异常";
@@ -485,8 +473,8 @@ export function useWorkbenchMap({
useEffect(() => {
const map = mapRef.current;
if (!map || !mapReady) return;
syncSimulationSourceData(map, simulationOverlay, allowDemoSimulation);
}, [allowDemoSimulation, mapReady, simulationOverlay]);
syncSimulationSourceData(map, simulationOverlay);
}, [mapReady, simulationOverlay]);
function fitToNetworkBounds() {
const map = mapRef.current;
@@ -542,23 +530,12 @@ function getScadaStatusMessage(snapshot: Awaited<ReturnType<typeof fetchScadaSna
function syncSimulationSourceData(
map: MapLibreMap,
backendOverlay: FeatureCollection<Geometry> | null,
allowDemoSimulation: boolean
backendOverlay: FeatureCollection<Geometry> | null
) {
const useBackendOverlay = Boolean(backendOverlay?.features.length);
(map.getSource(SIMULATION_SOURCE_IDS.backendAnalysis) as GeoJSONSource | undefined)?.setData(
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(
@@ -177,12 +177,15 @@ export function useWorkbenchRuntimeData(
const controller = new AbortController();
let timeoutId: number | undefined;
let refreshing = false;
const refreshRealtimePipeFlows = async () => {
if (controller.signal.aborted || refreshing) return;
if (document.visibilityState === "hidden") {
timeoutId = window.setTimeout(refreshRealtimePipeFlows, BACKEND_RUNTIME_REFRESH_INTERVAL_MS);
return;
}
try {
refreshing = true;
const snapshot = await fetchRealtimePipeFlows(getAccessToken, controller.signal);
if (!controller.signal.aborted) {
setRealtimePipeFlows(snapshot.records.length > 0 ? snapshot : null);
@@ -190,6 +193,7 @@ export function useWorkbenchRuntimeData(
} catch {
if (!controller.signal.aborted) setRealtimePipeFlows(null);
} finally {
refreshing = false;
if (!controller.signal.aborted) {
timeoutId = window.setTimeout(
refreshRealtimePipeFlows,
+45 -29
View File
@@ -99,6 +99,7 @@ import {
import { getResponsivePadding, getResponsiveWorkbenchPadding } from "./map/camera";
import { exportMapViewImage } from "./map/export-view";
import { setMapLayersVisibility } from "./map/layer-visibility";
import { MAP_STYLE_TOKENS } from "./map/map-colors";
import {
BASE_LAYER_OPTIONS,
INITIAL_LAYER_VISIBILITY,
@@ -298,9 +299,6 @@ function MapWorkbenchContent({
const artifactActive = artifactWorkspace.activeArtifact !== null;
const activeSpatialOverlay = artifactWorkspace.activeArtifact?.spatialOverlay ?? null;
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(
latestAnalysisArtifact?.blocks.some(
(block) =>
@@ -355,11 +353,6 @@ function MapWorkbenchContent({
useEffect(() => {
setImpactVisible(activeArtifactHasSpatialView);
setLayerVisibility((current) =>
current.simulation === activeArtifactHasSpatialView
? current
: { ...current, simulation: activeArtifactHasSpatialView }
);
}, [activeArtifactHasSpatialView, artifactWorkspace.activeArtifact?.id]);
function openAgentWorkspaceForViewport() {
@@ -458,7 +451,6 @@ function MapWorkbenchContent({
onSelectFeature: handleSelectFeature,
selectedFeature: detailFeature,
simulationOverlay: activeSpatialOverlay,
allowDemoSimulation,
networkView: projectPresentation.networkView,
geoserverWorkspace: getWorkbenchProject(getAccessToken)?.gs_workspace,
getAccessToken
@@ -690,23 +682,55 @@ function MapWorkbenchContent({
[activeToolId, mapReady, toggleToolbarTool]
);
const layerControlItems = useMemo(
() => createLayerControlItems(layerVisibility, availableSourceIds, sourceFeatureCounts),
[availableSourceIds, layerVisibility, sourceFeatureCounts]
const overlayLayers = [
{ scope: "investigation", description: "Agent 调查", surface: presentation },
{ 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(
() => createVisibleMapLegendItems(layerControlItems),
[layerControlItems]
);
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);
const map = mapRef.current;
setLayerVisibility((current) => ({ ...current, [layerControlId]: visible }));
if (layerControlId === "simulation") {
setImpactVisible(visible);
}
if (!map || !mapReady) {
return;
}
@@ -932,19 +956,15 @@ function MapWorkbenchContent({
}
async function handleStartNewAgentSession() {
mapController.clearScadaAnalysis();
await agent.startNewSession();
}
async function handleLoadAgentHistorySession(sessionId: string) {
const changesSession = sessionId !== agent.sessionId;
const loaded = await agent.loadHistorySession(sessionId);
if (!loaded) throw new Error("会话打开已取消");
if (changesSession) mapController.clearScadaAnalysis();
}
async function handleOpenTaskCollaboration(taskId: string, discussion?: InvestigationDiscussion) {
mapController.clearScadaAnalysis();
const id = await agent.openTaskCollaboration(taskId, discussion);
if (discussion?.reference && id) agent.setContentReference(discussion.reference);
readingWorkspace?.close();
@@ -956,7 +976,6 @@ function MapWorkbenchContent({
target: TaskExecutionHistoryTarget,
knownRunId?: string
) {
mapController.clearScadaAnalysis();
let runId = knownRunId;
if (!runId) {
const runs = await fetchOperationalTaskRuns(taskId, getAccessToken);
@@ -975,12 +994,8 @@ function MapWorkbenchContent({
}
async function handleDeleteAgentHistorySession(sessionId: string) {
const clearsActiveAnalysis = sessionId === agent.sessionId;
const deleted = await agent.deleteHistorySession(sessionId);
if (!deleted) throw new Error("会话删除失败,请重试");
if (deleted && clearsActiveAnalysis) {
mapController.clearScadaAnalysis();
}
}
async function handleAgentMapAction(
@@ -1079,7 +1094,7 @@ function MapWorkbenchContent({
measurement.clear();
drawing.clear();
geocoding.clear();
handleToggleLayer("simulation", false);
setImpactVisible(false);
showMapNotice({ tone: "success", message: "已清空所有高亮和覆盖层。" });
}
@@ -1462,6 +1477,7 @@ function MapWorkbenchContent({
{ scope: "preview", surface: previewPresentation, task: null, visible: previewPresentation.hasHistory || previewPresentation.localPreview }
].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}
chartWindowAutoOpen={surface.chartWindowAutoOpen} chartWindowScope={surface.chartWindowScope}
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))}
relatedLayers={scope === "investigation" ? presentationRelatedLayers : []} onToggleLayer={handleTogglePresentationLayer}
@@ -1471,10 +1487,10 @@ function MapWorkbenchContent({
</div>
{!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} />
</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} />
</div>
</>
@@ -1570,7 +1586,7 @@ function MapWorkbenchContent({
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
<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={{
bottom: mobileOverlayLayout.mapControlBottom,
"--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 { annotationPoints, impactAreaPolygon } from "../data/workbench-simulation";
import { MAP_STYLE_TOKENS } from "./map-colors";
export const SIMULATION_SOURCE_IDS = {
impactArea: "simulation-impact-area",
annotations: "simulation-annotations",
backendAnalysis: "simulation-backend-analysis"
} as const;
const EMPTY_FEATURE_COLLECTION = {
type: "FeatureCollection" as const,
features: []
};
export const simulationSources = {
impactArea: {
type: "geojson",
data: impactAreaPolygon
} satisfies GeoJSONSourceSpecification,
annotations: {
type: "geojson",
data: annotationPoints
} satisfies GeoJSONSourceSpecification,
backendAnalysis: {
type: "geojson",
data: EMPTY_FEATURE_COLLECTION
data: { type: "FeatureCollection", features: [] }
} satisfies GeoJSONSourceSpecification
};
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",
type: "fill",
@@ -82,83 +46,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"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",
type: "symbol",
@@ -176,25 +63,6 @@ export const simulationAnnotationLayers: StyleSpecification["layers"] = [
"text-halo-color": MAP_STYLE_TOKENS.canvas.casing,
"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",
"water_quality_monitoring_points",
"fx_20200423_pipes",
"fx_20200811_dn100_pipes",
"simulation"
"fx_20200811_dn100_pipes"
])
);
expect(items.find((item) => item.id === "fx_20200423_pipes")).toMatchObject({
@@ -104,12 +103,30 @@ describe("workbench supply layer controls", () => {
"valves",
"reservoirs",
"scada-pressure",
"scada-flow",
"simulation"
"scada-flow"
]);
});
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([
"major-pipe",
"medium-pipe",
@@ -134,8 +151,7 @@ describe("workbench supply layer controls", () => {
"source-hydrant",
"source-connection",
"source-customer-meter",
"source-non-residential-meter",
"impact-area"
"source-non-residential-meter"
]);
expect(MAP_LEGEND_ITEMS.find((item) => item.id === "valve")).toMatchObject({
imageSrc: "/map/valve.png"
@@ -186,11 +202,6 @@ describe("workbench supply layer controls", () => {
).toMatchObject({
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", () => {
@@ -222,8 +233,7 @@ describe("workbench supply layer controls", () => {
const reducedItems = createLayerControlItems({
...INITIAL_LAYER_VISIBILITY,
valves: false,
simulation: false
valves: false
});
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 { 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> = {
...Object.fromEntries(
SUPPLY_LAYER_CATALOG.filter((layer) => layer.id !== "scada").map((layer) => [
@@ -35,8 +22,17 @@ export const INITIAL_LAYER_VISIBILITY: Record<string, boolean> = {
layer.initialVisible
])
),
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true])),
simulation: false
...Object.fromEntries(SCADA_LAYER_CONTROL_ITEMS.map((item) => [item.id, true]))
};
export type AnalysisOverlayLayer = {
id: string;
title: string;
description: string;
visible: boolean;
disabled?: boolean;
featureCount: number;
color?: string;
};
const SOURCE_CONTROL_LABELS = Object.fromEntries(
@@ -421,25 +417,27 @@ export const MAP_LEGEND_ITEMS: MapLegendItem[] = [
imageSrc: SUPPLY_ASSET_ICON_PATHS.sourceNonResidentialMeter,
layerControlId: "fx_20200423_non_residential_meters",
...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(
layerItems: readonly Pick<MapLayerControlItem, "id" | "visible">[]
layerItems: readonly MapLayerControlItem[]
) {
const visibleLayerIds = new Set(layerItems.filter((item) => item.visible).map((item) => item.id));
return MAP_LEGEND_ITEMS.filter((item) =>
item.layerControlId ? visibleLayerIds.has(item.layerControlId) : true
);
return [
...MAP_LEGEND_ITEMS.filter((item) =>
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 = {
@@ -502,7 +500,8 @@ export const BASE_LAYER_OPTIONS: BaseLayerOption[] = [
export function createLayerControlItems(
layerVisibility: Record<string, boolean>,
availableSourceIds: readonly WaterNetworkSourceId[] = AVAILABLE_WATER_NETWORK_SOURCE_IDS,
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {}
featureCounts: Partial<Record<WaterNetworkSourceId, number>> = {},
analysisOverlays: readonly AnalysisOverlayLayer[] = []
): MapLayerControlItem[] {
const available = new Set<WaterNetworkSourceId>(availableSourceIds);
const catalogItems = SUPPLY_LAYER_CATALOG.filter(
@@ -560,16 +559,18 @@ export function createLayerControlItems(
groupDescription: "源数据快照与专项台账",
groupDefaultOpen: false
})),
{
id: "simulation",
label: "模拟结果",
description: "影响范围与事故标注",
visual: { kind: "area", color: MAP_STYLE_TOKENS.state.incident },
visible: layerVisibility.simulation,
...analysisOverlays.map((overlay) => ({
id: overlay.id,
label: overlay.title,
description: overlay.description,
featureCount: overlay.featureCount,
visual: { kind: "area" as const, color: overlay.color ?? MAP_STYLE_TOKENS.state.risk },
visible: overlay.visible,
disabled: overlay.disabled,
group: "analysis",
groupLabel: "分析覆盖",
groupDescription: "随已打开的空间成果显示"
}
groupDescription: "Agent 生成的范围与分析成果"
}))
];
}
@@ -589,8 +590,7 @@ export function getWorkbenchLayerIds(
.map((layer) => layer.id);
}
const layerIds = WORKBENCH_LAYER_GROUPS[groupId] ?? [];
return layerIds.filter((layerId) => map.getLayer(layerId));
return [];
}
function isWaterNetworkSourceId(value: string): value is WaterNetworkSourceId {
+1 -14
View File
@@ -1,5 +1,5 @@
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";
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> {
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 { MAP_STYLE_TOKENS } from "./map-colors";
import { SCADA_ANALYSIS_SOURCE_ID, createEmptyScadaAnalysisCollection } from "./scada-analysis";
import { SCADA_SOURCE_ID } from "./scada";
export const WATER_NETWORK_GLOBAL_VIEW = {
@@ -325,10 +324,6 @@ export function createBaseStyle(mapboxToken?: string, tiandituToken?: string): S
type: "geojson",
promoteId: "device_id",
data: { type: "FeatureCollection", features: [] }
},
[SCADA_ANALYSIS_SOURCE_ID]: {
type: "geojson",
data: createEmptyScadaAnalysisCollection()
}
};
const layers: StyleSpecification["layers"] = [
@@ -20,29 +20,23 @@ describe("WorkbenchMapController", () => {
expect(map.easeTo).not.toHaveBeenCalled();
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();
map.getLayer.mockReturnValue({} as never);
const setData = vi.fn();
map.getSource.mockReturnValue({ setData });
let resolveFeatures = (_value: FeatureCollection) => {};
const pendingAnalysis = new Promise<FeatureCollection>((resolve) => { resolveFeatures = resolve; });
const controller = new WorkbenchMapController({
getMap: () => map as unknown as MapLibreMap, isReady: () => true,
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" });
controller.applyLayerGroupStyle("pipes", { color: "#123456" });
const showingFlow = controller.setFlowVisible(true);
const analysis = controller.renderScadaAnalysis([{ device_id: "J-1", level: "high" }]);
controller.clearOverlays();
await showingFlow;
resolveFeatures(pointCollection);
await expect(analysis).rejects.toThrow("ACTION_SUPERSEDED");
expect(setData).toHaveBeenCalledWith({ type: "FeatureCollection", features: [] });
expect(controller.getSnapshot()).toMatchObject({
target: null, targets: [], targetResult: null, scadaAnalysis: null,
target: null, targets: [], targetResult: null,
flowVisible: false, pending: false, valueLabels: {}, styledGroups: ["pipes"]
});
expect(map.removeFeatureState).toHaveBeenCalledWith(expect.objectContaining({ id: "J-1" }), "highlighted");
@@ -552,37 +546,7 @@ describe("WorkbenchMapController", () => {
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" } }] };
@@ -1,5 +1,5 @@
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 { PRESENTATION_FEATURE_SOURCE } from "./presentation-highlight-style";
import {
@@ -36,14 +36,7 @@ import {
type ValueLabelSourceId,
type ValueLabelStyle
} 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 FeatureBatchTarget = { sourceId: WaterNetworkSourceId; featureIds: string[] };
@@ -56,7 +49,6 @@ export type WorkbenchMapErrorCode =
| "INVALID_FEATURE_IDS"
| "WFS_UNAVAILABLE"
| "SCADA_API_UNAVAILABLE"
| "SCADA_FEATURES_NOT_FOUND"
| "ACTION_SUPERSEDED"
| "FLOW_UNAVAILABLE"
| "INVALID_FLOW_CALCULATION"
@@ -72,7 +64,6 @@ export type WorkbenchMapControllerState = {
flowVisible: boolean;
flowSummary: PipeFlowSummary;
styledGroups: LayerGroupId[];
scadaAnalysis: ScadaAnalysisRenderResult | null;
};
export type ValueLabelRequest = Omit<ValueLabelStyle, "unit"> & { unit?: string };
@@ -89,8 +80,6 @@ export type WorkbenchMapCommands = {
clearValueLabel: (sourceId?: ValueLabelSourceId) => void;
applyLayerGroupStyle: (groupId: LayerGroupId, patch: LayerGroupStylePatch) => void;
resetLayerGroupStyle: (groupId: LayerGroupId) => void;
renderScadaAnalysis: (items: ScadaAnalysisItem[], signal?: AbortSignal) => Promise<ScadaAnalysisRenderResult>;
clearScadaAnalysis: () => { cleared: true };
resetAll: () => void;
};
@@ -114,15 +103,13 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
styledGroups: []
};
private listeners = new Set<() => void>();
private cachedFeatures = new Map<string, Feature>();
private flowState: FlowOverlayState = createFlowOverlayState();
private flowUpdateRevision = 0;
private flowVisibilityRevision = 0;
private scadaAnalysisRevision = 0;
private targetRequestRevision = 0;
private pendingTargetSourceId: 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 });
}
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 = () => {
this.clearHighlight();
this.clearValueLabel();
this.clearScadaAnalysis();
this.flowVisibilityRevision += 1;
const map = this.options.getMap();
if (map) hideFlowOverlay(map, this.flowState);
this.patchState({ flowVisible: false });
this.patchState({ flowVisible: false, pending: false, errorCode: null });
};
resetAll = () => {
@@ -536,8 +411,7 @@ export class WorkbenchMapController implements WorkbenchMapCommands {
errorCode: null,
flowVisible: false,
flowSummary: createEmptyPipeFlowSummary(),
styledGroups: [],
scadaAnalysis: null
styledGroups: []
});
};
@@ -1,3 +1,4 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
@@ -54,7 +55,7 @@ export function ScadaMonitoringNotice({ result, animationId, processing, onOpenI
<span>调查监测</span>
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
<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>}
</div>
<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";
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}%(逐测点)`;
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 {
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) {
const date = new Date(time);

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