Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae0a41e61 | ||
|
|
865491cb23 | ||
|
|
d7eb01652a | ||
|
|
038d3458bc | ||
|
|
692bbf609c | ||
|
|
ad1f7ca7a9 |
@@ -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
@@ -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` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
|
||||
|
||||
@@ -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,并在各仓库独立运行测试。
|
||||
|
||||
|
||||
## 展示动作历史
|
||||
|
||||
@@ -194,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 从内到外处理,外层尊重已消费事件;弹窗容器可承接程序焦点,业务控件仍保留可见焦点。模态遮罩阻断底层地图操作。
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+71
@@ -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': {}
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,8 +11,9 @@ 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;
|
||||
@@ -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,29 +104,38 @@ 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 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: 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 } : {}) },
|
||||
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, range[0], range[1]) : {}),
|
||||
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel },
|
||||
...(!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) => !hidden.includes(index)), aria: { enabled: true },
|
||||
color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
|
||||
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
|
||||
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) : "";
|
||||
? 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");
|
||||
@@ -128,31 +143,24 @@ export const InteractiveChart = memo(function InteractiveChartView(props: Answer
|
||||
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 },
|
||||
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(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors
|
||||
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value),
|
||||
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>
|
||||
<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}>
|
||||
</CardHeader> : null}
|
||||
<CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
|
||||
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
|
||||
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
|
||||
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
|
||||
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
|
||||
style={{ width: "calc((100% - 0.5rem) / 3)" }}
|
||||
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
|
||||
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
|
||||
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
|
||||
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
{tile ? <>
|
||||
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
|
||||
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
|
||||
</Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
|
||||
</Popover>
|
||||
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
|
||||
</Popover> : null}
|
||||
</> : props.embedded ? <CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
|
||||
</CollapsibleTrigger> : null}
|
||||
</div>
|
||||
|
||||
{!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
|
||||
</div> : null}
|
||||
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
|
||||
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
|
||||
className={`${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(() => {
|
||||
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}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<
|
||||
|
||||
@@ -72,13 +72,13 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export const AgentChartView = memo(function AgentChartView({ chart, embedded = false, expanded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) {
|
||||
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 }) {
|
||||
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,
|
||||
@@ -97,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}
|
||||
/>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,8 +7,63 @@ export function createChartSampleTimeSchema(zod: typeof z = z) {
|
||||
);
|
||||
}
|
||||
|
||||
type PublicationChart = {
|
||||
chart_type: string;
|
||||
x_data: string[];
|
||||
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
|
||||
};
|
||||
|
||||
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
|
||||
export function chartPublicationError(chart: PublicationChart): string | null {
|
||||
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
|
||||
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
|
||||
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
|
||||
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
|
||||
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
|
||||
const labels = chart.x_data;
|
||||
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
|
||||
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
|
||||
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
|
||||
const offsets = [0];
|
||||
for (let index = 1; index < seconds.length; index++) {
|
||||
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
|
||||
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
|
||||
offsets.push(offsets[index - 1] + step);
|
||||
}
|
||||
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
|
||||
const current = new Set<number>();
|
||||
const historical: number[] = [];
|
||||
for (const series of chart.series) {
|
||||
const times = series.sample_times;
|
||||
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
|
||||
const bases = new Set<number>();
|
||||
for (let index = 0; index < times.length; index++) {
|
||||
const at = times[index];
|
||||
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
|
||||
if (at === null) continue;
|
||||
const local = new Date(Date.parse(at) + 8 * 3600_000);
|
||||
const base = local.getTime() - offsets[index] * 1000;
|
||||
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
|
||||
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
|
||||
return "sample_times 超出同期对比可表达的日期范围";
|
||||
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
|
||||
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
|
||||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
|
||||
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
|
||||
bases.add(base);
|
||||
}
|
||||
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
|
||||
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
|
||||
else historical.push(...bases);
|
||||
}
|
||||
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
|
||||
const start = [...current][0];
|
||||
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
|
||||
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
|
||||
}
|
||||
|
||||
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
|
||||
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);
|
||||
@@ -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" }>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 属性与所选要素不一致");
|
||||
|
||||
@@ -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"]);
|
||||
@@ -432,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);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -110,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
|
||||
|
||||
@@ -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,8 +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 { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
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";
|
||||
@@ -19,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";
|
||||
@@ -36,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";
|
||||
@@ -68,6 +71,8 @@ export function InvestigationPresentationPanel({
|
||||
onLocateTargets,
|
||||
localPreview = false,
|
||||
charts,
|
||||
chartWindowAutoOpen = false,
|
||||
chartWindowScope,
|
||||
overlay,
|
||||
cameraPaused,
|
||||
status,
|
||||
@@ -80,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 ? "本地预览展示" : "调查实时展示";
|
||||
@@ -121,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"}>
|
||||
@@ -137,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">
|
||||
@@ -146,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"
|
||||
@@ -162,6 +171,7 @@ export function InvestigationPresentationPanel({
|
||||
</motion.span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
history?.entries.length
|
||||
@@ -242,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">
|
||||
@@ -291,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={formatChartDisplayText(item.chart.title)}
|
||||
className="border-border shadow-none"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{formatChartDisplayText(item.chart.title)}
|
||||
</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||
{item.chart.x_data.length} 个
|
||||
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
|
||||
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
|
||||
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<AgentChartView chart={item.chart} embedded expanded />
|
||||
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
|
||||
</div>}
|
||||
</FloatingWindow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={reviewing}
|
||||
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
|
||||
onClick={() => onCloseChart(item.chart_id)}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<AgentChartView chart={item.chart} embedded />
|
||||
{references(item.evidence_refs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{chartGroups.map((group) => (
|
||||
<PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
|
||||
<InvestigationChartGroup group={group} reviewing={reviewing ?? false}
|
||||
onCloseChart={onCloseChart} references={references} />
|
||||
</PresentationItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
@@ -343,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";
|
||||
@@ -179,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));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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([
|
||||
};
|
||||
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)
|
||||
))
|
||||
]).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]));
|
||||
// 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 = [
|
||||
...changedTasks,
|
||||
...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) => {
|
||||
}
|
||||
} catch (nextError) {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
||||
retryTimer = window.setTimeout(() => setEventRetryGeneration((current) => current + 1), eventRetryMsRef.current);
|
||||
retryTimer = window.setTimeout(() => void load(), eventRetryMsRef.current);
|
||||
eventRetryMsRef.current = Math.min(eventRetryMsRef.current * 2, 30_000);
|
||||
}
|
||||
});
|
||||
} 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,
|
||||
@@ -484,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;
|
||||
@@ -541,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,
|
||||
|
||||
@@ -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,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) =>
|
||||
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,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: [] };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { columnsForLayout, reconcileChartSlots, swapChartSlots, type ChartSlot } from "./chart-window-slots";
|
||||
|
||||
describe("fixed chart slots", () => {
|
||||
it("maps supported layouts to grid columns", () => {
|
||||
expect([columnsForLayout("one"), columnsForLayout("two"), columnsForLayout("four")]).toEqual([1, 2, 2]);
|
||||
});
|
||||
|
||||
it("keeps chart positions when publications update in another order", () => {
|
||||
const slots: ChartSlot[] = ["flow", null, "pressure", "plans"];
|
||||
expect(reconcileChartSlots(slots, ["plans", "pressure", "flow"], 2)).toBe(slots);
|
||||
});
|
||||
|
||||
it("leaves deleted positions vacant without moving surviving charts", () => {
|
||||
expect(reconcileChartSlots(["pressure", "flow", "plans", null], ["pressure", "plans"], 2))
|
||||
.toEqual(["pressure", null, "plans", null]);
|
||||
});
|
||||
|
||||
it("fills the earliest vacant slots before appending a padded row", () => {
|
||||
expect(reconcileChartSlots(["pressure", null, "flow", null], ["new-1", "flow", "new-2", "pressure", "new-3"], 2))
|
||||
.toEqual(["pressure", "new-1", "flow", "new-2", "new-3", null]);
|
||||
});
|
||||
|
||||
it("uses vacancies created by a deletion in the same update", () => {
|
||||
expect(reconcileChartSlots(["pressure", "removed", "flow", null], ["pressure", "flow", "new"], 2))
|
||||
.toEqual(["pressure", "new", "flow", null]);
|
||||
});
|
||||
|
||||
it("preserves linear positions when the column count changes", () => {
|
||||
const slots: ChartSlot[] = ["a", null, "b", "c", "d", null];
|
||||
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 1)).toEqual(["a", null, "b", "c", "d"]);
|
||||
expect(reconcileChartSlots(slots, ["a", "b", "c", "d"], 2)).toBe(slots);
|
||||
});
|
||||
|
||||
it("trims only empty trailing rows, retaining leading and middle vacancies", () => {
|
||||
expect(reconcileChartSlots([null, null, "a", null, null, null, null, null], ["a"], 2))
|
||||
.toEqual([null, null, "a", null]);
|
||||
expect(reconcileChartSlots(["a", null, null, null], ["a"], 2)).toEqual(["a", null]);
|
||||
expect(reconcileChartSlots(["a", null, null, null], [], 2)).toEqual([]);
|
||||
});
|
||||
|
||||
it("builds default slots in available order and never duplicates a chart", () => {
|
||||
expect(reconcileChartSlots([], ["a", "b", "a"], 2)).toEqual(["a", "b"]);
|
||||
expect(reconcileChartSlots(["a", "a", "b", null], ["a", "b", "c"], 2)).toEqual(["a", "c", "b", null]);
|
||||
});
|
||||
|
||||
it("swaps occupied slots without inserting or shifting adjacent charts", () => {
|
||||
const slots: ChartSlot[] = ["a", "b", "c", "d"];
|
||||
expect(swapChartSlots(slots, 0, 2)).toEqual(["c", "b", "a", "d"]);
|
||||
expect(slots).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("swaps across rows and permits an empty destination", () => {
|
||||
expect(swapChartSlots(["a", "b", "c", "d", "e", null, null, null], 0, 5))
|
||||
.toEqual([null, "b", "c", "d", "e", "a", null, null]);
|
||||
});
|
||||
|
||||
it("keeps the original array for invalid or unchanged swaps", () => {
|
||||
const slots: ChartSlot[] = ["a", null, null];
|
||||
for (const [source, target] of [[-1, 1], [0, 3], [3, 0], [0.5, 1], [0, Number.NaN], [0, 0], [1, 2]]) {
|
||||
expect(swapChartSlots(slots, source, target)).toBe(slots);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
export type ChartSlot = string | null;
|
||||
export type ChartLayout = "one" | "two" | "four";
|
||||
|
||||
export function columnsForLayout(layout: ChartLayout): 1 | 2 {
|
||||
return layout === "one" ? 1 : 2;
|
||||
}
|
||||
|
||||
/** Retain fixed positions, fill vacancies, and pad the final row without compacting. */
|
||||
export function reconcileChartSlots(slots: ChartSlot[], availableIds: string[], columns: 1 | 2): ChartSlot[] {
|
||||
const available = new Set(availableIds);
|
||||
const retained = new Set<string>();
|
||||
const next = slots.map((id) => {
|
||||
if (id === null || !available.has(id) || retained.has(id)) return null;
|
||||
retained.add(id);
|
||||
return id;
|
||||
});
|
||||
for (const id of available) {
|
||||
if (retained.has(id)) continue;
|
||||
const empty = next.indexOf(null);
|
||||
if (empty === -1) next.push(id);
|
||||
else next[empty] = id;
|
||||
}
|
||||
let last = next.length - 1;
|
||||
while (last >= 0 && next[last] === null) last -= 1;
|
||||
const length = Math.ceil((last + 1) / columns) * columns;
|
||||
if (next.length > length) next.length = length;
|
||||
while (next.length < length) next.push(null);
|
||||
return next.length === slots.length && next.every((id, index) => id === slots[index]) ? slots : next;
|
||||
}
|
||||
|
||||
/** Exchange two fixed positions, including vacant destinations; never shift neighbors. */
|
||||
export function swapChartSlots(slots: ChartSlot[], sourceIndex: number, targetIndex: number): ChartSlot[] {
|
||||
if (!Number.isInteger(sourceIndex) || !Number.isInteger(targetIndex) || sourceIndex < 0 || targetIndex < 0 ||
|
||||
sourceIndex >= slots.length || targetIndex >= slots.length || slots[sourceIndex] === slots[targetIndex]) return slots;
|
||||
const next = [...slots];
|
||||
[next[sourceIndex], next[targetIndex]] = [next[targetIndex], next[sourceIndex]];
|
||||
return next;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { groupInvestigationCharts } from "./investigation-chart-groups";
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
const comparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16"): InvestigationChart => ({
|
||||
action: "open_chart", chart_id: `chart_tc_${id}`, evidence_refs: [`evidence:${id}`],
|
||||
chart: { title: `${id} ${metric}七日同时刻对照(${unit})`, chart_type: "time_comparison",
|
||||
x_axis_name: "时间", y_axis_name: `${metric}(${unit})`, x_data: ["07:45", "08:00"],
|
||||
series: [
|
||||
{ name: `${id} 当前 ${date}`, type: "line", data: [30, 25], sample_times: [`${date}T07:45:00+08:00`, `${date}T08:00:00+08:00`] },
|
||||
{ name: `${id} 2026-07-09`, type: "scatter", data: [null, 32], sample_times: [null, "2026-07-09T08:00:00+08:00"] }
|
||||
] }
|
||||
});
|
||||
|
||||
const legacyComparison = (id: string, metric = "压力", unit = "m", date = "2026-07-16") => {
|
||||
const command = comparison(id, metric, unit, date);
|
||||
command.chart.title = `${id} ${metric}三小时趋势与七天同期对照(${unit})`;
|
||||
command.chart.series[0].name = `${id} ${date.slice(5)}(${unit})`;
|
||||
command.chart.series[1].name = "07-09";
|
||||
return command;
|
||||
};
|
||||
|
||||
describe("investigation comparison groups", () => {
|
||||
it("groups pressure objects and pipe flow objects separately without changing data or evidence", () => {
|
||||
const charts = [comparison("170486"), comparison("175618"), comparison("542017", "管流", "m³/h"), comparison("486033", "管流", "m³/h")];
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
|
||||
expect(groups[1].items.map((item) => item.label)).toEqual(["542017", "486033"]);
|
||||
expect(groups[0].title).toBe("压力七日同时刻对照(m)");
|
||||
expect(groups[0].items[1].command).toBe(charts[1]);
|
||||
expect(charts[1].chart.series[1].data).toEqual([null, 32]);
|
||||
});
|
||||
|
||||
it("groups saved pressure and flow charts whose titles put the metric before the object ID", () => {
|
||||
const charts = [["170486", "压力", "m"], ["175618", "压力", "m"], ["541061", "流量", "m³/h"], ["542017", "流量", "m³/h"]].map(([id, metric, unit]) => {
|
||||
const command = comparison(id, metric, unit);
|
||||
command.chart.title = `${metric}测点 ${id} 当前曲线与前七天同期对比(${unit})`;
|
||||
return command;
|
||||
});
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups.map((group) => ({ title: group.title, objects: group.items.map((item) => item.label) }))).toEqual([
|
||||
{ title: "压力当前曲线与前七天同期对比(m)", objects: ["170486", "175618"] },
|
||||
{ title: "流量当前曲线与前七天同期对比(m³/h)", objects: ["541061", "542017"] }
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes the same explicit object comparison in all three saved title formats", () => {
|
||||
const charts = [comparison("170486"), comparison("175618"), comparison("159673")];
|
||||
charts[0].chart.title = "170486 压力三小时与历史对照(m)";
|
||||
charts[1].chart.title = "测点 175618 压力三小时与历史对照(m)";
|
||||
charts[2].chart.title = "压力测点 159673 三小时与历史对照(m)";
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].title).toBe("压力三小时与历史对照(m)");
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618", "159673"]);
|
||||
});
|
||||
|
||||
it("groups saved date-only historical labels without rewriting commands, readings or evidence", () => {
|
||||
const charts = [legacyComparison("170486"), legacyComparison("175618")];
|
||||
charts[1].chart.series[1].data[1] = 0;
|
||||
const original = structuredClone(charts);
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "175618"]);
|
||||
groups[0].items.forEach((item, index) => {
|
||||
expect(item.command).toBe(charts[index]);
|
||||
expect(item.command.chart.series).toBe(charts[index].chart.series);
|
||||
expect(item.command.chart.series[1].sample_times).toBe(charts[index].chart.series[1].sample_times);
|
||||
expect(item.command.evidence_refs).toBe(charts[index].evidence_refs);
|
||||
});
|
||||
expect(charts).toEqual(original);
|
||||
});
|
||||
|
||||
it("keeps legacy comparisons with different units, windows or current dates separate", () => {
|
||||
const shifted = legacyComparison("175618");
|
||||
shifted.chart.x_data = ["08:00", "08:15"];
|
||||
shifted.chart.series[0].sample_times = ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"];
|
||||
shifted.chart.series[1].sample_times = [null, "2026-07-09T08:15:00+08:00"];
|
||||
const charts = [legacyComparison("170486"), legacyComparison("P2", "压力", "MPa"), shifted, legacyComparison("P3", "压力", "m", "2026-07-17")];
|
||||
expect(groupInvestigationCharts(charts)).toHaveLength(charts.length);
|
||||
});
|
||||
|
||||
it("leaves ambiguous or mixed-object legacy charts and unproven historical dates separate", () => {
|
||||
const ambiguous = legacyComparison("P2"); ambiguous.chart.title = "压力测点历史对照";
|
||||
const foreign = legacyComparison("P3"); foreign.chart.series[1].name = "P4 07-09";
|
||||
const mixed = legacyComparison("P4"); mixed.chart.series.push({ ...mixed.chart.series[0], name: "P5 当前 2026-07-16" });
|
||||
const missing = legacyComparison("P5"); delete missing.chart.series[1].sample_times;
|
||||
const mismatched = legacyComparison("P6"); mismatched.chart.series[1].sample_times = [null, "2026-07-10T08:00:00+08:00"];
|
||||
const empty = legacyComparison("P7"); empty.chart.series[1].data = [null, null]; empty.chart.series[1].sample_times = [null, null];
|
||||
const sameDateInUtc8 = legacyComparison("P8"); sameDateInUtc8.chart.series[1].sample_times = [null, "2026-07-09T00:00:00Z"];
|
||||
const charts = [legacyComparison("170486"), ambiguous, foreign, mixed, missing, mismatched, empty, sameDateInUtc8];
|
||||
const groups = groupInvestigationCharts(charts);
|
||||
expect(groups).toHaveLength(charts.length - 1);
|
||||
expect(groups[0].items.map((item) => item.label)).toEqual(["170486", "P8"]);
|
||||
});
|
||||
|
||||
it("keeps incompatible units, sampling windows, comparison dates and ordinary trends separate", () => {
|
||||
const base = comparison("170486");
|
||||
const window = comparison("175618"); window.chart.x_data = ["08:00", "08:15"];
|
||||
const trend = comparison("159673"); trend.chart.series = [trend.chart.series[0]];
|
||||
expect(groupInvestigationCharts([base, comparison("P2", "压力", "MPa"), comparison("P3", "压力", "m", "2026-07-17"), window, trend])).toHaveLength(5);
|
||||
const noTimes = [comparison("P4"), comparison("P5", "压力", "m", "2026-07-17")];
|
||||
for (const command of noTimes) for (const series of command.chart.series) delete series.sample_times;
|
||||
expect(groupInvestigationCharts(noTimes)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps group identity stable as objects arrive or are closed and leaves ambiguous titles alone", () => {
|
||||
const first = comparison("170486"), second = comparison("175618");
|
||||
expect(groupInvestigationCharts([first])[0].id).toBe(groupInvestigationCharts([second, first])[0].id);
|
||||
const ambiguous = comparison("159673"); ambiguous.chart.title = "压力同期对照";
|
||||
expect(groupInvestigationCharts([first, ambiguous])).toHaveLength(2);
|
||||
expect(groupInvestigationCharts([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
export type InvestigationChartGroup = {
|
||||
id: string;
|
||||
title: string;
|
||||
items: Array<{ label: string; command: InvestigationChart }>;
|
||||
};
|
||||
|
||||
type ChartSeries = InvestigationChart["chart"]["series"][number];
|
||||
|
||||
/** Saved date-only legends identify a day only when their actual observations prove it. */
|
||||
function matchesHistoricalDate(series: ChartSeries) {
|
||||
if (!/^(?:0[1-9]|1[0-2])-(?:0[1-9]|[12][0-9]|3[01])$/.test(series.name) ||
|
||||
!series.sample_times || series.sample_times.length !== series.data.length) return false;
|
||||
let observedDate: string | null = null;
|
||||
for (let index = 0; index < series.data.length; index++) {
|
||||
const at = series.sample_times[index];
|
||||
if (series.data[index] === null) {
|
||||
if (at !== null) return false;
|
||||
continue;
|
||||
}
|
||||
if (typeof at !== "string") return false;
|
||||
const time = Date.parse(at);
|
||||
if (!Number.isFinite(time)) return false;
|
||||
const date = new Date(time + 8 * 3600_000).toISOString().slice(0, 10);
|
||||
if (date.slice(5) !== series.name || observedDate !== null && observedDate !== date) return false;
|
||||
observedDate = date;
|
||||
}
|
||||
return observedDate !== null;
|
||||
}
|
||||
|
||||
function matchesObjectSeries(command: InvestigationChart, id: string) {
|
||||
const { chart } = command;
|
||||
const prefixed = (series: ChartSeries) => series.name.startsWith(`${id} `);
|
||||
if (chart.series.every(prefixed)) return true;
|
||||
const current = chart.series.filter((series) => series.type !== "scatter");
|
||||
return current.length === 1 && prefixed(current[0]) &&
|
||||
(current[0].type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)) === "line" &&
|
||||
chart.series.every((series) => prefixed(series) || series.type === "scatter" && matchesHistoricalDate(series));
|
||||
}
|
||||
|
||||
/** Group only explicitly named objects with the same comparison, axes and sampling window. */
|
||||
export function groupInvestigationCharts(charts: InvestigationChart[]): InvestigationChartGroup[] {
|
||||
const groups = new Map<string, InvestigationChartGroup>();
|
||||
for (const command of charts) {
|
||||
const { chart } = command;
|
||||
const match = /^(?:(压力|流量)测点\s+|测点\s+)?([A-Za-z0-9][A-Za-z0-9_.-]*)\s+(.+)$/.exec(chart.title);
|
||||
const object = match ? { id: match[2], title: `${match[1] ?? ""}${match[3]}` } : null;
|
||||
const comparable = object && chart.series.some((series) => series.type === "scatter") && matchesObjectSeries(command, object.id);
|
||||
const id = comparable ? JSON.stringify([
|
||||
object.title, chart.chart_type, chart.x_axis_name, chart.y_axis_name, chart.x_data,
|
||||
chart.series.filter((series) => series.type !== "scatter").map((series) => [
|
||||
series.name.slice(object.id.length).trim(), series.sample_times ?? null
|
||||
])
|
||||
]) : command.chart_id;
|
||||
let group = groups.get(id);
|
||||
if (!group) {
|
||||
group = { id, title: comparable ? object.title : chart.title, items: [] };
|
||||
groups.set(id, group);
|
||||
}
|
||||
group.items.push({ label: comparable ? object.id : chart.title, command });
|
||||
}
|
||||
return [...groups.values()];
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createInvestigationDevCase, investigationDevCases } from "./investigation-dev-cases";
|
||||
import { groupInvestigationCharts } from "./investigation-chart-groups";
|
||||
import { parseInvestigationDevJsonl } from "./investigation-dev-preview";
|
||||
|
||||
|
||||
@@ -47,9 +48,18 @@ describe("switchable investigation Dev cases", () => {
|
||||
expect(nodes[0].props.rows).toHaveLength(50);
|
||||
expect(nodes[1].props.rows).toEqual([]);
|
||||
const charts = parseInvestigationDevJsonl(createInvestigationDevCase("complex_charts"));
|
||||
const pressure = Object.values(charts.blocks[0].spec.elements)[0].props;
|
||||
expect(pressure.x_data).toHaveLength(96);
|
||||
expect(pressure.series).toHaveLength(8);
|
||||
const commands = charts.commands.filter((command) => command.action === "open_chart");
|
||||
expect(charts.blocks).toHaveLength(0);
|
||||
expect(commands).toHaveLength(13);
|
||||
const groups = groupInvestigationCharts(commands);
|
||||
expect(groups.map((group) => group.items.length)).toEqual([8, 4, 1]);
|
||||
for (const { chart } of commands.slice(0, 12)) {
|
||||
expect(chart.chart_type).toBe("time_comparison");
|
||||
expect(chart.x_data).toHaveLength(96);
|
||||
expect(chart.series).toHaveLength(8);
|
||||
expect(chart.series[0].sample_times).toHaveLength(96);
|
||||
expect(chart.series[1].data.filter((value) => value !== null)).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["data_only", "candidates_table", "valves_table"] as const)(
|
||||
|
||||
@@ -65,6 +65,11 @@ export const investigationDevCases = [
|
||||
id: "invalid_chart",
|
||||
label: "错误:图表数据不齐",
|
||||
expected: "第 1 行图表校验失败,时间轴和序列长度不一致时不能执行。"
|
||||
},
|
||||
{
|
||||
id: "invalid_comparison",
|
||||
label: "错误:同期时刻错位",
|
||||
expected: "第 1 行拒绝历史采样时间与横轴槽位不一致的复合图,整批不执行。"
|
||||
}
|
||||
] as const;
|
||||
|
||||
@@ -258,5 +263,14 @@ export function createInvestigationDevCase(
|
||||
}
|
||||
}
|
||||
]);
|
||||
case "invalid_comparison":
|
||||
return invalidJsonl([{ record_type: "presentation", command: {
|
||||
action: "open_chart", chart_id: "dev-invalid-comparison", evidence_refs: ["dev:synthetic"],
|
||||
chart: { title: "错误案例:历史点时刻错位", chart_type: "time_comparison", x_data: ["08:00", "08:15"],
|
||||
series: [
|
||||
{ name: "当前", type: "line", data: [32, 30], sample_times: ["2026-07-16T08:00:00+08:00", "2026-07-16T08:15:00+08:00"] },
|
||||
{ name: "历史", type: "scatter", data: [33, null], sample_times: ["2026-07-15T08:15:00+08:00", null] }
|
||||
] }
|
||||
} }]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { AnswerUiBlock } from "@/features/agent";
|
||||
import { createPresentationRecord, createUiRecord, serializeInvestigationDevJsonl } from "./investigation-dev-preview";
|
||||
|
||||
export const complexInvestigationDevCases = [
|
||||
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "3 张图、2 张表与合成覆盖面;可同时放大对比,单位分别展示,不查询真实要素。" },
|
||||
{ id: "complex_investigation", label: "复杂调查:多图多表联动", expected: "压力与管流分别聚合,另有方案柱状图、2 张表与合成覆盖面;右侧切换对象,下方控制图例。" },
|
||||
{ id: "complex_tables", label: "复杂表格:宽表、长文本与边界数据", expected: "50 行 8 列宽表、空表和含零值、负数、重复值的对照表;检查搜索、数值排序和横向滚动。" },
|
||||
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "96 个时间点、8 条压力序列,另有流量与方案柱状图;可切换数据表、筛选区间并独立放大。" }
|
||||
{ id: "complex_charts", label: "复杂图表:多测点与长时序", expected: "8 个压力点与 4 个管段各含 96 个时刻、当前曲线与七日历史散点,聚合为 2 个子图表,由总图表窗口统一管理,另有方案柱状图。" }
|
||||
] as const;
|
||||
|
||||
type ComplexCaseId = (typeof complexInvestigationDevCases)[number]["id"];
|
||||
@@ -19,25 +19,40 @@ const ui = (id: string, node: Node) => createUiRecord({
|
||||
const table = (id: string, title: string, columns: string[], rows: string[][]) =>
|
||||
ui(id, { type: "Table", props: { title, columns, rows }, children: [] });
|
||||
const times = Array.from({ length: 96 }, (_, index) => `${String(Math.floor(index / 4)).padStart(2, "0")}:${String(index % 4 * 15).padStart(2, "0")}`);
|
||||
const pressure = {
|
||||
title: "合成全天压力对比", chart_type: "line" as const, x_data: times,
|
||||
series: Array.from({ length: 8 }, (_, series) => ({
|
||||
name: `合成节点 ${INVESTIGATION_DEV_NETWORK.nodeAnchors[series].id} · ${series < 4 ? "主干管" : "末端支线"}压力水头(m)`,
|
||||
data: times.map((_, index) => Number((34 + series * 0.6 + Math.sin(index / 7 + series) * 2.4 - (index >= 32 && index < 44 ? 7 - series * 0.4 : 0)).toFixed(2)))
|
||||
}))
|
||||
};
|
||||
const flow = {
|
||||
title: "合成分区流量趋势", chart_type: "line" as const, x_data: times,
|
||||
series: Array.from({ length: 4 }, (_, series) => ({ name: `合成分区 ${series + 1} 流量(m³/h)`,
|
||||
data: times.map((_, index) => Number((120 + series * 28 + Math.sin(index / 9 + series) * 24 + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))) }))
|
||||
};
|
||||
const comparisonChart = (id: string, title: string, axis: string, baseline: number[], current: number[]) => ({
|
||||
title: `${id} ${title}`, chart_type: "time_comparison" as const,
|
||||
x_axis_name: "时间", y_axis_name: axis, x_data: times,
|
||||
series: [{ name: `${id} 当前 2026-07-16`, type: "line" as const, data: current,
|
||||
sample_times: times.map((time) => `2026-07-16T${time}:00+08:00`) },
|
||||
...Array.from({ length: 7 }, (_, day) => {
|
||||
const date = `2026-07-${String(9 + day).padStart(2, "0")}`;
|
||||
return { name: `${id} ${date}`, type: "scatter" as const,
|
||||
data: times.map((_, index) => index >= 40 && index <= 42 ? Number((baseline[index] + day * 0.1).toFixed(2)) : null),
|
||||
sample_times: times.map((time, index) => index >= 40 && index <= 42 ? `${date}T${time}:00+08:00` : null) };
|
||||
})]
|
||||
});
|
||||
const pressure = Array.from({ length: 8 }, (_, object) => {
|
||||
const baseline = times.map((_, index) => Number((34 + object * 0.6 + Math.sin(index / 7 + object) * 2.4).toFixed(2)));
|
||||
return comparisonChart(INVESTIGATION_DEV_NETWORK.nodeAnchors[object].id, "合成全天压力对比", "压力水头(m)", baseline,
|
||||
baseline.map((value, index) => Number((value - (index >= 32 && index < 44 ? 7 - object * 0.4 : 0)).toFixed(2))));
|
||||
});
|
||||
const flow = Array.from({ length: 4 }, (_, object) => {
|
||||
const baseline = times.map((_, index) => Number((120 + object * 28 + Math.sin(index / 9 + object) * 24).toFixed(1)));
|
||||
return comparisonChart(INVESTIGATION_DEV_NETWORK.pipeIds[object], "合成分区流量趋势", "流量(m³/h)", baseline,
|
||||
baseline.map((value, index) => Number((value + (index >= 32 && index < 44 ? 38 : 0)).toFixed(1))));
|
||||
});
|
||||
const chart = (id: string, value: ReturnType<typeof comparisonChart>) => createPresentationRecord({
|
||||
action: "open_chart", chart_id: `dev-complex-${id}`, evidence_refs: ["dev:synthetic"], chart: value
|
||||
});
|
||||
const objectCharts = () => [
|
||||
...pressure.map((value, index) => chart(`pressure-${index + 1}`, value)),
|
||||
...flow.map((value, index) => chart(`flow-${index + 1}`, value))
|
||||
];
|
||||
const comparison = {
|
||||
title: "合成候选方案影响对比", chart_type: "bar" as const,
|
||||
x_data: ["合成方案 A:局部隔离", "合成方案 B:扩大核查", "合成方案 C:保持供水", "合成方案 D:分段验证", "合成方案 E:待补充资料"],
|
||||
series: [{ name: "合成影响用户数(户)", data: [245, 610, 0, 128, 72] }]
|
||||
};
|
||||
const chart = (id: string, value: typeof pressure | typeof flow | typeof comparison) =>
|
||||
ui(id, { type: "Chart", props: { ...value, source: "本地确定性合成数据,仅供展示测试;曲线变化和方案名称均非实际分析结果。" }, children: [] });
|
||||
|
||||
const columns = ["序号", "管段 ID", "合成片区", "长度(m)", "压力变化(m)", "影响户数(户)", "核查状态", "核查说明"];
|
||||
const rows = Array.from({ length: 50 }, (_, index) => [
|
||||
@@ -53,7 +68,8 @@ const rows = Array.from({ length: 50 }, (_, index) => [
|
||||
|
||||
export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longitude, latitude]: [number, number]): string {
|
||||
if (caseId === "complex_charts") return serializeInvestigationDevJsonl([
|
||||
chart("pressure", pressure), chart("flow", flow), chart("comparison", comparison)
|
||||
...objectCharts(),
|
||||
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison })
|
||||
]);
|
||||
if (caseId === "complex_tables") return serializeInvestigationDevJsonl([
|
||||
table("wide-table", "合成候选核查宽表", columns, rows),
|
||||
@@ -69,7 +85,7 @@ export function createComplexInvestigationDevCase(caseId: ComplexCaseId, [longit
|
||||
createPresentationRecord({ action: "set_overlay", mode: "geometry", overlay_id: "dev-complex-coverage", title: "合成分区覆盖示意", evidence_id: "dev:synthetic",
|
||||
data: { type: "FeatureCollection", features: [-0.002, 0.002].map((dx, index) => ({ type: "Feature", properties: { project_code: INVESTIGATION_DEV_NETWORK.projectCode, origin: "synthetic", label: `合成片区 ${index + 1}` },
|
||||
geometry: { type: "Polygon", coordinates: [[point(dx - 0.001, -0.001), point(dx + 0.001, -0.001), point(dx + 0.001, 0.001), point(dx - 0.001, 0.001), point(dx - 0.001, -0.001)]] } })) } }),
|
||||
chart("pressure", pressure), chart("flow", flow),
|
||||
...objectCharts(),
|
||||
createPresentationRecord({ action: "open_chart", chart_id: "dev-complex-plans", evidence_refs: ["dev:synthetic"], chart: comparison }),
|
||||
table("candidates", "合成候选方案核查", columns, rows.slice(0, 24)),
|
||||
table("valves", "合成阀门核查清单", ["阀门", "合成片区", "位置描述", "核查要求", "当前状态"],
|
||||
|
||||
@@ -5,8 +5,22 @@ import {
|
||||
parseInvestigationDevJsonl
|
||||
} from "./investigation-dev-preview";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import chartCases from "../../../../tests/fixtures/chart-publication-cases.json";
|
||||
|
||||
describe("investigation Dev JSONL", () => {
|
||||
it.each(chartCases)("uses publication semantics for command and UI examples: $name", ({ chart, accepted }) => {
|
||||
const records = [
|
||||
{ record_type: "presentation", command: { action: "open_chart", chart_id: "comparison", chart, evidence_refs: ["dev:synthetic"] } },
|
||||
{ record_type: "ui_block", block: { version: "answer-ui@1", block_id: "comparison", fallback_text: "合成测试", spec: {
|
||||
root: "chart", elements: { chart: { type: "Chart", props: { ...chart, source: "合成测试" }, children: [] } }
|
||||
} } }
|
||||
];
|
||||
for (const record of records) {
|
||||
const parse = () => parseInvestigationDevJsonl(JSON.stringify(record));
|
||||
if (accepted) expect(parse).not.toThrow();
|
||||
else expect(parse).toThrow("第 1 行");
|
||||
}
|
||||
});
|
||||
it("separates explicit display actions, validated UI blocks and synthetic investigation data", () => {
|
||||
const result = parseInvestigationDevJsonl(
|
||||
createInvestigationDevSample()
|
||||
@@ -130,7 +144,7 @@ describe("migrated example data", () => {
|
||||
await import("../../../../tests/fixtures/unified-presentation.jsonl?raw");
|
||||
const { PRESENTATION_ACTIONS } = await import("@/features/agent");
|
||||
const preview = parseInvestigationDevJsonl(input);
|
||||
expect(preview.commands.map((command) => command.action).sort()).toEqual(
|
||||
expect([...new Set(preview.commands.map((command) => command.action))].sort()).toEqual(
|
||||
[...PRESENTATION_ACTIONS].sort()
|
||||
);
|
||||
expect(preview.blocks).toHaveLength(1);
|
||||
|
||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
||||
import {
|
||||
parseAnswerUiBlock,
|
||||
presentationCommandSchema,
|
||||
safeChartPublicationInput,
|
||||
chartPublicationError,
|
||||
type AnswerUiBlock
|
||||
} from "@/features/agent";
|
||||
import { type InvestigationPresentationCommand } from "../api/investigation-presentation";
|
||||
@@ -36,7 +38,11 @@ export const investigationActionLabels: Record<InvestigationPresentationCommand[
|
||||
};
|
||||
|
||||
const references = z.array(z.string().trim().min(1).max(128)).max(100);
|
||||
const command = presentationCommandSchema;
|
||||
const command = presentationCommandSchema.superRefine((value, ctx) => {
|
||||
if (value.action !== "open_chart") return;
|
||||
const chart = safeChartPublicationInput.safeParse(value.chart);
|
||||
if (!chart.success) for (const issue of chart.error.issues) ctx.addIssue({ ...issue, path: ["chart", ...issue.path] });
|
||||
});
|
||||
const presentationRecord = z.object({ record_type: z.literal("presentation"), command }).strict();
|
||||
const uiRecord = z
|
||||
.object({
|
||||
@@ -78,7 +84,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
|
||||
const record = value as Record<string, unknown>;
|
||||
if (record.record_type === "presentation") {
|
||||
const parsed = presentationRecord.safeParse(value);
|
||||
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法,请检查动作、参数和范围。`);
|
||||
if (!parsed.success) throw new Error(`第 ${line} 行展示指令不合法:${parsed.error.issues[0]?.message ?? "请检查动作、参数和范围。"}`);
|
||||
const data = parsed.data;
|
||||
result.commands.push(data.command);
|
||||
} else if (record.record_type === "ui_block") {
|
||||
@@ -86,6 +92,7 @@ export function parseInvestigationDevJsonl(input: string): InvestigationDevPrevi
|
||||
const block = parseAnswerUiBlock(wrapped.success ? wrapped.data.block : null);
|
||||
if (!block || blockIds.has(block.block_id))
|
||||
throw new Error(`第 ${line} 行界面块不合法或 block_id 重复。`);
|
||||
validateChartNodes(block, `第 ${line} 行`);
|
||||
blockIds.add(block.block_id);
|
||||
result.blocks.push(block);
|
||||
if (result.blocks.length > 8) throw new Error("一次最多预览 8 个界面块。");
|
||||
@@ -107,6 +114,7 @@ export function createPresentationRecord(command: InvestigationPresentationComma
|
||||
export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []) {
|
||||
const normalized = parseAnswerUiBlock({ ...block, phase: block.phase ?? "final" });
|
||||
if (!normalized) throw new Error("界面块未通过当前组件协议校验。");
|
||||
validateChartNodes(normalized, "界面块");
|
||||
return uiRecord.parse({
|
||||
record_type: "ui_block",
|
||||
block: normalized,
|
||||
@@ -114,6 +122,15 @@ export function createUiRecord(block: AnswerUiBlock, evidenceRefs: string[] = []
|
||||
});
|
||||
}
|
||||
|
||||
function validateChartNodes(block: AnswerUiBlock, prefix: string) {
|
||||
for (const node of Object.values(block.spec.elements)) {
|
||||
if (node.type !== "Chart") continue;
|
||||
// The answer catalog owns its text limits; only publication time semantics are shared.
|
||||
const message = chartPublicationError(node.props as Parameters<typeof chartPublicationError>[0]);
|
||||
if (message) throw new Error(`${prefix}图表不合法:${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeInvestigationDevJsonl(records: unknown[]) {
|
||||
const input = records.map((record) => JSON.stringify(record)).join("\n");
|
||||
parseInvestigationDevJsonl(input);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user