Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0ae0a41e61 | ||
|
|
865491cb23 | ||
|
|
d7eb01652a | ||
|
|
038d3458bc | ||
|
|
692bbf609c | ||
|
|
ad1f7ca7a9 | ||
|
|
aa9b12662e | ||
|
|
b948551c1f | ||
|
|
e0fe4a5a10 | ||
|
|
4e3ed5098e | ||
|
|
8a63926284 | ||
|
|
4a7781d5e5 | ||
|
|
e0436217a8 | ||
|
|
ea3b62573c | ||
|
|
eee65ac8fd | ||
|
|
ba71e2d529 |
@@ -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` | 精简与外层重复的补充提示;保留标题、说明、校验及草稿,提交反馈短时淡入 |
|
||||
|
||||
@@ -118,6 +118,8 @@ Agent 面板
|
||||
|
||||
图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。
|
||||
|
||||
图表通过“放大查看”打开独立浮窗,标题栏的“最大化窗口”按钮铺满可用视口,保留还原与关闭入口。内容宽度达到640px时,主图占据剩余空间,序列与摘要放在右侧;较窄窗口将资料放在主图下方独立滚动。展开、最大化与还原共享视图、序列显隐及表格筛选状态,调整窗口尺寸沿用同一图表实例。时间轴集中标注UTC+8,采样详情保留完整日期与偏移。工作台在独立层叠上下文内排列业务面板,浮窗与后续打开的菜单、对话框通过Portal显示。
|
||||
|
||||
正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。
|
||||
|
||||
空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史、引用任务或已有工具权限审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
# 调查任务展示与 Dev 测试
|
||||
|
||||
“调查测试”顶部的“全屏爆管视觉警告”区域提供持续预览、三档强度和“模拟事件处理结束”。默认使用标准档;关闭按钮位于屏幕底部状态提示右上角。真实警告随监测事件持续至处理结束,查看调查后继续显示;手动关闭按项目与事件在当前标签页记忆。预览操作只改变本地视觉状态。
|
||||
|
||||
可直接打开顶部 Dev 入口载入、编辑、校验并执行本地测试案例,无需先选择调查或开启实时跟随。纯数据和界面块可独立预览,包含地图或图表展示指令时需等待地图就绪。验证实际调查联动时,在“工况任务 → 调查任务”选择一项任务,再打开 Dev。Dev 默认打开“调查测试”页签,保留当前选择,显示调查代次、实时图表数、已保存报告中的图表/表格/地图入口数,以及最近的展示动作。上一轮报告会明确标注,不当成本轮新成果。
|
||||
|
||||
Dev 分为“调查测试”和“地图调试”两个页签:前者按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程,后者保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。
|
||||
Dev 分为“调查测试”“通知测试”和“地图调试”三个页签:调查测试按“准备数据 → 校验与执行 → 检查结果”组织 JSONL 流程;通知测试提供五种状态、报告与任务入口、长文本和连续六条通知;地图调试保留要素定位、水流、图层和标注工具。切换页签保留输入与展示状态,不清除当前预览。地图重置操作仅显示在地图调试页。
|
||||
|
||||
通知测试中的信息、成功提示默认 3.2 秒后关闭;警告和错误可手动关闭,加载状态通过“模拟加载完成”更新为成功提示。带操作入口的通知持续保留,点击查看仍保留,正文显示“保留至手动关闭”;多条通知沿用原有堆叠,关闭当前通知后可继续查看其余通知。报告和任务按钮仅记录本地点击反馈。“清除测试通知”只移除本页测试样例,不影响真实通知。
|
||||
|
||||
## 当前识别链路
|
||||
|
||||
@@ -16,11 +20,31 @@ Dev 分为“调查测试”和“地图调试”两个页签:前者按“准
|
||||
|
||||
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
|
||||
|
||||
## 图表和窗口管理
|
||||
|
||||
调查、本地预览与对话展示面板提供“图表窗口”入口,在一个可拖动、缩放、最大化的总窗口中集中查看所有子图表,包括 open_chart 形成的对象图组与报告中的 Chart。每张子图表保留自身的独立弹窗放大入口,可同时打开多个放大窗口;关闭总窗口收起其内部开启的放大窗口,关闭子窗口只收起该放大视图。普通趋势总览、柱状图和报告 Chart 保持各自的图表语义。
|
||||
|
||||
总窗口使用固定槽位布局,提供“单图布局”“左右布局”“四宫格布局”,每行分别显示 1、2、2 个固定位置,单图与左右布局每行约占一个可视区高度,四宫格每个可视区约显示两行。全部图表沿纵向连续排列,通过滚动查看;图表随槽位尺寸适配,末行按列数补齐空槽。窄窗口使用单图布局,调宽后恢复先前选择的布局。头部定位、布局和重置控件置于同一条浅底工具栏,控件保持等高,窄窗口允许自然换行。“快速定位图表”列出全部图表,选择后滚动到对应位置并聚焦目标;目标的位置栏短暂显示淡色背景和“已定位”,不使用整卡外框,避免高亮被滚动区域裁切。
|
||||
|
||||
槽内图表使用紧凑控件,绘图区下方的图例独立滚动,筛选、详情与依据通过入口展开。多对象图在宽槽右侧显示对象按钮,窄槽使用对象选择器;独立放大后使用完整控件。
|
||||
|
||||
整条图表位置栏均为拖拽手柄,悬停反馈覆盖整栏;在目标位置释放后交换两处内容,拖到空槽则移动到空槽,其他位置不推移。悬停时整张卡片显示轻量浅色高亮,拖动源与目标位置保留整卡浅色提示;位置栏使用柔和浅底和深色文字,不使用深色横幅。图表保持原有清晰度,目标位置使用细线提示,轻量标题预览沿用中性边框。原图表留在原位,不克隆或随指针重绘。不再提供“移动到”下拉菜单,下方图表通过纵向滚动和快速定位查看。
|
||||
|
||||
槽位映射依据稳定的子图表标识:同一图表实时更新时保留位置,删除图表后保留中间空槽并移除末尾空行,新图优先填补空槽,再追加位置与行。同一次任务查看期间,关闭并重新打开总窗口保留槽位与布局;任务、项目或执行代次切换后重新建立当前内容。交换位置不改变图表数据、对象选择或服务端的展示事件顺序。
|
||||
|
||||
工作台顶部的窗口列表入口列出已打开的总图表窗口、独立子图表和表格窗口,选择条目可将对应窗口置顶并定位到标题栏。列表中的“关闭全部窗口”统一收起当前打开的窗口,不删除已加载的图表、表格或任务结果;图表窗口可从原展示入口重新打开。
|
||||
|
||||
总窗口与子图表使用同一缩放交互:指针拖动窗口边缘时,图表按窗口实际尺寸适配,保持已有图表实例与筛选、序列显隐和对象选择;松开后确认最终尺寸。拖动期间按 Escape 或发生 pointercancel 时取消调整、恢复原尺寸;标题栏的 Shift 加方向键继续用于逐步调整大小。
|
||||
|
||||
只有正在跟随的运行中调查新收到 open_chart 时,才自动弹出一次总窗口,不自动弹出各子图表,不抢走键盘焦点;后续指令按 chart_id 更新其中的数据。打开、切换、刷新任务、SSE 重连补载、历史回看和回到当前都只恢复内容,已完成任务也不弹窗。用户关闭总窗口后,本次任务查看期间的后续更新不使其再次弹出;手动收起展示面板期间的新指令也不请求弹窗。总窗口已经打开时,历史回看只更新其中的快照,实时数据不覆盖正在查看的历史。切换任务、项目或执行代次关闭原窗口并重新静默载入,返回之前看过的任务也不重放自动弹出意图。Chat 与本地 Dev 预览使用手动入口。
|
||||
|
||||
open_chart 仍只提交 chart_id/chart/evidence_refs,close_chart 仍按 chart_id 移除对象;不新增 display 或 objects 字段。排列、布局与窗口打开意图仅存在当前浏览器中,不进入保存的展示快照或事件。displayed 回执表示展示内容已加载,不保证弹出了独立窗口。
|
||||
|
||||
## 本地测试步骤
|
||||
|
||||
1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
|
||||
2. 可选:点击“校验 JSONL”单独查看识别出的展示指令、界面块、调查数据数量。也可直接执行本地预览,执行入口会自动校验当前全部内容。
|
||||
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
|
||||
3. 点击“执行本地预览”,自动校验通过后对照地图和左下展示面板中的图表、结构化界面与数据摘要;手动打开“图表窗口”,核对单图、左右、四宫格的固定槽位、拖放交换、末行空槽与纵向滚动,再检查快速定位和子图表独立放大,并从顶部窗口列表切换已打开的窗口。Dev 的“原始数据检查”可点击对象查看原始行内容。动作记录区分“实时指令”和“本地测试”,显示应用或失败结果。
|
||||
4. 按需“下载测试 JSONL”保存当前已校验输入;“清除临时展示”清除当前浏览器的高亮、图表和覆盖层。测试不改变服务端事件游标,不发起调查、审批、工单或设备写入。
|
||||
|
||||
样例与导出仅存在当前页面或用户下载的文件中,不写 Agent 工作区,不伪造已保存证据。Dev 不会自动枚举 Agent 生成的本地文件;要检查已有 JSONL,可将通过受控入口取得的文件内容粘贴到编辑区。当前最多 200 KB、40 行、8 个界面块;地图与图表继续遵守原展示协议的数量、几何及序列约束。
|
||||
@@ -31,8 +55,10 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
|
||||
|
||||
| 案例 | 核对内容 |
|
||||
| --- | --- |
|
||||
| 组合联动 | 地图点、调查现有目标高亮、压力曲线、候选表格与数据 |
|
||||
| 地图定位与高亮 | 有目标时高亮真实关联目标;无目标时只显示合成点,不虚构资产查询编号 |
|
||||
| 组合联动 | 合成空间点、压力曲线、候选表格与数据;定位和高亮在地图调试页签操作 |
|
||||
| 复杂调查:多图多表联动 | 8 个压力点、4 个管段的同期复合图聚合为 2 个子图表,另有方案柱状图、2 个表格与合成覆盖面 |
|
||||
| 复杂图表:多测点与长时序 | 每个对象包含 96 个时刻、1 条当前曲线和 7 组稀疏历史散点;右侧切换对象,下方显隐序列 |
|
||||
| 复杂表格:宽表、长文本与边界数据 | 50 行 8 列宽表、空结果、零值、负数、重复值及缺口 |
|
||||
| 压力趋势对比 | 两条合成压力曲线、时间轴、单位与图例 |
|
||||
| 影响用户统计 | 合成用户数柱状图,不作为真实停水统计 |
|
||||
| 合成影响范围 | 当前地图中心附近的合成 Polygon |
|
||||
@@ -43,6 +69,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
|
||||
| 错误:JSON 语法 | 第二行语法错误时整批拒绝,不执行第一行合法动作 |
|
||||
| 错误:不支持的动作 | 拒绝协议未支持的动作 |
|
||||
| 错误:图表数据不齐 | 拒绝时间轴和序列长度不一致的图表 |
|
||||
| 错误:同期时刻错位 | 拒绝历史时间与横轴槽位不一致的复合图,整批不执行 |
|
||||
|
||||
切换前若执行过本地预览,会先清除它的当前临时地图、图表和业务结果,保留动作历史供回看;仅选择或编辑 JSONL 不执行新指令。有内容时即可点击“执行本地预览”,无需先点单独校验;每次执行都会重新校验当前全部输入。任何一行错误都会拦截整批,显示具体错误,不执行部分指令;地图尚未就绪时也显示原因。仅空输入或正在执行时禁用按钮,不以调查选择或实时跟随作为本地执行前提。测试不会写入 Server、重启调查或发送 Agent 对话。案例定义位于 [investigation-dev-cases.ts](../src/features/workbench/utils/investigation-dev-cases.ts)。
|
||||
|
||||
@@ -56,7 +83,7 @@ Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回
|
||||
{"record_type":"candidate","origin":"synthetic","source_evidence_refs":[],"element_id":"DEMO-PIPE-01","status":"example_only"}
|
||||
```
|
||||
|
||||
仓库内的可复制数据示例为 [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 写入接口。
|
||||
|
||||
@@ -69,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 任务入口继续执行证据归属和快照校验。
|
||||
|
||||
## 已有案例与示例迁移
|
||||
|
||||
@@ -91,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,并在各仓库独立运行测试。
|
||||
|
||||
|
||||
## 展示动作历史
|
||||
|
||||
@@ -32,7 +32,9 @@
|
||||
|
||||
凤阳使用已有的 `fengyang_v2` 工作空间及元数据地图范围。固定奉贤演示工况、场景与空间覆盖不会在凤阳兜底显示。奉贤原始 GIS 图层不复制,图层入口根据当前工作空间中的实际数据探测。
|
||||
|
||||
地址定位支持文字搜索及地图取点反编码,两者复用当前项目的配置与服务端 Key。取点结果保留点击坐标,关闭工具或切换项目取消未完成查询。地名搜索读取 Metadata DB 的 `projects.map_config.place_search`,按当前项目的 `enabled`、`provider`、`admin_code` 和 `region` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。
|
||||
定位工具按“地址地名”和“管网要素”分为两个页签。地址地名支持文字搜索及地图取点反编码,两者复用当前项目的配置与服务端 Key。管网要素按业务源与要素 ID 批量定位,支持使用地图选中、仅高亮、缩放到要素和清除高亮,不依赖地名搜索配置;与 Dev 面板复用同一定位组件。切换页签保留输入,进入管网要素时结束地图取点。
|
||||
|
||||
取点结果保留点击坐标,关闭工具或切换项目取消未完成查询。地名搜索读取 Metadata DB 的 `projects.map_config.place_search`,按当前项目的 `enabled`、`provider`、`admin_code` 和 `region` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。
|
||||
|
||||
## 验证
|
||||
|
||||
|
||||
@@ -32,9 +32,9 @@ Agent 输入框使用 `--shadow-agent-composer` 的柔和双层阴影区分白
|
||||
|
||||
助手回复底部使用单行操作栏:“查看处理过程”靠左,语音与复制组成右侧操作组,复制位于最右。展开区域使用白色背景、细边框和完整圆角,与回答正文区分;外边缘与过程入口左侧对齐,普通进度与工具调用的状态图标统一左对齐;工具调用平铺显示,不额外包裹卡片、边框或背景,参数内容按文字列对齐。过程入口保留标准 hover 反馈,使用对称的水平留白。普通步骤和工具调用复用同一状态图标组件:24px 圆形容器、14px 线性图标,完成为圆圈勾选、失败为圆圈叉号、进行中为加载图标。操作图标统一为 16px、2px 描边,按钮高 32px;窄容器下语音与复制收起可见标签,保留中文可访问名称和提示。复制反馈不改变操作栏布局,过程展开、语音播放与复制各自独立。
|
||||
|
||||
Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试与地图调试两个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 只保留演示区间、调频时间轴、主动监测操作和本周期结论,测点目录与原始读数表不在演示面板重复展示。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。
|
||||
Dev 测试、外围系统、调查实时展示与本地预览展示面板也遵循本节:使用实体表面、语义颜色、细边框和标准控件,复用 `workbench-shadcn-surface` 主题;日期与选择菜单通过 Portal 渲染时继续传递这一主题。外围面板内的工单、SCADA 和 Dev 内的 JSONL 测试区采用同一规则。Dev 按调查测试、通知测试与地图调试三个页签组织;工单用分类标题和摘要列表组织,完整报告按需展开,避免多层边框挤压表单;SCADA 只保留演示区间、调频时间轴、主动监测操作和本周期结论,测点目录与原始读数表不在演示面板重复展示。调查与本地展示面板使用固定标题和状态、独立滚动区、紧凑的地图操作及图表 Card;图表标题与关闭按钮同行,嵌入式渲染不再叠加旧灰色图表容器,长标题与图例可换行。面板与图表进入退出、依据展开使用 Motion 的短时透明度和位移过渡,布局变化采用 transform 投影;键盘操作及减少动态效果模式即时完成。地图定位采用可中断的 450ms 平滑 fit,覆盖层以 180ms 淡入淡出,减少动态效果时关闭这些过渡。两个浮层保持非模态,仅自身表面接收指针输入,外部地图保留点击、拖拽和滚轮操作。 展示面板的动作切换放在固定标题区,使用紧凑的 Select、前后按钮和“回到当前”;历史状态与后续动作数可见。回看切换保留短时淡入淡出,退出画面立即设为 inert 并从无障碍树隐藏,避免旧操作仍可点击。Dev 只显示测试输入、校验与原始数据,业务表格、卡片与图表统一进入展示面板。
|
||||
|
||||
SCADA 演示时间轴按用户指定采用 FM 调频盘样式:在选定时间区间内,玻璃刻度窗中的时间刻度带横向移动,红色细指示线固定居中。半透明、背景柔化、边缘高光和轻微内阴影仅用于这块刻度窗,数字、刻度和指示线保持清晰,其他外围区域继续采用实体表面。时间定位红色使用独立语义变量,不表示危险告警;背景模糊不可用时回退到实体底色。时间轴按 5 分钟时间戳组织刻度,不读取项目监测周期;鼠标和触摸拨动过程保持连续无缝,选定值及起止时间仍量化到 5 分钟。时间轴只用于回看且不能触发数据处理,方向键也按 5 分钟移动,监测按钮另按项目周期推进后台处理。减少动态效果时关闭连续平滑位移。详细区间与回放语义见 [SCADA 外围系统时段回放与异常检测设计](../../next-tjwater-server/docs/scada-peripheral-replay-design.md)。
|
||||
SCADA 演示时间轴按用户指定采用 FM 调频盘样式:在选定时间区间内,玻璃刻度窗中的时间刻度带横向移动,红色细指示线固定居中,上下三角箭头与指示线严格同轴并指向刻度。半透明、背景柔化、边缘高光和轻微内阴影仅用于这块刻度窗,数字、刻度和指示线保持清晰,其他外围区域继续采用实体表面。时间定位红色使用独立语义变量,不表示危险告警;背景模糊不可用时回退到实体底色。时间轴按 5 分钟时间戳组织刻度,不读取项目监测周期;鼠标和触摸拨动过程保持连续无缝,松手后以约 220ms 的减速动画吸附到最近的 5 分钟刻度,选定值及起止时间仍量化到 5 分钟。时间轴只用于回看且不能触发数据处理,方向键按 5 分钟移动并沿用同一段减速动画,动画期间保留当前位置到目标位置所需的完整刻度;Home、End 与 Page 键直接定位,监测按钮另按项目周期推进后台处理。时间轴获得键盘焦点时不额外绘制焦点外圈,减少动态效果时取消吸附及键盘动画并直接对齐。自动播放时,以连续演示时钟平滑移动刻度并标明“播放时间”,手动浏览继续按 5 分钟对齐。自动监测按项目周期等待后端回执后推进;播放控制与实际处理状态分开。播放区固定显示“播放”“暂停”“重新开始”三个独立按钮,按状态启用;暂停后播放从当前位置继续。“重新开始”先停止旧轮次并准备一轮新的待播放回放,时间回到区间起点,用户再次点击播放或单步监测后才推进。单步监测、停止本轮和失败重试放在下一行;正在处理时间步时禁止重新开始,区间完成后通过重新开始准备下一轮。监测状态区分别显示项目周期、演示倍速与每步播放耗时,后台处理耗时另计。最新监测通知置于时间轴上方,待监测、处理中和各类结果统一固定为 160px,统一按监测时间、状态说明和底部操作三行排列,使用小尺寸方形状态图标;待机为中性灰、处理中为信息色、正常和恢复为轻量绿色,异常使用警示色。各状态的时间行、图标、主副标题和底部说明共用同一排版,未显示调查按钮时也保留操作槽位,状态切换不推动下方控件;首次新异常使用两轮圆环扩散和面板边框透明度强调,总计 2.6 秒,随后保留静态提示,同一异常不重复,减少动态效果时关闭。后台持久化的 SCADA 新告警进入系统通知中心,并通过系统通知在工作台顶部居中显示弹窗;带“查看调查”等操作入口的通知持续保留至手动关闭,忽略调用方的自动关闭时长。点击“查看调查”关闭外围面板并定位对应工况任务,保留通知;关闭外围面板仍能接收通知,首次加载项目不重放历史告警。通知与调查任务关联,恢复后从待办与告警中移除活动事项,已弹出的关联通知保留至手动关闭;切换项目或离开工作台时清理原项目弹窗。详细区间与回放语义见 [SCADA 外围系统时段回放与异常检测设计](../../next-tjwater-server/docs/scada-peripheral-replay-design.md)。
|
||||
|
||||
Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、边框、圆角、阴影、排版、间距及交互状态。该规则覆盖主对话面板、手机 Agent 面板、历史对话、主题新建与管理、模型菜单、权限与问题确认、Agent 回答的展开阅读面板,以及这些界面的子弹窗和提示。
|
||||
|
||||
@@ -52,8 +52,20 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
|
||||
- 危险操作使用明确的确认步骤和 `destructive` 按钮变体;失败保留输入与原内容。审批、提问与历史操作的业务规则由对应功能文档维护。
|
||||
- Agent 打开的既有地图或业务工具仍遵循其所属模块规范。`AgentPersona` 动画资源按专用设计文档维护,周边面板与控件遵循本节。
|
||||
|
||||
### 系统弹窗通知
|
||||
|
||||
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
|
||||
|
||||
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。通知采用普通文档流纵向排版,间距为 8px,高度随换行自适应;保留 Sonner 淡入淡出,禁用按绝对定位计算的位移变换,避免退出时重复叠加堆叠偏移。每条通知均可单独关闭;支持 320px 窄屏。普通信息与成功提示默认 3.2 秒后关闭,警告与错误默认手动关闭,加载通知由其所属操作更新或结束。带操作入口时始终提供手动关闭按钮。Dev 的“通知测试”提供五种状态、加载完成、报告与任务入口、长文本、连续六条通知和清除测试通知。
|
||||
|
||||
通知表面优先接收鼠标与滚轮,不将操作穿透给下方任务浮动条或地图;不提供滑动移除,关闭通过关闭按钮完成,通知外仍可操作地图。地图拖动、缩放引起的旧瓦片请求取消不视为数据源故障;首次正常就绪保持静默,故障恢复时才显示连接成功提示。同一编号的通知更新内容时保留原位置,不先关闭再重建;所属组件离开或编号变化时才清理。
|
||||
|
||||
## 2. Tailwind 定制方式
|
||||
|
||||
SCADA 回放控制使用一个有边框的连续区域:顶部集中显示演示区间和事件时间;中部将模式、日期与时间读数放在同一行,下接 FM 刻度窗,播放按钮组居中并限制最大宽度;底部集中放置单步监测、停止和重试入口,已处理与下次监测时间并排呈现,窄屏自然换行。保留播放、暂停和重新开始三个独立控制,“监测选定时刻”与“下一步并监测”并排,前者检测指针指向的精确时间,后者按项目周期推进;“回到开始时间”只定位到区间起点。监测已处理进度之前的历史时刻会建立新一轮回放,操作前说明原记录保留,避免使用后续状态影响历史判断。
|
||||
|
||||
SCADA 监测事件采用持续视觉警告,跟随关联事件至完成或取消;指标恢复后,仍在处理的事件继续显示。刷新页面时恢复进行中的事件,手动关闭以当前标签页的项目与事件编号记忆;新事件重新提示。采用1px细边框、轻量边缘光和4.8秒四角呼吸光。四角通过横向渐隐与纵向遮罩相乘,形成沿两条相邻边衰减的柔和光晕;宽度随视口限制在72至180px,高度在52至100px。默认“标准”强度,中央保持清晰与可操作。底部状态提示复用系统弹出通知的 `glass-transient` 亚克力透明材质、背景模糊、细边框与浮层阴影,避开时间轴;关闭按钮固定在提示右上角;手机将“查看调查”放在下一行,查看操作保留警告。减少动态效果时使用静态光,强制颜色模式使用系统边框。Dev面板的“调查测试”提供“柔和/标准/醒目”强度比较、“测试爆管告警效果”和“模拟事件处理结束”,均为本地视觉操作。
|
||||
|
||||
样式统一进入 [src/styles.css](../src/styles.css)。Tailwind 工具类负责布局、间距、尺寸、排版、响应式及交互状态;共享变量负责颜色和材质参数,共享变体负责重复的组件外观。
|
||||
|
||||
1. 先选组件,再选语义表面,最后调整布局与状态。常用差异通过组件的 `variant`、`size` 表达;局部差异通过 `className` 与 [cn](../src/shared/ui/cn.ts) 合并。
|
||||
@@ -178,8 +190,14 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
|
||||
|
||||
`WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。
|
||||
|
||||
带查看入口的系统弹出通知采用上下两区:上方排列状态图标、标题和正文,右上角预留关闭按钮;下方用细分隔线划出操作行,查看入口靠右并带跳转箭头,使用轻量按钮。无操作的通知不显示底部操作行,长正文自然换行,关闭与查看入口的点击区域互不重叠。
|
||||
|
||||
每个业务区最多一个醒目的主操作。按钮覆盖默认、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",
|
||||
@@ -59,6 +62,7 @@
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-resizable-panels": "^4.14.7",
|
||||
"react-rnd": "10.5.3",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "^2.5.0",
|
||||
"swr": "^2.4.2",
|
||||
|
||||
Generated
+145
@@ -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
|
||||
@@ -123,6 +129,9 @@ importers:
|
||||
react-resizable-panels:
|
||||
specifier: ^4.14.7
|
||||
version: 4.14.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react-rnd:
|
||||
specifier: 10.5.3
|
||||
version: 10.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
@@ -467,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'}
|
||||
@@ -822,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==}
|
||||
|
||||
@@ -3180,6 +3213,10 @@ packages:
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||
hasBin: true
|
||||
|
||||
lru-cache@10.4.3:
|
||||
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
|
||||
|
||||
@@ -3481,6 +3518,10 @@ packages:
|
||||
nwsapi@2.2.24:
|
||||
resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==}
|
||||
|
||||
object-assign@4.1.1:
|
||||
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
obug@2.1.4:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
@@ -3590,6 +3631,9 @@ packages:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
prop-types@15.8.1:
|
||||
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
|
||||
|
||||
property-information@7.2.0:
|
||||
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
|
||||
|
||||
@@ -3603,11 +3647,26 @@ packages:
|
||||
quickselect@3.0.0:
|
||||
resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==}
|
||||
|
||||
re-resizable@6.11.2:
|
||||
resolution: {integrity: sha512-2xI2P3OHs5qw7K0Ud1aLILK6MQxW50TcO+DetD9eIV58j84TqYeHoZcL9H4GXFXXIh7afhH8mv5iUCXII7OW7A==}
|
||||
peerDependencies:
|
||||
react: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.13.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
react-dom@19.2.7:
|
||||
resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==}
|
||||
peerDependencies:
|
||||
react: ^19.2.7
|
||||
|
||||
react-draggable@4.7.2:
|
||||
resolution: {integrity: sha512-pmqDx72EqhMGoJe/x42ZO2wJ9TIz7qJ5OPnFu6lVWWznEuJumlSjR+J0V4tNyDFhUo6dVvv56FeiTqN+arTgnw==}
|
||||
peerDependencies:
|
||||
react: '>= 16.3.0'
|
||||
react-dom: '>= 16.3.0'
|
||||
|
||||
react-is@16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
@@ -3637,6 +3696,12 @@ packages:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
react-rnd@10.5.3:
|
||||
resolution: {integrity: sha512-s/sIT3pGZnQ+57egijkTp9mizjIWrJz68Pq6yd+F/wniFY3IriML18dUXnQe/HP9uMiJ+9MAp44hljG99fZu6Q==}
|
||||
peerDependencies:
|
||||
react: '>=16.3.0'
|
||||
react-dom: '>=16.3.0'
|
||||
|
||||
react-style-singleton@2.2.3:
|
||||
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3937,6 +4002,9 @@ packages:
|
||||
tslib@2.3.0:
|
||||
resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==}
|
||||
|
||||
tslib@2.6.2:
|
||||
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@@ -4529,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
|
||||
|
||||
@@ -4822,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': {}
|
||||
@@ -7088,6 +7197,10 @@ snapshots:
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
dependencies:
|
||||
js-tokens: 4.0.0
|
||||
|
||||
lru-cache@10.4.3: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
@@ -7654,6 +7767,8 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.24: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
oniguruma-parser@0.12.2: {}
|
||||
@@ -7760,6 +7875,12 @@ snapshots:
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
prop-types@15.8.1:
|
||||
dependencies:
|
||||
loose-envify: 1.4.0
|
||||
object-assign: 4.1.1
|
||||
react-is: 16.13.1
|
||||
|
||||
property-information@7.2.0: {}
|
||||
|
||||
protocol-buffers-schema@3.6.1: {}
|
||||
@@ -7768,11 +7889,25 @@ snapshots:
|
||||
|
||||
quickselect@3.0.0: {}
|
||||
|
||||
re-resizable@6.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
react-dom@19.2.7(react@19.2.7):
|
||||
dependencies:
|
||||
react: 19.2.7
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-draggable@4.7.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
clsx: 2.1.1
|
||||
prop-types: 15.8.1
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
react-is@16.13.1: {}
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.7):
|
||||
@@ -7799,6 +7934,14 @@ snapshots:
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
react-rnd@10.5.3(react-dom@19.2.7(react@19.2.7))(react@19.2.7):
|
||||
dependencies:
|
||||
re-resizable: 6.11.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
react-draggable: 4.7.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
tslib: 2.6.2
|
||||
|
||||
react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.7):
|
||||
dependencies:
|
||||
get-nonce: 1.0.1
|
||||
@@ -8148,6 +8291,8 @@ snapshots:
|
||||
|
||||
tslib@2.3.0: {}
|
||||
|
||||
tslib@2.6.2: {}
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tw-animate-css@1.4.0: {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { withAnswerDisplayTimes } from "./answer-display-time";
|
||||
|
||||
describe("answer presentation time zone", () => {
|
||||
it("formats report text, tables, evidence and axes while preserving IDs and source samples", () => {
|
||||
const spec: Spec = { root: "root", elements: {
|
||||
root: { type: "Result", props: { title: "诊断", summary: "目标2026-07-16T00:00:00Z,当前08:00(UTC+8)" }, children: [] },
|
||||
table: { type: "Table", props: { title: "读数", columns: ["时间"], rows: [["2026-07-16T00:00:00Z"]] }, children: [] },
|
||||
evidence: { type: "Evidence", props: { observed_at: "2026-07-16T00:00:00Z", evidence_id: "2026-07-16T00:00:00Z" }, children: [] },
|
||||
chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00 UTC"],
|
||||
series: [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }] }, children: [] }
|
||||
} };
|
||||
const original = JSON.stringify(spec);
|
||||
const result = withAnswerDisplayTimes(spec);
|
||||
expect(result.elements.root.props.summary).toBe("目标2026/7/16 08:00:00(UTC+8),当前08:00(UTC+8)");
|
||||
expect(result.elements.table.props.rows).toEqual([["2026/7/16 08:00:00(UTC+8)"]]);
|
||||
expect(result.elements.evidence.props.observed_at).toBe("2026/7/16 08:00:00(UTC+8)");
|
||||
expect(result.elements.evidence.props.evidence_id).toBe("2026-07-16T00:00:00Z");
|
||||
expect(result.elements.chart.props.x_axis_name).toBe("时间");
|
||||
expect(result.elements.chart.props.x_data).toEqual(["08:00"]);
|
||||
expect(JSON.stringify(spec)).toBe(original);
|
||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||
});
|
||||
|
||||
it("converts bare times when their table header or chart axis explicitly declares UTC", () => {
|
||||
const spec: Spec = { root: "chart", elements: {
|
||||
chart: { type: "Chart", props: { x_axis_name: "时间(UTC0)", x_data: ["00:00", "2026-12-31 17:00", "08:00+08:00"],
|
||||
series: [{ name: "压力", data: [20, 21, 22] }] }, children: [] },
|
||||
table: { type: "Table", props: { columns: ["观测时间(UTC)", "测点编号"],
|
||||
rows: [["2026-12-31 17:00", "00:00"]] }, children: [] }
|
||||
} };
|
||||
const result = withAnswerDisplayTimes(spec);
|
||||
expect(result.elements.chart.props.x_data).toEqual(["08:00", "2027/1/1 01:00:00", "08:00+08:00"]);
|
||||
expect(result.elements.table.props.columns).toEqual(["观测时间(UTC+8)", "测点编号"]);
|
||||
expect(result.elements.table.props.rows).toEqual([["2027/1/1 01:00:00(UTC+8)", "00:00"]]);
|
||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||
});
|
||||
it("removes chart timezone wording while preserving the window, data and sample instants", () => {
|
||||
const samples = ["2026-07-15T23:30:00Z", "2026-07-15T23:45:00Z"];
|
||||
const spec: Spec = { root: "chart", elements: {
|
||||
chart: { type: "Chart", props: {
|
||||
title: "回放流量:487434(m³/h,上海时间 07-16 04:45–07:45)",
|
||||
x_axis_name: "上海时间", x_data: ["07:30", "07:45"],
|
||||
series: [{ name: "当前回放(2026-07-16,UTC+8)", data: [420, 380], sample_times: samples }]
|
||||
}, children: [] }
|
||||
} };
|
||||
const result = withAnswerDisplayTimes(spec);
|
||||
expect(result.elements.chart.props.title).toBe("回放流量:487434(m³/h,07-16 04:45–07:45)");
|
||||
expect(result.elements.chart.props.x_axis_name).toBe("时间");
|
||||
expect(result.elements.chart.props.x_data).toEqual(["07:30", "07:45"]);
|
||||
expect(result.elements.chart.props.series).toEqual([
|
||||
{ name: "当前回放(2026-07-16)", data: [420, 380], sample_times: samples }
|
||||
]);
|
||||
expect(withAnswerDisplayTimes(result)).toEqual(result);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { formatDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
||||
|
||||
const textFields = new Set(["title", "description", "summary", "text", "label", "value", "source", "observed_at"]);
|
||||
const utcLabel = /UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/;
|
||||
const timeAxis = (value: string) => formatDisplayText(value).replace(/UTC(?:\s*\+?0(?::00)?)?(?![\w+:-])/g, "UTC+8");
|
||||
const bareTime = /^(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
|
||||
|
||||
function displayCell(value: string, heading: string): string {
|
||||
return formatDisplayText(utcLabel.test(heading) && bareTime.test(value) ? `${value} UTC` : value);
|
||||
}
|
||||
|
||||
/** Render display fields in UTC8 while keeping the stored spec and identifiers intact. */
|
||||
export function withAnswerDisplayTimes(spec: Spec): Spec {
|
||||
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => {
|
||||
const props = { ...node.props };
|
||||
for (const key of textFields) {
|
||||
if (typeof props[key] === "string") props[key] = formatDisplayText(props[key]);
|
||||
}
|
||||
if (node.type === "Table") {
|
||||
const columns = props.columns as string[];
|
||||
props.rows = (props.rows as string[][]).map((row) => row.map((cell, index) => displayCell(cell, columns[index] ?? "")));
|
||||
props.columns = columns.map(timeAxis);
|
||||
}
|
||||
if (node.type === "Chart") {
|
||||
Object.assign(props, withChartDisplayTimes(props as Parameters<typeof withChartDisplayTimes>[0]));
|
||||
}
|
||||
return [id, { ...node, props }];
|
||||
})) };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ export type AnswerUiMapTarget = {
|
||||
};
|
||||
export type AnswerUiInteractions = {
|
||||
disabled: boolean;
|
||||
chartPresentation?: "tile";
|
||||
onLocate?: (target: AnswerUiMapTarget) => Promise<unknown> | void;
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
evidenceFilter?: string;
|
||||
|
||||
@@ -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,
|
||||
@@ -28,6 +28,7 @@ import { AnswerContentNav } from "./answer-content-nav";
|
||||
import { getAnswerTaskLayout } from "./answer-task-layout";
|
||||
import { AnswerViewStateProvider, useAnswerBlockState, useAnswerNodeState, withAnswerNodeIdentity, withAnswerNodeIds } from "./answer-view-state-provider";
|
||||
import { type AnswerEvidenceState, type AnswerViewState, type AnswerViewStateChange } from "./answer-view-state";
|
||||
import { withAnswerDisplayTimes } from "./answer-display-time";
|
||||
|
||||
const { registry: baseRegistry } = defineRegistry(answerUiCatalog, {
|
||||
actions: {},
|
||||
@@ -102,14 +103,17 @@ 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(props.block.spec) }} />
|
||||
<AnswerUiBody {...props} block={{ ...props.block, spec: displayedSpec }} />
|
||||
</AnswerViewStateProvider>;
|
||||
}
|
||||
|
||||
function AnswerUiBody({
|
||||
block,
|
||||
disabled = false,
|
||||
chartPresentation,
|
||||
onLocate,
|
||||
onSubmit,
|
||||
submitMode = "send"
|
||||
@@ -130,10 +134,10 @@ function AnswerUiBody({
|
||||
const isGroup = (ids: string[]) => hasTaskLayout && ids.length > 1 && !ids.every((id) => block.spec.elements[id].type === "Section");
|
||||
const 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 }}>
|
||||
<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}
|
||||
|
||||
@@ -10,6 +10,10 @@ import {
|
||||
const NodeContext = createContext<string | undefined>(undefined);
|
||||
const ChangeContext = createContext<AnswerViewStateChange | undefined>(undefined);
|
||||
|
||||
export function AnswerNodeStateScope({ id, children }: { id: string; children: ReactNode }) {
|
||||
return <NodeContext.Provider value={id}>{children}</NodeContext.Provider>;
|
||||
}
|
||||
|
||||
/** The renderer clones props; local element metadata survives that adaptation. Never modify model props. */
|
||||
export function withAnswerNodeIds(spec: Spec): Spec {
|
||||
return { ...spec, elements: Object.fromEntries(Object.entries(spec.elements).map(([id, node]) => [id, { ...node, __answerNodeId: id }])) };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
|
||||
import { answerChartTableData, answerViewStateDirtyPaths, migrateAnswerViewState, normalizeAnswerViewState } from "./answer-view-state";
|
||||
|
||||
function spec(type: string, props: Record<string, unknown>): Spec {
|
||||
return { root: "node", elements: { node: { type, props, children: [] } } };
|
||||
@@ -10,6 +10,15 @@ const table = (rows = [["a", "1"], ["b", "2"]], columns = ["对象", "读数"])
|
||||
const form = (fields = [{ name: "record", kind: "text" }]) => spec("FollowUpForm", { fields });
|
||||
|
||||
describe("answer view state compatibility", () => {
|
||||
it("uses the same UTC8 sample display in chart tables while preserving stored samples", () => {
|
||||
const series = [{ name: "压力", data: [20], sample_times: ["2026-07-16T00:00:00Z"] }];
|
||||
const current = spec("Chart", { chart_type: "line", x_data: ["08:00"],
|
||||
series });
|
||||
const state = normalizeAnswerViewState(current).nodes.node;
|
||||
if (state.type !== "Chart") throw new Error("Expected chart state");
|
||||
expect(answerChartTableData(current.elements.node, state).rows[0][1]).toBe("20 (2026-07-16 08:00:00+08:00)");
|
||||
expect(series[0].sample_times[0]).toBe("2026-07-16T00:00:00Z");
|
||||
});
|
||||
it("ignores legacy row selection, retains search, and clears sort when columns change", () => {
|
||||
const before = table();
|
||||
const state = normalizeAnswerViewState(before, { nodes: { node: { type: "Table", query: "a", sort: { column: 1, descending: true }, selection: [0] } } });
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Spec } from "@json-render/core";
|
||||
import { chartSampleTime } from "./chart-presentation";
|
||||
|
||||
// `selection` is an empty legacy v1 wire field, never an interactive row selection.
|
||||
export type AnswerTableState = { type: "Table"; query: string; sort: { column: number; descending: boolean } | null; selection: number[] };
|
||||
@@ -30,7 +31,7 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
|
||||
const start = index(proposed[0], labels.length);
|
||||
const end = Math.max(start, index(proposed[1], labels.length, labels.length - 1));
|
||||
const hidden = indices(state.hidden, series.length);
|
||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type as "line" | "bar",
|
||||
const chart: AnswerChartState = { type: "Chart", view: ["line", "bar", "table"].includes(String(state.view)) ? state.view as AnswerChartState["view"] : node.props.chart_type === "bar" ? "bar" : "line",
|
||||
hidden: hidden.length < series.length ? hidden : [], range: [start, end], point: index(state.point, labels.length), inspecting: state.inspecting === true };
|
||||
const data = answerChartTableData(node, chart);
|
||||
const table = normalizeAnswerNodeState({ type: "Table", props: data, children: [] }, { ...record(state.table), type: "Table" }) as AnswerTableState;
|
||||
@@ -53,9 +54,12 @@ export function normalizeAnswerNodeState(node: Node, input?: unknown): AnswerUiN
|
||||
}
|
||||
|
||||
export function answerChartTableData(node: Node, state: AnswerChartState) {
|
||||
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: number[] }>;
|
||||
const labels = node.props.x_data as string[], series = node.props.series as Array<{ name: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
|
||||
const visible = series.filter((_, index) => !state.hidden.includes(index));
|
||||
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data }) => String(data[state.range[0] + index]))]) };
|
||||
return { columns: ["时间或类别", ...visible.map(({ name }) => name)], rows: labels.slice(state.range[0], state.range[1] + 1).map((label, index) => [label, ...visible.map(({ data, sample_times }) => {
|
||||
const offset = state.range[0] + index, time = chartSampleTime(sample_times?.[offset]);
|
||||
return `${data[offset] == null ? "缺测" : data[offset]}${time ? ` (${time})` : ""}`;
|
||||
})]) };
|
||||
}
|
||||
|
||||
export function normalizeAnswerViewState(spec: Spec, input?: unknown): AnswerViewState {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineCatalog } from "@json-render/core";
|
||||
import { schema } from "@json-render/react/schema";
|
||||
import { z } from "zod";
|
||||
import { createChartSampleTimeSchema } from "../presentation/command-schema.ts";
|
||||
|
||||
const shortText = z.string().max(200);
|
||||
const text = z.string().max(4000);
|
||||
@@ -63,12 +64,16 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
props: z
|
||||
.object({
|
||||
title: shortText,
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(shortText).min(1).max(100),
|
||||
series: z
|
||||
.array(
|
||||
z
|
||||
.object({ name: shortText, data: z.array(z.number().finite()).min(1).max(100) })
|
||||
.object({ name: shortText, type: z.enum(["line", "bar", "scatter"]).optional(),
|
||||
data: z.array(z.number().finite().nullable()).min(1).max(100),
|
||||
sample_times: z.array(createChartSampleTimeSchema().nullable()).min(1).max(100).optional() })
|
||||
.strict()
|
||||
)
|
||||
.min(1)
|
||||
@@ -77,7 +82,7 @@ export const answerUiCatalog = defineCatalog(schema, {
|
||||
})
|
||||
.strict(),
|
||||
description:
|
||||
"展示查询或用户提供的真实数据;series 必须与 x_data 对齐。浏览者可切换图形与数据表。"
|
||||
"展示真实数据。chart_type=line 为普通折线,bar 为柱状图,time_comparison 为当前曲线与历史散点的同期对比。同期对比的 x_data 为 UTC+8 的 HH:mm 或 HH:mm:ss 共享时刻槽,series.type 使用 line/scatter,每组 sample_times 与 data 等长,保留真实带时区日期,缺测均填 null;历史点放入对应时刻槽。浏览者可切换图形与数据表。"
|
||||
},
|
||||
Evidence: {
|
||||
props: z
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
|
||||
describe("monitoring chart comparison", () => {
|
||||
it("displays UTC samples in UTC8 and keeps explicit UTC8 instants stable across midnight", () => {
|
||||
expect(chartSampleTime("2026-12-31T16:00:00Z")).toBe("2027-01-01 00:00:00+08:00");
|
||||
expect(chartSampleTime("2027-01-01T00:00:00+08:00")).toBe("2027-01-01 00:00:00+08:00");
|
||||
expect(chartSampleTime(null)).toBe("");
|
||||
});
|
||||
it("frames current pressure and all historical points with a nonzero lower bound", () => {
|
||||
const series = [{ name: "当前", data: [34, 32, 28] }, { name: "前一日", data: [null, 36, 37] }];
|
||||
const bounds = chartValueBounds(series, 0, 2);
|
||||
expect(bounds.min).toBeGreaterThan(0);
|
||||
expect(bounds.min).toBeLessThan(28);
|
||||
expect(bounds.max).toBeGreaterThan(37);
|
||||
expect(chartValueBounds([{ name: "无数据", data: [null] }], 0, 0)).toEqual({});
|
||||
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1)).toEqual({ min: -0.08, max: 0.08 });
|
||||
});
|
||||
|
||||
it("uses aligned integer boundaries and steps without rounding monitoring readings", () => {
|
||||
const series = [{ name: "流量", data: [2494.274, 4742.033] }];
|
||||
const original = structuredClone(series);
|
||||
expect(chartValueBounds(series, 0, 1, true)).toEqual({ min: 2000, max: 5000, interval: 1000 });
|
||||
expect(series).toEqual(original);
|
||||
});
|
||||
|
||||
it("keeps a narrow pressure range near its readings instead of forcing a broad zero-based axis", () => {
|
||||
expect(chartValueBounds([{ name: "压力", data: [28.125, 28.875] }], 0, 1, true))
|
||||
.toEqual({ min: 28, max: 29, interval: 1 });
|
||||
expect(chartValueBounds([{ name: "压力", data: [9999, 28.14, 29.03, -9999] }], 1, 2, true))
|
||||
.toEqual({ min: 28, max: 30, interval: 1 });
|
||||
});
|
||||
|
||||
it("preserves negative flow and provides distinct integer ticks for zero flow", () => {
|
||||
expect(chartValueBounds([{ name: "反向流量", data: [-108.8, 12.125] }], 0, 1, true))
|
||||
.toEqual({ min: -150, max: 50, interval: 50 });
|
||||
expect(chartValueBounds([{ name: "零流量", data: [0, 0] }], 0, 1, true))
|
||||
.toEqual({ min: -1, max: 1, interval: 1 });
|
||||
});
|
||||
|
||||
it("limits integer ticks to confirmed monitoring names and units while preserving generic small ranges", () => {
|
||||
for (const name of ["压力(m)", "压力 ( m )", "压力水头(m)", "水头(m)", "流量(m³/h)", "流量 ( m3 / h )", "管流(m³/h)"])
|
||||
expect(chartUsesIntegerTicks(name)).toBe(true);
|
||||
for (const name of [undefined, "压力(MPa)", "流量(L/s)", "比率", ""])
|
||||
expect(chartUsesIntegerTicks(name)).toBe(false);
|
||||
const series = [{ name: "压力(MPa)", data: [0.2131, 0.2149] }];
|
||||
const bounds = chartValueBounds(series, 0, 1, chartUsesIntegerTicks("压力(MPa)"));
|
||||
expect(bounds.min).toBeGreaterThan(0.21);
|
||||
expect(bounds.max).toBeLessThan(0.22);
|
||||
expect(bounds).not.toHaveProperty("interval");
|
||||
});
|
||||
|
||||
it("preserves the original historical date in tooltip and marks missing values explicitly", () => {
|
||||
const series = [{ name: "当前", data: [28], sample_times: ["2026-06-14T12:00:00+08:00"] },
|
||||
{ name: "前一日", type: "scatter" as const, data: [37], sample_times: ["2026-06-13T12:00:00+08:00"] },
|
||||
{ name: "缺测日", data: [null], sample_times: [null] }];
|
||||
const tooltip = chartPointTooltip("12:00", series, 0);
|
||||
expect(tooltip).toContain("当前: 28\n采样时间 2026-06-14 12:00:00+08:00");
|
||||
expect(tooltip).toContain("前一日: 37\n采样时间 2026-06-13 12:00:00+08:00");
|
||||
expect(tooltip).not.toContain("缺测日");
|
||||
expect(chartValueLabel(null)).toBe("缺测");
|
||||
expect(chartValueLabel(0)).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { SafeChartSeries } from "../chart-data";
|
||||
import { formatDisplayTimestamp } from "@/shared/utils/date-time";
|
||||
|
||||
export function chartValueLabel(value: number | null | undefined): string {
|
||||
return value == null ? "缺测" : new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 2 }).format(value);
|
||||
}
|
||||
|
||||
export function chartSampleTime(time: string | null | undefined): string {
|
||||
return time ? formatDisplayTimestamp(time) : "";
|
||||
}
|
||||
|
||||
/** Only confirmed monitoring units opt into integer ticks; small generic units retain their scale. */
|
||||
export function chartUsesIntegerTicks(axisName: string | undefined): boolean {
|
||||
const normalized = axisName?.replace(/\s+/g, "").replace(/(/g, "(").replace(/)/g, ")").replace(/³/g, "3").toLowerCase();
|
||||
return /^(?:(?:压力|压力水头|水头)\(m\)|(?:流量|管流)\(m3\/h\))$/.test(normalized ?? "");
|
||||
}
|
||||
|
||||
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number, integerTicks = false) {
|
||||
const values = series.flatMap((item) => item.data.slice(start, end + 1))
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
if (!values.length) return {};
|
||||
const min = Math.min(...values), max = Math.max(...values);
|
||||
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
|
||||
const bounds = { min: min - padding, max: max + padding };
|
||||
if (!integerTicks) return bounds;
|
||||
const target = Math.max(1, (bounds.max - bounds.min) / 4);
|
||||
const magnitude = 10 ** Math.floor(Math.log10(target));
|
||||
const interval = ([1, 2, 5, 10].find((step) => step * magnitude >= target) ?? 10) * magnitude;
|
||||
return { min: Math.floor(bounds.min / interval) * interval, max: Math.ceil(bounds.max / interval) * interval, interval };
|
||||
}
|
||||
|
||||
/** The axis label is a projected comparison time; the sample timestamp remains authoritative. */
|
||||
export function chartPointTooltip(label: string, series: SafeChartSeries[], index: number): string {
|
||||
const rows = series.filter((item) => item.data[index] != null).map((item) => {
|
||||
const time = chartSampleTime(item.sample_times?.[index]);
|
||||
return `${item.name}: ${chartValueLabel(item.data[index])}${time ? `\n采样时间 ${time}` : ""}`;
|
||||
});
|
||||
return [label, ...rows].join("\n");
|
||||
}
|
||||
@@ -11,6 +11,19 @@ const block = {
|
||||
}
|
||||
};
|
||||
describe("untrusted answer components", () => {
|
||||
it("accepts a line with seven sparse history series while enforcing sample timestamp alignment", () => {
|
||||
const x_data = Array.from({ length: 13 }, (_, index) => String(index));
|
||||
const series = [{ name: "当前", type: "line", data: x_data.map(() => 31) },
|
||||
...Array.from({ length: 7 }, (_, index) => ({ name: `前${index + 1}日`, type: "scatter",
|
||||
data: [...Array(10).fill(null), 33, 34, 35],
|
||||
sample_times: [...Array(10).fill(null), ...[30, 45, 59].map((minute) => `2026-06-${String(13 - index).padStart(2, "0")}T11:${minute}:00+08:00`)] }))];
|
||||
const props = { title: "压力(m)", source: "合成样例", chart_type: "line", x_data, series };
|
||||
const make = (value: unknown) => ({ ...block, spec: { root: "chart", elements: { chart: { type: "Chart", props: value, children: [] } } } });
|
||||
expect(parseAnswerUiBlock(make(props))).not.toBeNull();
|
||||
expect(parseAnswerUiBlock(make({ ...props, series: [...series, series[0]] }))).toBeNull();
|
||||
expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: [null] }] }))).toBeNull();
|
||||
expect(parseAnswerUiBlock(make({ ...props, series: [{ ...series[1], sample_times: Array(13).fill("2026-06-13T12:00:00+08:00") }] }))).toBeNull();
|
||||
});
|
||||
it("keeps explicit UI phases and rejects invalid phase values", () => {
|
||||
expect(parseAnswerUiBlock(block)?.phase).toBeUndefined();
|
||||
expect(parseAnswerUiBlock({ ...block, phase: "process" })?.phase).toBe("process");
|
||||
|
||||
@@ -56,8 +56,10 @@ export function parseAnswerUiBlock(value: unknown): AnswerUiBlock | null {
|
||||
if (!definition?.props.safeParse(node.props).success) return null;
|
||||
if (!["Stack", "Section"].includes(node.type) && node.children.length) return null;
|
||||
if (node.type === "Chart") {
|
||||
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[] }> };
|
||||
if (data.series.some((series) => series.data.length !== data.x_data.length)) return null;
|
||||
const data = node.props as { x_data: unknown[]; series: Array<{ data: unknown[]; sample_times?: Array<string | null> }> };
|
||||
if (data.series.some((series) => series.data.length !== data.x_data.length ||
|
||||
series.sample_times && (series.sample_times.length !== data.x_data.length ||
|
||||
series.sample_times.some((time, index) => time !== null && series.data[index] === null)))) return null;
|
||||
}
|
||||
if (node.type === "Table") {
|
||||
const data = node.props as { columns: unknown[]; rows: unknown[][] };
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { memo, useContext, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion, useAnimationControls, useReducedMotion } from "motion/react";
|
||||
import ReactECharts from "echarts-for-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";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/shared/ui/collapsible";
|
||||
import { InteractiveDataTable } from "./interactive-data-table";
|
||||
import { ResponsiveChart } from "./responsive-chart";
|
||||
import { agentEase } from "../components/agent-motion";
|
||||
import type { SafeChartSeries } from "../chart-data";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { AnswerInteractionContext } from "./answer-ui-interaction-context";
|
||||
import { chartPointTooltip, chartSampleTime, chartUsesIntegerTicks, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
|
||||
export type AnswerChartProps = {
|
||||
title: string;
|
||||
chart_type: "line" | "bar";
|
||||
chart_type: "line" | "bar" | "time_comparison";
|
||||
x_data: string[];
|
||||
series: Array<{ name: string; data: number[] }>;
|
||||
series: SafeChartSeries[];
|
||||
x_axis_name?: string;
|
||||
y_axis_name?: string;
|
||||
source: string;
|
||||
};
|
||||
import { useAnswerNodeState } from "./answer-view-state-provider";
|
||||
import { AnswerNodeStateScope, AnswerViewStateProvider, useAnswerNodeState } from "./answer-view-state-provider";
|
||||
import type { AnswerChartState } from "./answer-view-state";
|
||||
|
||||
const colors = ["#2563eb", "#059669", "#d97706", "#7c3aed", "#0891b2", "#e11d48", "#475569", "#a16207"];
|
||||
@@ -29,12 +36,36 @@ function PointSelect({ label, value, labels, onChange }: {
|
||||
<SelectContent>{labels.map((text, index) => <SelectItem key={index} value={String(index)}>{text}</SelectItem>)}</SelectContent>
|
||||
</Select>;
|
||||
}
|
||||
export function InteractiveChart(props: AnswerChartProps) {
|
||||
type ChartPresentation = { tile?: boolean; expanded?: boolean; plotHeight?: number; embedded?: boolean; xAxisName?: string; yAxisName?: string };
|
||||
const standaloneRegistry = {};
|
||||
|
||||
/** Legacy chart envelopes use the same controls and chart engine as structured answers. */
|
||||
export function StandaloneInteractiveChart(props: AnswerChartProps & ChartPresentation) {
|
||||
const spec = { root: "chart", elements: { chart: { type: "Chart", props: {
|
||||
title: props.title, chart_type: props.chart_type, x_data: props.x_data, series: props.series, source: props.source
|
||||
}, children: [] } } };
|
||||
return <AnswerViewStateProvider spec={spec} registry={standaloneRegistry}><AnswerNodeStateScope id="chart"><InteractiveChart {...props} /></AnswerNodeStateScope></AnswerViewStateProvider>;
|
||||
}
|
||||
|
||||
export const InteractiveChart = memo(function InteractiveChartView(props: AnswerChartProps & ChartPresentation) {
|
||||
const { chartPresentation } = useContext(AnswerInteractionContext);
|
||||
const tile = props.tile ?? chartPresentation === "tile";
|
||||
const fitted = Boolean(props.expanded || tile);
|
||||
const [{ view, hidden, range, point, inspecting, table }, patch, migrationNotice] = useAnswerNodeState<AnswerChartState>("Chart", props);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const pointer = useRef(false);
|
||||
const plot = useRef<HTMLDivElement>(null);
|
||||
const [animateInteraction, setAnimateInteraction] = useState(false);
|
||||
const [plotWidth, setPlotWidth] = useState(360);
|
||||
useLayoutEffect(() => {
|
||||
const element = plot.current;
|
||||
if (fitted || !element || typeof ResizeObserver === "undefined") return;
|
||||
const resize = () => setPlotWidth(element.clientWidth);
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [view, fitted]);
|
||||
const contentMotion = useAnimationControls();
|
||||
const pendingTransition = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
@@ -47,7 +78,7 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
}, [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 });
|
||||
@@ -57,120 +88,79 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
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) => {
|
||||
const values = series.data.slice(range[0], range[1] + 1);
|
||||
return { name: series.name, first: values[0], last: values.at(-1)!, min: Math.min(...values), max: Math.max(...values) };
|
||||
const values = series.data.slice(range[0], range[1] + 1).filter((value): value is number => value !== null);
|
||||
return { name: series.name, first: values[0], last: values.at(-1),
|
||||
min: values.length ? Math.min(...values) : null, max: values.length ? Math.max(...values) : null };
|
||||
});
|
||||
const option = (type: "line" | "bar") => ({
|
||||
animation: animateInteraction && !reducedMotion,
|
||||
animationDuration: 180,
|
||||
animationDurationUpdate: 180,
|
||||
color: colors.filter((_, index) => !hidden.includes(index)),
|
||||
aria: { enabled: true },
|
||||
tooltip: { trigger: "axis", renderMode: "richText" },
|
||||
grid: { top: 16, right: 12, bottom: 38, left: 12, containLabel: true },
|
||||
xAxis: { type: "category", data: labels, axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: true } },
|
||||
yAxis: { type: "value", axisLabel: { fontSize: 11, color: "#64748b" }, splitLine: { lineStyle: { color: "#e2e8f0" } } },
|
||||
series: visible.map((series) => ({ name: series.name, type, data: series.data.slice(range[0], range[1] + 1),
|
||||
showSymbol: true, symbolSize: 7, emphasis: { focus: "series" } }))
|
||||
});
|
||||
return <Collapsible asChild><Card className="answer-ui-chart overflow-hidden" role="group" aria-label={props.title}
|
||||
onPointerDownCapture={() => { pointer.current = true; }}
|
||||
onKeyDownCapture={() => {
|
||||
pointer.current = false;
|
||||
setAnimateInteraction(false);
|
||||
contentMotion.stop();
|
||||
contentMotion.set({ opacity: 1, y: 0 });
|
||||
}}>
|
||||
<CardHeader className="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.title}</CardTitle>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent className="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}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className="relative mb-3 grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none">
|
||||
<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} />折线</TabsTrigger>
|
||||
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="answer-ui-chart-series mb-2 flex flex-wrap gap-2" aria-label="显示的数据序列">
|
||||
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
|
||||
className="h-8 gap-1.5 text-[11px]" 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 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} />{series.name}
|
||||
</Button>)}
|
||||
</div>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
|
||||
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
|
||||
<span className="text-xs text-muted-foreground">至</span>
|
||||
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
|
||||
onChange={(end) => setRange([Math.min(range[0], end), end])} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
|
||||
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
|
||||
</CollapsibleContent>
|
||||
<motion.div data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type}>
|
||||
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
|
||||
className="rounded-md focus-visible:outline-2 focus-visible:outline-ring"
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
|
||||
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
|
||||
patch({ point: next, inspecting: true });
|
||||
}
|
||||
if (event.key === "Escape") setInspecting(false);
|
||||
}}>
|
||||
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
|
||||
<ReactECharts option={option(type)} opts={{ renderer: "svg" }} notMerge style={{ height: 220 }}
|
||||
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
|
||||
if (event.componentType === "series" && typeof event.dataIndex === "number") {
|
||||
patch({ point: range[0] + event.dataIndex, inspecting: true });
|
||||
}
|
||||
} }} />
|
||||
</div>
|
||||
</TabsContent>)}
|
||||
<TabsContent value="table" forceMount style={view === "table" ? undefined : { display: "none" }}>
|
||||
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
|
||||
rows={labels.map((label, index) => [label, ...visible.map((series) => String(series.data[range[0] + index]))])}
|
||||
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
</Tabs>
|
||||
{view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
|
||||
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
|
||||
<span className="tabular-nums">{item.first} → {item.last}</span>
|
||||
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</span>
|
||||
</div>)}
|
||||
</div>
|
||||
</div> : null}
|
||||
{view !== "table" && inspecting ? <motion.section aria-label="数据点详情" className="rounded-lg border border-border p-2.5"
|
||||
const composite = props.series.some((series) => series.type === "scatter");
|
||||
const categoryColors = visible.length === 1;
|
||||
const horizontalBars = props.x_data.some((label) => label.length > 10);
|
||||
const numberLabel = chartValueLabel;
|
||||
const xAxisName = props.xAxisName ?? props.x_axis_name;
|
||||
const yAxisName = props.yAxisName ?? props.y_axis_name;
|
||||
// Keep tooltip functions and options stable across sizing, focus and table-only updates.
|
||||
const options = useMemo(() => {
|
||||
const option = (type: "line" | "bar") => {
|
||||
const horizontal = type === "bar" && horizontalBars;
|
||||
const horizontalAxisName = horizontal ? yAxisName : xAxisName;
|
||||
const verticalAxisName = horizontal ? xAxisName : yAxisName;
|
||||
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||
...(tile ? { margin: 4, lineHeight: 12 } : {}),
|
||||
...(horizontal ? { width: fitted && !tile ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: tile ? 12 : 16 } : {}) },
|
||||
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
|
||||
const valueAxis = { type: "value", scale: !includesBars,
|
||||
...(!includesBars ? chartValueBounds(visible, rangeStart, rangeEnd, chartUsesIntegerTicks(yAxisName)) : {}),
|
||||
...(chartUsesIntegerTicks(yAxisName) ? { minInterval: 1 } : {}),
|
||||
axisLabel: { fontSize: tile ? 10 : 11, color: "#64748b", formatter: numberLabel,
|
||||
...(tile ? { margin: 4, lineHeight: 12 } : {}) },
|
||||
splitLine: { lineStyle: { color: "#e2e8f0" } } };
|
||||
return {
|
||||
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
|
||||
color: colors.filter((_, index) => !hiddenSeries.has(index)), aria: { enabled: true },
|
||||
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
|
||||
...(composite ? { formatter: (params: unknown) => {
|
||||
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
|
||||
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
|
||||
const text = typeof first?.dataIndex === "number"
|
||||
? chartPointTooltip(labels[first.dataIndex], focused, rangeStart + first.dataIndex) : "";
|
||||
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
|
||||
// readable and never interprets model-provided labels as HTML.
|
||||
const content = document.createElement("div");
|
||||
content.textContent = text;
|
||||
content.style.whiteSpace = "pre-line";
|
||||
return content;
|
||||
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
|
||||
grid: { top: tile ? verticalAxisName ? 16 : 10 : 28, right: 24,
|
||||
bottom: tile ? horizontalAxisName ? 24 : 16 : xAxisName ? 48 : 28, left: 12, containLabel: true },
|
||||
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontalAxisName,
|
||||
nameLocation: "middle", nameGap: tile ? 16 : 30,
|
||||
...(tile ? { nameTextStyle: { fontSize: 10, lineHeight: 12 } } : {}) },
|
||||
yAxis: { ...(horizontal ? { ...categoryAxis, inverse: true } : valueAxis), name: verticalAxisName,
|
||||
...(tile ? { nameGap: 4, nameTextStyle: { fontSize: 10, lineHeight: 12, align: "left" } } : {}) },
|
||||
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
|
||||
data: series.data.slice(rangeStart, rangeEnd + 1).map((value, index) => type === "bar" && categoryColors
|
||||
? { value, itemStyle: { color: colors[(rangeStart + index) % colors.length] } } : value),
|
||||
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
|
||||
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
|
||||
};
|
||||
};
|
||||
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">
|
||||
@@ -183,13 +173,145 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
<dl className="my-2 space-y-1.5 text-xs">
|
||||
{visible.map((series, index) => <div key={index} className="flex items-baseline justify-between gap-3">
|
||||
<dt className="min-w-0 text-muted-foreground">{series.name}</dt>
|
||||
<dd className="shrink-0 font-medium tabular-nums">{series.data[pointIndex]}</dd>
|
||||
<dd className="min-w-0 text-right font-medium tabular-nums">{numberLabel(series.data[pointIndex])}
|
||||
{series.sample_times?.[pointIndex] ? <span className="block break-words text-[10px] font-normal text-muted-foreground">{chartSampleTime(series.sample_times[pointIndex])}</span> : null}
|
||||
</dd>
|
||||
</div>)}
|
||||
</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>{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>
|
||||
</>;
|
||||
const rangeControls = <div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<PointSelect label="区间起点" value={range[0]} labels={props.x_data}
|
||||
onChange={(start) => setRange([start, Math.max(start, range[1])])} />
|
||||
<span className="text-xs text-muted-foreground">至</span>
|
||||
<PointSelect label="区间终点" value={range[1]} labels={props.x_data}
|
||||
onChange={(end) => setRange([Math.min(range[0], end), end])} />
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={fullRange}
|
||||
onClick={() => setRange([0, props.x_data.length - 1])}>重置范围</Button>
|
||||
</div>;
|
||||
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${fitted ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} data-chart-tile={tile || undefined} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
|
||||
onPointerDownCapture={() => { pointer.current = true; }}
|
||||
onKeyDownCapture={() => {
|
||||
pointer.current = false;
|
||||
setAnimateInteraction(false);
|
||||
contentMotion.stop();
|
||||
contentMotion.set({ opacity: 1, y: 0 });
|
||||
}}>
|
||||
{!props.embedded ? <CardHeader className={`shrink-0 flex-row items-center justify-between gap-2 space-y-0 ${tile ? "px-2 py-1.5" : "px-3.5 py-2.5"}`}>
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.expanded ? "图形与数据" : props.title}</CardTitle>
|
||||
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
|
||||
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent>
|
||||
{() => <InteractiveChart {...props} expanded tile={false} />}
|
||||
</FloatingWindow> : null}
|
||||
{tile ? <Popover><PopoverTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="查看图表来源" title="查看图表来源"><FileText size={14} aria-hidden /></Button>
|
||||
</PopoverTrigger><PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto text-xs leading-5">{props.source}</PopoverContent></Popover> : <CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className="shrink-0 text-muted-foreground"><SlidersHorizontal size={14} aria-hidden /></Button>
|
||||
</CollapsibleTrigger>}
|
||||
</div>
|
||||
</CardHeader> : null}
|
||||
<CardContent className={`${fitted ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} ${props.embedded ? "px-0 pb-0" : tile ? "px-2 pb-2" : "px-3.5 pb-3.5"} pt-0`}>
|
||||
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
|
||||
<Tabs value={view} onValueChange={setView} className={fitted ? `grid min-h-0 flex-1 grid-cols-1 ${tile ? "grid-rows-[auto_minmax(100px,1fr)_minmax(32px,auto)] gap-y-1" : "grid-rows-[auto_auto_minmax(180px,1fr)_auto] gap-y-2"}` : undefined}>
|
||||
<div className={`flex min-w-0 items-center gap-2 ${fitted ? "col-start-1 row-start-1" : "mb-3"}`}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className={`relative grid min-w-0 flex-1 grid-cols-3 ${tile ? "min-h-7 p-0.5" : ""} [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
|
||||
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
|
||||
style={{ width: "calc((100% - 0.5rem) / 3)" }}
|
||||
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
|
||||
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
|
||||
<TabsTrigger value="line" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
|
||||
<TabsTrigger value="bar" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table" className={tile ? "min-h-6 px-1.5 py-0.5 text-[11px]" : undefined}><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
{tile ? <>
|
||||
<Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围" className={`h-7 shrink-0 ${fullRange ? "w-7" : "w-auto gap-1 px-1.5"}`}><SlidersHorizontal size={14} aria-hidden />
|
||||
{!fullRange ? <span className="max-w-20 truncate text-[10px]" title={`${props.x_data[range[0]]} 至 ${props.x_data[range[1]]}`}>{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</span> : null}
|
||||
</Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] p-3">{rangeControls}</PopoverContent>
|
||||
</Popover>
|
||||
{inspecting && view !== "table" ? <Popover open={inspecting} onOpenChange={setInspecting}><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label="数据点详情" title="数据点详情" className="size-7 shrink-0"><Database size={14} aria-hidden /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-[60dvh] overflow-auto p-2" onOpenAutoFocus={(event) => event.preventDefault()}>{pointDetails}</PopoverContent>
|
||||
</Popover> : null}
|
||||
</> : props.embedded ? <CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"><SlidersHorizontal size={14} aria-hidden /></Button>
|
||||
</CollapsibleTrigger> : null}
|
||||
</div>
|
||||
|
||||
{!tile ? <div className={fitted ? "col-start-1 row-start-2 min-w-0" : undefined}>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
{!tile ? <CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">{rangeControls}</CollapsibleContent> : null}
|
||||
</div> : null}
|
||||
<motion.div className={fitted ? `col-start-1 ${tile ? "row-start-2" : "row-start-3"} min-h-0 min-w-0` : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={fitted ? "h-full min-h-0" : undefined}>
|
||||
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
|
||||
className={`${fitted ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
onKeyDown={(event) => {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (["Enter", "ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? range[0] : event.key === "End" ? range[1]
|
||||
: Math.min(range[1], Math.max(range[0], pointIndex + (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0)));
|
||||
patch({ point: next, inspecting: true });
|
||||
}
|
||||
if (event.key === "Escape") setInspecting(false);
|
||||
}}>
|
||||
<span className="sr-only">点击图中数据点查看详情,也可使用回车和左右方向键。</span>
|
||||
<div role="img" aria-label={props.title} style={{ height: fitted ? "100%" : props.plotHeight ?? 220,
|
||||
minHeight: fitted && type === "bar" && horizontalBars ? labels.length * (tile ? 28 : 36) + 48 : undefined }}>
|
||||
<ResponsiveChart option={options[type]} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>)}
|
||||
<TabsContent value="table" className={fitted ? "h-full overflow-auto" : undefined} forceMount style={view === "table" ? undefined : { display: "none" }}>
|
||||
<InteractiveDataTable title={props.title} columns={["时间或类别", ...visible.map((series) => series.name)]}
|
||||
rows={labels.map((label, index) => [label, ...visible.map((series) => {
|
||||
const offset = range[0] + index, time = chartSampleTime(series.sample_times?.[offset]);
|
||||
return `${series.data[offset] == null ? "缺测" : series.data[offset]}${time ? ` (${time})` : ""}`;
|
||||
})])}
|
||||
state={table} onStateChange={(updates) => patch({ table: { query: "", sort: null, selection: [], ...table, ...updates } }, Object.keys(updates).map((key) => `table/${key}`))} />
|
||||
</TabsContent>
|
||||
</motion.div>
|
||||
<div aria-label="图表图例" className={fitted ? `col-start-1 ${tile ? "row-start-3 min-h-8 max-h-14" : `row-start-4 min-h-0 ${props.embedded ? "max-h-56" : "max-h-40"}`} min-w-0 overflow-y-auto border-t border-border ${tile ? "pt-1" : "pt-2"}` : "border-t border-border pt-3"}>
|
||||
<div className={`answer-ui-chart-series flex flex-wrap ${tile ? "gap-1" : "mb-2 gap-2"}`} aria-label="显示的数据序列">
|
||||
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
|
||||
className={`h-auto ${tile ? "min-h-6 px-2 py-0.5" : "min-h-8"} min-w-0 max-w-full justify-start gap-1.5 whitespace-normal text-left text-[11px] after:inset-0`} aria-pressed={!hidden.includes(index)}
|
||||
disabled={visible.length === 1 && !hidden.includes(index)}
|
||||
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion && !tile); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
|
||||
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
|
||||
</Button>)}
|
||||
</div>
|
||||
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
|
||||
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${fitted ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${fitted ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
|
||||
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
|
||||
<span className="min-w-0 break-words">{label}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
{props.expanded && !tile ? metadata : null}
|
||||
</div>
|
||||
{!fitted ? metadata : null}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card></Collapsible>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
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";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/shared/ui/table";
|
||||
|
||||
import { useAnswerNodeState } from "./answer-view-state-provider";
|
||||
import type { AnswerTableState } from "./answer-view-state";
|
||||
|
||||
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange }: {
|
||||
export function InteractiveDataTable({ title, columns, rows, state: controlledState, onStateChange, expanded = false }: {
|
||||
title: string; columns: string[]; rows: string[][];
|
||||
state?: Omit<AnswerTableState, "type">;
|
||||
onStateChange?: (patch: Partial<Omit<AnswerTableState, "type">>) => void;
|
||||
expanded?: boolean;
|
||||
}) {
|
||||
const [localState, patchLocal] = useAnswerNodeState<AnswerTableState>("Table", { title, columns, rows });
|
||||
const state = controlledState ?? localState;
|
||||
@@ -34,19 +36,22 @@ export function InteractiveDataTable({ title, columns, rows, state: controlledSt
|
||||
<Input aria-label={`搜索${title}`} placeholder="搜索当前数据" value={query} className="h-9 min-w-0 flex-1 text-xs"
|
||||
onChange={(event) => { patch({ query: event.currentTarget.value }); }} />
|
||||
<span className="text-[11px] tabular-nums text-muted-foreground">{filtered.length} / {rows.length} 行</span>
|
||||
{!expanded ? <FloatingWindow title={`${title} · 表格`} triggerLabel={`放大查看表格 ${title}`}>
|
||||
{() => <InteractiveDataTable title={title} columns={columns} rows={rows} state={state} onStateChange={patch} expanded />}
|
||||
</FloatingWindow> : null}
|
||||
</div>
|
||||
<div role="region" aria-label={title} tabIndex={0}
|
||||
className="answer-ui-table overflow-x-auto rounded-lg border border-border focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring">
|
||||
<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);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useLayoutEffect, useRef, type ComponentProps } from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
|
||||
const resizedWindows = new WeakMap<HTMLElement, number>();
|
||||
|
||||
/** Reflow the existing drawing, coalescing rapid pointer resizes without scaling it. */
|
||||
export function ResponsiveChart({ option, height, onPointSelect }: {
|
||||
option: ComponentProps<typeof ReactECharts>["option"];
|
||||
height: number | "100%";
|
||||
onPointSelect: (index: number) => void;
|
||||
}) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const chart = useRef<ReactECharts>(null);
|
||||
const ready = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") return;
|
||||
const shell = element.closest<HTMLElement>("[data-floating-window]");
|
||||
let frame = 0;
|
||||
let timer = 0;
|
||||
const resize = () => {
|
||||
frame = 0;
|
||||
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
|
||||
// Space expensive drawings in one window instead of redrawing every chart together.
|
||||
const remaining = 64 - (performance.now() - (shell ? resizedWindows.get(shell) ?? -Infinity : -Infinity));
|
||||
if (shell?.dataset.floatingWindowResizing === "true" && remaining > 0) {
|
||||
timer = window.setTimeout(() => { timer = 0; frame = requestAnimationFrame(resize); }, remaining);
|
||||
return;
|
||||
}
|
||||
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
|
||||
if (shell) resizedWindows.set(shell, performance.now());
|
||||
};
|
||||
const schedule = () => { if (!frame && !timer) frame = requestAnimationFrame(resize); };
|
||||
const observer = new ResizeObserver(schedule);
|
||||
const settled = new MutationObserver(() => {
|
||||
if (shell?.dataset.floatingWindowResizing === "true") return;
|
||||
clearTimeout(timer);
|
||||
timer = 0;
|
||||
schedule();
|
||||
});
|
||||
observer.observe(element);
|
||||
if (shell) settled.observe(shell, { attributes: true, attributeFilter: ["data-floating-window-resizing"] });
|
||||
return () => { clearTimeout(timer); cancelAnimationFrame(frame); observer.disconnect(); settled.disconnect(); };
|
||||
}, []);
|
||||
return <div ref={host} style={{ height }}>
|
||||
<ReactECharts ref={chart} option={option} opts={{ renderer: "svg" }} notMerge autoResize={false}
|
||||
style={{ height: "100%" }} onChartReady={(instance) => {
|
||||
ready.current = true;
|
||||
instance.resize({ width: "auto", height: "auto", animation: { duration: 0 } });
|
||||
}}
|
||||
onEvents={{ click: (event: { componentType?: string; dataIndex?: number }) => {
|
||||
if (event.componentType === "series" && typeof event.dataIndex === "number") onPointSelect(event.dataIndex);
|
||||
} }} />
|
||||
</div>;
|
||||
}
|
||||
@@ -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" ||
|
||||
|
||||
@@ -56,4 +56,14 @@ describe("chart data normalization", () => {
|
||||
expect(data?.xData).toHaveLength(5);
|
||||
expect(data?.series[0].data).toEqual([0, 1, 2, 3, 4]);
|
||||
});
|
||||
|
||||
it("preserves missing slots, real zero and scatter sample dates without shifting indices", () => {
|
||||
const data = normalizeSafeChartData({
|
||||
x_data: ["11:30", "11:45", "12:00", "12:15"],
|
||||
series: [{ name: "前一日", type: "scatter", data: [null, "bad", 0, 31.5],
|
||||
sample_times: ["2026-06-13T11:30:00+08:00", null, "2026-06-13T12:00:00+08:00", "2026-06-13T12:15:00+08:00"] }]
|
||||
});
|
||||
expect(data?.series[0]).toEqual({ name: "前一日", type: "scatter", data: [null, null, 0, 31.5],
|
||||
sample_times: [null, null, "2026-06-13T12:00:00+08:00", "2026-06-13T12:15:00+08:00"] });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { createChartSampleTimeSchema } from "./presentation/command-schema";
|
||||
|
||||
const sampleTimeSchema = createChartSampleTimeSchema();
|
||||
|
||||
export type SafeChartSpec = {
|
||||
title?: string;
|
||||
chartType: "line" | "bar";
|
||||
@@ -7,8 +11,9 @@ export type SafeChartSpec = {
|
||||
|
||||
export type SafeChartSeries = {
|
||||
name: string;
|
||||
data: number[];
|
||||
type?: "line" | "bar";
|
||||
data: Array<number | null>;
|
||||
type?: "line" | "bar" | "scatter";
|
||||
sample_times?: Array<string | null>;
|
||||
};
|
||||
|
||||
export type SafeChartData = {
|
||||
@@ -34,6 +39,7 @@ type RawChartSeries = {
|
||||
points?: unknown;
|
||||
values?: unknown;
|
||||
type?: unknown;
|
||||
sample_times?: unknown;
|
||||
};
|
||||
|
||||
export function parseChartSpec(value: unknown): SafeChartSpec {
|
||||
@@ -75,12 +81,12 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
|
||||
.map((point, index) => {
|
||||
const parsed = pointToLabelValue(point as RawChartPoint, xData[index] ?? `${index + 1}`);
|
||||
if (!parsed) {
|
||||
labelsFromPoints[index] = xData[index] ?? `${index + 1}`;
|
||||
return null;
|
||||
}
|
||||
labelsFromPoints[index] = parsed.label;
|
||||
return parsed.value;
|
||||
})
|
||||
.filter((item): item is number => item !== null);
|
||||
});
|
||||
|
||||
if (!data.length) {
|
||||
return null;
|
||||
@@ -93,7 +99,11 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
|
||||
return {
|
||||
name: stringValue(item.name) ?? `系列 ${seriesIndex + 1}`,
|
||||
data,
|
||||
type: item.type === "line" || item.type === "bar" ? item.type : undefined
|
||||
type: item.type === "line" || item.type === "bar" || item.type === "scatter" ? item.type : undefined,
|
||||
...(Array.isArray(item.sample_times) ? { sample_times: data.map((value, index) => {
|
||||
const time = item.sample_times as unknown[];
|
||||
return value !== null && sampleTimeSchema.safeParse(time[index]).success ? time[index] as string : null;
|
||||
}) } : {})
|
||||
};
|
||||
})
|
||||
.filter((item): item is SafeChartSeries => Boolean(item));
|
||||
@@ -101,7 +111,8 @@ export function normalizeSafeChartData(value: unknown, maxPoints = 24): SafeChar
|
||||
const clippedXData = xData.slice(0, maxPoints);
|
||||
const clippedSeries = normalizedSeries.map((series) => ({
|
||||
...series,
|
||||
data: series.data.slice(0, clippedXData.length)
|
||||
data: series.data.slice(0, clippedXData.length),
|
||||
...(series.sample_times ? { sample_times: series.sample_times.slice(0, clippedXData.length) } : {})
|
||||
}));
|
||||
|
||||
return clippedXData.length > 0 && clippedSeries.length > 0
|
||||
|
||||
@@ -557,7 +557,6 @@ export function AgentCommandPanel({
|
||||
{centeredComposer && !promptReference ? (
|
||||
<ConversationEmptyState className="agent-chat-empty-state">
|
||||
<h2 className="text-2xl font-medium tracking-tight text-foreground">有什么可以帮你?</h2>
|
||||
<p className="text-xs leading-6 text-muted-foreground">询问供水问题,或从最近的任务继续。</p>
|
||||
</ConversationEmptyState>
|
||||
) : null}
|
||||
<motion.div
|
||||
@@ -1067,6 +1066,11 @@ function BackendMessage({
|
||||
<AgentUserMessageText content={message.content} />
|
||||
)
|
||||
) : null}
|
||||
{message.role === "assistant" && message.runError && message.content !== `错误:${message.runError}` ? (
|
||||
<p role="status" className="whitespace-pre-wrap break-words text-sm text-destructive">
|
||||
错误:{message.runError}
|
||||
</p>
|
||||
) : null}
|
||||
{message.role === "assistant" && processComplete && hasProcessError ? (
|
||||
<p role="status" className="text-xs text-[var(--status-warning-foreground)]">
|
||||
部分查询或工具未完成,可展开处理过程查看原因。
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
|
||||
import { Ban, CheckCircle2, LoaderCircle, XCircle } from "lucide-react";
|
||||
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
|
||||
import type { AgentChatProgress } from "../types";
|
||||
|
||||
const states = {
|
||||
completed: { label: "已完成", tone: "success", Icon: CheckCircle2 },
|
||||
cancelled: { label: "已中止", tone: "neutral", Icon: Ban },
|
||||
error: { label: "失败", tone: "danger", Icon: XCircle },
|
||||
running: { label: "进行中", tone: "info", Icon: LoaderCircle }
|
||||
} satisfies Record<
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Activity, BarChart3, Database, History, LineChart, MonitorCog } from "lucide-react";
|
||||
import { AnimatePresence, MotionConfig, motion } from "motion/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { memo, type ReactNode } from "react";
|
||||
import { formatChartDisplayText, withChartDisplayTimes } from "@/shared/utils/date-time-text";
|
||||
import { StatusIcon } from "@/shared/ui/status";
|
||||
import { normalizeSafeChartData, parseChartSpec, type SafeChartData, type SafeChartSeries, type SafeChartSpec } from "../chart-data";
|
||||
import { StandaloneInteractiveChart } from "../answer-ui/interactive-chart";
|
||||
import { normalizeSafeChartData, parseChartSpec } from "../chart-data";
|
||||
import type { AgentUiResult } from "../types";
|
||||
import type { UIEnvelope } from "../ui-envelope";
|
||||
import type { parseFrontendChart } from "../frontend-action/chart";
|
||||
@@ -71,185 +72,35 @@ function AgentUiResultCard({ result }: { result: AgentUiResult }) {
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentChartView({ chart, embedded = false }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean }) {
|
||||
return <SafeChartEnvelope embedded={embedded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type }, data: { x_data: chart.x_data, series: chart.series } }} />;
|
||||
}
|
||||
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 }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean }) {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
const data = normalizeSafeChartData(envelope.data, 100);
|
||||
function SafeChartEnvelope({ envelope, embedded = false, expanded = false, plotHeight, tile = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean; plotHeight?: number; tile?: boolean }) {
|
||||
const parsed = parseChartSpec(envelope.spec);
|
||||
const raw = normalizeSafeChartData(envelope.data, 100);
|
||||
const displayed = withChartDisplayTimes({ title: parsed.title ?? "Agent 图表", x_axis_name: parsed.xAxisName,
|
||||
x_data: raw?.xData ?? [], series: raw?.series ?? [] });
|
||||
const spec = { ...parsed, title: displayed.title, xAxisName: displayed.x_axis_name };
|
||||
const data = raw ? { ...raw, xData: displayed.x_data, series: displayed.series } : null;
|
||||
|
||||
if (!data || data.series.length === 0 || data.xData.length === 0) {
|
||||
return <FallbackText text={envelope.fallbackText ?? "图表数据未通过前端安全子集校验。"} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={embedded ? "min-w-0" : "agent-panel-nested rounded-xl p-3"}>
|
||||
<div className={cn("mb-3 flex gap-3", embedded ? "min-w-0 flex-col" : "items-start justify-between")}>
|
||||
{!embedded ? <div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-slate-950" title={spec.title}>
|
||||
{spec.title ?? "Agent 图表"}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-[var(--ink-muted)]">
|
||||
{spec.chartType === "bar" ? "柱状图" : "折线图"}
|
||||
{spec.yAxisName ? ` · ${spec.yAxisName}` : ""}
|
||||
</p>
|
||||
</div> : null}
|
||||
<div className={cn("flex flex-wrap gap-1.5", embedded ? "min-w-0 justify-start" : "shrink-0 justify-end")}>
|
||||
{data.series.slice(0, 3).map((series, index) => (
|
||||
<span key={series.name} className={cn("flex items-center gap-1 text-xs", embedded ? "min-w-0 text-muted-foreground" : "text-slate-600")}>
|
||||
<span className={cn("h-2 w-2 shrink-0 rounded-full", chartColorClass(index))} />
|
||||
{embedded ? <span className="min-w-0 break-words [overflow-wrap:anywhere]">{series.name}</span> : series.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<SafeChartSvg spec={spec} data={data} />
|
||||
{spec.xAxisName ? <p className="mt-2 text-center text-xs text-[var(--ink-muted)]">{spec.xAxisName}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SafeChartSvg({ spec, data }: { spec: SafeChartSpec; data: SafeChartData }) {
|
||||
const width = 360;
|
||||
const height = 190;
|
||||
const padding = { top: 16, right: 18, bottom: 32, left: 38 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const values = data.series.flatMap((series) => series.data);
|
||||
const min = Math.min(0, ...values);
|
||||
const max = Math.max(...values);
|
||||
const range = max - min || 1;
|
||||
const xFor = (index: number) =>
|
||||
padding.left + (data.xData.length === 1 ? plotWidth / 2 : (index / (data.xData.length - 1)) * plotWidth);
|
||||
const yFor = (value: number) => padding.top + plotHeight - ((value - min) / range) * plotHeight;
|
||||
const ticks = createTicks(min, max);
|
||||
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} role="img" aria-label={spec.title ?? "Agent 图表"} className="h-48 w-full">
|
||||
<rect x="0" y="0" width={width} height={height} rx="12" className="fill-white/70" />
|
||||
{ticks.map((tick) => {
|
||||
const y = yFor(tick);
|
||||
return (
|
||||
<g key={tick}>
|
||||
<line x1={padding.left} x2={width - padding.right} y1={y} y2={y} className="stroke-slate-200" />
|
||||
<text x={padding.left - 8} y={y + 4} textAnchor="end" className="fill-slate-400 text-xs">
|
||||
{formatChartNumber(tick)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<line x1={padding.left} x2={padding.left} y1={padding.top} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
<line x1={padding.left} x2={width - padding.right} y1={height - padding.bottom} y2={height - padding.bottom} className="stroke-slate-300" />
|
||||
{data.xData.map((label, index) => (
|
||||
<text
|
||||
key={`${label}-${index}`}
|
||||
x={xFor(index)}
|
||||
y={height - 12}
|
||||
textAnchor="middle"
|
||||
className="fill-slate-500 text-xs"
|
||||
>
|
||||
{shortLabel(label)}
|
||||
</text>
|
||||
))}
|
||||
{data.series.slice(0, 4).map((series, seriesIndex) =>
|
||||
(series.type ?? spec.chartType) === "bar" ? (
|
||||
<BarSeries
|
||||
key={series.name}
|
||||
series={series}
|
||||
seriesIndex={seriesIndex}
|
||||
seriesCount={Math.min(data.series.length, 4)}
|
||||
xFor={xFor}
|
||||
yFor={yFor}
|
||||
baselineY={yFor(0)}
|
||||
/>
|
||||
) : (
|
||||
<LineSeries key={series.name} series={series} seriesIndex={seriesIndex} xFor={xFor} yFor={yFor} />
|
||||
)
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LineSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
xFor,
|
||||
yFor
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
}) {
|
||||
const points = series.data.map((value, index) => `${xFor(index)},${yFor(value)}`).join(" ");
|
||||
const stroke = chartStroke(seriesIndex);
|
||||
|
||||
return (
|
||||
<g>
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="agent-chart-line"
|
||||
/>
|
||||
{series.data.map((value, index) => (
|
||||
<circle
|
||||
key={`${series.name}-${index}`}
|
||||
cx={xFor(index)}
|
||||
cy={yFor(value)}
|
||||
r="2.8"
|
||||
fill={stroke}
|
||||
className="agent-chart-point"
|
||||
style={{ animationDelay: `${80 + index * 18}ms` }}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function BarSeries({
|
||||
series,
|
||||
seriesIndex,
|
||||
seriesCount,
|
||||
xFor,
|
||||
yFor,
|
||||
baselineY
|
||||
}: {
|
||||
series: SafeChartSeries;
|
||||
seriesIndex: number;
|
||||
seriesCount: number;
|
||||
xFor: (index: number) => number;
|
||||
yFor: (value: number) => number;
|
||||
baselineY: number;
|
||||
}) {
|
||||
const fill = chartStroke(seriesIndex);
|
||||
const barWidth = Math.max(5, 22 / seriesCount);
|
||||
const offset = (seriesIndex - (seriesCount - 1) / 2) * (barWidth + 2);
|
||||
|
||||
return (
|
||||
<g>
|
||||
{series.data.map((value, index) => {
|
||||
const y = yFor(value);
|
||||
return (
|
||||
<rect
|
||||
key={`${series.name}-${index}`}
|
||||
x={xFor(index) - barWidth / 2 + offset}
|
||||
y={Math.min(y, baselineY)}
|
||||
width={barWidth}
|
||||
height={Math.max(1, Math.abs(baselineY - y))}
|
||||
rx="2"
|
||||
fill={fill}
|
||||
className="agent-chart-bar"
|
||||
style={{ animationDelay: `${seriesIndex * 28 + index * 16}ms` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
return <StandaloneInteractiveChart
|
||||
title={spec.title ?? "Agent 图表"}
|
||||
chart_type={spec.chartType}
|
||||
x_data={data.xData}
|
||||
series={data.series.slice(0, 8)}
|
||||
source="来源见关联依据。"
|
||||
embedded={embedded}
|
||||
expanded={expanded} plotHeight={plotHeight} tile={tile}
|
||||
xAxisName={spec.xAxisName}
|
||||
yAxisName={spec.yAxisName}
|
||||
/>;
|
||||
}
|
||||
|
||||
function RegisteredComponentEnvelope({
|
||||
@@ -360,7 +211,7 @@ function getEnvelopeIcon(envelope: UIEnvelope) {
|
||||
function getEnvelopeTitle(envelope: UIEnvelope) {
|
||||
if (envelope.kind === "chart") {
|
||||
const spec = parseChartSpec(envelope.spec);
|
||||
return spec.title ?? "Agent 图表";
|
||||
return formatChartDisplayText(spec.title ?? "Agent 图表");
|
||||
}
|
||||
|
||||
if (envelope.kind === "registered_component") {
|
||||
@@ -377,35 +228,6 @@ function getSurfaceLabel(surface: UIEnvelope["surface"]) {
|
||||
return "地图叠加";
|
||||
}
|
||||
|
||||
function createTicks(min: number, max: number) {
|
||||
const range = max - min || 1;
|
||||
return [max, min + range * 0.5, min];
|
||||
}
|
||||
|
||||
function chartStroke(index: number) {
|
||||
return ["#2563eb", "#0aa6a6", "#ff7a45", "#0477bf"][index % 4] ?? "#2563eb";
|
||||
}
|
||||
|
||||
function chartColorClass(index: number) {
|
||||
return ["bg-[var(--action-blue)]", "bg-teal-600", "bg-orange-500", "bg-sky-700"][index % 4] ?? "bg-[var(--action-blue)]";
|
||||
}
|
||||
|
||||
function formatChartNumber(value: number) {
|
||||
if (Math.abs(value) >= 100) {
|
||||
return value.toFixed(0);
|
||||
}
|
||||
|
||||
if (Math.abs(value) >= 10) {
|
||||
return value.toFixed(1);
|
||||
}
|
||||
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function shortLabel(value: string) {
|
||||
return value.length > 8 ? `${value.slice(0, 7)}...` : value;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null;
|
||||
}
|
||||
|
||||
@@ -10,4 +10,14 @@ describe("frontend chart boundary", () => {
|
||||
expect(parseFrontendChart({ ...chart, series: [{ name: "D1", data: [20, Infinity] }] })).toBeNull();
|
||||
expect(parseFrontendChart({ ...chart, x_data: Array(101).fill("t") })).toBeNull();
|
||||
});
|
||||
it("accepts sparse history with real timestamp metadata and rejects invented or misaligned timestamps", () => {
|
||||
const series = { name: "前一天", type: "scatter", data: [null, 31],
|
||||
sample_times: [null, "2026-06-13T12:00:00+08:00"] };
|
||||
expect(parseFrontendChart({ ...chart, y_axis_name: "压力(m)", series: [chart.series[0], series] })).not.toBeNull();
|
||||
for (const sample_times of [["2026-06-13T11:45:00+08:00", series.sample_times[1]],
|
||||
[null], [null, "2026-06-13T12:00:00"], [null, "2026-02-30T12:00:00+08:00"]]) {
|
||||
expect(parseFrontendChart({ ...chart, series: [{ ...series, sample_times }] })).toBeNull();
|
||||
}
|
||||
expect(parseFrontendChart({ ...chart, series: [{ ...series, type: "pie" }] })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AgentApiClient } from "../api/client";
|
||||
|
||||
export function useAgentMessageInbox(client: AgentApiClient, sessionId: string | null) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { parseFrontendChart } from "../frontend-action/chart";
|
||||
import { parseAnswerUiBlock } from "../answer-ui/contract";
|
||||
import { normalizeSafeChartData } from "../chart-data";
|
||||
|
||||
// Keep this matrix in sync with Agent and Server chart timestamp conformance tests.
|
||||
const cases: Array<[string, boolean]> = [
|
||||
["2026-06-14T12:00:00Z", true], ["2026-06-14T12:00:00+08:00", true],
|
||||
["2026-06-14T12:00:00-03:30", true], ["2024-02-29T23:59:59.123456Z", true],
|
||||
["2026-06-14T12:00:00+23:59", true], [`2026-06-14T12:00:00.${"1".repeat(43)}Z`, true],
|
||||
["2026-06-14 12:00:00+08:00", false], ["2026-W24-7T12:00:00+08:00", false],
|
||||
["2026-06-14T12:00+08:00", false], ["2026-06-14T12:00:00+0800", false],
|
||||
["2026-02-30T12:00:00Z", false], ["2026-13-14T12:00:00Z", false],
|
||||
["2026-06-00T12:00:00Z", false], ["2026-02-29T12:00:00Z", false],
|
||||
["2026-06-14T24:00:00Z", false], ["2026-06-14T12:00:60Z", false],
|
||||
["2026-06-14T12:00:00+24:00", false], ["2026-06-14T12:00:00+08:60", false],
|
||||
["2026-06-14T12:00:00", false], ["2026-06-14T12:00:00z", false],
|
||||
["0000-06-14T12:00:00Z", false], [`2026-06-14T12:00:00.${"1".repeat(44)}Z`, false]
|
||||
];
|
||||
const chart = (data: Array<number | null>, sample_times: Array<string | null>) => ({
|
||||
title: "压力", chart_type: "line", x_data: ["12:00"], series: [{ name: "测点", data, sample_times }]
|
||||
});
|
||||
const block = (value: ReturnType<typeof chart>) => ({
|
||||
version: "answer-ui@1", block_id: "pressure", fallback_text: "压力数据", spec: { root: "chart", elements: {
|
||||
chart: { type: "Chart", props: { ...value, source: "实测" }, children: [] }
|
||||
} }
|
||||
});
|
||||
|
||||
it.each(cases)("uses the same timestamp boundary for chart commands, answer blocks and legacy metadata: %s", (time, accepted) => {
|
||||
const value = chart([1], [time]);
|
||||
expect(parseFrontendChart(value) !== null).toBe(accepted);
|
||||
expect(parseAnswerUiBlock(block(value)) !== null).toBe(accepted);
|
||||
expect(normalizeSafeChartData(value)?.series[0].sample_times).toEqual([accepted ? time : null]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[[0], [null], true], [[null], [null], true], [[null], ["2026-06-14T12:00:00Z"], false], [[1], [], false]
|
||||
] as Array<[Array<number | null>, Array<string | null>, boolean]>)("preserves zero, null and sample alignment: %j %j", (data, times, accepted) => {
|
||||
const value = chart(data, times);
|
||||
expect(parseFrontendChart(value) !== null).toBe(accepted);
|
||||
expect(parseAnswerUiBlock(block(value)) !== null).toBe(accepted);
|
||||
});
|
||||
@@ -1,21 +1,87 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/** Sample timestamps retain their original spelling within the shared RFC3339 subset. */
|
||||
export function createChartSampleTimeSchema(zod: typeof z = z) {
|
||||
return zod.string().max(64).datetime({ offset: true }).regex(
|
||||
/^(?:[1-9][0-9]{3}|0[1-9][0-9]{2}|00[1-9][0-9]|000[1-9])-[0-9]{2}-[0-9]{2}T(?:[01][0-9]|2[0-3]):[0-5][0-9]:[0-5][0-9](?:\.[0-9]+)?(?:Z|[+-](?:[01][0-9]|2[0-3]):[0-5][0-9])$/
|
||||
);
|
||||
}
|
||||
|
||||
type PublicationChart = {
|
||||
chart_type: string;
|
||||
x_data: string[];
|
||||
series: Array<{ type?: string; data: Array<number | null>; sample_times?: Array<string | null> }>;
|
||||
};
|
||||
|
||||
/** Mirrors Server time_comparison_chart.py for new publications, never historical reads. */
|
||||
export function chartPublicationError(chart: PublicationChart): string | null {
|
||||
const kinds = new Set(chart.series.map((series) => series.type ?? (chart.chart_type === "time_comparison" ? "line" : chart.chart_type)));
|
||||
if (chart.chart_type !== "time_comparison") return kinds.has("line") && kinds.has("scatter")
|
||||
? "曲线与历史散点复合图须使用 chart_type=time_comparison" : null;
|
||||
if (kinds.size !== 2 || !kinds.has("line") || !kinds.has("scatter"))
|
||||
return "time_comparison 必须包含当前 line 和历史 scatter,不能混入 bar";
|
||||
const labels = chart.x_data;
|
||||
if (!labels.every((label) => /^(?:[01][0-9]|2[0-3]):[0-5][0-9](?::[0-5][0-9])?$/.test(label)) || new Set(labels.map((label) => label.length)).size !== 1)
|
||||
return "time_comparison.x_data 必须使用一致精度的 HH:mm 或 HH:mm:ss 共享时刻槽";
|
||||
const seconds = labels.map((label) => label.split(":").reduce((value, part, index) => value + Number(part) * [3600, 60, 1][index], 0));
|
||||
const offsets = [0];
|
||||
for (let index = 1; index < seconds.length; index++) {
|
||||
const step = (seconds[index] - seconds[index - 1] + 86400) % 86400;
|
||||
if (!step) return "x_data 不能重复时刻,历史点应复用当前槽位";
|
||||
offsets.push(offsets[index - 1] + step);
|
||||
}
|
||||
if (offsets.at(-1)! >= 86400) return "x_data 必须按时间递增且小于一天";
|
||||
const current = new Set<number>();
|
||||
const historical: number[] = [];
|
||||
for (const series of chart.series) {
|
||||
const times = series.sample_times;
|
||||
if (!times || times.length !== labels.length) return "sample_times 必须提供并与 x_data 等长";
|
||||
const bases = new Set<number>();
|
||||
for (let index = 0; index < times.length; index++) {
|
||||
const at = times[index];
|
||||
if ((series.data[index] === null) !== (at === null)) return "有效值必须有真实采样时间,缺测值和时间均填 null";
|
||||
if (at === null) continue;
|
||||
const local = new Date(Date.parse(at) + 8 * 3600_000);
|
||||
const base = local.getTime() - offsets[index] * 1000;
|
||||
const year = local.getUTCFullYear(), baseYear = new Date(base).getUTCFullYear();
|
||||
if (!Number.isFinite(base) || year < 1 || year > 9999 || baseYear < 1 || baseYear > 9999)
|
||||
return "sample_times 超出同期对比可表达的日期范围";
|
||||
const microseconds = /\.(\d+)/.exec(at)?.[1].slice(0, 6) ?? "0";
|
||||
if (local.toISOString().slice(11, labels[index].length === 8 ? 19 : 16) !== labels[index] ||
|
||||
Number(microseconds) !== 0 || labels[index].length === 5 && local.getUTCSeconds() !== 0)
|
||||
return "sample_times 换算为 UTC+8 后须与 x_data 对应时刻对齐";
|
||||
bases.add(base);
|
||||
}
|
||||
if (bases.size > 1) return "同一序列的 sample_times 必须按同一日期窗口对齐";
|
||||
if ((series.type ?? "line") === "line") for (const base of bases) current.add(base);
|
||||
else historical.push(...bases);
|
||||
}
|
||||
if (current.size !== 1) return "当前 line 必须包含真实采样时间,且所有当前曲线使用同一窗口";
|
||||
const start = [...current][0];
|
||||
return historical.some((base) => start <= base || (start - base) % 86400_000 !== 0)
|
||||
? "历史 scatter 必须来自当前窗口之前的对应日期" : null;
|
||||
}
|
||||
|
||||
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
|
||||
export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
export function createViewSchemas(zod: typeof z = z, publication = false, validateCharts = publication) {
|
||||
const z = zod;
|
||||
|
||||
const presentationIdentifierSchema = z.string().trim().min(1).max(128);
|
||||
const chartSchema = z
|
||||
.object({
|
||||
title: z.string().max(200),
|
||||
chart_type: z.enum(["line", "bar"]),
|
||||
chart_type: z.enum(["line", "bar", "time_comparison"]),
|
||||
x_axis_name: z.string().max(128).optional(),
|
||||
y_axis_name: z.string().max(128).optional(),
|
||||
x_data: z.array(z.string().max(128)).min(1).max(100),
|
||||
series: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
name: z.string().max(128),
|
||||
data: z.array(z.number().finite()).min(1).max(100)
|
||||
type: z.enum(["line", "bar", "scatter"]).optional(),
|
||||
data: z.array(z.number().finite().nullable()).min(1).max(100),
|
||||
sample_times: z.array(createChartSampleTimeSchema(z).nullable()).min(1).max(100).optional()
|
||||
})
|
||||
.strict()
|
||||
)
|
||||
@@ -24,9 +90,15 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(value) => value.series.every((series) => series.data.length === value.x_data.length),
|
||||
(value) => value.series.every((series) => series.data.length === value.x_data.length &&
|
||||
(!series.sample_times || series.sample_times.length === value.x_data.length &&
|
||||
series.sample_times.every((time, index) => time === null || series.data[index] !== null))),
|
||||
"series must align with x_data"
|
||||
);
|
||||
).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())
|
||||
@@ -200,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>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { toast } from "sonner";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import type { MapNoticeTone } from "./notice-types";
|
||||
|
||||
vi.mock("sonner", () => ({ toast: { custom: vi.fn(), dismiss: vi.fn() } }));
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
it.each<MapNoticeTone>(["info", "success", "warning", "error", "loading"])(
|
||||
"keeps actionable %s notices until manual dismissal even with an explicit timeout", async (tone) => {
|
||||
const onAction = vi.fn();
|
||||
const id = showMapNotice({ tone, title: "报告已更新", message: "查看分析结果", duration: 10_000,
|
||||
dismissible: false, actionLabel: "查看报告", onAction });
|
||||
await Promise.resolve();
|
||||
const [content, options] = vi.mocked(toast.custom).mock.calls[0];
|
||||
expect(options).toMatchObject({ duration: Infinity, dismissible: true });
|
||||
render(content(id));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByRole("button", { name: "查看报告" }));
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
expect(toast.dismiss).not.toHaveBeenCalled();
|
||||
expect(options?.cancel).toMatchObject({ label: expect.anything(), onClick: expect.any(Function) });
|
||||
}
|
||||
);
|
||||
|
||||
it("preserves transient feedback and the explicit lifecycle of non-actionable notices", async () => {
|
||||
showMapNotice({ tone: "success", message: "已保存" });
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 3200 });
|
||||
showMapNotice({ tone: "warning", message: "稍后重试", duration: 8000, actionLabel: "缺少回调" });
|
||||
await Promise.resolve();
|
||||
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ duration: 8000 });
|
||||
showMapNotice({ tone: "loading", message: "正在加载" });
|
||||
await Promise.resolve();
|
||||
const [content, options] = vi.mocked(toast.custom).mock.lastCall!;
|
||||
expect(options).toMatchObject({ duration: Infinity, dismissible: false });
|
||||
render(content("loading"));
|
||||
expect(screen.queryByRole("button", { name: "关闭通知" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("publishes after effects and cancels notices dismissed before publication", async () => {
|
||||
showMapNotice({ id: "source", tone: "loading", message: "加载中" });
|
||||
showMapNotice({ id: "source", tone: "success", message: "已完成" });
|
||||
const cancelled = showMapNotice({ tone: "info", message: "已取消" });
|
||||
dismissMapNotice(cancelled);
|
||||
expect(toast.custom).not.toHaveBeenCalled();
|
||||
await Promise.resolve();
|
||||
expect(toast.custom).toHaveBeenCalledOnce();
|
||||
expect(vi.mocked(toast.custom).mock.lastCall?.[1]).toMatchObject({ id: "source", duration: 3200 });
|
||||
});
|
||||
@@ -1,46 +1,30 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { toast, Toaster } from "sonner";
|
||||
import { toast } from "sonner";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { MAP_ICON_CELL_RADIUS_CLASS_NAME } from "./map-control-styles";
|
||||
import { MapNoticeCloseIcon, MapNoticeContent } from "./notice-presentation";
|
||||
import type { MapNoticeOptions, MapNoticePosition } from "./notice-types";
|
||||
|
||||
type SonnerPosition = NonNullable<ComponentProps<typeof Toaster>["position"]>;
|
||||
|
||||
const DEFAULT_POSITION: MapNoticePosition = "top-center";
|
||||
|
||||
const positionClassNames: Record<MapNoticePosition, string> = {
|
||||
"top-center": "left-1/2! right-auto! top-[84px] [translate:-50%_0]",
|
||||
"top-right": "top-[84px] right-[72px]",
|
||||
"bottom-left": "bottom-12 left-4",
|
||||
"bottom-right": "bottom-12 right-4"
|
||||
};
|
||||
|
||||
const sonnerPositions: Record<MapNoticePosition, SonnerPosition> = {
|
||||
"top-center": "top-center",
|
||||
"top-right": "top-right",
|
||||
"bottom-left": "bottom-left",
|
||||
"bottom-right": "bottom-right"
|
||||
};
|
||||
const pendingNotices = new Map<string | number, () => void>();
|
||||
let nextNoticeId = 0;
|
||||
|
||||
export function showMapNotice(options: MapNoticeOptions) {
|
||||
const id = options.id ?? `map-notice:${++nextNoticeId}`;
|
||||
const tone = options.tone ?? "info";
|
||||
const position = options.position ?? DEFAULT_POSITION;
|
||||
const duration = options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||
const dismissible = options.dismissible ?? tone !== "loading";
|
||||
const hasAction = Boolean(options.actionLabel && options.onAction);
|
||||
// Actionable notices must remain available even when a caller supplies a timeout.
|
||||
const duration = hasAction
|
||||
? Infinity
|
||||
: options.duration ?? (tone === "info" || tone === "success" ? 3200 : Infinity);
|
||||
const dismissible = hasAction || (options.dismissible ?? tone !== "loading");
|
||||
|
||||
return toast.custom(
|
||||
const publish = () => toast.custom(
|
||||
() => <MapNoticeContent {...options} tone={tone} />,
|
||||
{
|
||||
id: options.id,
|
||||
duration,
|
||||
position: sonnerPositions[position],
|
||||
dismissible,
|
||||
id, duration, position, dismissible,
|
||||
cancel: dismissible
|
||||
? {
|
||||
label: <MapNoticeCloseIcon />,
|
||||
onClick: (event) => event.stopPropagation()
|
||||
}
|
||||
? { label: <MapNoticeCloseIcon />, onClick: (event) => event.stopPropagation() }
|
||||
: undefined,
|
||||
classNames: {
|
||||
cancelButton: cn(
|
||||
@@ -48,11 +32,22 @@ export function showMapNotice(options: MapNoticeOptions) {
|
||||
MAP_ICON_CELL_RADIUS_CLASS_NAME
|
||||
)
|
||||
},
|
||||
className: cn("fixed! z-[60]! w-[min(420px,calc(100vw-24px))]!", positionClassNames[position])
|
||||
className: "w-full"
|
||||
}
|
||||
);
|
||||
// Publish after React finishes effects: Sonner renews its subscription during
|
||||
// those effects and can otherwise miss an alert while map sources update.
|
||||
pendingNotices.set(id, publish);
|
||||
queueMicrotask(() => {
|
||||
if (pendingNotices.get(id) !== publish) return;
|
||||
pendingNotices.delete(id);
|
||||
publish();
|
||||
});
|
||||
return id;
|
||||
}
|
||||
|
||||
export function dismissMapNotice(id?: string | number) {
|
||||
if (id === undefined) pendingNotices.clear();
|
||||
else pendingNotices.delete(id);
|
||||
toast.dismiss(id);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import { AlertTriangle, ArrowUpRight, CheckCircle2, Info, Loader2, X, XCircle } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -32,16 +32,16 @@ export function MapNoticeContent({
|
||||
|
||||
return (
|
||||
<MapNoticeFrame tone={tone}>
|
||||
<div data-slot="map-notice-summary" className="flex items-start gap-3 py-3.5 pl-4 pr-12">
|
||||
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={
|
||||
actionLabel && onAction ? (
|
||||
<MapNoticeBody title={title} message={message} />
|
||||
</div>
|
||||
{actionLabel && onAction ? (
|
||||
<div data-slot="map-notice-actions" className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 pb-3 pl-14 pr-3">
|
||||
<span className="text-xs leading-5 text-[var(--ink-muted)]">保留至手动关闭</span>
|
||||
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</MapNoticeFrame>
|
||||
);
|
||||
}
|
||||
@@ -59,8 +59,11 @@ function MapNoticeFrame({ tone, children }: { tone: MapNoticeTone; children: Rea
|
||||
return (
|
||||
<div
|
||||
role={tone === "error" || tone === "warning" ? "alert" : "status"}
|
||||
// Sonner restores its saved focus on blur. Persistent action buttons must
|
||||
// let the user move directly to another workbench control instead.
|
||||
onFocus={(event) => event.stopPropagation()}
|
||||
className={cn(
|
||||
"glass-transient relative flex w-full items-start gap-2.5 overflow-hidden border py-2.5 pl-3 pr-10 text-sm text-slate-900",
|
||||
"glass-transient relative w-full overflow-hidden border text-sm text-slate-900",
|
||||
MAP_READABLE_RADIUS_CLASS_NAME,
|
||||
)}
|
||||
>
|
||||
@@ -90,23 +93,20 @@ function MapNoticeIcon({
|
||||
|
||||
function MapNoticeBody({
|
||||
title,
|
||||
message,
|
||||
action
|
||||
message
|
||||
}: {
|
||||
title?: string;
|
||||
message: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<span className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1">
|
||||
{title ? (
|
||||
<span className="block text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||
<span className="block break-words text-sm font-semibold leading-5 text-slate-950">{title}</span>
|
||||
) : null}
|
||||
<span className={cn("block text-sm leading-5 text-slate-600", title && "mt-0.5")}>
|
||||
<span className={cn("block break-words text-sm leading-6 text-slate-600", title && "mt-1")}>
|
||||
{message}
|
||||
</span>
|
||||
{action}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,11 +118,12 @@ function MapNoticeAction({ label, onClick }: { label: string; onClick: () => voi
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"pointer-events-auto mt-2 border-[var(--surface-border)] bg-[var(--action-soft)] text-[var(--action-blue-hover)]",
|
||||
"pointer-events-auto h-9 max-w-full gap-1.5 whitespace-normal border-[var(--surface-border)] bg-[var(--action-soft)] px-2.5 text-xs text-[var(--action-blue-hover)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export type MapNoticeOptions = {
|
||||
title?: string;
|
||||
message: string;
|
||||
tone?: MapNoticeTone;
|
||||
/** Actionable notices always remain until manually dismissed. */
|
||||
duration?: number;
|
||||
position?: MapNoticePosition;
|
||||
actionLabel?: string;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import { MapErrorNotice, MapLoadingNotice, MapSourceStatusNotice } from "./notice";
|
||||
|
||||
vi.mock("./notice-actions", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
it("keeps initial healthy sources quiet and announces recovery once", () => {
|
||||
const { rerender, unmount } = render(<MapSourceStatusNotice sourceName="管网" status="online" message="已就绪" />);
|
||||
expect(showMapNotice).not.toHaveBeenCalled();
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="瓦片已加载" />);
|
||||
expect(showMapNotice).not.toHaveBeenCalled();
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="offline" message="连接失败" />);
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error", message: "连接失败" }));
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="已恢复" />);
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "success", duration: 2600 }));
|
||||
rerender(<MapSourceStatusNotice sourceName="管网" status="online" message="全部瓦片已加载" />);
|
||||
expect(showMapNotice).toHaveBeenCalledTimes(2);
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("updates a degraded source in place and only removes it when unmounted", () => {
|
||||
const { rerender, unmount } = render(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="部分不可用" />);
|
||||
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="degraded" message="正在重试" />);
|
||||
rerender(<MapSourceStatusNotice id="source" sourceName="管网" status="online" message="已恢复" />);
|
||||
expect(showMapNotice).toHaveBeenCalledTimes(3);
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenCalledWith("source");
|
||||
});
|
||||
|
||||
it("updates map errors without cancelling the updated notice and cleans up a replaced id", () => {
|
||||
const { rerender, unmount } = render(<MapErrorNotice id="old" message="连接失败" />);
|
||||
rerender(<MapErrorNotice id="old" message="正在重新连接" />);
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
rerender(<MapErrorNotice id="new" message="另一项错误" />);
|
||||
expect(dismissMapNotice).toHaveBeenCalledExactlyOnceWith("old");
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenLastCalledWith("new");
|
||||
});
|
||||
|
||||
it("keeps loading visible while its title or message changes", () => {
|
||||
const { rerender, unmount } = render(<MapLoadingNotice id="loading" message="加载底图" />);
|
||||
rerender(<MapLoadingNotice id="loading" title="准备管网" message="加载业务图层" />);
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "loading", title: "准备管网", duration: Infinity }));
|
||||
expect(dismissMapNotice).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
expect(dismissMapNotice).toHaveBeenCalledWith("loading");
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useRef, type CSSProperties } from "react";
|
||||
import { Toaster } from "sonner";
|
||||
import { dismissMapNotice, showMapNotice } from "./notice-actions";
|
||||
import type { MapNoticeTone } from "./notice-types";
|
||||
@@ -8,14 +8,20 @@ export type { MapNoticeOptions, MapNoticePosition, MapNoticeTone } from "./notic
|
||||
export function MapToaster() {
|
||||
return (
|
||||
<Toaster
|
||||
className="map-notice-stack"
|
||||
style={{ fontFamily: "inherit", "--width": "min(420px, calc(100vw - 24px))" } as CSSProperties}
|
||||
containerAriaLabel="系统通知"
|
||||
closeButton={false}
|
||||
expand
|
||||
visibleToasts={4}
|
||||
position="top-center"
|
||||
offset={{ top: 84, right: 16, bottom: 48, left: 16 }}
|
||||
mobileOffset={{ top: 84, right: 12, bottom: 36, left: 12 }}
|
||||
swipeDirections={[]}
|
||||
toastOptions={{
|
||||
unstyled: true,
|
||||
classNames: {
|
||||
toast: "group pointer-events-none",
|
||||
toast: "group pointer-events-auto",
|
||||
closeButton:
|
||||
"pointer-events-auto absolute right-2 top-2 grid h-8 w-8 place-items-center rounded-lg border border-transparent text-[var(--ink-muted)] after:absolute after:-inset-1 after:content-[''] hover:bg-[var(--surface-reading)]/70 hover:text-slate-700"
|
||||
}
|
||||
@@ -25,6 +31,7 @@ export function MapToaster() {
|
||||
}
|
||||
|
||||
export function MapErrorNotice({ message, id = "map-error" }: { message: string; id?: string | number }) {
|
||||
useNoticeCleanup(id);
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
@@ -33,8 +40,6 @@ export function MapErrorNotice({ message, id = "map-error" }: { message: string;
|
||||
message,
|
||||
duration: Infinity
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message]);
|
||||
|
||||
return null;
|
||||
@@ -49,6 +54,7 @@ export function MapLoadingNotice({
|
||||
title?: string;
|
||||
id?: string | number;
|
||||
}) {
|
||||
useNoticeCleanup(id);
|
||||
useEffect(() => {
|
||||
showMapNotice({
|
||||
id,
|
||||
@@ -58,8 +64,6 @@ export function MapLoadingNotice({
|
||||
duration: Infinity,
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [id, message, title]);
|
||||
|
||||
return null;
|
||||
@@ -84,7 +88,16 @@ export function MapSourceStatusNotice({
|
||||
actionLabel,
|
||||
onAction
|
||||
}: MapSourceStatusNoticeProps) {
|
||||
const previous = useRef<{ id: string | number; status: MapSourceStatus } | null>(null);
|
||||
useNoticeCleanup(id);
|
||||
useEffect(() => {
|
||||
const last = previous.current;
|
||||
previous.current = { id, status };
|
||||
// Initial healthy sources and repeated tile-load messages stay quiet.
|
||||
// Success is announced when the same source recovers from a problem.
|
||||
if (status === "online" && !(actionLabel && onAction)
|
||||
&& (!last || last.id !== id || last.status === "online")) return;
|
||||
|
||||
showMapNotice({
|
||||
id,
|
||||
tone: getSourceStatusTone(status),
|
||||
@@ -94,13 +107,17 @@ export function MapSourceStatusNotice({
|
||||
actionLabel,
|
||||
onAction
|
||||
});
|
||||
|
||||
return () => dismissMapNotice(id);
|
||||
}, [actionLabel, id, message, onAction, sourceName, status]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useNoticeCleanup(id: string | number) {
|
||||
// Content updates reuse the toast id. Dismissing during an update races
|
||||
// Sonner's asynchronous removal against the newly published content.
|
||||
useEffect(() => () => dismissMapNotice(id), [id]);
|
||||
}
|
||||
|
||||
function getSourceStatusTone(status: MapSourceStatus): MapNoticeTone {
|
||||
if (status === "online") {
|
||||
return "success";
|
||||
|
||||
@@ -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 属性与所选要素不一致");
|
||||
|
||||
@@ -21,6 +21,7 @@ function task(generation: number | null | undefined = 2) {
|
||||
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
|
||||
...(generation === undefined ? {} : { execution_generation: generation }) };
|
||||
return {
|
||||
execution_scope: "production",
|
||||
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
|
||||
lifecycle_status: "waiting_human", active_run: null,
|
||||
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
|
||||
@@ -42,6 +43,28 @@ function task(generation: number | null | undefined = 2) {
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("operational task contract", () => {
|
||||
it.each(["event_investigation", "external_work_order", "scheduled_flow"])(
|
||||
"loads %s summaries and details with the Server execution scope", (taskType) => {
|
||||
const detail = { ...task(), task_type: taskType, execution_scope: "production" };
|
||||
const {
|
||||
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
|
||||
actions: _actions, permissions: _permissions, ...summary
|
||||
} = detail;
|
||||
expect(parseOperationalTaskSummary(summary)).toHaveProperty("execution_scope", "production");
|
||||
expect(parseOperationalTask(detail)).toHaveProperty("execution_scope", "production");
|
||||
}
|
||||
);
|
||||
|
||||
it.each([undefined, null, "", 1])("rejects an invalid execution scope %j", (scope) => {
|
||||
const detail = { ...task(), execution_scope: scope };
|
||||
const {
|
||||
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
|
||||
actions: _actions, permissions: _permissions, ...summary
|
||||
} = detail;
|
||||
expect(() => parseOperationalTaskSummary(summary)).toThrow();
|
||||
expect(() => parseOperationalTask(detail)).toThrow();
|
||||
});
|
||||
|
||||
describe.each(["event_investigation", "external_work_order", "scheduled_flow"])("%s replay context", (taskType) => {
|
||||
const replayContext = { replay_id: id, knowledge_cutoff: time, batch_sequence: 1 };
|
||||
|
||||
@@ -170,6 +193,7 @@ describe("operational task contract", () => {
|
||||
it("keeps unknown fields and invalid generations rejected", () => {
|
||||
expect(() => parseOperationalTask(task(0))).toThrow();
|
||||
const value = task();
|
||||
expect(() => parseOperationalTask({ ...value, unexpected: true })).toThrow();
|
||||
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
|
||||
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
|
||||
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { requestWorkbenchJson } from "./request-json";
|
||||
import { z } from "zod";
|
||||
import { investigationReportSchema, investigationDiagramSchema } from "./investigation-report";
|
||||
import { investigationPresentationCursorSchema, investigationPresentationHistoryPageSchema, type InvestigationPresentationHistoryEvent } from "./investigation-presentation";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
|
||||
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
|
||||
const taskPrioritySchema = z.enum(["normal", "urgent"]);
|
||||
@@ -50,6 +49,8 @@ const operationalTaskRunPageSchema = z.object({
|
||||
next_cursor: z.string().min(1).nullable()
|
||||
}).strict();
|
||||
const taskSummarySchema = z.object({
|
||||
execution_scope: z.string().min(1),
|
||||
handling_context: z.record(z.unknown()).optional(),
|
||||
project_id: z.string().uuid(),
|
||||
task_id: z.string().uuid(),
|
||||
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
|
||||
@@ -430,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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,77 @@ import {
|
||||
} from "./project-client";
|
||||
|
||||
describe("workbench project client", () => {
|
||||
it("explains project-list timeouts using the Server readiness result", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
if (new URL(String(input)).pathname === "/ready") return Response.json({
|
||||
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
|
||||
}, { status: 503 });
|
||||
throw new DOMException("signal timed out", "TimeoutError");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("读取授权项目超时(已等待 15 秒)");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const probe = fetchMock.mock.calls.find(([url]) => new URL(String(url)).pathname === "/ready");
|
||||
expect(probe).toBeDefined();
|
||||
expect(new Headers(probe?.[1]?.headers).has("Authorization")).toBe(false);
|
||||
});
|
||||
|
||||
it("identifies metadata unavailability instead of exposing the raw abort message", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (new URL(String(input)).pathname === "/ready") return Response.json({
|
||||
status: "not_ready", checks: { startup: "ready", metadata_database: "unavailable", mcp_transport: "ready" }
|
||||
}, { status: 503 });
|
||||
throw new DOMException("signal timed out", "TimeoutError");
|
||||
}));
|
||||
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("项目元数据库暂不可用");
|
||||
});
|
||||
|
||||
it("names backend startup and an unavailable listener separately", async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (new URL(String(input)).pathname === "/ready") return Response.json({
|
||||
status: "not_ready", checks: { startup: "not_ready", metadata_database: "not_checked", mcp_transport: "not_ready" }
|
||||
}, { status: 503 });
|
||||
throw new DOMException("signal timed out", "TimeoutError");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("后端服务正在启动");
|
||||
fetchMock.mockRejectedValue(new DOMException("signal timed out", "TimeoutError"));
|
||||
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("后端服务未响应");
|
||||
});
|
||||
|
||||
it("preserves caller cancellation without probing readiness or presenting a timeout", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(fetchWorkbenchProjects(undefined, controller.signal)).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries a timed-out lookup after the backend recovers without keeping its cached failure", async () => {
|
||||
let available = false;
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
if (new URL(String(input)).pathname === "/ready") return Response.json({
|
||||
status: "ready", checks: { startup: "ready", metadata_database: "ready", mcp_transport: "ready" }
|
||||
});
|
||||
if (!available) throw new DOMException("signal timed out", "TimeoutError");
|
||||
return Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1, limit: 1000, offset: 0 });
|
||||
}));
|
||||
const provider = async () => "token";
|
||||
await expect(findOwnedWorkbenchProject(provider)).rejects.toThrow("后端当前已就绪");
|
||||
available = true;
|
||||
await expect(findOwnedWorkbenchProject(provider)).resolves.toMatchObject({ project: { project_id: "supply" } });
|
||||
});
|
||||
|
||||
it("keeps authorization failures distinct from service availability", async () => {
|
||||
const fetchMock = vi.fn(async () => Response.json({ detail: "当前账号没有项目访问权限" }, { status: 403 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await expect(fetchWorkbenchProjects(async () => "token")).rejects.toThrow("当前账号没有项目访问权限");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("caches complete authorized lists across repeated openings and refreshes explicitly or after expiry", async () => {
|
||||
const clock = vi.spyOn(Date, "now").mockReturnValue(100);
|
||||
const fetch = vi.fn(async () => Response.json({ items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }], total: 1, limit: 1000, offset: 0 }));
|
||||
|
||||
@@ -5,6 +5,7 @@ import { env } from "@/shared/config/env";
|
||||
import type { NetworkViewParams } from "../map/camera";
|
||||
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
|
||||
import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context";
|
||||
import { PROJECT_REQUEST_TIMEOUT_MS, withProjectRequest } from "./project-request";
|
||||
|
||||
const projectSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
@@ -117,11 +118,11 @@ export async function findOwnedWorkbenchProject(
|
||||
if (existing && existing.expiresAt > Date.now()) {
|
||||
return { project: await awaitProject(existing.promise, signal), accessToken };
|
||||
}
|
||||
const loadProject = async () => {
|
||||
const loadProject = () => withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
|
||||
headers: createWorkbenchHeaders(accessToken),
|
||||
// One caller cancelling must not cancel other consumers of this lookup.
|
||||
signal: getAccessToken ? AbortSignal.timeout(15_000) : signal
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||
@@ -131,7 +132,7 @@ export async function findOwnedWorkbenchProject(
|
||||
);
|
||||
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
|
||||
return project;
|
||||
};
|
||||
}, getAccessToken ? undefined : signal);
|
||||
const pending = loadProject();
|
||||
if (getAccessToken) {
|
||||
projectContextPromises.set(getAccessToken, { promise: pending, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS });
|
||||
@@ -183,7 +184,7 @@ export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvide
|
||||
if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal);
|
||||
let cache = projectListCaches.get(getAccessToken);
|
||||
if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); }
|
||||
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(15_000)),
|
||||
const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS)),
|
||||
PROJECT_CACHE_TTL_MS, { force });
|
||||
if (!signal) return pending;
|
||||
signal.throwIfAborted();
|
||||
@@ -200,12 +201,14 @@ async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signa
|
||||
const projects: WorkbenchProject[] = [];
|
||||
let offset = 0;
|
||||
while (true) {
|
||||
const page = await withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
|
||||
headers: createWorkbenchHeaders(token), signal
|
||||
headers: createWorkbenchHeaders(token), signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||
const page = projectPageSchema.parse(payload);
|
||||
return projectPageSchema.parse(payload);
|
||||
}, signal);
|
||||
projects.push(...page.items.filter((project) => project.status === "active"));
|
||||
offset += page.items.length;
|
||||
if (offset >= page.total) break;
|
||||
@@ -236,9 +239,10 @@ async function fetchProjectMetadata(
|
||||
headers: Headers,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
return withProjectRequest(SERVER_API_BASE_URL, "读取项目配置", async (requestSignal) => {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
|
||||
headers,
|
||||
signal
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
@@ -249,6 +253,7 @@ async function fetchProjectMetadata(
|
||||
throw new Error("项目元数据与授权项目不一致");
|
||||
}
|
||||
return metadata;
|
||||
}, signal);
|
||||
}
|
||||
|
||||
async function fetchAccessContext(
|
||||
@@ -256,9 +261,10 @@ async function fetchAccessContext(
|
||||
headers: Headers,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
return withProjectRequest(SERVER_API_BASE_URL, "读取项目访问权限", async (requestSignal) => {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
|
||||
headers,
|
||||
signal
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
@@ -269,6 +275,7 @@ async function fetchAccessContext(
|
||||
throw new Error("访问上下文与授权项目不一致");
|
||||
}
|
||||
return accessContext;
|
||||
}, signal);
|
||||
}
|
||||
|
||||
export function createWorkbenchHeaders(
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const PROJECT_REQUEST_TIMEOUT_MS = 15_000;
|
||||
const READINESS_TIMEOUT_MS = 3_000;
|
||||
const readinessSchema = z.object({
|
||||
status: z.enum(["ready", "not_ready"]),
|
||||
checks: z.object({
|
||||
startup: z.string(),
|
||||
metadata_database: z.string(),
|
||||
mcp_transport: z.string()
|
||||
})
|
||||
});
|
||||
|
||||
const isTimeout = (error: unknown) => Boolean(error && typeof error === "object"
|
||||
&& "name" in error && error.name === "TimeoutError");
|
||||
|
||||
async function describeServerReadiness(baseUrl: string): Promise<string> {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/ready`, {
|
||||
signal: AbortSignal.timeout(READINESS_TIMEOUT_MS),
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store"
|
||||
});
|
||||
const parsed = readinessSchema.safeParse(await response.json());
|
||||
if (parsed.success) {
|
||||
const { status, checks } = parsed.data;
|
||||
if (checks.startup === "not_ready") {
|
||||
return "后端服务正在启动,尚未准备好处理项目请求。请稍后重新加载。";
|
||||
}
|
||||
if (checks.metadata_database === "unavailable") {
|
||||
return "后端项目元数据库暂不可用。请稍后重试,若持续失败,请检查后端数据库连接。";
|
||||
}
|
||||
if (checks.mcp_transport === "not_ready") {
|
||||
return "后端查询服务尚未就绪。请稍后重新加载。";
|
||||
}
|
||||
if (response.ok && status === "ready") {
|
||||
return "后端当前已就绪,请重新加载项目。";
|
||||
}
|
||||
}
|
||||
return "暂时无法确认后端就绪状态。服务可能正在重启或响应缓慢,请稍后重新加载。";
|
||||
} catch {
|
||||
return "后端服务未响应,可能正在重启或热重载。请稍后重新加载,若持续失败,请检查后端服务。";
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep caller cancellation distinct from a request deadline or unavailable backend. */
|
||||
export async function withProjectRequest<T>(
|
||||
baseUrl: string,
|
||||
operation: string,
|
||||
request: (signal: AbortSignal) => Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
timeoutMs: number | null = PROJECT_REQUEST_TIMEOUT_MS
|
||||
): Promise<T> {
|
||||
const deadline = timeoutMs == null ? undefined : AbortSignal.timeout(timeoutMs);
|
||||
const combined = signal && deadline ? AbortSignal.any([signal, deadline]) : signal ?? deadline ?? new AbortController().signal;
|
||||
try {
|
||||
combined.throwIfAborted();
|
||||
return await request(combined);
|
||||
} catch (error) {
|
||||
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
|
||||
if (timeoutMs != null && (isTimeout(error) || (combined.aborted && isTimeout(combined.reason)))) {
|
||||
const diagnosis = await describeServerReadiness(baseUrl);
|
||||
throw new Error(`${operation}超时(已等待 ${timeoutMs / 1000} 秒)。${diagnosis}`, { cause: error });
|
||||
}
|
||||
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
|
||||
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { requestWorkbenchJson } from "./request-json";
|
||||
import { z } from "zod";
|
||||
import 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({
|
||||
@@ -21,6 +21,22 @@ const eventSchema = z.object({
|
||||
detail: z.string(), task_id: z.string().nullable(), category: z.string().optional(),
|
||||
change: z.enum(["raised", "resolved"]).optional(), resolved_at: z.string().optional()
|
||||
});
|
||||
const monitoringWeightsSchema = z.object({
|
||||
burst_detection: z.number(), pressure: z.number(), outlet_flow: z.number()
|
||||
});
|
||||
const monitoringConfigSchema = z.object({
|
||||
rule_version: z.string(), decision_policy: z.enum(["weighted", "all_signals"]),
|
||||
pressure_device_ids: z.array(z.string()), outlet_flow_device_ids: z.array(z.string()),
|
||||
weights: monitoringWeightsSchema, alert_threshold: z.number()
|
||||
});
|
||||
const calibrationSchema = z.object({
|
||||
version: z.string().optional(), diagnostic_policy: z.string().optional(),
|
||||
status: z.enum(["ready", "insufficient_data"]),
|
||||
knowledge_cutoff: z.string(), history_start: z.string(),
|
||||
pressure: z.record(z.string(), z.object({ threshold_percent: z.number() })),
|
||||
outlet_flow: z.record(z.string(), z.object({ threshold_percent: z.number() })),
|
||||
burst_detection: z.object({ threshold: z.number().optional() }).optional()
|
||||
});
|
||||
const monitoringSchema = z.object({
|
||||
monitoring_id: z.string().uuid(),
|
||||
period_start: z.string(), period_end: z.string(), knowledge_cutoff: z.string(),
|
||||
@@ -29,7 +45,19 @@ const monitoringSchema = z.object({
|
||||
detail: z.string(), task_id: z.string().uuid().nullable(),
|
||||
pressure: z.record(z.string(), z.unknown()),
|
||||
outlet_flow: z.record(z.string(), z.unknown()),
|
||||
burst_detection: z.record(z.string(), z.unknown())
|
||||
burst_detection: z.record(z.string(), z.unknown()),
|
||||
anomaly_signals: z.array(z.object({
|
||||
event_id: z.string(), rule_id: z.string(), device_ids: z.array(z.string()), detail: z.string()
|
||||
})).optional(),
|
||||
weighted: z.object({
|
||||
score: z.number(), threshold: z.number(),
|
||||
weights: monitoringWeightsSchema
|
||||
}).passthrough().nullable().optional(),
|
||||
incident: z.object({
|
||||
incident_id: z.string().uuid(), status: z.enum(["active", "resolved"]),
|
||||
action: z.enum(["raised", "ongoing", "resolved"]),
|
||||
started_at: z.string(), last_observed_at: z.string(), recovery_count: z.number().int()
|
||||
}).nullable().optional()
|
||||
}).passthrough();
|
||||
export const scadaReplaySchema = z.object({
|
||||
replay_id: z.string().uuid(), project_id: z.string().uuid(), revision: z.number().int(),
|
||||
@@ -39,6 +67,8 @@ export const scadaReplaySchema = z.object({
|
||||
step_pending: z.boolean().default(false),
|
||||
monitoring_count: z.number().int().default(0),
|
||||
last_monitoring: monitoringSchema.nullable().default(null),
|
||||
monitoring_config: monitoringConfigSchema.nullable().optional(),
|
||||
monitoring_calibration: calibrationSchema.nullable().optional(),
|
||||
delivered_until: z.string().nullable(), processed_until: z.string().nullable(),
|
||||
processed_count: z.number(), anomaly_count: z.number(),
|
||||
devices: z.array(z.object({ device_id: z.string(), measurement_unit: z.string(), device_type: z.string() })),
|
||||
@@ -49,8 +79,11 @@ const capabilitiesSchema = z.object({
|
||||
available: z.boolean(), can_write: z.boolean(), reason: z.string().nullable(), current: scadaReplaySchema.nullable(),
|
||||
monitoring_interval_seconds: z.number().int().min(60).max(31_536_000).nullable().default(null),
|
||||
burst_monitoring_ready: z.boolean().default(false),
|
||||
burst_monitoring_reason: z.string().nullable().default(null)
|
||||
burst_monitoring_reason: z.string().nullable().default(null),
|
||||
monitoring_config: monitoringConfigSchema.nullable().optional()
|
||||
});
|
||||
export type ScadaMonitoringConfig = z.infer<typeof monitoringConfigSchema>;
|
||||
export type ScadaMonitoringCalibration = z.infer<typeof calibrationSchema>;
|
||||
export type ScadaReplay = z.infer<typeof scadaReplaySchema>;
|
||||
export type ScadaReplayRule = z.infer<typeof ruleSchema>;
|
||||
export type ScadaReplayEvent = z.infer<typeof eventSchema>;
|
||||
@@ -85,18 +118,20 @@ export class ScadaReplayClient {
|
||||
request_id: crypto.randomUUID(), expected_revision: replay.revision, action
|
||||
}));
|
||||
}
|
||||
async monitorAt(replay: ScadaReplay, atTime: string) {
|
||||
return scadaReplaySchema.parse(await this.request(`/${replay.replay_id}/commands`, {
|
||||
request_id: crypto.randomUUID(), expected_revision: replay.revision,
|
||||
action: "monitor", at_time: atTime
|
||||
}));
|
||||
}
|
||||
private async request(path: string, body?: unknown, signal?: AbortSignal) {
|
||||
this.projectId ??= findOwnedWorkbenchProject(this.access).then(({ project }) => project.project_id)
|
||||
.catch((error) => { this.projectId = null; throw error; });
|
||||
const [projectId, token] = await Promise.all([this.projectId, getWorkbenchAccessToken(this.access)]);
|
||||
const headers = createWorkbenchHeaders(token, projectId);
|
||||
if (body !== undefined) headers.set("Content-Type", "application/json");
|
||||
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
|
||||
method: body === undefined ? "GET" : "POST", headers, signal,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) })
|
||||
return requestWorkbenchJson(`/api/v1/scada-replays${path}`, "SCADA 回放", {
|
||||
credentials: { projectId, accessToken: token },
|
||||
init: { method: body === undefined ? "GET" : "POST", signal,
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }) }
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "SCADA 回放"));
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { requestWorkbenchJson } from "./request-json";
|
||||
import { z } from "zod";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
|
||||
const analysisHistoryItemSchema = z.object({
|
||||
run_id: z.string().trim().min(1),
|
||||
@@ -41,7 +40,6 @@ export type WorkbenchAnalysisHistoryPage = {
|
||||
nextOffset: number | null;
|
||||
};
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
|
||||
export async function fetchWorkbenchAnalysisHistory(
|
||||
options: {
|
||||
@@ -59,25 +57,13 @@ export async function fetchWorkbenchAnalysisHistory(
|
||||
if (!Number.isInteger(offset) || offset < 0) {
|
||||
throw new Error("分析成果历史偏移量无效");
|
||||
}
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(
|
||||
options.getAccessToken,
|
||||
options.signal
|
||||
);
|
||||
const search = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset)
|
||||
});
|
||||
const response = await fetch(
|
||||
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs?${search}`,
|
||||
{
|
||||
headers: createWorkbenchHeaders(accessToken, project.project_id),
|
||||
signal: options.signal
|
||||
}
|
||||
);
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(getServerError(payload, response.status, "分析成果历史请求"));
|
||||
}
|
||||
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs?${search}`, "分析成果历史请求", {
|
||||
access: options.getAccessToken, init: { signal: options.signal }
|
||||
});
|
||||
const page = analysisHistoryPageSchema.parse(payload);
|
||||
return {
|
||||
items: page.items.map((item) => ({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { requestWorkbenchJson } from "./request-json";
|
||||
import { z } from "zod";
|
||||
import type { FeatureCollection, Geometry, Position } from "geojson";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { formatDateTime } from "@/shared/utils/date-time";
|
||||
import type {
|
||||
ScheduledConditionRecord,
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
WorkbenchScenario
|
||||
} from "../types";
|
||||
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
|
||||
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
|
||||
|
||||
// Keep legacy analysis values parseable while old servers are rolling forward;
|
||||
// they are filtered before the operational workbench model is created.
|
||||
@@ -159,7 +158,6 @@ export type WorkbenchAnalysisProjection = {
|
||||
simulationOverlay: FeatureCollection<Geometry> | null;
|
||||
};
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
|
||||
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
|
||||
const snapshot = runtimeSnapshotSchema.parse(payload);
|
||||
@@ -264,18 +262,9 @@ export async function fetchWorkbenchAnalysisRun(
|
||||
): Promise<WorkbenchAnalysisProjection> {
|
||||
const normalizedRunId = runId.trim();
|
||||
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
const response = await fetch(
|
||||
`${SERVER_API_BASE_URL}/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`,
|
||||
{
|
||||
headers: createWorkbenchHeaders(accessToken, project.project_id),
|
||||
signal
|
||||
}
|
||||
);
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(getServerError(payload, response.status, "分析运行成果请求"));
|
||||
}
|
||||
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
|
||||
access: getAccessToken, init: { signal }
|
||||
});
|
||||
const analysis = runtimeLatestAnalysisSchema.parse(payload);
|
||||
if (analysis.run_id !== normalizedRunId) {
|
||||
throw new Error("分析运行成果与请求 ID 不一致");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { requestWorkbenchJson } from "./request-json";
|
||||
import { z } from "zod";
|
||||
import type { WorkbenchAccess } from "./request-context";
|
||||
import { env } from "@/shared/config/env";
|
||||
@@ -63,13 +64,9 @@ export async function fetchWorkbenchSyncSnapshot(
|
||||
getAccessToken?: WorkbenchAccess,
|
||||
signal?: AbortSignal
|
||||
): Promise<WorkbenchSyncSnapshot> {
|
||||
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
|
||||
headers: createWorkbenchHeaders(accessToken, project.project_id),
|
||||
signal
|
||||
const payload = await requestWorkbenchJson("/api/v1/workbench/sync-snapshot", "工作台同步快照请求", {
|
||||
access: getAccessToken, init: { signal }
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
|
||||
const envelope = snapshotEnvelopeSchema.parse(payload);
|
||||
return {
|
||||
eventCursor: envelope.event_cursor,
|
||||
|
||||
@@ -62,6 +62,7 @@ describe("execution step cards", () => {
|
||||
});
|
||||
|
||||
const taskBase = {
|
||||
execution_scope: "production",
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: "event_investigation",
|
||||
@@ -130,6 +131,48 @@ function taskPanel(task: OperationalTask, overrides: Partial<ComponentProps<type
|
||||
return { ...props, rerender };
|
||||
}
|
||||
|
||||
it.each(["failed", "unsupported"] as const)("retries an approved %s action using its current version", async (status) => {
|
||||
let finish!: () => void;
|
||||
const onExecute = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
|
||||
const task: OperationalTask = { ...taskBase,
|
||||
permissions: { ...taskBase.permissions, can_execute_work_orders: true },
|
||||
actions: [{ ...taskBase.actions[0], approval_status: "approved", execution_status: status }]
|
||||
};
|
||||
const props = taskPanel(task, { onExecute });
|
||||
const section = screen.getByRole("region", { name: "当前需处理" });
|
||||
expect(section).toHaveTextContent("待重试");
|
||||
const retry = within(section).getByRole("button", { name: "重试执行" });
|
||||
fireEvent.click(retry);
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.click(retry);
|
||||
expect(onExecute).toHaveBeenCalledExactlyOnceWith(task.actions[0].action_id, task.actions[0].version);
|
||||
expect(props.onDecision).not.toHaveBeenCalled();
|
||||
finish();
|
||||
await waitFor(() => expect(retry).toBeEnabled());
|
||||
props.rerender(<AgentExecutionPlanPanel {...props} task={{ ...task,
|
||||
actions: [{ ...task.actions[0], version: 4, execution_status: "submitted" }]
|
||||
}} />);
|
||||
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "核对执行回执" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("keeps retry disabled without work-order execution permission", () => {
|
||||
const props = taskPanel({ ...taskBase, actions: [{ ...taskBase.actions[0],
|
||||
approval_status: "approved", execution_status: "unsupported"
|
||||
}] });
|
||||
const retry = screen.getByRole("button", { name: "重试执行" });
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.click(retry);
|
||||
expect(props.onExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["pending", "rejected"] as const)("does not retry a %s action", (approval) => {
|
||||
taskPanel({ ...taskBase, permissions: { ...taskBase.permissions, can_execute_work_orders: true },
|
||||
actions: [{ ...taskBase.actions[0], approval_status: approval, execution_status: "failed" }]
|
||||
});
|
||||
expect(screen.queryByRole("button", { name: "重试执行" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps fixed question answers in the report and focuses them from a data gap", () => {
|
||||
const task: OperationalTask = { ...taskBase, actions: [], active_run: null, lifecycle_status: "waiting_human",
|
||||
questions: [{ question_request_id: "70000000-0000-4000-8000-000000000001", task_id: taskBase.task_id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getReportPresentationState, snapshotRound } from "../utils/report-presentation-state";
|
||||
import { InvestigationReportView } from "./investigation-report-view";
|
||||
import { formatDateTime as formatDisplayDateTime } from "@/shared/utils/date-time";
|
||||
import { InvestigationTaskReport } from "./investigation-task-report";
|
||||
@@ -22,6 +23,7 @@ import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { cn } from "@/shared/ui/cn";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { DateTimeField } from "@/shared/ui/date-time-field";
|
||||
import { StatusBadge, StatusIcon, type StatusTone } from "@/shared/ui/status";
|
||||
import type {
|
||||
OperationalTask,
|
||||
@@ -32,6 +34,7 @@ import type {
|
||||
OperationalTaskDataSnapshot
|
||||
} from "../api/operational-task-client";
|
||||
import type { TaskExecutionHistoryTarget } from "../api/operational-task-client";
|
||||
import { isRetryableInvestigationAction } from "../utils/investigation-presentation";
|
||||
|
||||
type ExecutionNode = {
|
||||
id: string;
|
||||
@@ -108,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
|
||||
@@ -542,7 +535,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
<select aria-label="工单优先级" className="form-field min-h-10 rounded-lg border border-[var(--surface-divider)] bg-[var(--surface-reading)] px-2 text-xs" value={String(draft.priority ?? "normal")} onChange={(event) => setDraft({ ...draft, priority: event.target.value })}><option value="normal">普通</option><option value="urgent">紧急</option></select>
|
||||
<Input aria-label="工单位置" value={String(draft.location ?? "")} onChange={(event) => setDraft({ ...draft, location: event.target.value || null })} />
|
||||
<Input aria-label="处理班组" value={String(draft.assignee ?? "")} onChange={(event) => setDraft({ ...draft, assignee: event.target.value || null })} />
|
||||
<Input aria-label="预约时间" type="datetime-local" value={toLocalDateTime(draft.scheduled_at)} onChange={(event) => setDraft({ ...draft, scheduled_at: event.target.value ? new Date(event.target.value).toISOString() : null })} />
|
||||
<DateTimeField label="预约时间" value={toLocalDateTime(draft.scheduled_at)} onChange={(value) => setDraft({ ...draft, scheduled_at: value ? new Date(value).toISOString() : null })} disabled={submitting !== null} />
|
||||
<div className="flex justify-end gap-2"><Button variant="outline" size="sm" onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(false); }}>取消编辑</Button><Button size="sm" disabled={submitting !== null || !String(draft.title ?? "").trim() || !String(draft.description ?? "").trim()} onClick={() => void run("save")}>保存草稿</Button></div>
|
||||
</div> : null}
|
||||
{action.evidence_refs.length > 0 ? (
|
||||
@@ -567,6 +560,7 @@ export function HumanActionCard({ task, action, onDecision, onExecute, onDraftUp
|
||||
<div className="mt-3 flex flex-wrap justify-end gap-2">
|
||||
{action.approval_status === "pending" && canDecide ? <><Button variant="outline" size="sm" disabled={submitting !== null} onClick={() => void run("reject")}>拒绝动作</Button>{!command && onDraftUpdate ? <Button variant="outline" size="sm" disabled={submitting !== null || editing} onClick={() => { setDraft(workOrderDraft(action.payload)); setEditing(true); }}>编辑草稿</Button> : null}<Button size="sm" disabled={submitting !== null || editing || savedVersion === action.version || (!command && !!onConfirmDispatch && !canExecute)} onClick={() => void run(!command && onConfirmDispatch ? "confirm" : "approve")}>{submitting === "approve" || submitting === "confirm" ? <Loader2 className="animate-spin" /> : null}{command ? "批准下发命令" : onConfirmDispatch ? "确认并创建预约工单" : "批准创建预约工单"}</Button></> : null}
|
||||
{action.approval_status === "approved" && action.execution_status === "not_submitted" ? <Button size="sm" disabled={!canExecute || submitting !== null} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : null}{command ? "下发命令" : "创建预约工单"}</Button> : null}
|
||||
{isRetryableInvestigationAction(action) ? <Button size="sm" disabled={!canExecute || submitting !== null || stale} title={!canExecute ? "当前账号无此动作执行权限" : undefined} onClick={() => void run("execute")}>{submitting === "execute" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}重试执行</Button> : null}
|
||||
{action.approval_status === "approved" && ["submitted", "unknown"].includes(action.execution_status) ? <Button variant="outline" size="sm" disabled={!canExecute || submitting !== null} onClick={() => void run("reconcile")}>{submitting === "reconcile" ? <Loader2 className="animate-spin" /> : <RefreshCw size={14} />}核对执行回执</Button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FeatureLocationPanel } from "./feature-location-panel";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const state = { pending: false, targets: [], targetResult: null, errorCode: null };
|
||||
const createCommands = () => ({ highlight: vi.fn(), zoomToFeature: vi.fn(), locateAndHighlight: vi.fn(), clearHighlight: vi.fn() });
|
||||
|
||||
describe("FeatureLocationPanel", () => {
|
||||
it("uses the selected feature's source and ID together", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
const onUseSelectedFeature = vi.fn();
|
||||
render(<FeatureLocationPanel commands={commands} controllerState={state}
|
||||
detailFeature={{ id: "PRESSURE-17", layer: "scada", title: "压力监测点", subtitle: "", properties: {} }}
|
||||
onUseSelectedFeature={onUseSelectedFeature} />);
|
||||
await user.click(screen.getByRole("button", { name: "使用当前地图选中" }));
|
||||
expect(screen.getByLabelText("要素 ID(可多个)")).toHaveValue("PRESSURE-17");
|
||||
await user.click(screen.getByRole("button", { name: "定位并高亮" }));
|
||||
expect(commands.locateAndHighlight).toHaveBeenCalledWith({ sourceId: "scada", featureIds: ["PRESSURE-17"] });
|
||||
expect(onUseSelectedFeature).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps map actions disabled until the map is ready and while a request is pending", async () => {
|
||||
const user = userEvent.setup();
|
||||
const commands = createCommands();
|
||||
const props = { commands, controllerState: state, detailFeature: null };
|
||||
const { rerender } = render(<FeatureLocationPanel {...props} enabled={false} />);
|
||||
await user.type(screen.getByLabelText("要素 ID(可多个)"), "P-17");
|
||||
const action = () => screen.getByRole("button", { name: "定位并高亮" });
|
||||
expect(action()).toBeDisabled();
|
||||
rerender(<FeatureLocationPanel {...props} enabled />);
|
||||
expect(action()).toBeEnabled();
|
||||
rerender(<FeatureLocationPanel {...props} controllerState={{ ...state, pending: true }} enabled />);
|
||||
expect(screen.getByRole("button", { name: "正在定位要素" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "仅高亮" })).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "缩放到要素" })).toBeDisabled();
|
||||
expect(commands.locateAndHighlight).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Crosshair, LocateFixed, MousePointer2, Trash2, ZoomIn } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import { AVAILABLE_WATER_NETWORK_SOURCE_IDS, SUPPLY_LAYER_CATALOG, type AvailableWaterNetworkSourceId } from "../map/sources";
|
||||
import type { FeatureBatchTarget, WorkbenchMapCommands, WorkbenchMapControllerState } from "../map/workbench-map-controller";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
|
||||
|
||||
type FeatureLocationDraft = {
|
||||
sourceId: AvailableWaterNetworkSourceId;
|
||||
featureId: string;
|
||||
};
|
||||
|
||||
export type FeatureLocationPanelProps = {
|
||||
commands: Pick<WorkbenchMapCommands, "highlight" | "zoomToFeature" | "locateAndHighlight" | "clearHighlight">;
|
||||
controllerState: Pick<WorkbenchMapControllerState, "pending" | "targets" | "targetResult" | "errorCode">;
|
||||
detailFeature: DetailFeature | null;
|
||||
enabled?: boolean;
|
||||
value?: FeatureLocationDraft;
|
||||
onValueChange?: (value: FeatureLocationDraft) => void;
|
||||
onUseSelectedFeature?: () => void;
|
||||
showError?: boolean;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function FeatureLocationPanel({ commands, controllerState, detailFeature, enabled = true,
|
||||
value, onValueChange, onUseSelectedFeature, showError = true, contentClassName }: FeatureLocationPanelProps) {
|
||||
const inputId = useId();
|
||||
const helpId = useId();
|
||||
const [draft, setDraft] = useState<FeatureLocationDraft>({ sourceId: "pipes", featureId: "" });
|
||||
const { sourceId, featureId } = value ?? draft;
|
||||
function updateDraft(next: FeatureLocationDraft) {
|
||||
if (!value) setDraft(next);
|
||||
onValueChange?.(next);
|
||||
}
|
||||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||||
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
|
||||
const canLocate = enabled && featureIds.length > 0 && !invalidIds && !controllerState.pending;
|
||||
const canUseSelected = Boolean(detailFeature && AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(detailFeature.layer as AvailableWaterNetworkSourceId));
|
||||
const target: FeatureBatchTarget = { sourceId, featureIds };
|
||||
const result = controllerState.targetResult;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<FieldSelect label="业务源" value={sourceId} contentClassName={contentClassName}
|
||||
onValueChange={(nextSourceId) => updateDraft({ sourceId: nextSourceId as AvailableWaterNetworkSourceId, featureId: "" })}
|
||||
options={SUPPLY_LAYER_CATALOG.filter((layer) => layer.available).map((layer) => ({ value: layer.id, label: layer.label }))}
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<label htmlFor={inputId} className="text-xs font-medium text-[var(--ink-muted)]">要素 ID(可多个)</label>
|
||||
<span className="text-[11px] tabular-nums text-[var(--ink-muted)]">{featureIds.length} / {MAX_MAP_FEATURE_IDS}</span>
|
||||
</div>
|
||||
<Textarea id={inputId} value={featureId} rows={3} placeholder="粘贴要素 ID,支持多个"
|
||||
onChange={(event) => updateDraft({ sourceId, featureId: event.target.value })} aria-describedby={helpId} aria-invalid={invalidIds}
|
||||
className="min-h-24 resize-y border-[var(--surface-border)] bg-[var(--surface-reading)] text-xs shadow-none md:text-xs"
|
||||
/>
|
||||
<p id={helpId} className="text-[11px] leading-5 text-[var(--ink-muted)]">逗号、空格或换行分隔,自动去重。</p>
|
||||
</div>
|
||||
{invalidIds ? <p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
<Button type="button" variant="secondary" className="w-full text-xs" disabled={!canUseSelected}
|
||||
onClick={() => {
|
||||
if (!detailFeature || !canUseSelected) return;
|
||||
updateDraft({ sourceId: detailFeature.layer as AvailableWaterNetworkSourceId, featureId: detailFeature.id });
|
||||
onUseSelectedFeature?.();
|
||||
}}>
|
||||
<MousePointer2 aria-hidden="true" />使用当前地图选中
|
||||
</Button>
|
||||
<div className="space-y-2 border-t border-[var(--surface-divider)] pt-3">
|
||||
<Button type="button" className="w-full text-xs" disabled={!canLocate}
|
||||
loading={controllerState.pending} loadingLabel="正在定位要素"
|
||||
onClick={() => void commands.locateAndHighlight(target)}>
|
||||
<LocateFixed aria-hidden="true" />定位并高亮
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.highlight(target)}>
|
||||
<Crosshair aria-hidden="true" />仅高亮
|
||||
</Button>
|
||||
<Button type="button" variant="outline" className="text-xs" disabled={!canLocate} onClick={() => void commands.zoomToFeature(target)}>
|
||||
<ZoomIn aria-hidden="true" />缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!enabled ? <p role="status" className="text-xs text-[var(--ink-muted)]">等待地图加载后即可定位</p> : null}
|
||||
{showError && controllerState.errorCode && FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode] ? (
|
||||
<p role="alert" className="text-xs leading-5 text-[var(--status-danger-foreground)]">{FEATURE_LOCATION_ERROR_LABELS[controllerState.errorCode]}</p>
|
||||
) : null}
|
||||
{result ? <p role="status" className="break-words text-xs leading-5 text-[var(--ink-muted)]">
|
||||
本次命中 {result.featureIds.length} 个{result.missingIds.length ? `,未找到 ${result.missingIds.length} 个:${result.missingIds.join("、")}` : ",全部找到"}
|
||||
</p> : null}
|
||||
<div className="flex items-center justify-between gap-2 border-t border-[var(--surface-divider)] pt-2">
|
||||
<p className="text-[11px] text-[var(--ink-muted)]">已高亮 {controllerState.targets.length} 个要素</p>
|
||||
<Button type="button" variant="ghost" className="shrink-0 text-xs" onClick={() => commands.clearHighlight()}>
|
||||
<Trash2 aria-hidden="true" />清除高亮
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChevronDown, Crosshair, LocateFixed, MapPin, Search, Trash2 } from "lucide-react";
|
||||
import { 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,21 +124,22 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
{!selectionCollapsed ? <ul ref={candidateListRef} aria-busy={pending} className={cn(RESULT_VIEWPORT_CLASS_NAME, "bg-transparent", pending && "opacity-60")}>
|
||||
{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}
|
||||
{result && selectionCollapsed ? (
|
||||
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden")}>
|
||||
<section aria-label="定位结果" className={cn(RESULT_VIEWPORT_CLASS_NAME, "flex flex-col gap-2 overflow-hidden", mapResult && "h-auto shrink-0")}>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain [scrollbar-width:thin]">
|
||||
<div className={mapResult ? "space-y-2" : "space-y-3"}>
|
||||
<div className={cn("flex items-center gap-3 border-b border-[var(--surface-divider)] px-1 text-xs", mapResult ? "min-h-12 py-1" : "min-h-16 py-3")}>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Check, FileText, X } from "lucide-react";
|
||||
import { AgentChartView } from "@/features/agent";
|
||||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { InvestigationChartGroup as ChartGroup } from "../utils/investigation-chart-groups";
|
||||
|
||||
export function InvestigationChartGroup({ group, reviewing, onCloseChart, references, tracked = true, tile = false }: {
|
||||
group: ChartGroup;
|
||||
tracked?: boolean;
|
||||
tile?: boolean;
|
||||
reviewing: boolean;
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const card = useRef<HTMLDivElement>(null);
|
||||
const [plotHeight, setPlotHeight] = useState(220);
|
||||
useLayoutEffect(() => {
|
||||
if (tile) return;
|
||||
const viewport = card.current?.closest<HTMLElement>("[data-investigation-content]");
|
||||
if (!viewport || typeof ResizeObserver === "undefined") return;
|
||||
const resize = () => setPlotHeight(Math.max(120, Math.min(220, viewport.clientHeight - 24)));
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [tile]);
|
||||
const [selectedId, setSelectedId] = useState(group.items[0].command.chart_id);
|
||||
const selected = group.items.find((item) => item.command.chart_id === selectedId) ?? group.items[0];
|
||||
const multiple = group.items.length > 1;
|
||||
const title = formatChartDisplayText(multiple ? group.title : selected.command.chart.title);
|
||||
const objectButtons = (compact = false) => <aside aria-label="选择图表对象"
|
||||
className={compact ? "hidden min-h-0 min-w-0 overflow-auto border-l border-border pl-2 @min-[420px]:block" : "min-h-0 min-w-0 overflow-auto border-t border-border pt-3 @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pl-3 @min-[560px]:pt-0"}>
|
||||
<div className={`${compact ? "mb-1" : "mb-2"} flex items-center justify-between text-[11px] text-muted-foreground`}>
|
||||
<span>监测对象</span><span className="tabular-nums">{group.items.length} 个</span>
|
||||
</div>
|
||||
<div className={compact ? "grid grid-cols-1 gap-1" : "grid grid-cols-2 gap-2 @min-[560px]:grid-cols-1"}>
|
||||
{group.items.map((item) => <Button key={item.command.chart_id} type="button" variant="outline" size="sm"
|
||||
aria-label={`切换对象 ${item.label}`} aria-pressed={selected.command.chart_id === item.command.chart_id}
|
||||
className={`h-auto ${compact ? "min-h-8 gap-1.5 px-2 py-1" : "min-h-10 gap-2 px-3 py-2"} min-w-0 justify-start whitespace-normal text-left font-normal after:inset-0 aria-pressed:border-primary aria-pressed:bg-accent aria-pressed:text-accent-foreground`}
|
||||
onClick={() => setSelectedId(item.command.chart_id)}>
|
||||
<span className={`size-2 shrink-0 rounded-full ${selected.command.chart_id === item.command.chart_id ? "bg-primary" : "bg-muted-foreground/50"}`} aria-hidden />
|
||||
<span className="min-w-0 flex-1 break-words tabular-nums">{item.label}</span>
|
||||
{selected.command.chart_id === item.command.chart_id ? <Check size={14} aria-hidden /> : null}
|
||||
</Button>)}
|
||||
</div>
|
||||
</aside>;
|
||||
const content = (expanded: boolean, compact = false) => compact ? <div className="@container flex h-full min-h-0 min-w-0 flex-col gap-1">
|
||||
{multiple ? <div className="shrink-0 @min-[420px]:hidden">
|
||||
<Select value={selected.command.chart_id} onValueChange={setSelectedId}>
|
||||
<SelectTrigger aria-label="选择图表对象" className="h-7 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="workbench-shadcn-surface z-[90]">{group.items.map((item) => <SelectItem key={item.command.chart_id} value={item.command.chart_id}>{item.label}</SelectItem>)}</SelectContent>
|
||||
</Select>
|
||||
</div> : null}
|
||||
<div className={`grid min-h-0 min-w-0 flex-1 grid-cols-1 grid-rows-1 gap-2 ${multiple ? "@min-[420px]:grid-cols-[minmax(0,1fr)_128px]" : ""}`}>
|
||||
<div className="flex min-h-0 min-w-0 flex-col"><AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded tile /></div>
|
||||
{multiple ? objectButtons(true) : null}
|
||||
</div>
|
||||
</div> : <div className={`@container min-w-0 ${expanded ? "flex h-full min-h-0 flex-col" : ""}`}>
|
||||
<div className={`grid min-w-0 grid-cols-1 gap-3 ${expanded ? multiple ? "flex-none @min-[560px]:min-h-0 @min-[560px]:flex-1" : "min-h-0 flex-1 grid-rows-1" : ""} ${multiple ? "@min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-1" : ""}`}>
|
||||
<div className={`min-w-0 ${expanded ? `flex min-h-0 flex-col ${multiple ? "h-[480px] @min-[560px]:h-full" : "h-full"}` : ""}`}>
|
||||
{multiple ? <p className="mb-1 shrink-0 text-xs font-medium tabular-nums">{selected.label}<span className="ml-2 font-normal text-muted-foreground">{selected.command.chart.x_data[0]} 至 {selected.command.chart.x_data.at(-1)}</span></p> : null}
|
||||
<AgentChartView key={selected.command.chart_id} chart={selected.command.chart} embedded expanded={expanded} tile={false} plotHeight={plotHeight} />
|
||||
</div>
|
||||
{multiple ? objectButtons() : null}
|
||||
</div>
|
||||
{selected.command.evidence_refs.length ? <div className={expanded ? "max-h-[20%] shrink-0 overflow-auto" : undefined}>{references(selected.command.evidence_refs)}</div> : null}
|
||||
</div>;
|
||||
return <Card ref={card} data-investigation-chart={tracked ? group.items[0].command.chart_id : undefined} data-chart-group-tile={tile || undefined}
|
||||
role="group" aria-label={title} className={`relative min-w-0 border-border shadow-none ${tile ? "flex h-full min-h-0 flex-col overflow-hidden" : ""}`}>
|
||||
{tracked ? group.items.slice(1).map((item) => <span key={item.command.chart_id} data-investigation-chart={item.command.chart_id} className="pointer-events-none absolute inset-0" />) : null}
|
||||
<CardHeader className={`flex-row items-start justify-between gap-2 space-y-0 ${tile ? "shrink-0 px-2 py-1.5" : "p-3 pb-2"}`}>
|
||||
<div className="min-w-0">
|
||||
<h3 className={`break-words text-xs font-semibold [overflow-wrap:anywhere] ${tile ? "leading-4" : "leading-5"}`}>{title}</h3>
|
||||
{!tile ? <p className="mt-1 text-[11px] text-muted-foreground">{multiple ? `${group.items.length} 个对象 · 点击切换 · ` : ""}{selected.command.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : selected.command.chart.chart_type === "bar" ? "柱状图" : "折线图"} · {selected.command.chart.x_data.length} 个{selected.command.chart.chart_type === "bar" ? "类别" : "时间点"}</p> : null}
|
||||
</div>
|
||||
<div className={`flex shrink-0 items-center gap-1 ${tile ? "[&>button]:size-6" : ""}`}>
|
||||
{tile && selected.command.evidence_refs.length ? <Popover><PopoverTrigger asChild><Button type="button" variant="ghost" size="icon-sm" aria-label={`查看图表依据 ${title}`} title="查看图表依据"><FileText size={14} aria-hidden /></Button></PopoverTrigger>
|
||||
<PopoverContent align="end" className="workbench-shadcn-surface z-[90] max-h-72 overflow-auto p-2">{references(selected.command.evidence_refs)}</PopoverContent>
|
||||
</Popover> : null}
|
||||
<FloatingWindow title={title} fillContent>{() => content(true)}</FloatingWindow>
|
||||
<Button type="button" variant="ghost" size="icon-sm" disabled={reviewing} aria-label={`关闭图表 ${title}`}
|
||||
onClick={() => group.items.forEach((item) => onCloseChart(item.command.chart_id))}><X size={14} aria-hidden /></Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={tile ? "flex min-h-0 flex-1 flex-col px-2 pb-2 pt-0" : "px-3 pb-2 pt-0"}>
|
||||
{content(tile, tile)}
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { DragDropProvider, DragOverlay, type DragEndEvent } from "@dnd-kit/react";
|
||||
import { Columns2, GripVertical, LayoutGrid, LocateFixed, RotateCcw, Square } from "lucide-react";
|
||||
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
import { useChartWindowLayout } from "../hooks/use-chart-window-layout";
|
||||
import type { InvestigationChartGroup } from "../utils/investigation-chart-groups";
|
||||
import { presentationChartBlocks } from "../utils/presentation-chart-blocks";
|
||||
import { InvestigationChartGroup as ChartGroup } from "./investigation-chart-group";
|
||||
import { ChartDropSlot, ManagedChartSlot } from "./investigation-chart-slot";
|
||||
|
||||
type ManagedChart = { id: string; title: string } & ({ group: InvestigationChartGroup; block?: never } | { block: AnswerUiBlock; group?: never });
|
||||
const layouts = [{ id: "one", label: "单图布局", icon: Square }, { id: "two", label: "左右布局", icon: Columns2 }, { id: "four", label: "四宫格布局", icon: LayoutGrid }] as const;
|
||||
|
||||
export function InvestigationChartManager({ groups, blocks, autoOpen, taskTitle, reviewing, onCloseChart, references }: {
|
||||
groups: InvestigationChartGroup[];
|
||||
blocks: AnswerUiBlock[];
|
||||
/** Transient live intent, never derived from restored chart data or history. */
|
||||
autoOpen: boolean;
|
||||
taskTitle: string;
|
||||
reviewing: boolean;
|
||||
onCloseChart: (id: string) => void;
|
||||
references: (ids: string[]) => ReactNode;
|
||||
}) {
|
||||
const entries = useMemo<ManagedChart[]>(() => [
|
||||
...groups.map((group) => ({ id: `command:${group.id}`, title: formatChartDisplayText(group.title), group })),
|
||||
...presentationChartBlocks(blocks).map((block) => ({ id: `report:${block.block_id}`, title: formatChartDisplayText(String(block.spec.elements[block.spec.root].props.title)), block }))
|
||||
], [groups, blocks]);
|
||||
const titles = useMemo(() => new Map(entries.map(({ id, title }) => [id, title])), [entries]);
|
||||
const arrangement = useChartWindowLayout(entries.map(({ id }) => id));
|
||||
const { layout, slots, columns } = arrangement;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [located, setLocated] = useState<{ id: string } | null>(null);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const cards = useRef(new Map<string, HTMLElement>());
|
||||
const pendingLocation = useRef<string | null>(null);
|
||||
const helpId = useId();
|
||||
useEffect(() => {
|
||||
if (!located) return;
|
||||
const timer = window.setTimeout(() => setLocated(null), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [located]);
|
||||
const move = (id: string, target: number) => {
|
||||
arrangement.move(id, target);
|
||||
setAnnouncement(`${titles.get(id)},已移至位置 ${target + 1}`);
|
||||
};
|
||||
const drop = (event: DragEndEvent) => {
|
||||
const { source, target } = event.operation;
|
||||
if (!event.canceled && source && target && typeof target.data.slot === "number") move(String(source.id), target.data.slot);
|
||||
};
|
||||
const count = entries.length;
|
||||
return <span className="inline-flex shrink-0 [&>button]:w-8 [&>button]:px-0 @min-[340px]:[&>button]:w-auto @min-[340px]:[&>button]:px-2">
|
||||
<FloatingWindow title="图表管理" navigationTitle={`${taskTitle} · 图表管理`} triggerLabel="打开图表管理" triggerContent={<span className="hidden @min-[340px]:inline">图表窗口 <span className="tabular-nums">{count}</span></span>}
|
||||
initialSize={{ width: 1120, height: 760 }} fillContent
|
||||
// A session gets one automatic request. Updates never revoke a user's dismissal.
|
||||
openRequestKey={autoOpen ? "live-charts" : undefined}>
|
||||
{() => <section ref={arrangement.containerRef} aria-label="全部图表" className="@container flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<div className="shrink-0 space-y-2 border-b border-border pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1"><p className="break-words text-sm font-medium">{taskTitle}</p><p className="mt-1 text-xs text-muted-foreground">{reviewing ? "历史回看" : "当前结果"} · 拖到固定位置交换,支持单图放大</p></div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground tabular-nums">{count} 张图表</span>
|
||||
</div>
|
||||
<div role="toolbar" aria-label="图表管理工具" className="flex min-w-0 flex-wrap items-center gap-1 rounded-lg bg-muted/50 p-1">
|
||||
<Select value="" onValueChange={(id) => { setLocated({ id }); pendingLocation.current = id; }} disabled={!count}>
|
||||
<SelectTrigger aria-label="快速定位图表" className="h-8 w-auto min-w-0 max-w-72 flex-[1_1_160px] gap-2 border-transparent bg-transparent px-2 text-xs shadow-none [--field-focus-border:transparent] hover:bg-background/70 focus-visible:ring-1 focus-visible:ring-border [&>span]:flex-1 [&>span]:truncate [&>span]:text-left"><LocateFixed size={14} className="shrink-0" aria-hidden /><SelectValue placeholder="快速定位图表" /></SelectTrigger>
|
||||
<SelectContent position="popper" className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||||
onCloseAutoFocus={(event) => {
|
||||
const id = pendingLocation.current;
|
||||
pendingLocation.current = null;
|
||||
const card = id ? cards.current.get(id) : undefined;
|
||||
if (card) {
|
||||
event.preventDefault();
|
||||
card.scrollIntoView({ block: "start", inline: "nearest", behavior: "auto" });
|
||||
card.focus({ preventScroll: true });
|
||||
}
|
||||
}}>
|
||||
{slots.map((id, index) => id ? <SelectItem key={id} value={id} className="whitespace-normal text-xs [&>span:last-child]:break-words">位置 {index + 1} · {titles.get(id)}</SelectItem> : null)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div role="group" aria-label="图表排列方式" className="flex items-center gap-1">
|
||||
{layouts.map(({ id, label, icon: Icon }) => <Button key={id} type="button" variant={layout === id ? "secondary" : "ghost"} size="icon-sm"
|
||||
aria-label={label} title={label} aria-pressed={layout === id} onClick={() => arrangement.setLayout(id)}><Icon size={14} aria-hidden /></Button>)}
|
||||
</div>
|
||||
<span className="inline-flex border-l border-border pl-1">
|
||||
<Button type="button" variant="ghost" size="sm" className="h-8 gap-1.5 px-2 text-xs" aria-label="重置图表排列" disabled={!count}
|
||||
onClick={() => { arrangement.reset(); scrollRef.current?.scrollTo({ top: 0, behavior: "instant" }); setAnnouncement("已恢复默认图表位置"); }}><RotateCcw size={14} aria-hidden />重置</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p id={helpId} className="sr-only">拖动整条位置栏到目标位置可交换图表。向下滚动查看全部图表,也可快速定位。</p>
|
||||
<p role="status" aria-live="polite" className="sr-only">{announcement}</p>
|
||||
<DragDropProvider onDragEnd={drop}>
|
||||
<div ref={scrollRef} tabIndex={0} aria-label="图表滚动区" className="min-h-0 flex-1 overflow-auto overscroll-contain p-0.5" style={{ containerType: "size", scrollbarGutter: "stable" }} data-chart-manager-scroll="">
|
||||
{count ? <div data-chart-layout={layout} className={`isolate grid min-h-full min-w-0 content-start gap-3 ${layout === "four" ? "grid-cols-2 auto-rows-[max(256px,calc((100cqh-12px)/2))]" : layout === "two" ? "grid-cols-2 auto-rows-[max(256px,100cqh)]" : "grid-cols-1 auto-rows-[max(256px,100cqh)]"}`}>
|
||||
{slots.map((id, index) => <ChartDropSlot key={index} index={index} columns={columns} occupied={Boolean(id)} />)}
|
||||
{entries.map((entry) => {
|
||||
const index = slots.indexOf(entry.id);
|
||||
return <ManagedChartSlot key={entry.id} id={entry.id} title={entry.title} index={index} columns={columns}
|
||||
located={located?.id === entry.id} helpId={helpId}
|
||||
register={(id, node) => { if (node) cards.current.set(id, node); else cards.current.delete(id); }}>
|
||||
{entry.group ? <ChartGroup group={entry.group} tile tracked={false} reviewing={reviewing} onCloseChart={onCloseChart} references={references} /> : <AnswerUiView block={entry.block} chartPresentation="tile" disabled={reviewing} />}
|
||||
</ManagedChartSlot>;
|
||||
})}
|
||||
</div> : <p role="status" className="py-8 text-center text-sm text-muted-foreground">当前没有图表,新的图表将在这里更新。</p>}
|
||||
</div>
|
||||
<DragOverlay dropAnimation={null} style={{ position: "absolute", zIndex: 100 }}>
|
||||
{(source) => <div data-chart-drag-preview="" className="workbench-shadcn-surface flex max-w-80 items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs text-foreground shadow-xs"><GripVertical size={14} className="shrink-0 text-muted-foreground" aria-hidden /><span className="min-w-0 break-words">{String(source.data.title)}</span></div>}
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
</section>}
|
||||
</FloatingWindow>
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/react";
|
||||
import { pointerIntersection } from "@dnd-kit/collision";
|
||||
import { GripVertical } from "lucide-react";
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
function chartSlotPosition(index: number, columns: number): CSSProperties {
|
||||
return { gridRow: Math.floor(index / columns) + 1, gridColumn: index % columns + 1 };
|
||||
}
|
||||
|
||||
export function ChartDropSlot({ index, columns, occupied }: { index: number; columns: number; occupied: boolean }) {
|
||||
const { ref, isDropTarget } = useDroppable({ id: `slot:${index}`, type: "slot", accept: "chart", data: { slot: index }, collisionDetector: pointerIntersection });
|
||||
return <div ref={ref} data-chart-slot={index} style={chartSlotPosition(index, columns)}
|
||||
className={`pointer-events-none relative flex min-h-0 min-w-0 items-center justify-center rounded-xl border border-dashed ${isDropTarget ? "z-10 border-primary/40 bg-primary/5 ring-1 ring-primary/15 ring-inset" : occupied ? "border-transparent" : "border-border bg-muted/20"}`}>
|
||||
{!occupied ? <p className="text-center text-xs leading-6 text-muted-foreground"><span className="block tabular-nums">位置 {index + 1}</span>拖动图表到这里</p> : null}
|
||||
</div>;
|
||||
}
|
||||
|
||||
/** The chart stays under the same grid parent when its CSS position changes. */
|
||||
export function ManagedChartSlot({ id, title, index, columns, located, helpId, register, children }: {
|
||||
id: string;
|
||||
title: string;
|
||||
index: number;
|
||||
columns: number;
|
||||
located: boolean;
|
||||
helpId: string;
|
||||
register: (id: string, element: HTMLElement | null) => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const { ref, handleRef, isDragSource } = useDraggable({ id, type: "chart", data: { title } });
|
||||
return <article ref={(node) => { ref(node); register(id, node); }} tabIndex={-1}
|
||||
data-managed-chart={id} data-chart-title={title} data-slot-index={index} data-chart-located={located} data-chart-dragging={isDragSource}
|
||||
aria-label={`图表窗口 ${title}`} style={chartSlotPosition(index, columns)}
|
||||
className="group/chart-slot relative z-0 flex min-h-0 min-w-0 flex-col rounded-xl outline-hidden">
|
||||
<div className={`flex h-9 shrink-0 items-center rounded-lg ${located ? "bg-primary/10" : ""}`}>
|
||||
<button ref={handleRef} type="button" data-slot="button" className={`flex h-full w-full touch-none select-none items-center justify-start gap-1 rounded-lg border border-transparent px-2 text-xs font-normal outline-hidden focus-visible:outline-1 focus-visible:outline-ring focus-visible:-outline-offset-1 active:cursor-grabbing ${isDragSource ? "cursor-grabbing bg-muted text-foreground" : "cursor-grab bg-transparent text-muted-foreground [@media(hover:hover)]:hover:bg-muted/70 [@media(hover:hover)]:hover:text-foreground"}`}
|
||||
aria-label={`拖动图表 ${title}`} aria-describedby={helpId}>
|
||||
<GripVertical size={14} aria-hidden /><span className="tabular-nums">位置 {index + 1}</span>
|
||||
{located ? <span aria-hidden="true" className="ml-auto shrink-0 text-xs font-medium text-primary">已定位</span> : null}
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1">{children}</div>
|
||||
<div aria-hidden="true" data-chart-hover-highlight="" className={`pointer-events-none absolute inset-0 z-10 rounded-xl bg-primary/[0.04] ${isDragSource ? "opacity-100" : "opacity-0 [@media(hover:hover)]:group-hover/chart-slot:opacity-100"}`} />
|
||||
</article>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
|
||||
import { parseInvestigationDevJsonl } from "../utils/investigation-dev-preview";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
@@ -18,8 +19,7 @@ describe("investigation Dev case selection", () => {
|
||||
mapReady
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={(id = "combined") => createInvestigationDevCase(id, [121.48, 31.1])}
|
||||
onSample={(id = "combined") => createInvestigationDevCase(id)}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
@@ -29,7 +29,7 @@ describe("investigation Dev case selection", () => {
|
||||
await user.click(cases);
|
||||
await user.click(screen.getByRole("option", { name: "候选管段对比" }));
|
||||
expect((screen.getByLabelText("调查展示 JSONL") as HTMLTextAreaElement).value).toContain(
|
||||
"DEMO-PIPE-01"
|
||||
INVESTIGATION_DEV_NETWORK.pipeIds[0]
|
||||
);
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
@@ -39,10 +39,10 @@ describe("investigation Dev case selection", () => {
|
||||
expect.any(String)
|
||||
);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table", [121.48, 31.1])),
|
||||
parseInvestigationDevJsonl(createInvestigationDevCase("candidates_table")),
|
||||
"候选管段对比"
|
||||
);
|
||||
expect(screen.getByRole("region", { name: "JSONL 检查结果" })).toHaveTextContent("展示面板");
|
||||
expect(screen.getByRole("status")).toHaveTextContent("本地预览完成");
|
||||
expect(screen.queryByText("合成候选管段对比")).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "载入合成样例" }));
|
||||
expect(cases).toHaveTextContent("候选管段对比");
|
||||
@@ -57,7 +57,6 @@ describe("investigation Dev case selection", () => {
|
||||
mapReady={false}
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={vi.fn()}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
@@ -65,7 +64,7 @@ describe("investigation Dev case selection", () => {
|
||||
);
|
||||
const input = screen.getByLabelText("调查展示 JSONL");
|
||||
await user.click(input);
|
||||
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
|
||||
await user.paste(createInvestigationDevCase("data_only"));
|
||||
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
@@ -75,7 +74,7 @@ describe("investigation Dev case selection", () => {
|
||||
expect.any(String)
|
||||
);
|
||||
await user.clear(input);
|
||||
await user.paste(createInvestigationDevCase("impact_area", [121.48, 31.1]));
|
||||
await user.paste(createInvestigationDevCase("impact_area"));
|
||||
await user.click(screen.getByRole("button", { name: "校验 JSONL" }));
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
@@ -93,15 +92,14 @@ describe("investigation Dev case selection", () => {
|
||||
mapReady
|
||||
charts={0}
|
||||
overlay={null}
|
||||
activity={[]}
|
||||
onSample={(caseId = "combined") => createInvestigationDevCase(caseId, [121.48, 31.1])}
|
||||
onSample={(caseId = "combined") => createInvestigationDevCase(caseId)}
|
||||
onPreview={onPreview}
|
||||
onClear={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const input = screen.getByLabelText("调查展示 JSONL");
|
||||
await user.click(input);
|
||||
await user.paste(createInvestigationDevCase(id, [121.48, 31.1]));
|
||||
await user.paste(createInvestigationDevCase(id));
|
||||
const execute = screen.getByRole("button", { name: "执行本地预览" });
|
||||
expect(execute).toBeEnabled();
|
||||
await user.click(execute);
|
||||
@@ -111,7 +109,7 @@ describe("investigation Dev case selection", () => {
|
||||
expect(onPreview).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("region", { name: "JSONL 本地预览" })).not.toBeInTheDocument();
|
||||
await user.clear(input);
|
||||
await user.paste(createInvestigationDevCase("data_only", [121.48, 31.1]));
|
||||
await user.paste(createInvestigationDevCase("data_only"));
|
||||
await user.click(execute);
|
||||
expect(onPreview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ commands: [] }),
|
||||
|
||||
@@ -10,12 +10,10 @@ import {
|
||||
} from "../utils/investigation-dev-cases";
|
||||
import type { OperationalTask } from "../api/operational-task-client";
|
||||
import {
|
||||
investigationActionLabels,
|
||||
investigationReportJsonl,
|
||||
investigationReportUiSummary,
|
||||
parseInvestigationDevJsonl,
|
||||
type InvestigationDevPreview,
|
||||
type InvestigationPresentationActivity
|
||||
type InvestigationDevPreview
|
||||
} from "../utils/investigation-dev-preview";
|
||||
|
||||
export type InvestigationDevToolsProps = {
|
||||
@@ -23,7 +21,6 @@ export type InvestigationDevToolsProps = {
|
||||
mapReady: boolean;
|
||||
charts: number;
|
||||
overlay: string | null;
|
||||
activity: InvestigationPresentationActivity[];
|
||||
onSample: (caseId?: InvestigationDevCaseId) => string;
|
||||
onPreview: (preview: InvestigationDevPreview, label?: string) => Promise<void>;
|
||||
onClear: () => void;
|
||||
@@ -34,7 +31,6 @@ export function InvestigationDevTools({
|
||||
mapReady,
|
||||
charts,
|
||||
overlay,
|
||||
activity,
|
||||
onSample,
|
||||
onPreview,
|
||||
onClear
|
||||
@@ -43,11 +39,9 @@ export function InvestigationDevTools({
|
||||
const [input, setInput] = useState("");
|
||||
const [caseId, setCaseId] = useState<InvestigationDevCaseId | undefined>();
|
||||
const [checked, setChecked] = useState<InvestigationDevPreview | null>(null);
|
||||
const [preview, setPreview] = useState<InvestigationDevPreview | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [selectedRecord, setSelectedRecord] = useState(0);
|
||||
const previewActive = useRef(false);
|
||||
const inputId = useId();
|
||||
const summary = useMemo(() => investigationReportUiSummary(task), [task]);
|
||||
@@ -56,10 +50,8 @@ export function InvestigationDevTools({
|
||||
setInput(value);
|
||||
setCaseId(nextCaseId);
|
||||
setChecked(null);
|
||||
setPreview(null);
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setSelectedRecord(0);
|
||||
}
|
||||
function clearLoadedPreview() {
|
||||
if (!previewActive.current) return;
|
||||
@@ -100,7 +92,6 @@ export function InvestigationDevTools({
|
||||
}
|
||||
setRunning(true);
|
||||
setError(null);
|
||||
setPreview(parsed);
|
||||
previewActive.current = true;
|
||||
try {
|
||||
await onPreview(parsed, selectedCase?.label ?? "JSONL");
|
||||
@@ -174,7 +165,7 @@ export function InvestigationDevTools({
|
||||
<div id="investigation-dev-lab" className="min-w-0 space-y-3">
|
||||
<h3 className="text-xs font-semibold">01 准备数据</h3>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
仅在当前浏览器预览,不写入任务或触发调查。合成样例不代表现场事实。
|
||||
仅在当前浏览器预览,不写入任务或触发调查。空间位置与要素 ID 以 tjwater_v2 为基准;读数与方案为合成样例,不代表现场事实。
|
||||
</p>
|
||||
<FieldSelect
|
||||
label="JSONL 测试案例"
|
||||
@@ -197,7 +188,7 @@ export function InvestigationDevTools({
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">
|
||||
切换即载入,执行时自动校验;已执行的本地预览会清除。
|
||||
切换即载入,执行时自动校验;已执行的本地预览会清除。定位和高亮请使用“地图调试”页签的业务源与要素 ID。
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
@@ -296,7 +287,6 @@ export function InvestigationDevTools({
|
||||
onClick={() => {
|
||||
onClear();
|
||||
previewActive.current = false;
|
||||
setPreview(null);
|
||||
setMessage("已清除当前浏览器的临时展示。");
|
||||
setError(null);
|
||||
}}
|
||||
@@ -319,92 +309,8 @@ export function InvestigationDevTools({
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{preview ? (
|
||||
<section aria-label="JSONL 检查结果" className="min-w-0 space-y-3 overflow-x-auto">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
业务结果已在展示面板打开,可切换动作历史回看。
|
||||
</p>
|
||||
<Disclosure label="原始数据检查">
|
||||
{preview.records.length ? (
|
||||
<div className="max-h-48 overflow-auto">
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">已识别的调查数据,仅供预览</caption>
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="py-1 pr-3 font-medium">类型</th>
|
||||
<th className="py-1 pr-3 font-medium">对象</th>
|
||||
<th className="py-1 pr-3 font-medium">来源</th>
|
||||
<th className="py-1 text-right font-medium">证据</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{preview.records.map((record, index) => (
|
||||
<tr key={index} className="border-b last:border-0">
|
||||
<td className="break-all py-1.5 pr-3">{String(record.record_type)}</td>
|
||||
<td className="py-1.5 pr-3">
|
||||
<button
|
||||
type="button"
|
||||
className="break-all text-left underline underline-offset-2 focus-visible:outline-2 focus-visible:outline-[var(--action-focus-ring)]"
|
||||
aria-label={`查看第 ${index + 1} 条调查数据`}
|
||||
aria-pressed={selectedRecord === index}
|
||||
onClick={() => setSelectedRecord(index)}
|
||||
>
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-1.5 pr-3">{String(record.origin)}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">
|
||||
{(record.source_evidence_refs as string[]).length}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<pre
|
||||
aria-label="调查数据详情"
|
||||
className="mt-2 max-h-36 overflow-auto whitespace-pre-wrap break-all rounded-md bg-muted p-2 font-mono text-[11px] leading-5"
|
||||
>
|
||||
{JSON.stringify(preview.records[selectedRecord], null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</Disclosure>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{activity.length ? (
|
||||
<section aria-label="最近展示动作" className="space-y-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">最近展示动作</h3>
|
||||
<ol
|
||||
aria-label="动作识别记录"
|
||||
className="space-y-1 border-l border-border pl-3 text-[11px] leading-5"
|
||||
>
|
||||
{activity.slice(-3).map((item) => (
|
||||
<li key={`${item.source}-${item.id}`} className="break-words">
|
||||
<span className="text-muted-foreground">
|
||||
{item.source === "preview" ? "本地测试" : "实时指令"} ·{" "}
|
||||
</span>
|
||||
{investigationActionLabels[item.action]} ·{" "}
|
||||
{item.status === "completed"
|
||||
? "已应用"
|
||||
: item.status === "error"
|
||||
? "未完成"
|
||||
: "展示中"}
|
||||
{item.message ? (
|
||||
<span className="text-[var(--status-warning-foreground)]">:{item.message}</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
||||
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause } from "lucide-react";
|
||||
import type { AnswerUiBlock } from "@/features/agent";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
@@ -18,6 +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 { 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";
|
||||
@@ -34,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";
|
||||
@@ -66,6 +71,8 @@ export function InvestigationPresentationPanel({
|
||||
onLocateTargets,
|
||||
localPreview = false,
|
||||
charts,
|
||||
chartWindowAutoOpen = false,
|
||||
chartWindowScope,
|
||||
overlay,
|
||||
cameraPaused,
|
||||
status,
|
||||
@@ -78,6 +85,7 @@ export function InvestigationPresentationPanel({
|
||||
onFitOverlay,
|
||||
onToggleLayer
|
||||
}: Props) {
|
||||
const chartGroups = groupInvestigationCharts(charts);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [keyboardAction, setKeyboardAction] = useState(false);
|
||||
const reducedMotion = useReducedMotion();
|
||||
@@ -104,7 +112,7 @@ export function InvestigationPresentationPanel({
|
||||
|
||||
return (
|
||||
<motion.aside
|
||||
layout={immediate ? false : "size"}
|
||||
layout={immediate || localPreview ? false : "size"}
|
||||
{...presentationFade(immediate)}
|
||||
onKeyDownCapture={() => setKeyboardAction(true)}
|
||||
onPointerDownCapture={() => setKeyboardAction(false)}
|
||||
@@ -112,18 +120,18 @@ export function InvestigationPresentationPanel({
|
||||
data-testid="investigation-presentation-panel"
|
||||
data-presentation-scope={scope}
|
||||
data-investigation-collapsed={collapsed}
|
||||
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg`}
|
||||
className={`workbench-shadcn-surface pointer-events-auto ${embedded ? "relative shrink-0" : "absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] left-3 z-40"} flex max-h-[55dvh] flex-col overflow-hidden rounded-xl border border-border bg-background text-foreground shadow-lg ${localPreview ? `${collapsed ? "h-28" : "h-[min(480px,55dvh)]"} [&>[data-investigation-content]]:flex-1` : ""}`}
|
||||
style={{ width, maxWidth: embedded ? "100%" : "calc(100% - 24px)" }}
|
||||
>
|
||||
<motion.header
|
||||
layout={immediate ? false : "position"}
|
||||
layout={immediate || localPreview ? false : "position"}
|
||||
transition={presentationTransition(immediate)}
|
||||
className="shrink-0 border-b border-border px-3 py-2.5"
|
||||
className={`@container shrink-0 border-b border-border px-3 ${localPreview ? `${collapsed ? "h-[110px]" : "min-h-[110px]"} py-2` : "py-2.5"}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<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"}>
|
||||
<h2 className="text-sm font-semibold leading-6">{title}</h2>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className={`flex flex-wrap items-center gap-2 ${localPreview ? "col-span-2 row-start-2" : ""}`}>
|
||||
<Badge variant="secondary" className="px-1.5 py-0 text-[10px] font-medium">
|
||||
{reviewing
|
||||
? "历史回看"
|
||||
@@ -134,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">
|
||||
@@ -143,26 +151,27 @@ 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"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
className={`shrink-0 ${localPreview ? "col-start-2 row-start-1" : ""}`}
|
||||
aria-label={collapsed ? "展开展示" : "收起展示"}
|
||||
title={collapsed ? "展开展示" : "收起展示"}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={contentId}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
>
|
||||
<motion.span
|
||||
className="inline-flex"
|
||||
animate={{ rotate: collapsed ? 180 : 0 }}
|
||||
transition={presentationTransition(immediate)}
|
||||
>
|
||||
<motion.span className="inline-flex" animate={{ rotate: collapsed ? 180 : 0 }} transition={presentationTransition(immediate)}>
|
||||
<ChevronDown size={16} aria-hidden />
|
||||
</motion.span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className={
|
||||
history?.entries.length
|
||||
@@ -180,12 +189,12 @@ export function InvestigationPresentationPanel({
|
||||
? "已载入所选调查保存的展示结果"
|
||||
: "跟随所选调查,展示地图操作与结果"}
|
||||
</p>
|
||||
{history?.entries.length ? <PresentationHistorySwitcher {...history} /> : null}
|
||||
{history?.entries.length ? <PresentationHistorySwitcher {...history} playback={collapsed ? undefined : history.playback} /> : null}
|
||||
</motion.header>
|
||||
<AnimatePresence initial={false} mode="popLayout">
|
||||
{!collapsed ? (
|
||||
<PresentationViewport
|
||||
key={history?.selectedId ?? "current"}
|
||||
key="presentation-content"
|
||||
immediate={immediate}
|
||||
id={contentId}
|
||||
>
|
||||
@@ -243,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">
|
||||
@@ -292,42 +301,10 @@ export function InvestigationPresentationPanel({
|
||||
</Card>
|
||||
</PresentationItem>
|
||||
) : null}
|
||||
{charts.map((item) => (
|
||||
<PresentationItem key={item.chart_id} immediate={immediate}>
|
||||
<Card
|
||||
data-investigation-chart={item.chart_id}
|
||||
role="group"
|
||||
aria-label={item.chart.title}
|
||||
className="border-border shadow-none"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{item.chart.title}
|
||||
</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||
{item.chart.x_data.length} 个
|
||||
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={reviewing}
|
||||
aria-label={`关闭图表 ${item.chart.title}`}
|
||||
onClick={() => onCloseChart(item.chart_id)}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<AgentChartView chart={item.chart} embedded />
|
||||
{references(item.evidence_refs)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{chartGroups.map((group) => (
|
||||
<PresentationItem key={`${chartWindowScope}:${group.id}`} immediate={immediate}>
|
||||
<InvestigationChartGroup group={group} reviewing={reviewing ?? false}
|
||||
onCloseChart={onCloseChart} references={references} />
|
||||
</PresentationItem>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AnswerUiView, parseAnswerUiBlock, type AnswerUiBlock, type AnswerUiSelection } from "@/features/agent";
|
||||
import type { OperationalTask, OperationalTaskReport } from "../api/operational-task-client";
|
||||
import type { InvestigationDiscussion, InvestigationMapTarget } from "../api/investigation-report";
|
||||
import { formatInvestigationText } from "../utils/investigation-text";
|
||||
|
||||
export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate }: {
|
||||
task: Pick<OperationalTask, "evidence">;
|
||||
@@ -56,7 +57,7 @@ export function InvestigationReportBlocks({ task, snapshot, onContinue, onLocate
|
||||
{block ? <AnswerUiView block={block} submitMode="draft"
|
||||
onSubmit={(text) => quote({ title: "补充资料", text })}
|
||||
onLocate={onLocate ? (target) => onLocate(target.feature_ids.map((id) => ({ source_id: target.layer, feature_id: id }))) : undefined} />
|
||||
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{item.block.fallback_text}</p>}
|
||||
: <p className="whitespace-pre-wrap rounded-lg border p-3 text-sm leading-6">{formatInvestigationText(item.block.fallback_text)}</p>}
|
||||
{remainingRefs.length ? <p className="text-[11px] leading-5 text-muted-foreground">
|
||||
引用依据:{[...new Set(remainingRefs.map((id) => evidence.find((entry) => entry.evidence_id === id)?.source_label ?? `${id}(记录当前不可用)`))].join("、")}
|
||||
</p> : null}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getReportPresentationState } from "../utils/report-presentation-state";
|
||||
import { FileText } from "lucide-react";
|
||||
import { StatusBadge } from "@/shared/ui/status";
|
||||
import { formatDateTime } from "@/shared/utils/date-time";
|
||||
@@ -7,9 +8,7 @@ import type { OperationalTask } from "../api/operational-task-client";
|
||||
export function InvestigationReportView({ task, reportLabel }: { task: OperationalTask; reportLabel: string }) {
|
||||
const snapshot = task.latest_report;
|
||||
const report = snapshot?.report;
|
||||
const insufficient = report?.outcome === "insufficient_data";
|
||||
const current = snapshot?.execution_generation === task.execution_generation;
|
||||
const ended = current && task.lifecycle_status === "completed";
|
||||
const { insufficient, current, ended } = getReportPresentationState(task, snapshot);
|
||||
const insufficientLabel = ended ? "本轮已结束 · 无法形成结论"
|
||||
: task.lifecycle_status === "failed" ? "证据不足 · 本轮执行失败"
|
||||
: task.lifecycle_status === "cancelled" ? "证据不足 · 本轮已取消"
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getReportPresentationState } from "../utils/report-presentation-state";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { InvestigationQuestions } from "./investigation-questions";
|
||||
import { History, LoaderCircle, MessageSquare } from "lucide-react";
|
||||
@@ -18,7 +19,8 @@ import type {
|
||||
import {
|
||||
investigationDiagram,
|
||||
investigationStatus,
|
||||
isPendingInvestigationAction
|
||||
isPendingInvestigationAction,
|
||||
isRetryableInvestigationAction
|
||||
} from "../utils/investigation-presentation";
|
||||
import { InvestigationDiagramView } from "./investigation-diagram-view";
|
||||
import { InvestigationExpandCard } from "./investigation-expand-card";
|
||||
@@ -178,22 +180,13 @@ export function InvestigationTaskReport({
|
||||
// A current final report owns its archived graph. During investigation, a
|
||||
// progress snapshot owns its whole graph, including an explicit null. A prior
|
||||
// round's final report must never hide the new round's progress.
|
||||
const currentFinalReport = snapshot?.execution_generation === task.execution_generation
|
||||
&& report?.stage === "final";
|
||||
const { currentFinalReport, previousRound, olderRevision, insufficient } = getReportPresentationState(task, snapshot);
|
||||
const diagram = live
|
||||
? progress && !currentFinalReport ? progress.progress.diagram : investigationDiagram(task, snapshot)
|
||||
: selection.diagram;
|
||||
const diagramRunning = Boolean(live && !currentFinalReport && progress && task.active_run?.run_id === progress.run_id && task.active_run.status === "running");
|
||||
const previousRound = Boolean(
|
||||
snapshot && snapshot.execution_generation !== task.execution_generation
|
||||
);
|
||||
const olderRevision = Boolean(
|
||||
snapshot && task.latest_report && snapshot.revision !== task.latest_report.revision
|
||||
);
|
||||
const latestAvailable = !live;
|
||||
const insufficient = report?.outcome === "insufficient_data";
|
||||
const currentInsufficient = task.latest_report?.execution_generation === task.execution_generation
|
||||
&& task.latest_report?.report.outcome === "insufficient_data";
|
||||
const { currentInsufficient } = getReportPresentationState(task, task.latest_report);
|
||||
const pendingActions = task.actions.filter(isPendingInvestigationAction);
|
||||
const completedActions = task.actions.filter((action) => !isPendingInvestigationAction(action));
|
||||
const evidence = task.evidence.filter((item) => report?.evidence_refs.includes(item.evidence_id));
|
||||
@@ -433,6 +426,8 @@ export function InvestigationTaskReport({
|
||||
{actionTitle(action)} ·{" "}
|
||||
{action.approval_status === "pending"
|
||||
? "待审核"
|
||||
: isRetryableInvestigationAction(action)
|
||||
? "待重试"
|
||||
: action.execution_status === "not_submitted"
|
||||
? "待执行"
|
||||
: "待核对回执"}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Crosshair, RotateCcw, X, ZoomIn } from "lucide-react";
|
||||
import { RotateCcw, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import { Disclosure } from "@/shared/ui/disclosure";
|
||||
import { FieldSelect } from "@/shared/ui/field-select";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import {
|
||||
LAYER_GROUP_GEOMETRIES,
|
||||
@@ -15,18 +14,20 @@ import {
|
||||
import { MAP_STYLE_TOKENS } from "../map/map-colors";
|
||||
import { getNumericFeatureProperties } from "../map/value-label";
|
||||
import type {
|
||||
FeatureBatchTarget,
|
||||
WorkbenchMapCommands,
|
||||
WorkbenchMapControllerState
|
||||
} from "../map/workbench-map-controller";
|
||||
import { MAX_MAP_FEATURE_IDS, MAX_MAP_FEATURE_ID_LENGTH } from "../map/map-feature-query";
|
||||
import {
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS,
|
||||
SUPPLY_LAYER_CATALOG,
|
||||
type AvailableWaterNetworkSourceId
|
||||
} from "../map/sources";
|
||||
import type { DetailFeature } from "../types";
|
||||
import { INVESTIGATION_DEV_NETWORK } from "../utils/investigation-dev-network";
|
||||
import { InvestigationDevTools, type InvestigationDevToolsProps } from "./investigation-dev-tools";
|
||||
import { FeatureLocationPanel } from "./feature-location-panel";
|
||||
import { FEATURE_LOCATION_ERROR_LABELS } from "../map/map-feature-feedback";
|
||||
import type { ScadaAlertIntensity } from "./scada-alert-atmosphere";
|
||||
import { NoticeDevTools } from "./notice-dev-tools";
|
||||
|
||||
type MapDevPanelProps = {
|
||||
commands: WorkbenchMapCommands;
|
||||
@@ -37,6 +38,11 @@ type MapDevPanelProps = {
|
||||
investigationFollow?: boolean;
|
||||
onInvestigationFollowChange?: (enabled: boolean) => void;
|
||||
investigationTools?: InvestigationDevToolsProps;
|
||||
onPreviewScadaAlert?: () => void;
|
||||
onEndScadaAlertPreview?: () => void;
|
||||
scadaAlertPreviewActive?: boolean;
|
||||
scadaAlertIntensity?: ScadaAlertIntensity;
|
||||
onScadaAlertIntensityChange?: (intensity: ScadaAlertIntensity) => void;
|
||||
};
|
||||
|
||||
const GROUP_LABELS = Object.fromEntries(
|
||||
@@ -93,13 +99,7 @@ const LABEL_PRESETS: Record<
|
||||
};
|
||||
|
||||
const ERROR_LABELS: Record<string, string> = {
|
||||
MAP_NOT_READY: "地图尚未就绪",
|
||||
FEATURE_NOT_FOUND: "目标要素不存在",
|
||||
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
|
||||
WFS_UNAVAILABLE: "WFS 查询暂不可用",
|
||||
SCADA_API_UNAVAILABLE: "SCADA API 查询暂不可用",
|
||||
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
|
||||
ACTION_SUPERSEDED: "SCADA 地图动作已被新请求替代",
|
||||
...FEATURE_LOCATION_ERROR_LABELS,
|
||||
FLOW_UNAVAILABLE: "水流图层初始化失败",
|
||||
INVALID_FLOW_CALCULATION: "管道 ID 或有符号流速无效",
|
||||
INVALID_STYLE_PATCH: "参数不符合受控范围"
|
||||
@@ -113,13 +113,21 @@ export function MapDevPanel({
|
||||
onClose,
|
||||
investigationFollow = true,
|
||||
onInvestigationFollowChange,
|
||||
investigationTools
|
||||
investigationTools,
|
||||
onPreviewScadaAlert,
|
||||
onEndScadaAlertPreview,
|
||||
scadaAlertPreviewActive = false,
|
||||
scadaAlertIntensity = "standard",
|
||||
onScadaAlertIntensityChange
|
||||
}: MapDevPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState(onInvestigationFollowChange ? "investigation" : "map");
|
||||
const [sourceId, setSourceId] = useState<AvailableWaterNetworkSourceId>("pipes");
|
||||
const [featureId, setFeatureId] = useState("440681");
|
||||
const [locationDraft, setLocationDraft] = useState<{ sourceId: AvailableWaterNetworkSourceId; featureId: string }>({
|
||||
sourceId: "pipes", featureId: INVESTIGATION_DEV_NETWORK.pipeIds[0]
|
||||
});
|
||||
const { sourceId, featureId } = locationDraft;
|
||||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||||
const [property, setProperty] = useState("diameter");
|
||||
const [flowPipeId, setFlowPipeId] = useState("440681");
|
||||
const [flowPipeId, setFlowPipeId] = useState<string>(INVESTIGATION_DEV_NETWORK.pipeIds[0]);
|
||||
const [flowVelocity, setFlowVelocity] = useState("-1");
|
||||
const [precision, setPrecision] = useState(0);
|
||||
const [unit, setUnit] = useState("mm");
|
||||
@@ -148,10 +156,6 @@ export function MapDevPanel({
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const featureIds = [...new Set(featureId.split(/[\s,,;;、]+/u).filter(Boolean))];
|
||||
const target: FeatureBatchTarget = { sourceId, featureIds };
|
||||
const invalidIds = featureIds.length > MAX_MAP_FEATURE_IDS || featureIds.some((id) => id.length > MAX_MAP_FEATURE_ID_LENGTH);
|
||||
const canLocate = featureIds.length > 0 && !invalidIds && !controllerState.pending;
|
||||
const geometry = LAYER_GROUP_GEOMETRIES[groupId];
|
||||
const activeLabel = controllerState.valueLabels[groupId];
|
||||
const hasActiveLabels = Object.keys(controllerState.valueLabels).length > 0;
|
||||
@@ -171,14 +175,6 @@ export function MapDevPanel({
|
||||
|
||||
function useSelectedFeature() {
|
||||
if (!detailFeature) return;
|
||||
if (
|
||||
AVAILABLE_WATER_NETWORK_SOURCE_IDS.includes(
|
||||
detailFeature.layer as AvailableWaterNetworkSourceId
|
||||
)
|
||||
) {
|
||||
setSourceId(detailFeature.layer as AvailableWaterNetworkSourceId);
|
||||
}
|
||||
setFeatureId(detailFeature.id);
|
||||
if (detailFeature.layer === "pipes") setFlowPipeId(detailFeature.id);
|
||||
if (detailFeature.layer in LAYER_GROUP_GEOMETRIES) {
|
||||
const nextGroupId = detailFeature.layer as LayerGroupId;
|
||||
@@ -246,7 +242,7 @@ export function MapDevPanel({
|
||||
return (
|
||||
<aside
|
||||
aria-label="地图开发面板"
|
||||
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-40 hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
className="workbench-shadcn-surface bg-background text-foreground shadow-lg pointer-events-auto absolute right-3 top-[4.5rem] z-[80] hidden max-h-[calc(100dvh-5.5rem)] w-[400px] max-w-[calc(100vw-1.5rem)] flex-col overflow-hidden rounded-2xl border lg:flex"
|
||||
data-testid="map-dev-panel"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-3">
|
||||
@@ -256,7 +252,7 @@ export function MapDevPanel({
|
||||
<span className="ml-1 text-[11px] font-medium text-muted-foreground">Dev Panel</span>
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
|
||||
调查联动验证与地图工具 · 仅当前页面生效
|
||||
调查、通知与地图工具 · 仅当前页面生效
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -272,12 +268,27 @@ export function MapDevPanel({
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="shrink-0 border-b border-border px-4 py-2">
|
||||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-2">
|
||||
<TabsList aria-label="开发测试类型" className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="investigation" disabled={!onInvestigationFollowChange}>调查测试</TabsTrigger>
|
||||
<TabsTrigger value="notices">通知测试</TabsTrigger>
|
||||
<TabsTrigger value="map">地图调试</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="investigation" hidden={activeTab !== "investigation"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{onPreviewScadaAlert ? <section aria-label="爆管视觉警告测试" className="mb-4 space-y-2 border-b border-border pb-4">
|
||||
<h3 className="text-xs font-semibold">全屏爆管视觉警告</h3>
|
||||
<p className="text-[11px] leading-5 text-muted-foreground">本地持续预览,可模拟事件处理结束。</p>
|
||||
{onScadaAlertIntensityChange ? <div role="group" aria-label="告警灯效强度" className="flex gap-2">
|
||||
{([ ["soft", "柔和"], ["standard", "标准"], ["strong", "醒目"] ] as const).map(([value, label]) =>
|
||||
<Button key={value} type="button" size="sm" variant="outline" aria-pressed={scadaAlertIntensity === value}
|
||||
onClick={() => onScadaAlertIntensityChange(value)}>{label}</Button>)}
|
||||
</div> : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={onPreviewScadaAlert}>测试爆管告警效果</Button>
|
||||
<Button type="button" size="sm" variant="ghost" disabled={!scadaAlertPreviewActive}
|
||||
onClick={onEndScadaAlertPreview}>模拟事件处理结束</Button>
|
||||
</div>
|
||||
</section> : null}
|
||||
{onInvestigationFollowChange ? (
|
||||
<section aria-label="调查联动" className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
@@ -288,6 +299,9 @@ export function MapDevPanel({
|
||||
</section>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
<TabsContent value="notices" hidden={activeTab !== "notices"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
<NoticeDevTools />
|
||||
</TabsContent>
|
||||
<TabsContent value="map" hidden={activeTab !== "map"} forceMount className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-3 data-[state=inactive]:hidden [scrollbar-gutter:stable]">
|
||||
{controllerState.errorCode ? (
|
||||
<div role="alert" className="status-tone-danger my-3 break-words rounded-lg border border-[var(--status-border)] bg-[var(--status-soft)] px-3 py-2 text-xs text-[var(--status-foreground)]">
|
||||
@@ -297,100 +311,14 @@ export function MapDevPanel({
|
||||
|
||||
<PanelSection
|
||||
title="要素定位"
|
||||
description="批量读取同一业务源的要素,支持高亮全部目标或缩放到整体范围。"
|
||||
description="按业务源与要素 ID 定位,也可从地图工具的定位面板使用。"
|
||||
initiallyOpen
|
||||
status={
|
||||
controllerState.pending ? "执行中" : controllerState.targets.length ? `已高亮 ${controllerState.targets.length} 个` : undefined
|
||||
}
|
||||
status={controllerState.pending ? "执行中" : undefined}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<FieldSelect
|
||||
contentClassName="workbench-shadcn-surface"
|
||||
label="业务源"
|
||||
value={sourceId}
|
||||
onValueChange={(value) => {
|
||||
setSourceId(value as AvailableWaterNetworkSourceId);
|
||||
setFeatureId("");
|
||||
}}
|
||||
options={AVAILABLE_WATER_NETWORK_SOURCE_IDS.map((id) => ({
|
||||
value: id,
|
||||
label: GROUP_LABELS[id]
|
||||
}))}
|
||||
/>
|
||||
<Field label="要素 ID(可多个)">
|
||||
<Textarea
|
||||
value={featureId}
|
||||
onChange={(event) => setFeatureId(event.target.value)}
|
||||
rows={3}
|
||||
placeholder="例如:440681, 440682 440683"
|
||||
aria-describedby="map-dev-feature-ids-help"
|
||||
aria-invalid={invalidIds}
|
||||
className="resize-y text-xs"
|
||||
/>
|
||||
</Field>
|
||||
<p id="map-dev-feature-ids-help" className="text-[11px] leading-5 text-muted-foreground">
|
||||
用逗号、空格或换行分隔,自动去重。已输入 {featureIds.length} / {MAX_MAP_FEATURE_IDS} 个。
|
||||
</p>
|
||||
{invalidIds ? <p role="alert" className="text-xs text-destructive">单次最多 {MAX_MAP_FEATURE_IDS} 个,每个 ID 最长 {MAX_MAP_FEATURE_ID_LENGTH} 字符。</p> : null}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!detailFeature}
|
||||
onClick={useSelectedFeature}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
使用当前地图选中
|
||||
</Button>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.highlight(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<Crosshair size={14} />
|
||||
仅高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.zoomToFeature(target)}
|
||||
variant="outline"
|
||||
className="w-full text-xs"
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
缩放到要素
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={!canLocate}
|
||||
onClick={() => void commands.locateAndHighlight(target)}
|
||||
className="w-full text-xs"
|
||||
>
|
||||
定位并高亮
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => commands.clearHighlight()}
|
||||
className="w-full text-xs"
|
||||
variant="outline"
|
||||
>
|
||||
清除高亮
|
||||
</Button>
|
||||
{controllerState.targets.length ? (
|
||||
<StatusText>
|
||||
已高亮 {controllerState.targets.length} 个要素:{controllerState.targets.map((item) => `${item.sourceId}:${item.featureId}`).join("、")}
|
||||
</StatusText>
|
||||
) : null}
|
||||
{controllerState.targetResult ? (
|
||||
<StatusText tone={controllerState.targetResult.missingIds.length ? "warning" : "success"}>
|
||||
本次命中 {controllerState.targetResult.featureIds.length} 个
|
||||
{controllerState.targetResult.missingIds.length ? `,未找到 ${controllerState.targetResult.missingIds.length} 个:${controllerState.targetResult.missingIds.join("、")}` : ",全部找到"}
|
||||
</StatusText>
|
||||
) : null}
|
||||
<FeatureLocationPanel commands={commands} controllerState={controllerState}
|
||||
detailFeature={detailFeature} value={locationDraft} onValueChange={setLocationDraft}
|
||||
onUseSelectedFeature={useSelectedFeature} showError={false}
|
||||
contentClassName="workbench-shadcn-surface" />
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
|
||||
import { NoticeDevTools } from "./notice-dev-tools";
|
||||
|
||||
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
|
||||
afterEach(() => { cleanup(); vi.clearAllMocks(); });
|
||||
|
||||
it("previews every tone, completes loading in place and clears only its own notices", async () => {
|
||||
render(<NoticeDevTools />);
|
||||
const user = userEvent.setup();
|
||||
for (const label of ["信息", "成功", "警告", "错误", "加载"]) {
|
||||
await user.click(screen.getByRole("button", { name: `测试${label}通知` }));
|
||||
}
|
||||
expect(vi.mocked(showMapNotice).mock.calls.map(([options]) => options.tone)).toEqual(["info", "success", "warning", "error", "loading"]);
|
||||
await user.click(screen.getByRole("button", { name: "模拟加载完成" }));
|
||||
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ id: "dev-notice:loading", tone: "success" }));
|
||||
await user.click(screen.getByRole("button", { name: "测试报告通知" }));
|
||||
const report = vi.mocked(showMapNotice).mock.lastCall![0];
|
||||
expect(report.actionLabel).toBe("查看报告");
|
||||
await user.click(screen.getByRole("button", { name: "连续 6 条通知" }));
|
||||
expect(vi.mocked(showMapNotice).mock.calls.slice(-6).map(([options]) => options.id)).toEqual(
|
||||
Array.from({ length: 6 }, (_, index) => `dev-notice:batch-${index}`)
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "清除测试通知" }));
|
||||
expect(vi.mocked(dismissMapNotice).mock.calls.every(([id]) => typeof id === "string" && id.startsWith("dev-notice:"))).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from "react";
|
||||
import { dismissMapNotice, showMapNotice, type MapNoticeOptions } from "@/features/map/core";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
const samples = [
|
||||
{ key: "info", label: "信息通知", tone: "info", title: "地图视图已更新", message: "已切换到当前项目的管网视图。", lifetime: "3.2 秒后关闭" },
|
||||
{ key: "success", label: "成功通知", tone: "success", title: "操作已完成", message: "当前管网视图已导出,可以继续分析。", lifetime: "3.2 秒后关闭" },
|
||||
{ key: "warning", label: "警告通知", tone: "warning", title: "部分监测数据延迟", message: "最新读数尚未到达,请稍后查看。", lifetime: "手动关闭" },
|
||||
{ key: "error", label: "错误通知", tone: "error", title: "管网数据加载失败", message: "暂时无法连接数据源,请检查连接后重试。", lifetime: "手动关闭" },
|
||||
{ key: "loading", label: "加载通知", tone: "loading", title: "正在加载管网", message: "正在准备图层和监测数据,请稍候。", lifetime: "等待完成" }
|
||||
] satisfies (MapNoticeOptions & { key: string; label: string; lifetime: string })[];
|
||||
|
||||
const extraKeys = ["report", "task", "long", ...Array.from({ length: 6 }, (_, index) => `batch-${index}`)];
|
||||
const noticeId = (key: string) => `dev-notice:${key}`;
|
||||
|
||||
export function NoticeDevTools() {
|
||||
const [feedback, setFeedback] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
function showLinked(key: string, target: "报告" | "任务", message?: string) {
|
||||
showMapNotice({
|
||||
id: noticeId(key),
|
||||
tone: target === "报告" ? "success" : "warning",
|
||||
title: `${target === "报告" ? "调查报告已更新" : "调查任务待处理"} · 测试`,
|
||||
message: message ?? (target === "报告"
|
||||
? "城南片区压力异常调查报告已更新,可查看分析结论与处置建议。"
|
||||
: "新收到一项管网异常调查任务,请查看现场情况与待办事项。"),
|
||||
actionLabel: `查看${target}`,
|
||||
onAction: () => setFeedback(`已触发“查看${target}”测试操作,通知仍保留。`)
|
||||
});
|
||||
}
|
||||
|
||||
function clearSamples() {
|
||||
[...samples.map((sample) => sample.key), ...extraKeys].forEach((key) => dismissMapNotice(noticeId(key)));
|
||||
setLoading(false);
|
||||
setFeedback("已清除测试通知。");
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="通知测试" className="space-y-5">
|
||||
<p className="text-xs leading-5 text-muted-foreground">
|
||||
本地样例,仅当前页面生效。报告和任务入口只记录点击反馈,不创建真实业务数据。
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-xs font-semibold">通知类型</h3>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{samples.map(({ key, label, lifetime, ...options }) => (
|
||||
<Button key={key} type="button" variant="outline" aria-label={`测试${label}`}
|
||||
className="h-auto min-h-14 flex-col items-start gap-1 px-3 py-2 text-xs"
|
||||
onClick={() => {
|
||||
showMapNotice({ ...options, id: noticeId(key), title: `${options.title} · 测试` });
|
||||
if (key === "loading") setLoading(true);
|
||||
}}>
|
||||
<span>{label}</span>
|
||||
<span className="font-normal text-muted-foreground">{lifetime}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<Button type="button" variant="ghost" size="sm" disabled={!loading}
|
||||
onClick={() => {
|
||||
showMapNotice({ id: noticeId("loading"), tone: "success", title: "管网加载完成 · 测试", message: "图层和监测数据已准备就绪。" });
|
||||
setLoading(false);
|
||||
}}>模拟加载完成</Button>
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">报告与任务入口</h3>
|
||||
<p className="text-xs leading-5 text-muted-foreground">带操作入口的通知保留至手动关闭,点击查看后继续保留。</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("report", "报告")}>测试报告通知</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("task", "任务")}>测试任务通知</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 border-t border-border pt-4">
|
||||
<h3 className="text-xs font-semibold">布局与堆叠</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => showLinked("long", "报告",
|
||||
"城南供水片区夜间压力持续下降,多处监测点出现关联异常。调查报告已整理受影响管段、关键监测读数、候选原因和后续核查建议,请结合现场情况查看并安排处置。关联编号:SCADA-PRESSURE-20261107-REPORT-000128。")}>测试长文本通知</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => {
|
||||
Array.from({ length: 6 }, (_, index) => showLinked(`batch-${index}`, index % 2 ? "任务" : "报告", `第 ${index + 1} 条测试通知。手动关闭当前通知后,可继续查看其余通知。`));
|
||||
}}>连续 6 条通知</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={clearSamples}>清除测试通知</Button>
|
||||
{feedback ? <p role="status" className="text-xs leading-5 text-muted-foreground">{feedback}</p> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AnswerUiView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import type { InvestigationMapTarget } from "../api/investigation-report";
|
||||
|
||||
type Props = {
|
||||
@@ -38,10 +39,21 @@ export function PresentationBusinessResults({
|
||||
))}
|
||||
{!blocks.length && records.length ? (
|
||||
<Card className="border-border shadow-none">
|
||||
<CardHeader className="p-3 pb-2">
|
||||
<CardHeader className="flex-row items-center justify-between gap-2 space-y-0 p-3 pb-2">
|
||||
<h3 className="text-xs font-semibold">调查数据 · {records.length} 条</h3>
|
||||
<FloatingWindow title="调查数据摘要" triggerLabel="放大查看表格 调查数据摘要">{() => <PresentationRecordTable records={records} />}</FloatingWindow>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto px-3 pb-3 pt-0">
|
||||
<PresentationRecordTable records={records} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PresentationRecordTable({ records }: { records: Array<Record<string, unknown>> }) {
|
||||
return (
|
||||
<table className="w-full text-left text-[11px] leading-5">
|
||||
<caption className="sr-only">调查数据摘要</caption>
|
||||
<thead>
|
||||
@@ -69,9 +81,5 @@ export function PresentationBusinessResults({
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,3 +58,24 @@ it("keeps the final historical result selectable when current has been cleared",
|
||||
await user.click(screen.getByRole("option", { name: /^2\. 阀门列表/ }));
|
||||
expect(onSelect).toHaveBeenCalledWith(history[1].id);
|
||||
});
|
||||
|
||||
|
||||
it("offers playback, speed, progress and keeps the latest snapshot pinned until returning to current", async () => {
|
||||
const user = userEvent.setup();
|
||||
const history = entries();
|
||||
const onSelect = vi.fn();
|
||||
const playback = { playing: true, speed: 1, onToggle: vi.fn(), onSpeedChange: vi.fn(), onSeek: vi.fn() };
|
||||
render(<PresentationHistorySwitcher entries={history} currentEntryId={history[1].id} selectedId={history[1].id} onSelect={onSelect} playback={playback} />);
|
||||
expect(screen.getByLabelText("历史播放位置")).toHaveTextContent("2/2");
|
||||
await user.click(screen.getByRole("button", { name: "暂停历史播放" }));
|
||||
expect(playback.onToggle).toHaveBeenCalledTimes(1);
|
||||
const progress = screen.getByRole("slider", { name: "历史播放进度" });
|
||||
progress.focus();
|
||||
await user.keyboard("{ArrowLeft}");
|
||||
// jsdom does not implement the browser's native range keyboard default.
|
||||
await user.click(screen.getByRole("combobox", { name: "历史播放速度" }));
|
||||
await user.click(screen.getByRole("option", { name: /^2×$/ }));
|
||||
expect(playback.onSpeedChange).toHaveBeenCalledWith(2);
|
||||
await user.click(screen.getByRole("button", { name: "回到当前" }));
|
||||
expect(onSelect).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronLeft, ChevronRight, Radio } from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight, Pause, Play, Radio } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/shared/ui/select";
|
||||
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
|
||||
@@ -9,18 +9,22 @@ export function PresentationHistorySwitcher({
|
||||
entries,
|
||||
currentEntryId,
|
||||
selectedId,
|
||||
onSelect
|
||||
onSelect,
|
||||
playback
|
||||
}: PresentationHistoryControls) {
|
||||
const currentEntry = entries.find((entry) => entry.id === currentEntryId);
|
||||
const available = entries.filter(
|
||||
(entry) => entry.status !== "running" && entry.id !== currentEntryId
|
||||
(entry) => entry.status !== "running" && (entry.id !== currentEntryId || entry.id === selectedId)
|
||||
);
|
||||
const index =
|
||||
selectedId === null
|
||||
? available.length
|
||||
: available.findIndex((entry) => entry.id === selectedId);
|
||||
const playable = entries.filter((entry) => entry.status === "completed");
|
||||
const progress = Math.max(0, playable.findIndex((entry) => entry.id === selectedId));
|
||||
return (
|
||||
<div className="mt-2 flex min-w-0 items-center gap-1" role="group" aria-label="展示动作历史">
|
||||
<div className="mt-2 min-w-0 space-y-1.5" role="group" aria-label="展示动作历史">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -56,7 +60,7 @@ export function PresentationHistorySwitcher({
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, position) =>
|
||||
entry.id === currentEntryId ? null : (
|
||||
entry.id === currentEntryId && entry.id !== selectedId ? null : (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={String(entry.id)}
|
||||
@@ -93,5 +97,29 @@ export function PresentationHistorySwitcher({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{playback ? (
|
||||
<div className="flex min-w-0 items-center gap-2" role="group" aria-label="历史播放">
|
||||
<Button type="button" variant="outline" size="icon-sm" className="size-8 shrink-0"
|
||||
aria-label={playback.playing ? "暂停历史播放" : "播放历史"}
|
||||
disabled={!playable.length} onClick={playback.onToggle}>
|
||||
{playback.playing ? <Pause size={14} aria-hidden /> : <Play size={14} aria-hidden />}
|
||||
</Button>
|
||||
<input type="range" aria-label="历史播放进度" className="h-8 w-0 min-w-0 flex-1 cursor-pointer accent-primary disabled:cursor-default"
|
||||
min={0} max={Math.max(0, playable.length - 1)} step={1} value={selectedId === null ? Math.max(0, playable.length - 1) : progress}
|
||||
disabled={playable.length < 2}
|
||||
aria-valuetext={selectedId === null ? "当前最新结果" : `第 ${progress + 1} 个,共 ${playable.length} 个结果`}
|
||||
onChange={(event) => { const entry = playable[Number(event.target.value)]; if (entry) playback.onSeek(entry.id); }} />
|
||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground" aria-label="历史播放位置">
|
||||
{selectedId === null ? playable.length : playable.length ? progress + 1 : 0}/{playable.length}
|
||||
</span>
|
||||
<Select value={String(playback.speed)} onValueChange={(value) => playback.onSpeedChange(Number(value))}>
|
||||
<SelectTrigger aria-label="历史播放速度" className="h-8 w-[68px] shrink-0 gap-1 px-2 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent position="popper" className="workbench-shadcn-surface z-[90]">
|
||||
{[0.5, 1, 2].map((speed) => <SelectItem key={speed} value={String(speed)} className="text-xs">{speed}×</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export function ProjectStartupScreen({ state, error, onRetry }: {
|
||||
<StartupScreen
|
||||
brand={{ name: "TJWater", description: "智慧水务平台", icon: <Droplet strokeWidth={1.5} /> }}
|
||||
title={error ? "暂时无法进入工作台" : readingProjects ? "正在加载授权项目" : "正在准备项目环境"}
|
||||
description={error ? "请查看原因后重试。" : "正在准备你的项目工作台,完成后自动进入。"}
|
||||
description={error ? "项目加载已中断,具体原因如下。" : "正在准备你的项目工作台,完成后自动进入。"}
|
||||
progressLabel="项目加载进度"
|
||||
steps={[
|
||||
{ id: "projects", label: "读取授权项目", state: readingProjects ? "loading" : "complete",
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { ScadaAlertAtmosphere } from "./scada-alert-atmosphere";
|
||||
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
|
||||
|
||||
const preview: ScadaAlertAtmosphereState = { id: "preview-1", projectId: "project-1", preview: true, alert: null };
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("shows a local preview with a dismiss button and no investigation action", () => {
|
||||
const onDismiss = vi.fn(), onOpen = vi.fn();
|
||||
render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("爆管告警效果预览");
|
||||
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
|
||||
const close = screen.getByRole("button", { name: "关闭告警灯效" });
|
||||
expect(close).toHaveTextContent("关闭灯效");
|
||||
fireEvent.click(close);
|
||||
expect(onDismiss).toHaveBeenCalledWith("preview-1");
|
||||
expect(onOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens the actual linked investigation while retaining the visual warning", () => {
|
||||
const onDismiss = vi.fn(), onOpen = vi.fn();
|
||||
const alert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常", description: "已启动调查", time: "08:00" };
|
||||
render(<ScadaAlertAtmosphere atmosphere={{ ...preview, id: alert.id, preview: false, alert }} onDismiss={onDismiss} onOpen={onOpen} />);
|
||||
expect(screen.getByRole("status")).toHaveTextContent("爆管风险调查中");
|
||||
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(onOpen).toHaveBeenCalledWith(alert);
|
||||
});
|
||||
|
||||
it("keeps the warning for a long-running event until its state is removed", () => {
|
||||
vi.useFakeTimers();
|
||||
const onDismiss = vi.fn(), onOpen = vi.fn();
|
||||
const { rerender } = render(<ScadaAlertAtmosphere atmosphere={preview} onDismiss={onDismiss} onOpen={onOpen} />);
|
||||
act(() => vi.advanceTimersByTime(24 * 60 * 60_000));
|
||||
expect(onDismiss).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("status")).toBeVisible();
|
||||
rerender(<ScadaAlertAtmosphere atmosphere={null} onDismiss={onDismiss} onOpen={onOpen} />);
|
||||
expect(screen.queryByRole("status")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowUpRight, TriangleAlert, X } from "lucide-react";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import type { ScadaAlertAtmosphereState } from "../hooks/use-scada-alert-notices";
|
||||
import type { WorkbenchAlert } from "../types";
|
||||
|
||||
export type ScadaAlertIntensity = "soft" | "standard" | "strong";
|
||||
|
||||
/** Event-lifetime viewport warning with an independently dismissible visual layer. */
|
||||
export function ScadaAlertAtmosphere({ atmosphere, onDismiss, onOpen, intensity = "standard" }: {
|
||||
atmosphere: ScadaAlertAtmosphereState | null;
|
||||
onDismiss: (id: string) => void;
|
||||
onOpen: (alert: WorkbenchAlert) => void;
|
||||
intensity?: ScadaAlertIntensity;
|
||||
}) {
|
||||
if (!atmosphere) return null;
|
||||
const alert = atmosphere.alert;
|
||||
return createPortal(
|
||||
<div className="scada-alert-atmosphere status-tone-danger"
|
||||
data-testid="scada-alert-atmosphere" data-alert-id={atmosphere.id} data-preview={atmosphere.preview} data-intensity={intensity}>
|
||||
<div className="scada-alert-atmosphere-frame" aria-hidden="true" />
|
||||
<div className="scada-alert-atmosphere-light" aria-hidden="true" />
|
||||
<div role="status" aria-live="polite" className="scada-alert-atmosphere-notice glass-transient items-center gap-3 rounded-xl border p-3 text-foreground">
|
||||
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-[var(--status-soft)] text-[var(--status-foreground)]">
|
||||
<TriangleAlert size={19} aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<p className="text-[13px] leading-5 font-semibold">{atmosphere.preview ? "爆管告警效果预览"
|
||||
: (atmosphere.count ?? 1) > 1 ? `${atmosphere.count} 起爆管风险事件处理中` : "爆管风险调查中"}</p>
|
||||
<p className="text-[11px] leading-4 text-[var(--ink-muted)]">{atmosphere.preview ? "持续预览 · 可在 Dev 面板模拟结束" : "持续至事件处理结束"}</p>
|
||||
</div>
|
||||
{alert?.conditionId ? <Button type="button" size="sm" variant="ghost" className="scada-alert-atmosphere-action h-9 shrink-0 gap-1 px-2 text-xs"
|
||||
onClick={() => onOpen(alert)}>
|
||||
查看调查<ArrowUpRight size={14} aria-hidden="true" />
|
||||
</Button> : null}
|
||||
<Button type="button" size="sm" variant="outline" className="scada-alert-atmosphere-close h-9 shrink-0 gap-1.5 px-2.5 text-xs" aria-label="关闭告警灯效" title="关闭当前视觉警告"
|
||||
onClick={() => onDismiss(atmosphere.id)}><X size={14} aria-hidden="true" />关闭灯效</Button>
|
||||
</div>
|
||||
</div>, document.body
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,8 @@ import {
|
||||
import { waterNetworkToolbarItems } from "../map/toolbar-config";
|
||||
import type { WorkbenchGeocoding } from "../hooks/use-workbench-geocoding";
|
||||
import { GeocodingPanel } from "./geocoding-panel";
|
||||
import { FeatureLocationPanel, type FeatureLocationPanelProps } from "./feature-location-panel";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
|
||||
export type ToolbarToolId = "geocoding" | "layers" | "measure" | "analysis" | "annotation" | "more";
|
||||
export type ExportViewPreset = "current" | "4k";
|
||||
@@ -98,6 +100,7 @@ const DRAW_TOOL_OPTIONS: Array<{
|
||||
|
||||
export type ToolbarPanelProps = {
|
||||
geocoding: WorkbenchGeocoding;
|
||||
featureLocation: FeatureLocationPanelProps;
|
||||
activeToolId: ToolbarToolId | null;
|
||||
activeMeasureModeId: WorkbenchMeasureMode;
|
||||
activeMeasureUnitId: WorkbenchMeasureUnit;
|
||||
@@ -140,6 +143,7 @@ export type ToolbarPanelProps = {
|
||||
|
||||
export function ToolbarPanel({
|
||||
geocoding,
|
||||
featureLocation,
|
||||
activeToolId,
|
||||
activeMeasureModeId,
|
||||
activeMeasureUnitId,
|
||||
@@ -203,6 +207,7 @@ export function ToolbarPanel({
|
||||
);
|
||||
|
||||
const [renderedToolId, setRenderedToolId] = useState<ToolbarToolId | null>(activeToolId);
|
||||
const [locationMode, setLocationMode] = useState("address");
|
||||
const panelVisible = Boolean(activeToolId);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -232,12 +237,28 @@ export function ToolbarPanel({
|
||||
if (renderedToolId === "geocoding") {
|
||||
return (
|
||||
<MapControlPanel
|
||||
key="geocoding" title="地址定位" description={geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
|
||||
key="geocoding" title="定位" description={locationMode === "features" ? "按图层和要素 ID 定位" : geocoding.configured ? `${geocoding.region} · 天地图地名搜索` : "当前项目暂未配置地名搜索"} icon={Icon}
|
||||
widthClassName={`${panelWidthClassName ?? "w-[324px] max-h-[calc(100dvh-13.25rem)]"} flex flex-col overflow-hidden`}
|
||||
contentClassName="flex min-h-0 flex-1 flex-col"
|
||||
visible={panelVisible}
|
||||
>
|
||||
<Tabs value={locationMode} onValueChange={(value) => {
|
||||
if (value === "features" && geocoding.picking) geocoding.togglePicking();
|
||||
setLocationMode(value);
|
||||
}} className="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TabsList aria-label="定位方式" className="surface-control grid shrink-0 grid-cols-2">
|
||||
<TabsTrigger value="address">地址地名</TabsTrigger>
|
||||
<TabsTrigger value="features">管网要素</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="address" forceMount hidden={locationMode !== "address"}
|
||||
className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<GeocodingPanel geocoding={geocoding} />
|
||||
</TabsContent>
|
||||
<TabsContent value="features" forceMount hidden={locationMode !== "features"}
|
||||
className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-0.5">
|
||||
<FeatureLocationPanel {...featureLocation} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</MapControlPanel>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Bot,
|
||||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Columns2,
|
||||
FileText,
|
||||
Keyboard,
|
||||
@@ -17,10 +16,12 @@ import {
|
||||
UserRound,
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence } from "motion/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,
|
||||
@@ -54,8 +55,14 @@ 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)] focus:bg-[var(--surface-reading)] focus:text-[var(--action-blue-hover)]",
|
||||
mapOptionInteractionVariants()
|
||||
);
|
||||
|
||||
export function AnalysisViewMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -123,6 +130,7 @@ export function AlertMenu({
|
||||
compact = false,
|
||||
conditionFeedVisible,
|
||||
onSelectAlert,
|
||||
onRemoveAlert,
|
||||
onToggleConditionFeed,
|
||||
onAnalyzeAlertsWithAgent
|
||||
}: {
|
||||
@@ -133,14 +141,33 @@ export function AlertMenu({
|
||||
compact?: boolean;
|
||||
conditionFeedVisible: boolean;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onRemoveAlert?: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onAnalyzeAlertsWithAgent: () => void | Promise<void>;
|
||||
}) {
|
||||
const reduceMotion = useReducedMotion();
|
||||
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>
|
||||
@@ -149,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,36 +225,68 @@ export function AlertMenu({
|
||||
tone={hasAlerts ? alertSummaryTone : "success"}
|
||||
/>
|
||||
<MenuSectionLabel>待处理事项</MenuSectionLabel>
|
||||
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
|
||||
<div className="scheduled-feed-scroll relative max-h-[min(52dvh,420px)] overflow-x-hidden overflow-y-auto pr-1">
|
||||
<AnimatePresence initial={false} mode="popLayout" custom={skipRemovalMotion}>
|
||||
{hasAlerts ? alerts.map((alert) => {
|
||||
const tone = alert.severity === "warning" ? "warning" : "danger";
|
||||
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<motion.div
|
||||
key={alert.id}
|
||||
data-alert-id={alert.id}
|
||||
layout="position"
|
||||
initial={false}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit="removed"
|
||||
variants={{ removed: (skip: boolean) => ({
|
||||
opacity: 0, x: skip ? 0 : 12, pointerEvents: "none",
|
||||
transition: { duration: skip ? 0 : 0.18, ease: [0.4, 0, 1, 1] }
|
||||
}) }}
|
||||
transition={{ duration: skipRemovalMotion ? 0 : 0.18, ease: [0.4, 0, 1, 1] }}
|
||||
className={cn(
|
||||
toneClassName,
|
||||
"group my-1 grid min-h-[72px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 border border-transparent px-2.5 py-2.5 transition-colors focus:border-[var(--status-border)]",
|
||||
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={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)}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<StatusDot tone={tone} size="md" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold leading-5 text-slate-950">{alert.title}</span>
|
||||
<span className="shrink-0 font-mono text-xs font-semibold leading-4 text-[var(--ink-muted)] tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<span className="mt-1 block line-clamp-2 text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</span>
|
||||
</span>
|
||||
<span className={cn(toneClassName, "grid h-7 w-7 shrink-0 place-items-center bg-[var(--surface-reading)] text-[var(--ink-muted)] transition-colors group-focus:text-[var(--status-foreground)]", MAP_ICON_CELL_RADIUS_CLASS_NAME)} aria-hidden="true">
|
||||
<ChevronRight size={14} />
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`打开通知:${alert.title}`}
|
||||
/>
|
||||
</DropdownMenuItem>
|
||||
<div className="pointer-events-none relative">
|
||||
<span className="flex min-w-0 items-center gap-2 pr-10 text-xs text-[var(--ink-muted)]">
|
||||
<StatusDot tone={tone} size="md" />
|
||||
<span className="font-mono tabular-nums">{alert.time}</span>
|
||||
</span>
|
||||
<p className="mt-1 break-words pr-10 text-sm font-semibold leading-5 text-slate-950">{alert.title}</p>
|
||||
<p className="mt-0.5 break-words text-xs leading-5 text-[var(--ink-muted)]">{alert.description}</p>
|
||||
</div>
|
||||
{onRemoveAlert ? (
|
||||
<DropdownMenuItem
|
||||
aria-label={`移除通知:${alert.title}`}
|
||||
title="移除通知"
|
||||
className={cn(alertActionClassName, "absolute right-1 top-1 z-10")}
|
||||
onSelect={(event) => {
|
||||
event.preventDefault();
|
||||
onRemoveAlert(alert);
|
||||
}}
|
||||
>
|
||||
<X size={14} aria-hidden="true" />
|
||||
</DropdownMenuItem>
|
||||
) : null}
|
||||
</motion.div>
|
||||
);
|
||||
}) : (
|
||||
<div className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div key="empty-alert-queue" className={cn("surface-reading my-1 grid min-h-[76px] place-items-center border border-dashed border-[var(--surface-divider)] px-3 py-3 text-center", MAP_COMPACT_RADIUS_CLASS_NAME)}>
|
||||
<div className="min-w-0">
|
||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||
<CheckCircle2 size={16} />
|
||||
@@ -236,6 +296,7 @@ export function AlertMenu({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-[var(--surface-divider)]/70 pt-2">
|
||||
<MenuPlainButton
|
||||
@@ -339,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" />
|
||||
@@ -424,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>
|
||||
@@ -454,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" />
|
||||
@@ -512,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}
|
||||
>
|
||||
@@ -524,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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user