Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7eb01652a | ||
|
|
038d3458bc | ||
|
|
692bbf609c | ||
|
|
ad1f7ca7a9 | ||
|
|
aa9b12662e | ||
|
|
b948551c1f | ||
|
|
e0fe4a5a10 | ||
|
|
4e3ed5098e | ||
|
|
8a63926284 | ||
|
|
4a7781d5e5 | ||
|
|
e0436217a8 | ||
|
|
ea3b62573c | ||
|
|
eee65ac8fd | ||
|
|
ba71e2d529 | ||
|
|
0801e161ec | ||
|
|
78481903d7 | ||
|
|
0ec74b15b2 | ||
|
|
74bf7c64c4 |
@@ -89,6 +89,7 @@ pnpm format # 使用 Prettier 格式化文件
|
||||
pnpm test # 运行 Vitest 单元测试
|
||||
pnpm test:watch # 监听模式运行单元测试
|
||||
pnpm test:browser # 运行 Playwright 浏览器测试
|
||||
pnpm check:contracts # 在三端集成工作区只读检查前端生成的 Agent/Server 契约
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -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,6 +20,8 @@ Dev 分为“调查测试”和“地图调试”两个页签:前者按“准
|
||||
|
||||
Dev 与工况详情共用右侧工具位置,切换调查时先关闭 Dev 回到任务列表,再打开 Dev 检查新的选择。实时展示面板位于地图左下方,打开 Dev 后仍可看见。新图表进入其滚动区时会滚动到图表,保留标题;手动收起面板后仍接收指令,展开再查看。切换项目、调查或执行代次会清理临时效果并取消未完成的本地预览。上述清理保留本次页面会话内的动作快照,只有切换项目会同时清空历史。已绑定调查时关闭跟随也会清理该调查的当前临时展示;未选择调查的本地预览不受跟随开关影响。面板外仍可操作地图,手动拖拽或滚轮暂停镜头自动跟随。
|
||||
|
||||
调查与本地预览在展示分析图表时自动打开可拖动、缩放和最大化的独立窗口,覆盖 `open_chart` 指令和结构化界面中的 `Chart`。自动打开不抢走键盘焦点;关闭窗口仅收起放大视图,卡片中的图表和放大入口继续保留。同一图表内容未变化时,其他地图动作不会使已关闭的窗口再次弹出;图表内容更新或进入、退出历史回看时可再次自动打开。回放窗口随所选快照更新,保留已有窗口的位置和尺寸,期间收到的实时数据不替换历史图表。关闭图表指令或切换任务时移除对应窗口;普通对话图表仍由用户手动放大。
|
||||
|
||||
## 本地测试步骤
|
||||
|
||||
1. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
|
||||
|
||||
@@ -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,7 +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 分钟时间戳组织刻度,不读取项目监测周期;鼠标和触摸拨动过程保持连续无缝,松手后以约 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 回答的展开阅读面板,以及这些界面的子弹窗和提示。
|
||||
|
||||
@@ -50,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) 合并。
|
||||
@@ -176,6 +190,8 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
|
||||
|
||||
`WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。
|
||||
|
||||
带查看入口的系统弹出通知采用上下两区:上方排列状态图标、标题和正文,右上角预留关闭按钮;下方用细分隔线划出操作行,查看入口靠右并带跳转箭头,使用轻量按钮。无操作的通知不显示底部操作行,长正文自然换行,关闭与查看入口的点击区域互不重叠。
|
||||
|
||||
每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。
|
||||
|
||||
地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
|
||||
|
||||
@@ -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",
|
||||
@@ -59,6 +60,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
+74
@@ -123,6 +123,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)
|
||||
@@ -3180,6 +3183,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 +3488,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 +3601,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 +3617,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 +3666,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 +3972,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==}
|
||||
|
||||
@@ -7088,6 +7126,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 +7696,8 @@ snapshots:
|
||||
|
||||
nwsapi@2.2.24: {}
|
||||
|
||||
object-assign@4.1.1: {}
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
oniguruma-parser@0.12.2: {}
|
||||
@@ -7760,6 +7804,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 +7818,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 +7863,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 +8220,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`);
|
||||
}
|
||||
|
||||
@@ -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 }];
|
||||
})) };
|
||||
}
|
||||
@@ -11,5 +11,6 @@ export type AnswerUiInteractions = {
|
||||
onSubmit?: (text: string) => Promise<void> | void;
|
||||
evidenceFilter?: string;
|
||||
submitMode?: "send" | "draft";
|
||||
chartWindowKey?: string;
|
||||
};
|
||||
export const AnswerInteractionContext = createContext<AnswerUiInteractions>({ disabled: true });
|
||||
|
||||
@@ -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: {},
|
||||
@@ -103,7 +104,7 @@ const registry = withAnswerNodeIdentity(baseRegistry);
|
||||
type AnswerUiViewProps = { block: AnswerUiBlock; viewState?: AnswerViewState; onViewStateChange?: AnswerViewStateChange } & Partial<AnswerUiInteractions>;
|
||||
export function AnswerUiView({ viewState, onViewStateChange, ...props }: AnswerUiViewProps) {
|
||||
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: withAnswerNodeIds(withAnswerDisplayTimes(props.block.spec)) }} />
|
||||
</AnswerViewStateProvider>;
|
||||
}
|
||||
|
||||
@@ -112,6 +113,7 @@ function AnswerUiBody({
|
||||
disabled = false,
|
||||
onLocate,
|
||||
onSubmit,
|
||||
chartWindowKey,
|
||||
submitMode = "send"
|
||||
}: { block: AnswerUiBlock } & Partial<AnswerUiInteractions>) {
|
||||
const [{ evidenceFilter: filter }, setFilter] = useAnswerBlockState();
|
||||
@@ -130,7 +132,7 @@ 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 }}>
|
||||
<InteractionContext.Provider value={{ disabled, onLocate, onSubmit, submitMode, evidenceFilter: filter, chartWindowKey }}>
|
||||
<AnswerMotionScope className="answer-ui-root" data-answer-ui={block.block_id} data-answer-phase={block.phase} data-answer-intent={intent} data-answer-density={density}>
|
||||
<AnswerContentNav block={block} contentRef={contentRef} />
|
||||
<div ref={contentRef}>
|
||||
|
||||
@@ -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,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { chartPointTooltip, chartSampleTime, 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("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,28 @@
|
||||
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) : "";
|
||||
}
|
||||
|
||||
export function chartValueBounds(series: SafeChartSeries[], start: number, end: number) {
|
||||
const values = series.flatMap((item) => item.data.slice(start, end + 1))
|
||||
.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||
if (!values.length) return {};
|
||||
const min = Math.min(...values), max = Math.max(...values);
|
||||
const padding = (max - min || Math.abs(min) * 0.1 || 1) * 0.08;
|
||||
return { min: min - padding, max: max + padding };
|
||||
}
|
||||
|
||||
/** 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,29 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { memo, useContext, useLayoutEffect, 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 { 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 { chartPointTooltip, chartSampleTime, chartValueBounds, chartValueLabel } from "./chart-presentation";
|
||||
import { AnswerInteractionContext } from "./answer-ui-interaction-context";
|
||||
|
||||
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 +35,34 @@ 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 = { 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 { chartWindowKey } = useContext(AnswerInteractionContext);
|
||||
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 (props.expanded || !element || typeof ResizeObserver === "undefined") return;
|
||||
const resize = () => setPlotWidth(element.clientWidth);
|
||||
resize();
|
||||
const observer = new ResizeObserver(resize);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [view, props.expanded]);
|
||||
const contentMotion = useAnimationControls();
|
||||
const pendingTransition = useRef(false);
|
||||
useLayoutEffect(() => {
|
||||
@@ -62,111 +90,65 @@ export function InteractiveChart(props: AnswerChartProps) {
|
||||
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="当前区间摘要">
|
||||
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 option = (type: "line" | "bar") => {
|
||||
const horizontal = type === "bar" && horizontalBars;
|
||||
const seriesType = (series: SafeChartSeries) => view === (props.chart_type === "bar" ? "bar" : "line") ? series.type ?? type : type;
|
||||
const includesBars = visible.some((series) => seriesType(series) === "bar");
|
||||
const categoryAxis = { type: "category", data: labels, boundaryGap: type === "bar",
|
||||
axisLabel: { fontSize: 11, color: "#64748b", hideOverlap: !horizontal, interval: horizontal ? 0 : "auto",
|
||||
...(horizontal ? { width: props.expanded ? 150 : Math.min(210, Math.max(96, plotWidth * 0.28)), overflow: "break", lineHeight: 16 } : {}) },
|
||||
axisTick: { alignWithLabel: true }, axisLine: { lineStyle: { color: "#cbd5e1" } } };
|
||||
const valueAxis = { type: "value", scale: !includesBars,
|
||||
...(!includesBars ? chartValueBounds(visible, range[0], range[1]) : {}),
|
||||
axisLabel: { fontSize: 11, color: "#64748b", formatter: numberLabel },
|
||||
splitLine: { lineStyle: { color: "#e2e8f0" } } };
|
||||
return {
|
||||
animation: animateInteraction && !reducedMotion, animationDuration: 180, animationDurationUpdate: 180,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif' },
|
||||
color: colors.filter((_, index) => !hidden.includes(index)), aria: { enabled: true },
|
||||
tooltip: { trigger: composite ? "item" : "axis", renderMode: composite ? "html" : "richText", confine: true,
|
||||
textStyle: { fontFamily: '"Noto Sans SC Variable", "PingFang SC", "Microsoft YaHei", sans-serif', fontSize: 12 },
|
||||
...(composite ? { formatter: (params: unknown) => {
|
||||
const first = (Array.isArray(params) ? params[0] : params) as { dataIndex?: number; seriesIndex?: number } | undefined;
|
||||
const focused = composite && typeof first?.seriesIndex === "number" ? visible.slice(first.seriesIndex, first.seriesIndex + 1) : visible;
|
||||
const text = typeof first?.dataIndex === "number"
|
||||
? chartPointTooltip(labels[first.dataIndex], focused, range[0] + first.dataIndex) : "";
|
||||
// ECharts richText tooltips omit the font family. Plain DOM text keeps CJK
|
||||
// readable and never interprets model-provided labels as HTML.
|
||||
const content = document.createElement("div");
|
||||
content.textContent = text;
|
||||
content.style.whiteSpace = "pre-line";
|
||||
return content;
|
||||
} } : {}), axisPointer: { type: type === "bar" ? "shadow" : "line" } },
|
||||
grid: { top: 28, right: 24, bottom: (props.xAxisName ?? props.x_axis_name) ? 48 : 28, left: 12, containLabel: true },
|
||||
xAxis: { ...(horizontal ? valueAxis : categoryAxis), name: horizontal ? props.yAxisName ?? props.y_axis_name : props.xAxisName ?? props.x_axis_name,
|
||||
nameLocation: "middle", nameGap: 30 },
|
||||
yAxis: horizontal ? { ...categoryAxis, inverse: true, name: props.xAxisName ?? props.x_axis_name } : { ...valueAxis, name: props.yAxisName ?? props.y_axis_name },
|
||||
series: visible.map((series) => ({ name: series.name, type: seriesType(series),
|
||||
data: series.data.slice(range[0], range[1] + 1).map((value, index) => type === "bar" && categoryColors
|
||||
? { value, itemStyle: { color: colors[(range[0] + index) % colors.length] } } : value),
|
||||
barMaxWidth: 32, barCategoryGap: "35%", connectNulls: false,
|
||||
showSymbol: labels.length <= 24, symbolSize: seriesType(series) === "scatter" ? 8 : 6, emphasis: { focus: "series" } }))
|
||||
};
|
||||
};
|
||||
const metadata = <>
|
||||
{!props.embedded && view !== "table" ? <div className="rounded-lg bg-muted/40 p-2.5" role="group" aria-label="当前区间摘要">
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-1">
|
||||
<span className="text-[11px] font-medium">区间摘要 <span className="font-normal text-muted-foreground">· 本地统计</span></span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{summaries.map((item) => <div key={item.name} className="answer-chart-range-summary text-[11px] leading-5">
|
||||
<span className="min-w-0 break-words text-muted-foreground">{item.name}</span>
|
||||
<span className="tabular-nums">{item.first} → {item.last}</span>
|
||||
<span className="text-muted-foreground">最小 {item.min} · 最大 {item.max}</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}
|
||||
@@ -183,13 +165,117 @@ 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}
|
||||
<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} 组序列<br />来源:{props.source}</span>
|
||||
</p>
|
||||
</>;
|
||||
return <Collapsible asChild><Card className={`answer-ui-chart overflow-hidden ${props.embedded || props.expanded ? "border-0 shadow-none" : ""} ${props.expanded ? "@container flex h-full min-h-0 min-w-0 flex-1 flex-col" : ""}`} role={props.embedded ? undefined : "group"} aria-label={props.embedded ? undefined : props.title}
|
||||
onPointerDownCapture={() => { pointer.current = true; }}
|
||||
onKeyDownCapture={() => {
|
||||
pointer.current = false;
|
||||
setAnimateInteraction(false);
|
||||
contentMotion.stop();
|
||||
contentMotion.set({ opacity: 1, y: 0 });
|
||||
}}>
|
||||
<CardHeader className="shrink-0 flex-row items-center justify-between gap-2 space-y-0 px-3.5 py-2.5">
|
||||
<CardTitle className="flex min-w-0 items-center gap-2 text-xs leading-5 tracking-normal"><TrendingUp size={15} className="shrink-0" aria-hidden />{props.embedded || props.expanded ? "图形与数据" : props.title}</CardTitle>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!props.embedded && !props.expanded ? <FloatingWindow title={props.title} fillContent
|
||||
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, props])}>
|
||||
{() => <InteractiveChart {...props} expanded />}
|
||||
</FloatingWindow> : null}
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="筛选数据范围" title="筛选数据范围"
|
||||
className="shrink-0 text-muted-foreground">
|
||||
<SlidersHorizontal size={14} aria-hidden />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className={`${props.expanded ? "flex min-h-0 flex-1 flex-col" : "space-y-3"} px-3.5 pb-3.5 pt-0`}>
|
||||
{migrationNotice ? <p role="status" className="text-xs leading-5 text-muted-foreground">{migrationNotice}</p> : null}
|
||||
<Tabs value={view} onValueChange={setView} className={props.expanded ? "grid min-h-0 flex-1 grid-cols-1 grid-rows-[auto_auto_minmax(120px,1fr)_minmax(0,auto)] gap-x-3 gap-y-2 @min-[560px]:grid-cols-[minmax(0,1fr)_176px] @min-[560px]:grid-rows-[auto_auto_minmax(120px,1fr)]" : undefined}>
|
||||
<TabsList aria-label={`${props.title}显示方式`} className={`${props.expanded ? "col-start-1 row-start-1 min-w-0" : "mb-3"} relative grid w-full grid-cols-3 [&>[role=tab]]:relative [&>[role=tab]]:z-10 [&>[role=tab][data-state=active]]:bg-transparent [&>[role=tab][data-state=active]]:shadow-none`}>
|
||||
<motion.span aria-hidden className="pointer-events-none absolute inset-y-1 left-1 rounded-md bg-card shadow-xs"
|
||||
style={{ width: "calc((100% - 0.5rem) / 3)" }}
|
||||
initial={false} animate={{ x: view === "line" ? "0%" : view === "bar" ? "100%" : "200%" }}
|
||||
transition={{ duration: animateInteraction && !reducedMotion ? 0.18 : 0, ease: agentEase }} />
|
||||
<TabsTrigger value="line"><TrendingUp size={13} />{composite ? "复合" : "折线"}</TabsTrigger>
|
||||
<TabsTrigger value="bar"><BarChart3 size={13} />柱状</TabsTrigger>
|
||||
<TabsTrigger value="table"><Table2 size={13} />数据</TabsTrigger>
|
||||
</TabsList>
|
||||
<aside className={props.expanded ? "col-start-1 row-start-4 max-h-32 min-h-0 min-w-0 space-y-2 overflow-y-auto border-t border-border pt-2 @min-[560px]:col-start-2 @min-[560px]:row-start-1 @min-[560px]:row-span-3 @min-[560px]:max-h-none @min-[560px]:border-t-0 @min-[560px]:border-l @min-[560px]:pt-0 @min-[560px]:pl-3" : undefined}>
|
||||
<div className={`answer-ui-chart-series mb-2 flex flex-wrap gap-2 ${props.expanded ? "@min-[560px]:flex-col @min-[560px]:flex-nowrap @min-[560px]:items-stretch" : ""}`} aria-label="显示的数据序列">
|
||||
{props.series.map((series, index) => <Button key={index} size="sm" variant="outline"
|
||||
className={`h-auto min-h-8 min-w-0 max-w-full gap-1.5 whitespace-normal text-left text-[11px] after:inset-0 ${props.expanded ? "justify-start" : ""}`} aria-pressed={!hidden.includes(index)}
|
||||
disabled={visible.length === 1 && !hidden.includes(index)}
|
||||
onClick={() => { setAnimateInteraction(pointer.current && !reducedMotion); patch({ hidden: hidden.includes(index) ? hidden.filter((item) => item !== index) : [...hidden, index],
|
||||
table: { query: "", ...table, sort: null, selection: [] } }, ["hidden", "table/sort"]); }}>
|
||||
<span className="size-2 shrink-0 rounded-full" style={{ background: hidden.includes(index) ? "#cbd5e1" : colors[index] }} /><span className="min-w-0 break-words">{series.name}</span>
|
||||
</Button>)}
|
||||
</div>
|
||||
{hidden.length ? <Button type="button" variant="ghost" size="sm" onClick={() => patch({ hidden: [] }, ["hidden"])}>显示全部序列</Button> : null}
|
||||
{view === "bar" && categoryColors ? <div aria-label="类别图例" className={`mb-3 grid gap-1.5 rounded-md bg-muted/30 p-2 ${props.expanded ? "" : "max-h-40 overflow-y-auto"}`} style={{ gridTemplateColumns: `repeat(${props.expanded ? 1 : plotWidth >= 700 ? 3 : plotWidth >= 420 ? 2 : 1},minmax(0,1fr))` }}>
|
||||
{labels.map((label, index) => <div key={index} className="flex min-w-0 items-start gap-2 text-[11px] leading-5">
|
||||
<span className="mt-1.5 size-2 shrink-0 rounded-sm" style={{ background: colors[(range[0] + index) % colors.length] }} />
|
||||
<span className="min-w-0 break-words">{label}</span>
|
||||
<span className="ml-auto shrink-0 tabular-nums">{numberLabel(visible[0].data[range[0] + index])}</span>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
{props.expanded ? metadata : null}
|
||||
</aside>
|
||||
<div className={props.expanded ? "col-start-1 row-start-2 min-w-0" : undefined}>
|
||||
{!fullRange ? <p className="mb-2 text-[11px] text-muted-foreground">{props.x_data[range[0]]} 至 {props.x_data[range[1]]}</p> : null}
|
||||
<CollapsibleContent className="mb-2 space-y-2 rounded-lg border bg-muted/30 p-2.5">
|
||||
<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>
|
||||
</div>
|
||||
<motion.div className={props.expanded ? "col-start-1 row-start-3 min-h-0 min-w-0" : undefined} data-chart-view={view} initial={false} animate={contentMotion}>
|
||||
{(["line", "bar"] as const).map((type) => <TabsContent key={type} value={type} className={props.expanded ? "h-full min-h-0" : undefined}>
|
||||
<div ref={view === type ? plot : undefined} role="group" aria-label={`${props.title},按回车查看数据点,方向键切换数据点`} tabIndex={0}
|
||||
className={`${props.expanded ? (type === "bar" && horizontalBars ? "h-full min-h-0 overflow-x-hidden overflow-y-auto" : "h-full min-h-0 overflow-hidden") : ""} rounded-md focus-visible:outline-2 focus-visible:outline-ring`}
|
||||
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: props.expanded ? "100%" : props.plotHeight ?? 220,
|
||||
minHeight: props.expanded && type === "bar" && horizontalBars ? labels.length * 36 + 48 : undefined }}>
|
||||
<ResponsiveChart option={option(type)} height="100%" onPointSelect={(index) => patch({ point: range[0] + index, inspecting: true })} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>)}
|
||||
<TabsContent value="table" className={props.expanded ? "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>
|
||||
{!props.expanded ? metadata : null}
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card></Collapsible>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,16 +2,18 @@ import { useMemo } from "react";
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown } 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,6 +36,9 @@ 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">
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useLayoutEffect, useRef, type ComponentProps } from "react";
|
||||
import ReactECharts from "echarts-for-react";
|
||||
|
||||
/** Resize the existing drawing at most once per frame without replacing its options. */
|
||||
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;
|
||||
let frame = 0;
|
||||
const observer = new ResizeObserver(() => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!ready.current || !element.clientWidth || !element.clientHeight) return;
|
||||
chart.current?.getEchartsInstance().resize({ width: "auto", height: "auto", animation: { duration: 0 } });
|
||||
});
|
||||
});
|
||||
observer.observe(element);
|
||||
return () => { cancelAnimationFrame(frame); observer.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 }: { chart: NonNullable<ReturnType<typeof parseFrontendChart>>; embedded?: boolean; expanded?: boolean }) {
|
||||
return <SafeChartEnvelope embedded={embedded} expanded={expanded} envelope={{ kind: "chart", schemaVersion: "agent-ui@1", grammar: "echarts-safe-subset", surface: "side_panel", spec: { title: chart.title, chart_type: chart.chart_type,
|
||||
...(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 }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: 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}
|
||||
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,
|
||||
|
||||
@@ -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,5 +1,12 @@
|
||||
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])$/
|
||||
);
|
||||
}
|
||||
|
||||
/** Shared host/native schema factory. Native OpenCode supplies its own Zod instance. */
|
||||
export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
const z = zod;
|
||||
@@ -8,14 +15,18 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
|
||||
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,7 +35,9 @@ 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"
|
||||
);
|
||||
const position = z
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,7 +57,7 @@ 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"
|
||||
>
|
||||
|
||||
@@ -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}>
|
||||
<MapNoticeIcon tone={tone} icon={Icon} />
|
||||
<MapNoticeBody
|
||||
title={title}
|
||||
message={message}
|
||||
action={
|
||||
actionLabel && onAction ? (
|
||||
<MapNoticeAction label={actionLabel} onClick={onAction} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<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} />
|
||||
</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} />
|
||||
</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";
|
||||
|
||||
@@ -47,7 +47,7 @@ export function MapToolbar({
|
||||
"pointer-events-auto p-1",
|
||||
MAP_CONTROL_RADIUS_CLASS_NAME,
|
||||
MAP_CONTROL_SURFACE_CLASS_NAME,
|
||||
isVertical ? "flex w-12 flex-col" : "inline-flex",
|
||||
isVertical ? "flex w-fit flex-col" : "inline-flex",
|
||||
className
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -86,7 +86,7 @@ export function MapZoom({
|
||||
<div
|
||||
role="group"
|
||||
aria-label="地图缩放"
|
||||
className={cn("pointer-events-auto flex w-12 flex-col gap-2", className)}
|
||||
className={cn("pointer-events-auto flex w-fit flex-col gap-2", className)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -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,57 @@ 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 };
|
||||
|
||||
it.each([null, replayContext])("loads summaries and details with replay_context %j", (context) => {
|
||||
const value = task();
|
||||
const detail = { ...value, task_type: taskType, source: { ...value.source, replay_context: context } };
|
||||
const {
|
||||
latest_plan: _latestPlan, latest_report: _latestReport, evidence: _evidence,
|
||||
actions: _actions, permissions: _permissions, ...summary
|
||||
} = detail;
|
||||
|
||||
expect(parseOperationalTaskSummary(summary).source).toHaveProperty("replay_context", context);
|
||||
expect(parseOperationalTask(detail).source).toHaveProperty("replay_context", context);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ ...replayContext, replay_id: "not-a-uuid" },
|
||||
{ ...replayContext, knowledge_cutoff: "not-a-date" },
|
||||
{ ...replayContext, batch_sequence: 0 },
|
||||
{ ...replayContext, batch_sequence: 1.5 },
|
||||
{ ...replayContext, unexpected: true }
|
||||
])("rejects invalid replay metadata %j", (context) => {
|
||||
const value = task();
|
||||
expect(() => parseOperationalTask({
|
||||
...value, task_type: taskType, source: { ...value.source, replay_context: context }
|
||||
})).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("loads task runs used to resolve a selected report round", async () => {
|
||||
const run = {
|
||||
run_id: id,
|
||||
@@ -141,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"]);
|
||||
@@ -13,7 +12,12 @@ const taskSourceSchema = z.object({
|
||||
external_id: z.string().min(1).nullable(),
|
||||
external_revision: z.number().int().positive().nullable(),
|
||||
event_kind: z.string().min(1).nullable(),
|
||||
deleted_at: workbenchDateTimeSchema.nullish()
|
||||
deleted_at: workbenchDateTimeSchema.nullish(),
|
||||
replay_context: z.object({
|
||||
replay_id: z.string().uuid(),
|
||||
knowledge_cutoff: workbenchDateTimeSchema,
|
||||
batch_sequence: z.number().int().positive()
|
||||
}).strict().nullish()
|
||||
}).strict();
|
||||
const taskTriggerSchema = z.object({
|
||||
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
|
||||
@@ -45,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"]),
|
||||
@@ -425,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 response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, {
|
||||
headers: createWorkbenchHeaders(token), signal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||
const page = projectPageSchema.parse(payload);
|
||||
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: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
|
||||
return projectPageSchema.parse(payload);
|
||||
}, signal);
|
||||
projects.push(...page.items.filter((project) => project.status === "active"));
|
||||
offset += page.items.length;
|
||||
if (offset >= page.total) break;
|
||||
@@ -236,19 +239,21 @@ async function fetchProjectMetadata(
|
||||
headers: Headers,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
|
||||
headers,
|
||||
signal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(getServerError(payload, response.status, "项目元数据查询"));
|
||||
}
|
||||
const metadata = projectMetadataSchema.parse(payload);
|
||||
if (metadata.project_id !== project.project_id) {
|
||||
throw new Error("项目元数据与授权项目不一致");
|
||||
}
|
||||
return metadata;
|
||||
return withProjectRequest(SERVER_API_BASE_URL, "读取项目配置", async (requestSignal) => {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, {
|
||||
headers,
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(getServerError(payload, response.status, "项目元数据查询"));
|
||||
}
|
||||
const metadata = projectMetadataSchema.parse(payload);
|
||||
if (metadata.project_id !== project.project_id) {
|
||||
throw new Error("项目元数据与授权项目不一致");
|
||||
}
|
||||
return metadata;
|
||||
}, signal);
|
||||
}
|
||||
|
||||
async function fetchAccessContext(
|
||||
@@ -256,19 +261,21 @@ async function fetchAccessContext(
|
||||
headers: Headers,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
|
||||
headers,
|
||||
signal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(getServerError(payload, response.status, "访问上下文查询"));
|
||||
}
|
||||
const accessContext = accessContextSchema.parse(payload);
|
||||
if (accessContext.project_id !== project.project_id) {
|
||||
throw new Error("访问上下文与授权项目不一致");
|
||||
}
|
||||
return accessContext;
|
||||
return withProjectRequest(SERVER_API_BASE_URL, "读取项目访问权限", async (requestSignal) => {
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, {
|
||||
headers,
|
||||
signal: requestSignal
|
||||
});
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(getServerError(payload, response.status, "访问上下文查询"));
|
||||
}
|
||||
const accessContext = accessContextSchema.parse(payload);
|
||||
if (accessContext.project_id !== project.project_id) {
|
||||
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),
|
||||
@@ -44,7 +44,8 @@ const latestScadaReadingSchema = z.object({
|
||||
transmission_frequency: z.string(),
|
||||
observed_at: z.string().datetime({ offset: true }).nullable(),
|
||||
monitored_value: z.number().finite().nullable(),
|
||||
cleaned_value: z.number().finite().nullable()
|
||||
cleaned_value: z.number().finite().nullable(),
|
||||
quality: z.enum(["valid", "corrected", "missing", "invalid", "unvalidated"]).optional()
|
||||
});
|
||||
|
||||
const latestScadaReadingsSchema = z.object({
|
||||
@@ -67,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;
|
||||
|
||||
@@ -76,14 +76,14 @@ export class ScadaSystemClient {
|
||||
|
||||
constructor(private readonly getAccessToken?: WorkbenchAccess) {}
|
||||
|
||||
async getLatestReadings(signal?: AbortSignal): Promise<LatestScadaReadings> {
|
||||
async getLatestReadings(signal?: AbortSignal, offset = 0): Promise<LatestScadaReadings> {
|
||||
const [projectId, accessToken] = await Promise.all([
|
||||
this.resolveProjectId(),
|
||||
getWorkbenchAccessToken(this.getAccessToken)
|
||||
]);
|
||||
return latestScadaReadingsSchema.parse(
|
||||
await requestJson(
|
||||
"/api/v1/timeseries/scada-readings/latest?limit=500&offset=0",
|
||||
`/api/v1/timeseries/scada-readings/latest?limit=500&offset=${offset}`,
|
||||
accessToken,
|
||||
projectId,
|
||||
signal
|
||||
@@ -243,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 } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { requestWorkbenchJson } from "./request-json";
|
||||
import { z } from "zod";
|
||||
import { findOwnedWorkbenchProject } from "./project-client";
|
||||
import { getWorkbenchAccessToken, type WorkbenchAccess } from "./request-context";
|
||||
|
||||
const ruleSchema = z.object({
|
||||
rule_id: z.string(), device_ids: z.array(z.string()), kind: z.enum(["relative_change", "bounds"]),
|
||||
direction: z.enum(["both", "increase", "decrease"]).optional(), threshold: z.number().nullable().optional(),
|
||||
lower: z.number().nullable().optional(), upper: z.number().nullable().optional(), unit: z.string(), metric_name: z.string().nullable().optional(),
|
||||
minimum_samples: z.number().optional(), window_samples: z.number().optional(),
|
||||
consecutive_samples: z.number().optional(), recovery_samples: z.number().optional(), minimum_devices: z.number().optional()
|
||||
});
|
||||
const readingSchema = z.object({
|
||||
time: z.string().datetime({ offset: true }), device_id: z.string(),
|
||||
monitored_value: z.number().finite().nullable(), cleaned_value: z.number().finite().nullable(),
|
||||
quality: z.enum(["valid", "corrected", "missing", "invalid", "unvalidated"]), reason: z.string()
|
||||
});
|
||||
const eventSchema = z.object({
|
||||
event_id: z.string(), rule_id: z.string(), device_ids: z.array(z.string()),
|
||||
occurred_at: z.string(), last_observed_at: z.string(), status: z.enum(["active", "resolved"]),
|
||||
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(),
|
||||
batch_sequence: z.number().int(), rule_version: z.string(),
|
||||
status: z.enum(["normal", "attention", "insufficient_data", "suspected_burst"]),
|
||||
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()),
|
||||
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(),
|
||||
status: z.enum(["ready", "paused", "completed", "stopped", "failed"]),
|
||||
start_time: z.string(), end_time: z.string(), virtual_now: z.string(),
|
||||
monitoring_interval_seconds: z.number().int().min(60).max(31_536_000).nullable().default(null),
|
||||
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() })),
|
||||
rules: z.array(ruleSchema), latest: z.array(readingSchema), active_events: z.array(eventSchema),
|
||||
error: z.string().nullable(), created_at: z.string(), updated_at: z.string()
|
||||
});
|
||||
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),
|
||||
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>;
|
||||
export type ScadaMonitoringResult = z.infer<typeof monitoringSchema>;
|
||||
export type ScadaReplayCapabilities = z.infer<typeof capabilitiesSchema>;
|
||||
export type ScadaReplayAction = "step" | "stop" | "retry";
|
||||
|
||||
export class ScadaReplayClient {
|
||||
private projectId: Promise<string> | null = null;
|
||||
constructor(private readonly access?: WorkbenchAccess) {}
|
||||
|
||||
async capabilities(signal?: AbortSignal) {
|
||||
return capabilitiesSchema.parse(await this.request("/capabilities", undefined, signal));
|
||||
}
|
||||
async get(id: string, signal?: AbortSignal) {
|
||||
return scadaReplaySchema.parse(await this.request(`/${encodeURIComponent(id)}`, undefined, signal));
|
||||
}
|
||||
async events(id: string, signal?: AbortSignal) {
|
||||
return z.object({ items: z.array(eventSchema), next_offset: z.number().nullable() })
|
||||
.parse(await this.request(`/${encodeURIComponent(id)}/events?limit=100&offset=0`, undefined, signal)).items;
|
||||
}
|
||||
async observations(id: string, through: string, signal?: AbortSignal) {
|
||||
return z.object({ items: z.array(readingSchema), through: z.string() }).parse(
|
||||
await this.request(`/${encodeURIComponent(id)}/observations?${new URLSearchParams({ through })}`, undefined, signal)
|
||||
).items;
|
||||
}
|
||||
async create(input: { start_time: string; end_time: string; device_ids: string[]; rules: ScadaReplayRule[] }) {
|
||||
return scadaReplaySchema.parse(await this.request("", { request_id: crypto.randomUUID(), ...input }));
|
||||
}
|
||||
async command(replay: ScadaReplay, action: ScadaReplayAction) {
|
||||
return scadaReplaySchema.parse(await this.request(`/${replay.replay_id}/commands`, {
|
||||
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)]);
|
||||
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) }) }
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -38,11 +37,11 @@ type OperationalRuntimeConditionPayload = RuntimeConditionPayload & {
|
||||
const runtimeAlertSchema = z.object({
|
||||
id: z.string().trim().min(1),
|
||||
condition_id: z.string().trim().min(1).nullable(),
|
||||
source: z.object({
|
||||
source: z.union([z.object({
|
||||
task_type: z.enum(["scheduled_task", "analysis_task", "other_task"]),
|
||||
task_id: z.string().trim().min(1),
|
||||
run_id: z.string().trim().min(1)
|
||||
}),
|
||||
}), z.object({ kind: z.literal("scada_replay"), replay_id: z.string(), rule_id: z.string(), batch_sequence: z.number().int() })]),
|
||||
title: z.string().trim().min(1),
|
||||
description: z.string().trim().min(1),
|
||||
occurred_at: z.string().datetime({ offset: true }),
|
||||
@@ -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);
|
||||
@@ -199,7 +197,12 @@ export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntim
|
||||
id: alert.id,
|
||||
title: alert.title,
|
||||
description: alert.description,
|
||||
source: {
|
||||
source: "kind" in alert.source ? {
|
||||
kind: "scada_replay" as const,
|
||||
replayId: alert.source.replay_id,
|
||||
ruleId: alert.source.rule_id,
|
||||
batchSequence: alert.source.batch_sequence
|
||||
} : {
|
||||
taskType: alert.source.task_type,
|
||||
taskId: alert.source.task_id,
|
||||
runId: alert.source.run_id
|
||||
@@ -259,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>
|
||||
);
|
||||
}
|
||||
@@ -137,7 +137,7 @@ export function GeocodingPanel({ geocoding }: { geocoding: WorkbenchGeocoding })
|
||||
))}
|
||||
</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")}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useId, useState } from "react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ChevronDown, Eye, EyeOff, LocateFixed, MapPinned, Pause, X } from "lucide-react";
|
||||
import { AgentChartView, type AnswerUiBlock } from "@/features/agent";
|
||||
import { formatChartDisplayText } from "@/shared/utils/date-time-text";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader } from "@/shared/ui/card";
|
||||
@@ -18,6 +19,7 @@ import type { OperationalTask } from "../api/operational-task-client";
|
||||
import type { InvestigationOverlay } from "../api/investigation-presentation";
|
||||
import type { InvestigationChart } from "../hooks/use-investigation-presentation";
|
||||
|
||||
import { FloatingWindow } from "@/shared/ui/floating-window";
|
||||
import { PresentationHistorySwitcher } from "./presentation-history-switcher";
|
||||
import { PresentationBusinessResults } from "./presentation-business-results";
|
||||
import type { PresentationHistoryControls } from "../utils/presentation-view-history";
|
||||
@@ -84,6 +86,7 @@ export function InvestigationPresentationPanel({
|
||||
const immediate = Boolean(reducedMotion) || keyboardAction;
|
||||
const contentId = useId();
|
||||
const reviewing = history?.selectedId != null;
|
||||
const chartWindowKey = scope === "chat" ? undefined : JSON.stringify([scope, task?.task_id, reviewing ? "history" : "live"]);
|
||||
const selected = history?.entries.find((entry) => entry.id === history.selectedId);
|
||||
const pending = selected ? history!.entries.filter((entry) => entry.id > selected.id).length : 0;
|
||||
const title = scope === "chat" ? "对话展示" : localPreview ? "本地预览展示" : "调查实时展示";
|
||||
@@ -104,7 +107,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 +115,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={`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
|
||||
? "历史回看"
|
||||
@@ -147,18 +150,14 @@ export function InvestigationPresentationPanel({
|
||||
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>
|
||||
@@ -180,12 +179,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}
|
||||
>
|
||||
@@ -297,31 +296,40 @@ export function InvestigationPresentationPanel({
|
||||
<Card
|
||||
data-investigation-chart={item.chart_id}
|
||||
role="group"
|
||||
aria-label={item.chart.title}
|
||||
aria-label={formatChartDisplayText(item.chart.title)}
|
||||
className="border-border shadow-none"
|
||||
>
|
||||
<CardHeader className="flex-row items-start justify-between gap-2 space-y-0 p-3 pb-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="break-words text-xs font-semibold leading-5 [overflow-wrap:anywhere]">
|
||||
{item.chart.title}
|
||||
{formatChartDisplayText(item.chart.title)}
|
||||
</h3>
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||
{item.chart.series.some((series) => series.type === "scatter") ? "折线与历史散点" : item.chart.chart_type === "bar" ? "柱状图" : "折线图"} ·{" "}
|
||||
{item.chart.x_data.length} 个
|
||||
{item.chart.chart_type === "bar" ? "类别" : "时间点"}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={reviewing}
|
||||
aria-label={`关闭图表 ${item.chart.title}`}
|
||||
onClick={() => onCloseChart(item.chart_id)}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<FloatingWindow title={formatChartDisplayText(item.chart.title)} fillContent
|
||||
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
|
||||
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
|
||||
<AgentChartView chart={item.chart} embedded expanded />
|
||||
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
|
||||
</div>}
|
||||
</FloatingWindow>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
disabled={reviewing}
|
||||
aria-label={`关闭图表 ${formatChartDisplayText(item.chart.title)}`}
|
||||
onClick={() => onCloseChart(item.chart_id)}
|
||||
>
|
||||
<X size={14} aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-3 pb-2 pt-0">
|
||||
<AgentChartView chart={item.chart} embedded />
|
||||
@@ -335,6 +343,7 @@ export function InvestigationPresentationPanel({
|
||||
blocks={blocks}
|
||||
records={records}
|
||||
reviewing={reviewing}
|
||||
chartWindowKey={chartWindowKey}
|
||||
onLocateTargets={onLocateTargets}
|
||||
/>
|
||||
{!charts.length &&
|
||||
|
||||
@@ -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,9 +426,11 @@ export function InvestigationTaskReport({
|
||||
{actionTitle(action)} ·{" "}
|
||||
{action.approval_status === "pending"
|
||||
? "待审核"
|
||||
: action.execution_status === "not_submitted"
|
||||
? "待执行"
|
||||
: "待核对回执"}
|
||||
: isRetryableInvestigationAction(action)
|
||||
? "待重试"
|
||||
: action.execution_status === "not_submitted"
|
||||
? "待执行"
|
||||
: "待核对回执"}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -138,8 +138,6 @@ function createCommands() {
|
||||
clearValueLabel: vi.fn(),
|
||||
applyLayerGroupStyle: vi.fn(),
|
||||
resetLayerGroupStyle: vi.fn(),
|
||||
renderScadaAnalysis: vi.fn(),
|
||||
clearScadaAnalysis: vi.fn(),
|
||||
resetAll: vi.fn()
|
||||
} satisfies WorkbenchMapCommands;
|
||||
}
|
||||
@@ -163,7 +161,6 @@ function createControllerState(
|
||||
invalid: 0
|
||||
},
|
||||
styledGroups: [],
|
||||
scadaAnalysis: null,
|
||||
...patch
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,11 +1,13 @@
|
||||
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 = {
|
||||
blocks: AnswerUiBlock[];
|
||||
records: Array<Record<string, unknown>>;
|
||||
reviewing: boolean;
|
||||
chartWindowKey?: string;
|
||||
onLocateTargets?: (targets: InvestigationMapTarget[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
@@ -13,6 +15,7 @@ export function PresentationBusinessResults({
|
||||
blocks,
|
||||
records,
|
||||
reviewing,
|
||||
chartWindowKey,
|
||||
onLocateTargets
|
||||
}: Props) {
|
||||
if (!blocks.length && !records.length) return null;
|
||||
@@ -23,6 +26,7 @@ export function PresentationBusinessResults({
|
||||
key={block.block_id}
|
||||
block={block}
|
||||
disabled={reviewing}
|
||||
chartWindowKey={chartWindowKey}
|
||||
onLocate={
|
||||
onLocateTargets && !reviewing
|
||||
? (target) =>
|
||||
@@ -38,40 +42,47 @@ 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">
|
||||
<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 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="break-all py-1.5 pr-3">
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="break-all py-1.5">{String(record.origin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
<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 font-medium">来源</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{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="break-all py-1.5 pr-3">
|
||||
{String(
|
||||
record.element_id ??
|
||||
record.device_id ??
|
||||
record.action_id ??
|
||||
record.item ??
|
||||
`记录 ${index + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="break-all py-1.5">{String(record.origin)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,88 +9,116 @@ 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="展示动作历史">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="上一个动作"
|
||||
disabled={index <= 0}
|
||||
onClick={() => onSelect(available[index - 1].id)}
|
||||
>
|
||||
<ChevronLeft size={14} aria-hidden />
|
||||
</Button>
|
||||
<Select
|
||||
value={selectedId === null ? "current" : String(selectedId)}
|
||||
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="切换展示动作"
|
||||
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||||
>
|
||||
<SelectItem value="current" className="text-xs">
|
||||
当前(最新)
|
||||
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
|
||||
{entries.length} 个动作
|
||||
</SelectItem>
|
||||
{entries
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, position) =>
|
||||
entry.id === currentEntryId ? null : (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={String(entry.id)}
|
||||
disabled={entry.status === "running"}
|
||||
className="whitespace-normal text-xs [&>span:last-child]:break-words"
|
||||
>
|
||||
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="下一个动作"
|
||||
disabled={selectedId === null}
|
||||
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
</Button>
|
||||
{selectedId !== null ? (
|
||||
<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="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
|
||||
onClick={() => onSelect(null)}
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="上一个动作"
|
||||
disabled={index <= 0}
|
||||
onClick={() => onSelect(available[index - 1].id)}
|
||||
>
|
||||
<Radio size={12} aria-hidden />
|
||||
回到当前
|
||||
<ChevronLeft size={14} aria-hidden />
|
||||
</Button>
|
||||
<Select
|
||||
value={selectedId === null ? "current" : String(selectedId)}
|
||||
onValueChange={(value) => onSelect(value === "current" ? null : Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label="切换展示动作"
|
||||
className="h-8 min-w-0 flex-1 px-2 text-xs [&>span]:truncate"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
sideOffset={4}
|
||||
className="workbench-shadcn-surface z-[90] max-w-[calc(100vw-24px)]"
|
||||
>
|
||||
<SelectItem value="current" className="text-xs">
|
||||
当前(最新)
|
||||
{currentEntry ? ` · ${entries.indexOf(currentEntry) + 1}. ${currentEntry.label}` : ""} ·{" "}
|
||||
{entries.length} 个动作
|
||||
</SelectItem>
|
||||
{entries
|
||||
.slice()
|
||||
.reverse()
|
||||
.map((entry, position) =>
|
||||
entry.id === currentEntryId && entry.id !== selectedId ? null : (
|
||||
<SelectItem
|
||||
key={entry.id}
|
||||
value={String(entry.id)}
|
||||
disabled={entry.status === "running"}
|
||||
className="whitespace-normal text-xs [&>span:last-child]:break-words"
|
||||
>
|
||||
{entries.length - position}. {entry.label} · {statusLabels[entry.status]}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-8 shrink-0"
|
||||
aria-label="下一个动作"
|
||||
disabled={selectedId === null}
|
||||
onClick={() => onSelect(index + 1 < available.length ? available[index + 1].id : null)}
|
||||
>
|
||||
<ChevronRight size={14} aria-hidden />
|
||||
</Button>
|
||||
{selectedId !== null ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 shrink-0 gap-1 px-2 text-[11px]"
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
<Radio size={12} aria-hidden />
|
||||
回到当前
|
||||
</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}
|
||||
>
|
||||
<GeocodingPanel geocoding={geocoding} />
|
||||
<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,
|
||||
@@ -18,7 +17,7 @@ import {
|
||||
type LucideIcon
|
||||
} from "lucide-react";
|
||||
import { useRef, useState, type ReactNode } from "react";
|
||||
import { AnimatePresence } from "motion/react";
|
||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||
import { ProjectSwitchDialog } from "./project-switch-dialog";
|
||||
import {
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
@@ -56,6 +55,11 @@ const menuSurfaceClassName = cn(
|
||||
);
|
||||
const menuReadableRowClassName =
|
||||
"surface-well focus:bg-[var(--action-soft)] focus:text-slate-950";
|
||||
const alertActionClassName = cn(
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
"h-10 w-10 shrink-0 justify-center border border-transparent bg-transparent p-0 text-[var(--ink-muted)] transition-colors hover:border-[var(--action-blue)] hover:bg-[var(--action-blue)] hover:text-white focus:border-[var(--action-blue)] focus:bg-[var(--action-blue)] focus:text-white data-[highlighted]:border-[var(--action-blue)] data-[highlighted]:bg-[var(--action-blue)] data-[highlighted]:text-white active:scale-95"
|
||||
);
|
||||
|
||||
export function AnalysisViewMenu({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -123,6 +127,7 @@ export function AlertMenu({
|
||||
compact = false,
|
||||
conditionFeedVisible,
|
||||
onSelectAlert,
|
||||
onRemoveAlert,
|
||||
onToggleConditionFeed,
|
||||
onAnalyzeAlertsWithAgent
|
||||
}: {
|
||||
@@ -133,9 +138,13 @@ 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 = useRef(false);
|
||||
const skipRemovalMotion = reduceMotion === true || keyboardInteraction.current;
|
||||
const hasAlerts = alerts.length > 0;
|
||||
const hasCriticalAlerts = alerts.some((alert) => alert.severity !== "warning");
|
||||
const alertSummaryTone = hasCriticalAlerts ? "danger" : hasAlerts ? "warning" : "neutral";
|
||||
@@ -188,7 +197,10 @@ export function AlertMenu({
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}>
|
||||
<DropdownMenuContent align="end" sideOffset={8} className={cn("w-[min(392px,calc(100vw-24px))]", menuSurfaceClassName)}
|
||||
onKeyDownCapture={() => { keyboardInteraction.current = true; }}
|
||||
onPointerDownCapture={() => { keyboardInteraction.current = false; }}
|
||||
>
|
||||
<AlertQueueSummary
|
||||
count={alerts.length}
|
||||
latestAlertTime={latestAlertTime}
|
||||
@@ -197,45 +209,75 @@ export function AlertMenu({
|
||||
tone={hasAlerts ? alertSummaryTone : "success"}
|
||||
/>
|
||||
<MenuSectionLabel>待处理事项</MenuSectionLabel>
|
||||
<div className="scheduled-feed-scroll max-h-[min(52dvh,420px)] overflow-y-auto pr-1">
|
||||
{hasAlerts ? alerts.map((alert) => {
|
||||
const tone = alert.severity === "warning" ? "warning" : "danger";
|
||||
const toneClassName = tone === "warning" ? "status-tone-warning" : "status-tone-danger";
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={alert.id}
|
||||
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)]",
|
||||
MAP_COMPACT_RADIUS_CLASS_NAME,
|
||||
menuReadableRowClassName
|
||||
)}
|
||||
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>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}) : (
|
||||
<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 className="min-w-0">
|
||||
<SemanticStatusIcon tone="success" size="md" className="mx-auto">
|
||||
<CheckCircle2 size={16} />
|
||||
</SemanticStatusIcon>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
|
||||
<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";
|
||||
return (
|
||||
<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(
|
||||
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
|
||||
)}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
asChild
|
||||
className="absolute inset-0 z-0 h-full w-full cursor-pointer rounded-[inherit] border-0 bg-transparent p-0 hover:bg-transparent focus:bg-transparent focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--action-focus-ring)]"
|
||||
onSelect={() => onSelectAlert(alert)}
|
||||
>
|
||||
<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 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} />
|
||||
</SemanticStatusIcon>
|
||||
<p className="mt-1 text-xs font-semibold text-slate-700">当前无待处理事项</p>
|
||||
<p className="mt-0.5 text-xs leading-4 text-[var(--ink-muted)]">新的待办和告警会进入这里。</p>
|
||||
</div>
|
||||
</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
|
||||
|
||||
@@ -44,6 +44,7 @@ export type WorkbenchTopBarProps = {
|
||||
devPanelEnabled: boolean;
|
||||
devPanelOpen: boolean;
|
||||
onSelectAlert: (alert: WorkbenchAlert) => void;
|
||||
onRemoveAlert?: (alert: WorkbenchAlert) => void;
|
||||
onToggleConditionFeed: () => void;
|
||||
onToggleTimeline: () => void;
|
||||
onToggleTaskTicker: () => void;
|
||||
@@ -81,6 +82,7 @@ export function WorkbenchTopBar({
|
||||
devPanelEnabled,
|
||||
devPanelOpen,
|
||||
onSelectAlert,
|
||||
onRemoveAlert,
|
||||
onToggleConditionFeed,
|
||||
onToggleTimeline,
|
||||
onToggleTaskTicker,
|
||||
@@ -145,6 +147,7 @@ export function WorkbenchTopBar({
|
||||
sourceLabel={getAlertSourceLabel(alertDataSource)}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
onSelectAlert={onSelectAlert}
|
||||
onRemoveAlert={onRemoveAlert}
|
||||
onToggleConditionFeed={onToggleConditionFeed}
|
||||
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||
/>
|
||||
@@ -242,6 +245,7 @@ export function WorkbenchTopBar({
|
||||
sourceLabel={getAlertSourceLabel(alertDataSource)}
|
||||
conditionFeedVisible={conditionFeedVisible}
|
||||
onSelectAlert={onSelectAlert}
|
||||
onRemoveAlert={onRemoveAlert}
|
||||
onToggleConditionFeed={onToggleConditionFeed}
|
||||
onAnalyzeAlertsWithAgent={onAnalyzeAlertsWithAgent}
|
||||
/>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { MapAnnotation } from "../types";
|
||||
|
||||
export const mapAnnotations: MapAnnotation[] = [
|
||||
{
|
||||
id: "burst",
|
||||
label: "演示爆管位置",
|
||||
coordinate: [121.5215, 30.9084],
|
||||
kind: "burst"
|
||||
},
|
||||
{
|
||||
id: "dn600",
|
||||
label: "演示 DN600",
|
||||
coordinate: [121.512, 30.9102],
|
||||
kind: "pipe-label"
|
||||
},
|
||||
{
|
||||
id: "east-gate",
|
||||
label: "演示片区",
|
||||
coordinate: [121.545, 30.951],
|
||||
kind: "district"
|
||||
},
|
||||
{
|
||||
id: "impact",
|
||||
label: "演示影响范围 约 1.82 km²",
|
||||
coordinate: [121.567, 30.896],
|
||||
kind: "tooltip"
|
||||
}
|
||||
];
|
||||
|
||||
export const impactAreaPolygon = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: [
|
||||
{
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: "impact-area",
|
||||
label: "演示影响范围",
|
||||
area: "1.82 km²"
|
||||
},
|
||||
geometry: {
|
||||
type: "Polygon" as const,
|
||||
coordinates: [
|
||||
[
|
||||
[121.5205, 30.9084],
|
||||
[121.548, 30.928],
|
||||
[121.604, 30.918],
|
||||
[121.626, 30.886],
|
||||
[121.584, 30.862],
|
||||
[121.538, 30.873],
|
||||
[121.506, 30.895],
|
||||
[121.5205, 30.9084]
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
export const annotationPoints = {
|
||||
type: "FeatureCollection" as const,
|
||||
features: mapAnnotations.map((annotation) => ({
|
||||
type: "Feature" as const,
|
||||
properties: {
|
||||
id: annotation.id,
|
||||
label: annotation.label,
|
||||
kind: annotation.kind
|
||||
},
|
||||
geometry: {
|
||||
type: "Point" as const,
|
||||
coordinates: annotation.coordinate
|
||||
}
|
||||
}))
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { AgentQuestionRequest } from "@/features/agent";
|
||||
import { createAgentInteractionReplies } from "./agent-interaction-replies";
|
||||
import type { AgentSessionRuntime } from "./agent-session-runtime";
|
||||
import { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
it("keeps a reply bound to its original session when the user switches conversations", async () => {
|
||||
let resolve: () => void = () => {};
|
||||
const request = vi.fn(() => new Promise<void>((done) => { resolve = done; }));
|
||||
const original = { sessionId: "original", client: { replyQuestion: request }, questionOverrides: {} } as unknown as AgentSessionRuntime;
|
||||
const next = { sessionId: "next", questionOverrides: {} } as AgentSessionRuntime;
|
||||
const resources = new WorkbenchSessionResources(); resources.activeAgent = original;
|
||||
const setters = { permission: vi.fn(), question: vi.fn() };
|
||||
const replies = createAgentInteractionReplies(resources, setters);
|
||||
const question = { sessionId: "original", requestId: "question" } as AgentQuestionRequest;
|
||||
const submitted = replies.replyQuestion(question, [["A"]]);
|
||||
expect(original.questionOverrides.question.status).toBe("submitting");
|
||||
resources.activeAgent = next; setters.question.mockClear();
|
||||
resolve(); await submitted;
|
||||
expect(original.questionOverrides.question).toEqual({ status: "answered", answers: [["A"]] });
|
||||
expect(next.questionOverrides).toEqual({});
|
||||
expect(setters.question).not.toHaveBeenCalled();
|
||||
await replies.replyQuestion(question, [["B"]]);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { AgentPermissionReply, AgentPermissionRequest, AgentQuestionRequest } from "@/features/agent";
|
||||
import { showMapNotice } from "@/features/map/core";
|
||||
import { toPermissionStatus, type PermissionOverride, type QuestionOverride } from "./agent-session-message-state";
|
||||
import type { AgentSessionRuntime } from "./agent-session-runtime";
|
||||
import type { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
|
||||
export function createAgentInteractionReplies(resources: WorkbenchSessionResources, setters: {
|
||||
permission: (value: Record<string, PermissionOverride>) => void;
|
||||
question: (value: Record<string, QuestionOverride>) => void;
|
||||
}) {
|
||||
const permissionUpdate = (target: AgentSessionRuntime, id: string, value: PermissionOverride) => {
|
||||
target.permissionOverrides = { ...target.permissionOverrides, [id]: value };
|
||||
if (resources.activeAgent === target) setters.permission(target.permissionOverrides);
|
||||
resources.notify();
|
||||
};
|
||||
const questionUpdate = (target: AgentSessionRuntime, id: string, value: QuestionOverride) => {
|
||||
target.questionOverrides = { ...target.questionOverrides, [id]: value };
|
||||
if (resources.activeAgent === target) setters.question(target.questionOverrides);
|
||||
resources.notify();
|
||||
};
|
||||
const submit = async <T extends PermissionOverride | QuestionOverride>(
|
||||
request: { sessionId: string; requestId: string },
|
||||
update: (target: AgentSessionRuntime, id: string, value: T | { status: "submitting" } | { status: "error"; error: string }) => void,
|
||||
execute: (target: AgentSessionRuntime) => Promise<unknown>,
|
||||
success: T,
|
||||
failure: { id: string; title: string; message: string }
|
||||
) => {
|
||||
const target = resources.activeAgent;
|
||||
if (!target || target.sessionId !== request.sessionId) return;
|
||||
const apply = (value: T | { status: "submitting" } | { status: "error"; error: string }) => update(target, request.requestId, value);
|
||||
apply({ status: "submitting" });
|
||||
try {
|
||||
await execute(target);
|
||||
apply(success);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : failure.message;
|
||||
apply({ status: "error", error: message });
|
||||
showMapNotice({ id: failure.id, tone: "error", title: failure.title, message });
|
||||
}
|
||||
};
|
||||
const questionFailure = { id: "agent-question-status", title: "Agent 问题提交失败", message: "问题回复提交失败。" };
|
||||
return {
|
||||
replyPermission: (permission: AgentPermissionRequest, reply: AgentPermissionReply) => submit<PermissionOverride>(
|
||||
permission, permissionUpdate,
|
||||
(target) => target.client.replyPermission(permission.requestId, { sessionId: permission.sessionId, reply }),
|
||||
{ status: toPermissionStatus(reply) },
|
||||
{ id: "agent-permission-status", title: "Agent 权限提交失败", message: "权限请求提交失败。" }
|
||||
),
|
||||
replyQuestion: (question: AgentQuestionRequest, answers: string[][]) => submit<QuestionOverride>(
|
||||
question, questionUpdate,
|
||||
(target) => target.client.replyQuestion(question.requestId, { sessionId: question.sessionId, answers }),
|
||||
{ status: "answered", answers }, questionFailure
|
||||
),
|
||||
rejectQuestion: (question: AgentQuestionRequest) => submit<QuestionOverride>(
|
||||
question, questionUpdate,
|
||||
(target) => target.client.rejectQuestion(question.requestId, { sessionId: question.sessionId }),
|
||||
{ status: "rejected" }, { ...questionFailure, message: "问题跳过失败。" }
|
||||
)
|
||||
};
|
||||
}
|
||||
@@ -125,3 +125,76 @@ it("keeps a malformed persisted data payload from crashing the conversation", ()
|
||||
expect(toAgentChatMessages(messages, {}, {})[0].content).toBe("这段结构化内容无法显示。");
|
||||
}
|
||||
});
|
||||
|
||||
it("restores an aborted run's terminal state from canonical history", () => {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [
|
||||
{ type: "text", text: "已核查的部分结果" },
|
||||
{ type: "data-progress", data: { id: "query", title: "查询", status: "running" } },
|
||||
{ type: "data-run_status", data: { status: "aborted", message: "请求已中断" } }
|
||||
] }]);
|
||||
expect(toAgentChatMessages(messages, {}, {})[0]).toMatchObject({ content: "已核查的部分结果", progress: [{ status: "cancelled" }] });
|
||||
});
|
||||
|
||||
describe("terminal tool progress", () => {
|
||||
const parts = [
|
||||
{ type: "text", text: "已核查的部分结果" },
|
||||
{ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running", tool: { name: "query", input: { id: 12 } } } },
|
||||
{ type: "data-todo_update", data: { session_id: "s", todos: [{ id: "todo", content: "核查", status: "in_progress" }] } },
|
||||
{ type: "data-permission_request", data: { request_id: "permission", session_id: "s", permission: "read", patterns: [] } },
|
||||
{ type: "data-question_request", data: { request_id: "question", session_id: "s", questions: [{ question: "继续吗?", options: [] }] } }
|
||||
];
|
||||
const initial = () => toAgentUiMessages([{ id: "answer", role: "assistant", parts }]);
|
||||
|
||||
it.each(["done", "error", "aborted"] as const)("settles running tool rows after resumed %s", (type) => {
|
||||
const messages = applySessionStreamEvent(initial(), { type, data: { message: "模型请求失败" } });
|
||||
const answer = toAgentChatMessages(messages, {}, {})[0];
|
||||
expect(answer.progress?.[0]).toMatchObject({ status: type === "done" ? "completed" : type === "aborted" ? "cancelled" : "error", tool: { name: "query", input: { id: 12 } } });
|
||||
expect(answer.todos?.todos[0].status).toBe("cancelled");
|
||||
expect(answer.content).toBe("已核查的部分结果");
|
||||
if (type === "error") expect(answer.runError).toBe("模型请求失败");
|
||||
});
|
||||
|
||||
it("preserves the reason when reopening a failed answer with partial structured output", () => {
|
||||
const messages = toAgentUiMessages([{ id: "answer", role: "assistant", parts: [...parts,
|
||||
{ type: "data-final_answer", data: { content: "已核查的部分结果" } },
|
||||
{ type: "data-run_status", data: { status: "error", message: "模型请求失败" } }
|
||||
] }]);
|
||||
const answer = toAgentChatMessages(messages, {}, {})[0];
|
||||
expect(answer).toMatchObject({ content: "已核查的部分结果", runError: "模型请求失败", progress: [{ status: "error", detail: "模型请求失败" }] });
|
||||
});
|
||||
|
||||
it("does not let an old reply override revive stopped cards", () => {
|
||||
const messages = applySessionStreamEvent(initial(), { type: "run_status", data: { status: "aborted" } });
|
||||
const answer = toAgentChatMessages(messages, { permission: { status: "error", error: "late HTTP response" } },
|
||||
{ question: { status: "submitting" } })[0];
|
||||
expect(answer.permissions?.[0].status).toBe("aborted");
|
||||
expect(answer.questions?.[0].status).toBe("rejected");
|
||||
const stopped = applySessionStreamEvent(messages, { type: "error", data: { message: "请求已中断" } });
|
||||
expect(toAgentChatMessages(stopped, {}, {})[0].runError).toBeUndefined();
|
||||
expect(toAgentChatMessages(stopped, {}, {})[0].progress?.[0].status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("settles legacy history using the session status while leaving active tools running", () => {
|
||||
for (const status of ["completed", "aborted", "error", "waiting_input", "running"] as const) {
|
||||
const messages = toAgentUiMessages(initial(), { runStatus: status, isStreaming: status === "running" });
|
||||
expect(toAgentChatMessages(messages, {}, {})[0].progress?.[0].status)
|
||||
.toBe(status === "running" ? "running" : status === "error" ? "error" : status === "aborted" ? "cancelled" : "completed");
|
||||
}
|
||||
const state = applySessionStreamEvent(initial(), { type: "state", sessionId: "s", isStreaming: false, messages: initial() });
|
||||
expect(toAgentChatMessages(state, {}, {})[0].progress?.[0].status).toBe("completed");
|
||||
});
|
||||
|
||||
it("keeps a saved failure authoritative when a legacy session defaults to completed", () => {
|
||||
const saved = applySessionStreamEvent(initial(), { type: "error", data: { message: "历史错误" } });
|
||||
const answer = toAgentChatMessages(toAgentUiMessages(saved, { runStatus: "completed", isStreaming: false }), {}, {})[0];
|
||||
expect(answer.runError).toBe("历史错误");
|
||||
});
|
||||
|
||||
it("routes historical question replies to the owning assistant without changing the current turn", () => {
|
||||
const messages = [...initial(), ...toAgentUiMessages([{ id: "current", role: "assistant", parts: [{ type: "text", text: "新一轮" }] }])];
|
||||
const updated = applySessionStreamEvent(messages, { type: "question_response", data: { request_id: "question", rejected: true } });
|
||||
const answers = toAgentChatMessages(updated, {}, {});
|
||||
expect(answers[0].questions?.[0].status).toBe("rejected");
|
||||
expect(updated[1]).toEqual(messages[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { UIMessage } from "ai";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import {
|
||||
parseAnswerUiBlock,
|
||||
completeRunningProgress,
|
||||
cancelRunningTodos,
|
||||
finalizeAssistantMessageAfterAbort,
|
||||
finalizeAssistantMessageAfterError,
|
||||
readInteractionReply,
|
||||
getFinalAnswerTranscript,
|
||||
type AnswerUiPart,
|
||||
@@ -17,7 +21,8 @@ import {
|
||||
type AgentPermissionStatus,
|
||||
type AgentQuestionRequest,
|
||||
type AgentSessionStreamEvent,
|
||||
type AgentStreamRenderState
|
||||
type AgentStreamRenderState,
|
||||
type AgentRunStatus
|
||||
} from "@/features/agent";
|
||||
|
||||
type AgentUiDataParts = {
|
||||
@@ -26,6 +31,7 @@ type AgentUiDataParts = {
|
||||
task_context: Record<string, unknown>;
|
||||
execution_plan: Record<string, unknown>;
|
||||
progress: Record<string, unknown>;
|
||||
run_status: Record<string, unknown>;
|
||||
final_answer: Record<string, unknown>;
|
||||
credential_refresh_required: Record<string, unknown>;
|
||||
credential_refreshed: Record<string, unknown>;
|
||||
@@ -137,11 +143,14 @@ export function toAgentChatMessages(
|
||||
...(answerParts ? { answerParts } : {})
|
||||
};
|
||||
|
||||
let runState: Record<string, unknown> | undefined;
|
||||
for (const part of message.parts) {
|
||||
if (!isAgentDataPart(part)) {
|
||||
continue;
|
||||
}
|
||||
if (part.type === "data-interaction_reply" && message.role === "user") {
|
||||
if (part.type === "data-run_status") {
|
||||
runState = part.data;
|
||||
} else if (part.type === "data-interaction_reply" && message.role === "user") {
|
||||
const reply = readInteractionReply(part.data, message.id);
|
||||
if (reply) next = { ...next, interactionReply: reply };
|
||||
} else if (part.type === "data-task_context") {
|
||||
@@ -193,23 +202,44 @@ export function toAgentChatMessages(
|
||||
if (next.finalAnswer) {
|
||||
next.content = getFinalAnswerTranscript(next);
|
||||
}
|
||||
if (runState?.status === "aborted") {
|
||||
next = finalizeAssistantMessageAfterAbort(next);
|
||||
} else if (runState?.status === "error") {
|
||||
next = finalizeAssistantMessageAfterError(next, getDataString(runState, "message") ?? "Agent response failed");
|
||||
} else if (runState?.status === "completed" || runState?.status === "waiting_input") {
|
||||
next = { ...next, progress: completeRunningProgress(next.progress),
|
||||
...(runState.status === "completed" ? { todos: cancelRunningTodos(next.todos) } : {}) };
|
||||
}
|
||||
return [applyQuestionOverrides(applyPermissionOverrides(next, permissionOverrides), questionOverrides)];
|
||||
});
|
||||
}
|
||||
|
||||
export function toAgentUiMessages(messages: unknown[]): AgentUiMessage[] {
|
||||
return messages.flatMap((message) => {
|
||||
export function toAgentUiMessages(
|
||||
messages: unknown[],
|
||||
session?: { runStatus?: AgentRunStatus; isStreaming?: boolean }
|
||||
): AgentUiMessage[] {
|
||||
const normalized = messages.flatMap((message) => {
|
||||
if (isAgentUiMessage(message)) {
|
||||
return [message];
|
||||
}
|
||||
const legacyMessage = toAgentUiMessageFromLegacy(message);
|
||||
return legacyMessage ? [legacyMessage] : [];
|
||||
});
|
||||
if (!session) return normalized;
|
||||
const lastAssistant = normalized.reduce((last, message, index) => message.role === "assistant" ? index : last, -1);
|
||||
return normalized.map((message, index) => {
|
||||
if (message.role !== "assistant" || message.parts.some((part) => part.type === "data-run_status")) return message;
|
||||
const status = index === lastAssistant
|
||||
? session.runStatus ?? (session.isStreaming === false ? "completed" : undefined)
|
||||
: "completed";
|
||||
if (status === "completed" && !message.parts.some((part) => part.type === "data-progress" || part.type === "data-todo_update")) return message;
|
||||
return status && status !== "running" ? upsertUiDataPart(message, "run_status", { id: "run-status", status }) : message;
|
||||
});
|
||||
}
|
||||
|
||||
export function applySessionStreamEvent(messages: AgentUiMessage[], event: AgentSessionStreamEvent): AgentUiMessage[] {
|
||||
if (event.type === "state") {
|
||||
return toAgentUiMessages(event.messages);
|
||||
return toAgentUiMessages(event.messages, event);
|
||||
}
|
||||
|
||||
if (event.type === "user_input") {
|
||||
@@ -226,15 +256,34 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
return token ? updateLastAssistantUiMessage(messages, (message) => appendTextToUiMessage(message, token)) : messages;
|
||||
}
|
||||
|
||||
if (event.type === "done" || event.type === "error" || event.type === "aborted") {
|
||||
return updateLastAssistantUiMessage(messages, (message) => {
|
||||
const previous = [...message.parts].reverse().find((part) => part.type === "data-run_status");
|
||||
const data = previous && isAgentDataPart(previous) ? previous.data : {};
|
||||
return upsertUiDataPart(message, "run_status", {
|
||||
...data, id: "run-status",
|
||||
status: data.status === "aborted" || event.type === "aborted" || event.data.status === "aborted" ? "aborted"
|
||||
: event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed",
|
||||
...(event.type === "error" || event.type === "aborted" ? { message: getDataString(event.data, "message") ?? getDataString(data, "message") ?? "Agent stream failed" } : {})
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (event.type === "permission_response" || event.type === "question_response") {
|
||||
const requestType = event.type === "permission_response" ? "data-permission_request" : "data-question_request";
|
||||
return messages.map((message) => message.role === "assistant" && message.parts.some((part) =>
|
||||
part.type === requestType && isAgentDataPart(part) && part.data.request_id === event.data.request_id)
|
||||
? upsertUiDataPart(message, event.type, event.data) : message);
|
||||
}
|
||||
|
||||
if (
|
||||
event.type === "run_status" ||
|
||||
event.type === "final_answer" ||
|
||||
event.type === "progress" ||
|
||||
event.type === "execution_plan" ||
|
||||
event.type === "todo_update" ||
|
||||
event.type === "permission_request" ||
|
||||
event.type === "permission_response" ||
|
||||
event.type === "question_request" ||
|
||||
event.type === "question_response" ||
|
||||
event.type === "answer_ui" ||
|
||||
event.type === "ui_envelope" ||
|
||||
event.type === "session_title"
|
||||
@@ -244,13 +293,6 @@ export function applySessionStreamEvent(messages: AgentUiMessage[], event: Agent
|
||||
);
|
||||
}
|
||||
|
||||
if (event.type === "error") {
|
||||
const message = getDataString(event.data, "message") ?? "Agent stream failed";
|
||||
return updateLastAssistantUiMessage(messages, (item) =>
|
||||
appendTextToUiMessage(item, item.parts.some((part) => part.type === "text") ? `\n\n错误:${message}` : `错误:${message}`)
|
||||
);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
@@ -478,7 +520,7 @@ function applyPermissionOverrides(
|
||||
...message,
|
||||
permissions: message.permissions.map((permission) => {
|
||||
const override = permissionOverrides[permission.requestId];
|
||||
return override
|
||||
return override && ["pending", "submitting", "error"].includes(permission.status)
|
||||
? {
|
||||
...permission,
|
||||
status: override.status,
|
||||
@@ -502,7 +544,7 @@ function applyQuestionOverrides(
|
||||
...message,
|
||||
questions: message.questions.map((question) => {
|
||||
const override = questionOverrides[question.requestId];
|
||||
return override
|
||||
return override && ["checking", "pending", "submitting", "error"].includes(question.status)
|
||||
? {
|
||||
...question,
|
||||
status: override.status,
|
||||
|
||||
@@ -3,11 +3,42 @@ import type { ChatTransport, UIMessageChunk } from "ai";
|
||||
import { AgentSessionStreamError, AgentStreamDisconnectedError, type AgentApiClient } from "@/features/agent";
|
||||
import { WorkbenchSessionResources } from "./workbench-session-resources";
|
||||
import { createAgentRuntime, mergeHistoryMessages, resumeAgentRuntime, runtimeRunning } from "./agent-session-runtime";
|
||||
import type { AgentUiMessage } from "./agent-session-message-state";
|
||||
import { toAgentChatMessages, type AgentUiMessage } from "./agent-session-message-state";
|
||||
|
||||
const message = (id: string, text = id): AgentUiMessage => ({ id, role: "assistant", parts: [{ type: "text", text }] });
|
||||
|
||||
describe("independent conversation runtimes", () => {
|
||||
it.each(["completed", "error", "aborted"] as const)("settles direct tool progress when the response is %s without a final progress event", async (status) => {
|
||||
const resources = new WorkbenchSessionResources();
|
||||
let controller!: ReadableStreamDefaultController<UIMessageChunk>;
|
||||
const runtime = createAgentRuntime(resources, "project:a", {} as AgentApiClient, {
|
||||
sendMessages: async ({ abortSignal }) => new ReadableStream<UIMessageChunk>({ start(stream) {
|
||||
controller = stream;
|
||||
abortSignal?.addEventListener("abort", () => stream.error(new DOMException("Aborted", "AbortError")), { once: true });
|
||||
} }),
|
||||
reconnectToStream: async () => null,
|
||||
});
|
||||
resources.activeAgent = runtime;
|
||||
try {
|
||||
const request = runtime.chat.sendMessage({ text: "核查工具状态" });
|
||||
await vi.waitFor(() => expect(controller).toBeDefined());
|
||||
controller.enqueue({ type: "start", messageId: "answer" });
|
||||
controller.enqueue({ type: "data-progress", data: { id: "tool", phase: "tool", title: "正在处理", status: "running" } });
|
||||
await vi.waitFor(() => expect(toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1)?.progress?.[0].status).toBe("running"));
|
||||
if (status === "aborted") await runtime.chat.stop();
|
||||
else {
|
||||
if (status === "error") controller.enqueue({ type: "error", errorText: "工具执行失败" });
|
||||
controller.enqueue({ type: "finish", finishReason: status === "error" ? "error" : "stop" });
|
||||
controller.close();
|
||||
}
|
||||
await request;
|
||||
const answer = toAgentChatMessages(runtime.chat.messages, {}, {}).at(-1);
|
||||
expect(answer?.progress?.[0].status).toBe(status === "aborted" ? "cancelled" : status);
|
||||
expect(runtime.runStatus).toBe(status);
|
||||
if (status === "error") expect(answer?.runError).toBe("工具执行失败");
|
||||
} finally { resources.dispose(); }
|
||||
});
|
||||
|
||||
it("retries a disconnected subscription and preserves the draft, pages and reading position", async () => {
|
||||
vi.useFakeTimers();
|
||||
const resources = new WorkbenchSessionResources();
|
||||
@@ -141,7 +172,9 @@ describe("independent conversation runtimes", () => {
|
||||
});
|
||||
resumeAgentRuntime(resources, runtime);
|
||||
await vi.waitFor(() => expect(runtime.controller).toBeUndefined());
|
||||
expect(runtime.chat.messages).toEqual([message("a"), message("b", "new"), message("c")]);
|
||||
expect(toAgentChatMessages(runtime.chat.messages, {}, {})).toMatchObject([
|
||||
{ id: "a", content: "a" }, { id: "b", content: "new" }, { id: "c", content: "c" }
|
||||
]);
|
||||
expect(submit).toHaveBeenCalledWith("a", "action-a", expect.objectContaining({
|
||||
status: "failed", error: expect.objectContaining({ code: "SESSION_NOT_ACTIVE" }),
|
||||
}));
|
||||
|
||||
@@ -91,7 +91,7 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
|
||||
const active = () => resources.activeAgent === runtime;
|
||||
const notify = () => queueMicrotask(() => resources.notify());
|
||||
runtime.chat = new Chat<AgentUiMessage>({
|
||||
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? []),
|
||||
id: runtime.key, transport, messages: toAgentUiMessages(loaded?.messages ?? [], loaded),
|
||||
onData: (part) => {
|
||||
if (resources.disposed) return;
|
||||
const data = part as AgentDataPart;
|
||||
@@ -99,6 +99,10 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
|
||||
if (sessionId && runtime.sessionId && sessionId !== runtime.sessionId) return;
|
||||
if (sessionId) runtime.sessionId = sessionId;
|
||||
if (data.type === "data-interaction_request") runtime.runStatus = "waiting_input";
|
||||
if (data.type === "data-run_status") {
|
||||
const status = data.data.status;
|
||||
if (status === "completed" || status === "error" || status === "aborted" || status === "waiting_input") runtime.runStatus = status;
|
||||
}
|
||||
if (data.type === "data-session_title") runtime.title = getDataString(data.data, "title") ?? runtime.title;
|
||||
if (data.type === "data-credential_refresh_required") {
|
||||
void refreshRuntimeCredential(runtime, data.data);
|
||||
@@ -118,10 +122,16 @@ export function createAgentRuntime(resources: WorkbenchSessionResources, scope:
|
||||
if (resources.disposed) return;
|
||||
runtime.loadedAt = Date.now();
|
||||
runtime.updatedAt = Date.now();
|
||||
runtime.runStatus = isAbort ? "aborted" : isError
|
||||
runtime.runStatus = isAbort || runtime.runStatus === "aborted" ? "aborted" : isError
|
||||
? isPendingInteractionError(runtime.error) ? "waiting_input" : "error"
|
||||
: runtime.runStatus === "waiting_input" ? "waiting_input" : "completed";
|
||||
const recovering = isError && !isAbort && runtime.transportInterrupted && runtime.sessionId;
|
||||
if (!recovering) {
|
||||
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, {
|
||||
type: runtime.runStatus === "error" || runtime.runStatus === "aborted" ? "error" : "done",
|
||||
data: { status: runtime.runStatus, reason: runtime.runStatus, ...(runtime.error ? { message: runtime.error } : {}) }
|
||||
});
|
||||
}
|
||||
if (active()) { if (!recovering) runtime.handlers?.onFinish(runtime.runStatus); }
|
||||
else if (!recovering) notifyBackgroundCompletion(runtime);
|
||||
if (recovering) {
|
||||
@@ -173,7 +183,7 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
|
||||
const sessionId = event.type === "state" ? event.sessionId : event.sessionId ?? getDataString(event.data, "session_id");
|
||||
if (sessionId && sessionId !== runtime.sessionId) return;
|
||||
if (event.type === "state") {
|
||||
const tail = toAgentUiMessages(event.messages);
|
||||
const tail = toAgentUiMessages(event.messages, event);
|
||||
const prior = runtime.chat.messages;
|
||||
// Start filling a disconnected interval before reconciling the live tail.
|
||||
if (prior.length && tail.length && !tail.some((message) => prior.some((old) => old.id === message.id))) {
|
||||
@@ -191,11 +201,16 @@ export function resumeAgentRuntime(resources: WorkbenchSessionResources, runtime
|
||||
if (event.type === "credential_refresh_required") void refreshRuntimeCredential(runtime, event.data);
|
||||
else if (resources.activeAgent !== runtime && event.type === "frontend_action") void rejectBackgroundAction(resources, runtime, event.data);
|
||||
runtime.chat.messages = applySessionStreamEvent(runtime.chat.messages, event);
|
||||
if (event.type === "run_status") {
|
||||
const status = event.data.status;
|
||||
if (status === "aborted" || status === "error" || status === "completed" || status === "waiting_input") runtime.runStatus = status;
|
||||
}
|
||||
if (event.type === "session_title") runtime.title = getDataString(event.data, "title") ?? runtime.title;
|
||||
if (event.type === "done" || event.type === "error") {
|
||||
if (event.type === "done" || event.type === "error" || event.type === "aborted") {
|
||||
terminal = true;
|
||||
runtime.resumed = false;
|
||||
runtime.runStatus = event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
|
||||
runtime.runStatus = event.type === "aborted" || event.data.status === "aborted" || runtime.runStatus === "aborted" ? "aborted"
|
||||
: event.type === "error" ? "error" : event.data.reason === "waiting_input" ? "waiting_input" : "completed";
|
||||
runtime.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ describe("live investigation presentation following", () => {
|
||||
test.channel.publish({ type: "events", events: [event(12, openChart), event(20, openChart), event(21, closeChart)] });
|
||||
oldDisplay.resolve();
|
||||
await vi.waitFor(() => expect(execute.mock.calls.map(([command]) => command.action)).toEqual(["highlight_features", "close_chart"]));
|
||||
expect(test.clear.mock.calls.length).toBeGreaterThan(clearCount);
|
||||
expect(test.clear.mock.calls.length).toBe(clearCount);
|
||||
expect(execute.mock.calls[1]?.[1].aborted).toBe(false);
|
||||
} finally { oldDisplay.resolve(); test.follower.stop(); }
|
||||
});
|
||||
@@ -199,3 +199,51 @@ it("reads saved history even before the live event stream opens", async () => {
|
||||
expect(test.execute).not.toHaveBeenCalled();
|
||||
} finally { test.follower.stop(); }
|
||||
});
|
||||
|
||||
|
||||
it("keeps successful results across disconnect and failed reconnect, clearing only for a new identity", async () => {
|
||||
let visible = "saved chart";
|
||||
const clear = vi.fn(() => { visible = ""; });
|
||||
const fetchCursor = vi.fn(async () => cursor(10));
|
||||
const test = setup({ clear, fetchCursor });
|
||||
test.follower.start(); test.channel.publish({ type: "open" });
|
||||
try {
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("live"));
|
||||
visible = "saved chart";
|
||||
clear.mockClear();
|
||||
test.channel.publish({ type: "disconnect" });
|
||||
expect(visible).toBe("saved chart");
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
fetchCursor.mockRejectedValueOnce(new Error("offline"));
|
||||
test.channel.publish({ type: "open" });
|
||||
await vi.waitFor(() => expect(test.onStatus).toHaveBeenCalledWith("error", "offline"));
|
||||
expect(visible).toBe("saved chart");
|
||||
fetchCursor.mockResolvedValueOnce(cursor(20, 2));
|
||||
test.channel.publish({ type: "open" });
|
||||
await vi.waitFor(() => expect(clear).toHaveBeenCalledTimes(1));
|
||||
expect(visible).toBe("");
|
||||
} finally { test.follower.stop(); }
|
||||
});
|
||||
|
||||
it("clears a changed generation before hydrating its saved results and ignores a stale connection", async () => {
|
||||
const stale = deferred<InvestigationPresentationCursor>();
|
||||
const order: string[] = [];
|
||||
const fetchCursor = vi.fn(async () => cursor(10))
|
||||
.mockResolvedValueOnce(cursor(10))
|
||||
.mockImplementationOnce(() => stale.promise)
|
||||
.mockResolvedValueOnce(cursor(30, 2));
|
||||
const test = setup({ fetchCursor, clear: () => order.push("clear"), loadHistory: async (next) => { order.push(`history:${next.execution_generation}`); } });
|
||||
test.follower.start();
|
||||
try {
|
||||
await vi.waitFor(() => expect(order).toContain("history:1"));
|
||||
order.length = 0;
|
||||
test.channel.publish({ type: "open" });
|
||||
test.channel.publish({ type: "disconnect" });
|
||||
test.channel.publish({ type: "open" });
|
||||
await vi.waitFor(() => expect(order).toContain("history:2"));
|
||||
expect(order).toEqual(["clear", "history:2"]);
|
||||
stale.resolve(cursor(20));
|
||||
await Promise.resolve();
|
||||
expect(order).toEqual(["clear", "history:2"]);
|
||||
} finally { stale.resolve(cursor(20)); test.follower.stop(); }
|
||||
});
|
||||
|
||||
@@ -156,12 +156,16 @@ export class InvestigationPresentationFollower {
|
||||
const cursor = await this.options.fetchCursor(controller.signal);
|
||||
if (controller.signal.aborted || epoch !== this.epoch) return;
|
||||
if (cursor.task_id !== this.options.taskId) throw new Error("调查游标与所选任务不一致");
|
||||
if (this.identity && cursor.execution_generation < this.identity.execution_generation)
|
||||
throw new Error("调查游标已过期,等待重新同步");
|
||||
// Clear a changed identity before loading its replacement, never after hydration.
|
||||
// Leave the baseline unbound until history is ready so live events stay buffered.
|
||||
this.updateIdentity(cursor);
|
||||
await this.options.loadHistory?.(cursor, controller.signal);
|
||||
if (controller.signal.aborted || epoch !== this.epoch) return;
|
||||
this.baseline = cursor.event_cursor;
|
||||
this.lastSeen = cursor.event_cursor;
|
||||
this.pending = this.pending.filter((item) => item.sequence > cursor.event_cursor);
|
||||
await this.options.loadHistory?.(cursor, controller.signal);
|
||||
if (controller.signal.aborted || epoch !== this.epoch) return;
|
||||
this.updateIdentity(cursor);
|
||||
this.options.onStatus(
|
||||
this.streamConnected && this.options.follow !== false ? "live" : "history"
|
||||
);
|
||||
@@ -180,12 +184,13 @@ export class InvestigationPresentationFollower {
|
||||
this.binding = null;
|
||||
this.work.abort();
|
||||
this.work = new AbortController();
|
||||
this.identity = null;
|
||||
// Keep the last verified identity and successful view through transport failures.
|
||||
// The owning hook clears task/project changes; updateIdentity handles new runs.
|
||||
this.baseline = null;
|
||||
this.lastSeen = 0;
|
||||
this.lookupKey = null;
|
||||
this.pending = [];
|
||||
this.options.clear();
|
||||
this.options.stopPulse();
|
||||
this.options.onStatus("disconnected");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
import { useDismissedWorkbenchAlerts } from "./use-dismissed-workbench-alerts";
|
||||
import type { WorkbenchAlert } from "../types";
|
||||
|
||||
const alert: WorkbenchAlert = { id: "alert-1", title: "压力异常", description: "等待核查", time: "08:00" };
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("keeps removed alerts hidden after refresh while new notifications still appear", () => {
|
||||
const { result, rerender, unmount } = renderHook(({ alerts }) => useDismissedWorkbenchAlerts(alerts, "project-1", "operator"),
|
||||
{ initialProps: { alerts: [alert] } });
|
||||
act(() => { expect(result.current.dismissAlert(alert)).toBe(true); });
|
||||
expect(result.current.alerts).toEqual([]);
|
||||
rerender({ alerts: [{ ...alert }, { ...alert, id: "alert-2" }] });
|
||||
expect(result.current.alerts.map((item) => item.id)).toEqual(["alert-2"]);
|
||||
unmount();
|
||||
const restored = renderHook(() => useDismissedWorkbenchAlerts([alert], "project-1", "operator"));
|
||||
expect(restored.result.current.alerts).toEqual([]);
|
||||
});
|
||||
|
||||
it("isolates users and projects and shows a new notification version", () => {
|
||||
const item = { ...alert, notificationVersion: 1 };
|
||||
const { result, rerender } = renderHook(({ project, user, version }) =>
|
||||
useDismissedWorkbenchAlerts([{ ...item, notificationVersion: version }], project, user),
|
||||
{ initialProps: { project: "project-1", user: "operator", version: 1 } });
|
||||
act(() => { result.current.dismissAlert(item); });
|
||||
rerender({ project: "project-2", user: "operator", version: 1 });
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
rerender({ project: "project-1", user: "viewer", version: 1 });
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
rerender({ project: "project-1", user: "operator", version: 1 });
|
||||
expect(result.current.alerts).toEqual([]);
|
||||
rerender({ project: "project-1", user: "operator", version: 2 });
|
||||
expect(result.current.alerts).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves the row when local storage cannot save the removal", () => {
|
||||
const { result } = renderHook(() => useDismissedWorkbenchAlerts([alert], "project-1", "operator"));
|
||||
const spy = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("storage unavailable"); });
|
||||
act(() => { expect(result.current.dismissAlert(alert)).toBe(false); });
|
||||
expect(result.current.alerts).toEqual([alert]);
|
||||
spy.mockRestore();
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { WorkbenchAlert } from "../types";
|
||||
|
||||
function readDismissed(key: string): string[] {
|
||||
try {
|
||||
const value: unknown = JSON.parse(localStorage.getItem(key) ?? "[]");
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function alertKey(alert: WorkbenchAlert): string {
|
||||
return JSON.stringify([alert.id, alert.notificationVersion ?? null]);
|
||||
}
|
||||
|
||||
export function useDismissedWorkbenchAlerts(
|
||||
alerts: WorkbenchAlert[], projectId: string | null, username: string
|
||||
) {
|
||||
const storageKey = `tjwater.dismissed-alerts@1:${JSON.stringify([projectId, username])}`;
|
||||
const [stored, setStored] = useState(() => ({ key: storageKey, ids: readDismissed(storageKey) }));
|
||||
// Switch scopes synchronously so another user's or project's rows never flash hidden.
|
||||
if (stored.key !== storageKey) {
|
||||
setStored({ key: storageKey, ids: readDismissed(storageKey) });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sync = (event: StorageEvent) => {
|
||||
if (event.storageArea === localStorage && (event.key === storageKey || event.key === null)) {
|
||||
setStored({ key: storageKey, ids: readDismissed(storageKey) });
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", sync);
|
||||
return () => window.removeEventListener("storage", sync);
|
||||
}, [storageKey]);
|
||||
|
||||
const dismissAlert = useCallback((alert: WorkbenchAlert) => {
|
||||
if (!projectId) return false;
|
||||
const ids = [...new Set([...readDismissed(storageKey), alertKey(alert)])];
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(ids));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
setStored({ key: storageKey, ids });
|
||||
return true;
|
||||
}, [projectId, storageKey]);
|
||||
|
||||
const dismissed = new Set(stored.key === storageKey ? stored.ids : readDismissed(storageKey));
|
||||
return { alerts: alerts.filter((alert) => !dismissed.has(alertKey(alert))), dismissAlert };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { act, cleanup, renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import type { RefObject } from "react";
|
||||
import type { Map as MapLibreMap } from "maplibre-gl";
|
||||
import type { WorkbenchMapController } from "../map/workbench-map-controller";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
import { useInvestigationPresentation } from "./use-investigation-presentation";
|
||||
|
||||
const sync = { subscribePresentationEvents: vi.fn(() => () => undefined) } as unknown as WorkbenchSyncState;
|
||||
const mapRef: RefObject<MapLibreMap | null> = { current: null };
|
||||
const controller = { queryPresentationFeatures: vi.fn() } as unknown as WorkbenchMapController;
|
||||
const padding = () => ({ top: 0, right: 0, bottom: 0, left: 0 });
|
||||
const onClearLayers = vi.fn();
|
||||
const options = { scope: "preview" as const, enabled: false, task: null, projectId: "one", sync, mapRef, mapReady: false, controller, getPadding: padding, onClearLayers };
|
||||
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
|
||||
it("restores the last successful result when an unfinished display is aborted", async () => {
|
||||
const { result } = renderHook(() => useInvestigationPresentation(options));
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "saved" }], commands: [] }));
|
||||
const abort = new AbortController();
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => {
|
||||
pending = result.current.executeCommand({ action: "open_chart", chart_id: "pending", evidence_refs: ["test"], chart: {
|
||||
title: "Unfinished", chart_type: "line", x_data: ["10:00"], series: [{ name: "pressure", data: [20] }]
|
||||
} }, abort.signal, "preview").catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => expect(result.current.charts).toHaveLength(1));
|
||||
await act(async () => { abort.abort(); await pending; });
|
||||
expect(result.current.charts).toEqual([]);
|
||||
expect(result.current.records).toEqual([{ value: "saved" }]);
|
||||
expect(result.current.history.entries.at(-1)?.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("retries a cancelled live event in the same history row", async () => {
|
||||
const { result } = renderHook(() => useInvestigationPresentation(options));
|
||||
const command = { action: "open_chart" as const, chart_id: "retry", evidence_refs: ["test"], chart: {
|
||||
title: "Recoverable", chart_type: "line" as const, x_data: ["10:00"], series: [{ name: "pressure", data: [20] }],
|
||||
} };
|
||||
const event = { task_id: "50000000-0000-4000-8000-000000000001", run_id: "55000000-0000-4000-8000-000000000001",
|
||||
execution_generation: 1, event_seq: 1, command };
|
||||
const abort = new AbortController();
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => { pending = result.current.executeCommand(command, abort.signal, "live", event).catch(() => undefined); });
|
||||
await waitFor(() => expect(result.current.charts).toHaveLength(1));
|
||||
await act(async () => { abort.abort(); await pending; });
|
||||
const id = result.current.history.entries[0].id;
|
||||
act(() => result.current.history.onSelect(id));
|
||||
await act(() => result.current.executeCommand(command, new AbortController().signal, "live", event));
|
||||
expect(result.current.history.entries).toHaveLength(1);
|
||||
expect(result.current.history.entries[0]).toMatchObject({ id, status: "completed" });
|
||||
act(() => result.current.history.onSelect(null));
|
||||
expect(result.current.charts[0].chart.title).toBe("Recoverable");
|
||||
});
|
||||
|
||||
it("does not restore an old partial display after the project changes", async () => {
|
||||
const { result, rerender } = renderHook(({ projectId }) => useInvestigationPresentation({ ...options, projectId }), { initialProps: { projectId: "one" } });
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: "old project" }], commands: [] }));
|
||||
const abort = new AbortController();
|
||||
let pending!: Promise<unknown>;
|
||||
act(() => {
|
||||
pending = result.current.executeCommand({ action: "open_chart", chart_id: "pending", evidence_refs: ["test"], chart: {
|
||||
title: "Unfinished", chart_type: "line", x_data: ["10:00"], series: [{ name: "pressure", data: [20] }]
|
||||
} }, abort.signal, "preview").catch(() => undefined);
|
||||
});
|
||||
await waitFor(() => expect(result.current.charts).toHaveLength(1));
|
||||
rerender({ projectId: "two" });
|
||||
await act(async () => { abort.abort(); await pending; });
|
||||
expect(result.current.charts).toEqual([]);
|
||||
expect(result.current.records).toEqual([]);
|
||||
expect(result.current.history.entries).toEqual([]);
|
||||
});
|
||||
|
||||
|
||||
it("advances saved snapshots on its timer, ignores new results and stops when the user clears overlays", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { result } = renderHook(() => useInvestigationPresentation(options));
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 1 }], commands: [] }));
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 2 }], commands: [] }));
|
||||
act(() => result.current.history.playback.onToggle());
|
||||
expect(result.current.records).toEqual([{ value: 1 }]);
|
||||
expect(result.current.history.playback.playing).toBe(true);
|
||||
await act(() => result.current.runPreview({ blocks: [], records: [{ value: 3 }], commands: [] }));
|
||||
expect(result.current.records).toEqual([{ value: 1 }]);
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
expect(result.current.records).toEqual([{ value: 2 }]);
|
||||
act(() => vi.advanceTimersByTime(1500));
|
||||
expect(result.current.history.playback.playing).toBe(false);
|
||||
expect(result.current.records).toEqual([{ value: 2 }]);
|
||||
act(() => result.current.history.playback.onToggle());
|
||||
expect(result.current.history.playback.playing).toBe(true);
|
||||
act(() => result.current.clearMapOverlays());
|
||||
expect(result.current.history.playback.playing).toBe(false);
|
||||
expect(result.current.records).toEqual([{ value: 3 }]);
|
||||
act(() => vi.advanceTimersByTime(3000));
|
||||
expect(result.current.records).toEqual([{ value: 3 }]);
|
||||
});
|
||||
@@ -127,6 +127,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
"connecting" | "live" | "history" | "disconnected" | "error"
|
||||
>("disconnected");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||
const [activity, setActivity] = useState<InvestigationPresentationActivity[]>([]);
|
||||
const activityIdRef = useRef(0);
|
||||
const previewRef = useRef<AbortController | null>(null);
|
||||
@@ -171,7 +172,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
[publish, source]
|
||||
);
|
||||
const selectHistory = useCallback(
|
||||
(id: number | null) => {
|
||||
(id: number | null, pin = false) => {
|
||||
const store = storeRef.current;
|
||||
if (store.selectedId === null)
|
||||
store.update({
|
||||
@@ -179,7 +180,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
camera:
|
||||
capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera
|
||||
});
|
||||
if (!store.select(id === store.currentEntryId ? null : id)) return;
|
||||
if (!store.select(!pin && id === store.currentEntryId ? null : id)) return;
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
|
||||
@@ -228,8 +229,9 @@ export function useInvestigationPresentation(options: Options) {
|
||||
signal = AbortSignal.any([signal, mapCommandsRef.current.signal]);
|
||||
signal.throwIfAborted();
|
||||
const store = storeRef.current;
|
||||
const previousView = store.current;
|
||||
const eventKey = event ? presentationEventKey(event.task_id, event.event_seq) : undefined;
|
||||
if (eventKey && store.entries.some((entry) => entry.key === eventKey))
|
||||
if (eventKey && store.entries.some((entry) => entry.key === eventKey && entry.status === "completed"))
|
||||
return { recorded: true, duplicate: true };
|
||||
update((view) => ({
|
||||
...view,
|
||||
@@ -252,6 +254,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
valuesRef.current.task?.title ?? null,
|
||||
{ key: eventKey, taskId: valuesRef.current.task?.task_id }
|
||||
);
|
||||
const pendingEntry = store.entries.find((entry) => entry.id === historyId);
|
||||
const id = ++activityIdRef.current;
|
||||
setActivity((current) => [
|
||||
...current,
|
||||
@@ -259,6 +262,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
]);
|
||||
let actionSnapshot = store.current;
|
||||
const apply = (updater: (view: PresentationView) => PresentationView) => {
|
||||
signal.throwIfAborted();
|
||||
update(updater);
|
||||
actionSnapshot = store.current;
|
||||
};
|
||||
@@ -285,7 +289,7 @@ export function useInvestigationPresentation(options: Options) {
|
||||
})
|
||||
);
|
||||
signal.throwIfAborted();
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot);
|
||||
if (historyId !== null) store.finish(historyId, "completed", undefined, actionSnapshot, pendingEntry);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, status: "completed" } : item))
|
||||
@@ -300,13 +304,22 @@ export function useInvestigationPresentation(options: Options) {
|
||||
viewing_history: true
|
||||
};
|
||||
} catch (cause) {
|
||||
// An unfinished display may already have applied a chart or geometry before
|
||||
// waiting for its frame. Roll back only its own view, never a newer scope.
|
||||
if (signal.aborted && store.current === actionSnapshot) {
|
||||
store.update(previousView);
|
||||
actionSnapshot = previousView;
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && store.selectedId === null && valuesRef.current.mapReady)
|
||||
renderPresentationSnapshot(scene, map, previousView);
|
||||
}
|
||||
const message = signal.aborted
|
||||
? "展示已取消"
|
||||
: cause instanceof Error
|
||||
? cause.message
|
||||
: "展示失败";
|
||||
if (historyId !== null)
|
||||
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot);
|
||||
store.finish(historyId, signal.aborted ? "cancelled" : "error", message, actionSnapshot, pendingEntry);
|
||||
publish();
|
||||
setActivity((current) =>
|
||||
current.map((item) => (item.id === id ? { ...item, status: "error", message } : item))
|
||||
@@ -339,6 +352,8 @@ export function useInvestigationPresentation(options: Options) {
|
||||
loadHistory: async (cursor, signal) => {
|
||||
const task = valuesRef.current.task;
|
||||
if (!task || task.task_id !== taskId) return;
|
||||
if (task.execution_generation !== cursor.execution_generation)
|
||||
throw new Error("调查展示代次正在同步,请稍后重试");
|
||||
const events = await fetchOperationalTaskPresentationHistory(
|
||||
taskId,
|
||||
cursor.event_cursor,
|
||||
@@ -365,6 +380,9 @@ export function useInvestigationPresentation(options: Options) {
|
||||
})
|
||||
});
|
||||
signal.throwIfAborted();
|
||||
if (valuesRef.current.task?.task_id !== task.task_id ||
|
||||
valuesRef.current.task.execution_generation !== task.execution_generation)
|
||||
throw new Error("调查任务已切换,忽略旧展示结果");
|
||||
storeRef.current.update(next);
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
@@ -514,25 +532,26 @@ export function useInvestigationPresentation(options: Options) {
|
||||
const createSample = useCallback((caseId: InvestigationDevCaseId = "combined") => {
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (!map) return "";
|
||||
const task = valuesRef.current.task;
|
||||
const progress =
|
||||
task?.latest_progress?.execution_generation === task?.execution_generation
|
||||
? task?.latest_progress
|
||||
: null;
|
||||
const target = progress?.progress.diagram?.node_refs.flatMap(
|
||||
(node) => node.map_targets ?? []
|
||||
)[0];
|
||||
return createInvestigationDevCase(
|
||||
caseId,
|
||||
map.getCenter().toArray() as [number, number],
|
||||
target
|
||||
);
|
||||
return createInvestigationDevCase(caseId);
|
||||
}, []);
|
||||
|
||||
const taskEntries =
|
||||
!options.scope || options.scope === "investigation"
|
||||
? storeRef.current.entries.filter((entry) => entry.taskId === taskId)
|
||||
: storeRef.current.entries;
|
||||
const playing = storeRef.current.playing;
|
||||
const selectedHistoryId = storeRef.current.selectedId;
|
||||
useEffect(() => {
|
||||
if (!playing) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
const store = storeRef.current;
|
||||
store.advancePlayback();
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
|
||||
}, 1500 / playbackSpeed);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [playing, selectedHistoryId, playbackSpeed, publish, scene]);
|
||||
const visible = storeRef.current.visible;
|
||||
const reviewing = storeRef.current.selectedId !== null;
|
||||
return {
|
||||
@@ -552,7 +571,27 @@ export function useInvestigationPresentation(options: Options) {
|
||||
currentEntryId: storeRef.current.currentEntryId,
|
||||
entries: taskEntries,
|
||||
selectedId: storeRef.current.selectedId,
|
||||
onSelect: selectHistory
|
||||
onSelect: selectHistory,
|
||||
playback: {
|
||||
playing,
|
||||
speed: playbackSpeed,
|
||||
onToggle: () => {
|
||||
const store = storeRef.current;
|
||||
if (store.playing) store.pausePlayback();
|
||||
else {
|
||||
if (store.selectedId === null)
|
||||
store.update({ ...store.current, camera: capturePresentationCamera(valuesRef.current.mapRef.current) ?? store.current.camera });
|
||||
store.startPlayback(taskEntries);
|
||||
}
|
||||
publish();
|
||||
const map = valuesRef.current.mapRef.current;
|
||||
if (map && valuesRef.current.mapReady) renderPresentationSnapshot(scene, map, store.visible);
|
||||
},
|
||||
onSpeedChange: (speed: number) => {
|
||||
if ([0.5, 1, 2].includes(speed)) setPlaybackSpeed(speed);
|
||||
},
|
||||
onSeek: (id: number) => selectHistory(id, true)
|
||||
}
|
||||
},
|
||||
executeCommand,
|
||||
runPreview,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
|
||||
function task(taskType: OperationalTask["task_type"]): OperationalTask {
|
||||
return {
|
||||
execution_scope: "production",
|
||||
project_id: "11111111-1111-4111-8111-111111111111",
|
||||
task_id: "22222222-2222-4222-8222-222222222222",
|
||||
task_type: taskType,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user