Compare commits

..
Author SHA1 Message Date
jiang 4a7781d5e5 fix(charts): fit analysis windows across viewport sizes
Generic Container CI/CD / test-build-publish (push) Successful in 2m34s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 2m34s
2026-11-07 20:13:43 +08:00
jiang e0436217a8 style(workbench): refine notification cards and hit targets 2026-11-07 20:04:28 +08:00
jiang ea3b62573c feat(workbench): refine alert actions and removal transitions 2026-11-07 19:56:14 +08:00
jiang eee65ac8fd style: remove trailing blank line from event picker 2026-11-07 19:50:31 +08:00
jiang ba71e2d529 feat(workbench): expand SCADA monitoring and investigation presentation 2026-11-07 19:50:14 +08:00
jiang 0801e161ec feat(scada): add manual monitoring timeline
Generic Container CI/CD / test-build-publish (push) Successful in 1m12s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m12s
2026-11-04 14:27:08 +08:00
jiang 78481903d7 test: migrate workbench fixtures to persistent event streams
Static fulfilled SSE bodies closed immediately and made ordinary UI cases reconnect. Serve persistent fixture streams, retain outage and cursor recovery coverage, and update legacy approval and investigation assertions for current disclosures and SVG diagrams.

Validation: 12 related browser scenarios, 877 unit tests, type checking, scoped ESLint, and diff checks. Unrelated SCADA replay changes remain outside this commit.
2026-11-02 18:56:57 +08:00
jiang 0ec74b15b2 fix: restore investigation and appointment task loading
Server responses now include source.replay_context, including null for ordinary tasks. The strict frontend source schema rejected the new field and blocked the shared task snapshot and detail loading path.

Validate replay metadata explicitly and cover investigation, appointment, and scheduled task contracts plus browser loading. Six acceptance regressions failed before the fix; all 877 unit tests and the focused browser loading test passed after it. Lint and type checking passed. The separate approval browser case remains blocked by its reconnect-toast assertion.
2026-11-02 18:37:55 +08:00
jiang 74bf7c64c4 fix: align map control hover spacing
Let control surfaces size to their 40px buttons, padding and borders. Reserve the resulting 50px mobile dock height to keep the map controls clear.

The initial alignment check covered only horizontal spacing and missed the mobile height constraint. Check all four outer edges at desktop and mobile widths, plus the dock gap, to prevent recurrence.
2026-11-02 15:35:11 +08:00
155 changed files with 8173 additions and 1835 deletions
+2
View File
@@ -118,6 +118,8 @@ Agent 面板
图表首先显示标题、视图切换、序列和趋势。范围选择默认收起,展开后可选择起止点及重置;收起只改变展示,不取消已选范围。序列按钮使用低强调色,图例颜色保留数据对应关系;至少一条序列可见。数据点的各序列值分行对齐,保留原始精度。
图表通过“放大查看”打开独立浮窗,标题栏的“最大化窗口”按钮铺满可用视口,保留还原与关闭入口。内容宽度达到640px时,主图占据剩余空间,序列与摘要放在右侧;较窄窗口将资料放在主图下方独立滚动。展开、最大化与还原共享视图、序列显隐及表格筛选状态,调整窗口尺寸沿用同一图表实例。时间轴集中标注UTC+8,采样详情保留完整日期与偏移。工作台在独立层叠上下文内排列业务面板,浮窗与后续打开的菜单、对话框通过Portal显示。
正式报告展开后采用连续文档排版,章节之间使用细分隔线。目录跟随阅读区滚动,包含实际存在的结论、分析、调查进展、待办、处置、缺口、脉络、依据、时间线和版本。目录使用导航语义,点击或键盘激活后立即滚动并将焦点交给目标章节;手机目录可横向滚动。接近文档末尾、目标无法滚至顶部时,保留主动选择的章节标记,直到用户再次滚动。该导航只改变阅读位置,不控制任务执行或审批。
空会话采用“简短引导、首次输入、推荐问题”的顺序,整组在 Agent 面板内横向居中,放在会话栏下方可用区域的中部。居中容器最多 600px,桌面内边距 24px,手机左右内边距 16px;短屏内容超过可用高度时从顶部排列并允许滚动。判断依据是当前会话有无消息,不取决于帐号是否保存其他历史会话。加载历史、引用任务或已有工具权限审批、结构化结果时保留正常阅读区,不覆盖为欢迎页。
+7 -1
View File
@@ -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. 在默认展开的“调查测试”页签准备数据。“载入合成样例”在当前地图中心生成合成空间展示、压力曲线、候选表格和一条候选数据;所选调查进度存在地图目标时,另加入该目标的高亮测试。所有合成结果均有明确标记。
+3 -1
View File
@@ -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` 启用查询并显示区域文案。奉贤、凤阳和其他项目使用同一实现;未配置、关闭或配置无效时显示“当前项目暂未配置地名搜索”。区域由后端从授权项目读取,浏览器不能在搜索请求中覆盖行政区。外围样板服务仍有独立项目配置;本次复制其空表结构,不复制奉贤工单,也不扩大外部服务的访问范围。
## 验证
+15 -1
View File
@@ -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,18 @@ Agent 相关界面保持项目本地 shadcn/ui 组件的设计,包括背景、
- 危险操作使用明确的确认步骤和 `destructive` 按钮变体;失败保留输入与原内容。审批、提问与历史操作的业务规则由对应功能文档维护。
- Agent 打开的既有地图或业务工具仍遵循其所属模块规范。`AgentPersona` 动画资源按专用设计文档维护,周边面板与控件遵循本节。
### 系统弹窗通知
通知沿用 `glass-transient` 亚克力透明材质、12px 圆角、背景模糊与浮层阴影,复用工作台透明度、字体和语义色状态图标。标题、正文、操作与关闭区域分开排布,长文本自然换行。操作区使用宝石蓝入口,并提示“保留至手动关闭”;点击查看不移除通知。
通知沿用原有堆叠、按钮外观和动画实现,定位与尺寸保留新排版,不另建滚动容器,不覆盖材质与阴影。每条通知均可单独关闭;支持 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 +188,8 @@ Agent 与主工作区、地图与报告使用共享 `Resizable` 分栏;连续
`WorkbenchSection` 统一标题、说明、状态和操作;`WorkbenchNotice` 使用局部状态线;`WorkbenchEmptyState` 只说明为空原因与下一步。以分隔线、留白和浅色差组织内容,避免重复嵌套同级卡片。
带查看入口的系统弹出通知采用上下两区:上方排列状态图标、标题和正文,右上角预留关闭按钮;下方用细分隔线划出操作行,查看入口靠右并带跳转箭头,使用轻量按钮。无操作的通知不显示底部操作行,长正文自然换行,关闭与查看入口的点击区域互不重叠。
每个业务区最多一个醒目的主操作。按钮覆盖默认、hover、按压、焦点、禁用和加载态;hover 仅用于支持 hover 的设备,加载时阻止重复提交并保留可读状态。地图与业务控件沿用共享按压反馈,Agent 控件保留本地 shadcn/ui 交互状态;具有专用资源交互的 `AgentPersona` 保留原版悬停,不额外缩放或变暗。
地图与业务表单字段复用 `.form-field`,聚焦只改变原有 1px 边框;组合输入由外层承担焦点边界,无边框输入使用内描边。Agent 字段保留本地 shadcn/ui 输入组件的边框、焦点和错误样式。错误通过文字、`aria-invalid` 和关联说明表达,失败后保留输入。选中与焦点不改变控件尺寸,底图选择卡片使用主题边框和勾选标记,键盘焦点使用共享内描边。
+1
View File
@@ -59,6 +59,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",
+74
View File
@@ -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: {}
@@ -0,0 +1,39 @@
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("时间(UTC+8)");
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);
});
});
@@ -0,0 +1,37 @@
import type { Spec } from "@json-render/core";
import { formatDisplayText } 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") {
const axis = typeof props.x_axis_name === "string" ? props.x_axis_name : "";
props.x_data = (props.x_data as string[]).map((label) => {
const displayed = displayCell(label, axis);
return timeAxis(axis).includes("UTC+8") ? displayed.replace(/(UTC\+8)$/, "") : displayed;
});
if (typeof props.x_axis_name === "string") props.x_axis_name = timeAxis(props.x_axis_name);
props.series = (props.series as Array<Record<string, unknown>>).map((series) => ({ ...series,
name: typeof series.name === "string" ? formatDisplayText(series.name) : series.name }));
}
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[] };
@@ -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 {
+6 -1
View File
@@ -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);
@@ -64,11 +65,15 @@ export const answerUiCatalog = defineCatalog(schema, {
.object({
title: shortText,
chart_type: z.enum(["line", "bar"]),
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)
@@ -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");
+4 -2
View File
@@ -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[][] };
+192 -106
View File
@@ -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";
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 ? 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>;
}
+10
View File
@@ -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"] });
});
});
+17 -6
View File
@@ -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
@@ -1,9 +1,9 @@
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 { 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,11 +71,13 @@ 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 }) {
function SafeChartEnvelope({ envelope, embedded = false, expanded = false }: { envelope: Extract<UIEnvelope, { kind: "chart" }>; embedded?: boolean; expanded?: boolean }) {
const spec = parseChartSpec(envelope.spec);
const data = normalizeSafeChartData(envelope.data, 100);
@@ -83,173 +85,17 @@ function SafeChartEnvelope({ envelope, embedded = false }: { envelope: Extract<U
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({
@@ -377,35 +223,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();
});
});
@@ -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;
@@ -9,13 +16,17 @@ export function createViewSchemas(zod: typeof z = z, publication = false) {
.object({
title: z.string().max(200),
chart_type: z.enum(["line", "bar"]),
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
@@ -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;
+6 -1
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, type CSSProperties } from "react";
import { Toaster } from "sonner";
import { dismissMapNotice, showMapNotice } from "./notice-actions";
import type { MapNoticeTone } from "./notice-types";
@@ -8,10 +8,15 @@ 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 }}
toastOptions={{
unstyled: true,
classNames: {
+1 -1
View File
@@ -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
)}
>
+1 -1
View File
@@ -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(
@@ -42,6 +42,35 @@ function task(generation: number | null | undefined = 2) {
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
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,
@@ -13,7 +13,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 +50,7 @@ const operationalTaskRunPageSchema = z.object({
next_cursor: z.string().min(1).nullable()
}).strict();
const taskSummarySchema = z.object({
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"]),
@@ -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 }));
+43 -36
View File
@@ -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,69 @@
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
): Promise<T> {
const deadline = AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS);
const combined = signal ? AbortSignal.any([signal, deadline]) : deadline;
try {
combined.throwIfAborted();
return await request(combined);
} catch (error) {
if (signal?.aborted && !isTimeout(signal.reason)) throw signal.reason;
if (isTimeout(error) || (combined.aborted && isTimeout(combined.reason))) {
const diagnosis = await describeServerReadiness(baseUrl);
throw new Error(`${operation}超时(已等待 15 秒)。${diagnosis}`, { cause: error });
}
if (error instanceof TypeError || (error instanceof DOMException && error.name === "NetworkError")) {
throw new Error(`${operation}失败:无法连接后端服务。请检查网络和后端服务状态后重试。`, { cause: error });
}
throw error;
}
}
+4 -3
View File
@@ -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({
@@ -76,14 +77,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
@@ -0,0 +1,141 @@
import { z } from "zod";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } 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)]);
const headers = createWorkbenchHeaders(token, projectId);
if (body !== undefined) headers.set("Content-Type", "application/json");
const response = await fetch(`${env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "")}/api/v1/scada-replays${path}`, {
method: body === undefined ? "GET" : "POST", headers, signal,
...(body === undefined ? {} : { body: JSON.stringify(body) })
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "SCADA 回放"));
return payload;
}
}
@@ -38,11 +38,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 }),
@@ -199,7 +199,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
@@ -22,6 +22,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,
@@ -542,7 +543,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 ? (
@@ -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>
);
}
@@ -18,6 +18,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 +85,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 +106,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 +114,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 +149,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 +178,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}
>
@@ -306,22 +304,31 @@ export function InvestigationPresentationPanel({
{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={item.chart.title} fillContent
openRequestKey={chartWindowKey === undefined ? undefined : JSON.stringify([chartWindowKey, item])}>
{() => <div className="flex h-full min-h-0 min-w-0 flex-col gap-2">
<AgentChartView chart={item.chart} embedded expanded />
{item.evidence_refs.length ? <div className="max-h-[35%] shrink-0 overflow-auto">{references(item.evidence_refs)}</div> : null}
</div>}
</FloatingWindow>
<Button
type="button"
variant="ghost"
size="icon-sm"
className="shrink-0"
disabled={reviewing}
aria-label={`关闭图表 ${item.chart.title}`}
onClick={() => onCloseChart(item.chart_id)}
>
<X size={14} aria-hidden />
</Button>
</div>
</CardHeader>
<CardContent className="px-3 pb-2 pt-0">
<AgentChartView chart={item.chart} embedded />
@@ -335,6 +342,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,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&#10;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}
/>
@@ -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,
@@ -0,0 +1,97 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import type { WorkbenchAlert } from "../types";
import { useScadaAlertNotices } from "./use-scada-alert-notices";
vi.mock("@/features/map/core", () => ({ showMapNotice: vi.fn(), dismissMapNotice: vi.fn() }));
const alert: WorkbenchAlert = {
id: "incident-1", title: "SCADA 指标异常", description: "已启动调查,详细诊断见调查报告。",
time: "08:00", severity: "warning", conditionId: "operational-task:task-1",
source: { kind: "scada_replay", replayId: "replay-1", ruleId: "scada_diagnosis", batchSequence: 2 }
};
afterEach(() => { cleanup(); sessionStorage.clear(); vi.clearAllMocks(); });
it("restores old notifications quietly and announces each newly persisted SCADA incident once", () => {
const onOpen = vi.fn();
const { result, rerender } = renderHook(({ alerts }: { alerts: WorkbenchAlert[] | null }) =>
useScadaAlertNotices("project-1", alerts, onOpen), { initialProps: { alerts: null as WorkbenchAlert[] | null } });
rerender({ alerts: [alert] });
expect(showMapNotice).not.toHaveBeenCalled();
expect(result.current.atmosphere?.alert).toEqual(alert);
const next = { ...alert, id: "incident-2", conditionId: "operational-task:task-2" };
rerender({ alerts: [alert, next] });
rerender({ alerts: [alert, { ...next }] });
expect(showMapNotice).toHaveBeenCalledTimes(1);
expect(result.current.atmosphere).toMatchObject({ preview: false, count: 2 });
act(() => result.current.dismissAtmosphere(result.current.atmosphere!.id));
rerender({ alerts: [alert, { ...next }] });
expect(result.current.atmosphere).toBeNull();
expect(showMapNotice).toHaveBeenCalledWith(expect.objectContaining({
title: alert.title, tone: "warning", position: "top-center", actionLabel: "查看调查", duration: Infinity
}));
vi.mocked(showMapNotice).mock.calls[0][0].onAction?.();
expect(onOpen).toHaveBeenCalledWith(next);
expect(dismissMapNotice).not.toHaveBeenCalled();
});
it("retains linked notices after recovery and never reannounces the same incident", () => {
const { result, rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [alert] });
expect(result.current.atmosphere?.alert).toEqual(alert);
rerender({ alerts: [] });
expect(result.current.atmosphere).toBeNull();
expect(dismissMapNotice).not.toHaveBeenCalled();
rerender({ alerts: [alert] });
expect(showMapNotice).toHaveBeenCalledTimes(1);
});
it("clears recovered data-quality notices without task links", () => {
const { rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [{ ...alert, conditionId: undefined }] });
rerender({ alerts: [] });
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-1:incident-1");
});
it("clears popups when leaving a project and keeps a different project's initial alerts quiet", () => {
const { result, rerender, unmount } = renderHook(({ project, alerts }) =>
useScadaAlertNotices(project, alerts, vi.fn()),
{ initialProps: { project: "project-1", alerts: [] as WorkbenchAlert[] } });
rerender({ project: "project-1", alerts: [alert] });
rerender({ project: "project-2", alerts: [alert] });
expect(result.current.atmosphere?.projectId).toBe("project-2");
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-1:incident-1");
expect(showMapNotice).toHaveBeenCalledTimes(1);
rerender({ project: "project-2", alerts: [alert, { ...alert, id: "incident-2", severity: "critical" }] });
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ tone: "error" }));
unmount();
expect(dismissMapNotice).toHaveBeenCalledWith("scada-alert:project-2:incident-2");
});
it("ignores other sources and leaves data quality notices without a fabricated investigation link", () => {
const { result, rerender } = renderHook(({ alerts }) => useScadaAlertNotices("project-1", alerts, vi.fn()),
{ initialProps: { alerts: [] as WorkbenchAlert[] } });
rerender({ alerts: [{ ...alert, source: undefined }] });
expect(showMapNotice).not.toHaveBeenCalled();
rerender({ alerts: [{ ...alert, conditionId: undefined }] });
expect(showMapNotice).toHaveBeenLastCalledWith(expect.objectContaining({ actionLabel: undefined, onAction: undefined }));
expect(result.current.atmosphere).toBeNull();
rerender({ alerts: [{ ...alert, id: "quality-only", source: { ...alert.source!, kind: "scada_replay", replayId: "replay-1", ruleId: "quality-P1", batchSequence: 2 } }] });
expect(result.current.atmosphere).toBeNull();
});
it("previews locally and an older dismissal cannot clear a newer real incident", () => {
const { result, rerender } = renderHook(({ project, alerts }) => useScadaAlertNotices(project, alerts, vi.fn()),
{ initialProps: { project: "project-1", alerts: [] as WorkbenchAlert[] } });
act(() => result.current.previewAtmosphere());
const preview = result.current.atmosphere!;
expect(preview).toMatchObject({ preview: true, alert: null, projectId: "project-1" });
expect(showMapNotice).not.toHaveBeenCalled();
rerender({ project: "project-1", alerts: [alert] });
act(() => result.current.dismissAtmosphere(preview.id));
expect(result.current.atmosphere?.alert).toEqual(alert);
rerender({ project: "project-2", alerts: [] });
expect(result.current.atmosphere).toBeNull();
});
@@ -0,0 +1,64 @@
import { useEffect, useRef } from "react";
import { dismissMapNotice, showMapNotice } from "@/features/map/core";
import type { WorkbenchAlert } from "../types";
import { useScadaEventAtmosphere, type ScadaEventTask } from "./use-scada-event-atmosphere";
export type { ScadaAlertAtmosphereState } from "./use-scada-event-atmosphere";
/** Announce newly persisted alerts, including while the peripheral panel is closed. */
export function useScadaAlertNotices(
projectId: string | null,
alerts: WorkbenchAlert[] | null,
onOpen: (alert: WorkbenchAlert) => void,
tasks: readonly ScadaEventTask[] | null = null
) {
const atmosphere = useScadaEventAtmosphere(projectId, alerts, tasks);
const openRef = useRef(onOpen);
openRef.current = onOpen;
const state = useRef<{ initialized: boolean; seen: Set<string>; visible: Set<string>; linked: Set<string> }>({
initialized: false, seen: new Set(), visible: new Set(), linked: new Set()
});
useEffect(() => {
const current = { initialized: false, seen: new Set<string>(), visible: new Set<string>(), linked: new Set<string>() };
state.current = current;
return () => { current.visible.forEach((id) => dismissMapNotice(id)); };
}, [projectId]);
useEffect(() => {
if (!alerts) return;
const current = state.current;
const incoming = alerts.filter((alert) => alert.source && "kind" in alert.source
&& alert.source.kind === "scada_replay");
const noticeId = (id: string) => `scada-alert:${projectId}:${id}`;
const activeIds = new Set(incoming.map((alert) => noticeId(alert.id)));
for (const id of current.visible) {
if (!activeIds.has(id) && !current.linked.has(id)) {
dismissMapNotice(id);
current.visible.delete(id);
}
}
for (const alert of incoming) {
if (current.seen.has(alert.id)) continue;
current.seen.add(alert.id);
// Opening a project restores its notification center without replaying old popups.
if (!current.initialized) continue;
const id = noticeId(alert.id);
current.visible.add(id);
if (alert.conditionId) current.linked.add(id);
showMapNotice({
id,
title: alert.title,
message: alert.description,
tone: alert.severity === "critical" ? "error" : "warning",
position: "top-center",
duration: alert.conditionId ? Infinity : 10_000,
actionLabel: alert.conditionId ? "查看调查" : undefined,
onAction: alert.conditionId ? () => openRef.current(alert) : undefined
});
}
current.initialized = true;
}, [alerts, projectId]);
return atmosphere;
}
@@ -0,0 +1,63 @@
import { act, cleanup, renderHook } from "@testing-library/react";
import { afterEach, expect, it } from "vitest";
import { useScadaEventAtmosphere, type ScadaEventTask } from "./use-scada-event-atmosphere";
import type { WorkbenchAlert } from "../types";
const task: ScadaEventTask = {
task_id: "task-1", project_id: "project-1", task_type: "event_investigation", workflow: "scada_anomaly",
lifecycle_status: "open", title: "监测事件", created_at: "2026-07-16T00:00:00Z",
source: { kind: "external_system", external_id: "scada:1", external_revision: 1, event_kind: "scada_anomaly",
replay_context: { replay_id: "replay-1", knowledge_cutoff: "2026-07-16T00:00:00Z", batch_sequence: 1 } }
};
const alert: WorkbenchAlert = { id: "incident-1", conditionId: "operational-task:task-1", title: "SCADA 指标异常",
time: "08:00", description: "调查已启动", source: { kind: "scada_replay", replayId: "replay-1", ruleId: "scada_diagnosis", batchSequence: 1 } };
afterEach(() => { cleanup(); sessionStorage.clear(); });
it("retains an event after sensor-alert recovery, human waiting and execution failure, until event completion", () => {
const { result, rerender } = renderHook(({ alerts, tasks }) => useScadaEventAtmosphere("project-1", alerts, tasks),
{ initialProps: { alerts: [alert], tasks: [task] } });
expect(result.current.atmosphere?.count).toBe(1);
rerender({ alerts: [], tasks: [{ ...task, lifecycle_status: "waiting_human" }] });
expect(result.current.atmosphere?.alert?.conditionId).toBe(alert.conditionId);
rerender({ alerts: [], tasks: [{ ...task, lifecycle_status: "failed" }] });
expect(result.current.atmosphere).not.toBeNull();
rerender({ alerts: [alert], tasks: [{ ...task, lifecycle_status: "completed" }] });
expect(result.current.atmosphere).toBeNull();
});
it("restores unfinished events on reload and remembers a manual dismissal for that event", () => {
const first = renderHook(() => useScadaEventAtmosphere("project-1", null, [task]));
expect(first.result.current.atmosphere).not.toBeNull();
act(() => first.result.current.dismissAtmosphere(first.result.current.atmosphere!.id));
expect(first.result.current.atmosphere).toBeNull();
first.unmount();
const next = renderHook(() => useScadaEventAtmosphere("project-1", [alert], [task]));
expect(next.result.current.atmosphere).toBeNull();
});
it("keeps other events visible when one ends and shows a new event after the earlier group was dismissed", () => {
const second = { ...task, task_id: "task-2" };
const { result, rerender } = renderHook(({ tasks }) => useScadaEventAtmosphere("project-1", [], tasks),
{ initialProps: { tasks: [task, second] } });
expect(result.current.atmosphere?.count).toBe(2);
rerender({ tasks: [{ ...task, lifecycle_status: "cancelled" }, second] });
expect(result.current.atmosphere?.count).toBe(1);
act(() => result.current.dismissAtmosphere(result.current.atmosphere!.id));
rerender({ tasks: [second, { ...task, task_id: "task-3" }] });
expect(result.current.atmosphere?.count).toBe(1);
expect(result.current.atmosphere?.alert?.conditionId).toBe("operational-task:task-3");
});
it("isolates project state and lets Dev end only the local preview", () => {
const { result, rerender } = renderHook(({ project, tasks }) => useScadaEventAtmosphere(project, [], tasks),
{ initialProps: { project: "project-1", tasks: [] as ScadaEventTask[] } });
act(() => result.current.previewAtmosphere());
expect(result.current.atmosphere?.preview).toBe(true);
act(() => result.current.endAtmospherePreview());
expect(result.current.atmosphere).toBeNull();
rerender({ project: "project-1", tasks: [task] });
act(() => result.current.endAtmospherePreview());
expect(result.current.atmosphere?.preview).toBe(false);
rerender({ project: "project-2", tasks: [task] });
expect(result.current.atmosphere).toBeNull();
});
@@ -0,0 +1,67 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { OperationalTaskSummary } from "../api/operational-task-client";
import type { WorkbenchAlert } from "../types";
export type ScadaEventTask = Pick<OperationalTaskSummary,
"task_id" | "project_id" | "task_type" | "workflow" | "lifecycle_status" | "source" | "title" | "created_at">;
export type ScadaAlertAtmosphereState = {
id: string;
projectId: string | null;
alert: WorkbenchAlert | null;
preview: boolean;
count?: number;
};
const storageKey = (project: string | null) => `tjwater.scada-visual-dismissed@1:${project}`;
function readDismissed(project: string | null): string[] {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(storageKey(project)) ?? "[]");
return Array.isArray(value) ? value.filter((id): id is string => typeof id === "string") : [];
} catch { return []; }
}
/** Visual state follows event completion, independently of sensor recovery or run outcomes. */
export function useScadaEventAtmosphere(projectId: string | null, alerts: WorkbenchAlert[] | null,
tasks: readonly ScadaEventTask[] | null) {
const storedDismissed = useMemo(() => readDismissed(projectId), [projectId]);
const [dismissed, setDismissed] = useState<{ project: string | null; ids: string[] } | null>(null);
const muted = dismissed?.project === projectId ? dismissed.ids : storedDismissed;
const [preview, setPreview] = useState<ScadaAlertAtmosphereState | null>(null);
useEffect(() => setPreview(null), [projectId]);
const candidates = new Map<string, WorkbenchAlert>();
const currentTasks = tasks?.filter((task) => task.project_id === projectId) ?? [];
const ended = new Set(currentTasks.filter((task) => ["completed", "cancelled"].includes(task.lifecycle_status))
.map((task) => `operational-task:${task.task_id}`));
for (const alert of alerts ?? []) {
if (alert.source && "kind" in alert.source && alert.source.kind === "scada_replay"
&& alert.source.ruleId === "scada_diagnosis" && alert.conditionId && !ended.has(alert.conditionId)) {
candidates.set(alert.conditionId, alert);
}
}
for (const task of currentTasks) {
const id = `operational-task:${task.task_id}`;
if (task.task_type !== "event_investigation" || !task.source.replay_context
|| !["scada_anomaly", "burst_report"].includes(task.workflow) || ended.has(id) || candidates.has(id)) continue;
candidates.set(id, { id, conditionId: id, title: task.title,
description: "监测事件持续处理中", occurredAt: task.created_at, time: "" });
}
const ids = [...candidates.keys()].filter((id) => !muted.includes(id)).sort();
const realKey = ids.join("|");
useEffect(() => { if (realKey) setPreview(null); }, [realKey]);
const atmosphere: ScadaAlertAtmosphereState | null = ids.length ? {
id: realKey, projectId, alert: candidates.get(ids[0])!, preview: false, count: ids.length
} : preview?.projectId === projectId ? preview : null;
const previewAtmosphere = useCallback(() => {
if (realKey) return;
setPreview({ id: `preview:${crypto.randomUUID()}`, projectId, alert: null, preview: true });
}, [projectId, realKey]);
const endAtmospherePreview = useCallback(() => setPreview(null), []);
const dismissAtmosphere = (id: string) => {
if (id !== atmosphere?.id) return;
setPreview(null);
if (atmosphere.preview) return;
const next = [...new Set([...muted, ...ids])];
setDismissed({ project: projectId, ids: next });
try { sessionStorage.setItem(storageKey(projectId), JSON.stringify(next)); } catch { /* Retain the current page preference. */ }
};
return { atmosphere, previewAtmosphere, endAtmospherePreview, dismissAtmosphere };
}
@@ -27,12 +27,12 @@ describe("workbench floating panels", () => {
expect(resolveMobileWorkbenchOverlayLayout(true)).toEqual({
timelineBottom: 12,
dockBottom: 68,
mapControlBottom: 120
mapControlBottom: 126
});
expect(resolveMobileWorkbenchOverlayLayout(false)).toEqual({
timelineBottom: 12,
dockBottom: 12,
mapControlBottom: 64
mapControlBottom: 70
});
});
@@ -32,7 +32,7 @@ export const WORKBENCH_LAYOUT = {
mobileOverlay: {
edgeInset: 12,
gap: 8,
dockHeight: 44,
dockHeight: 50, // 40px buttons + 4px padding and 1px border on each side.
timelineSummaryHeight: 48
}
} as const;
+58 -51
View File
@@ -38,8 +38,7 @@ import type { WorkbenchProjectSelection } from "./components/workbench-top-bar-m
import { AgentTaskTicker } from "./components/agent-task-ticker";
import { AnalysisResultsPanel } from "./components/analysis-results-panel";
import { PeripheralSamplePanel } from "./peripheral-sample/peripheral-sample-panel";
import type { PeripheralTestDataRecord, PeripheralWorkOrder } from "./peripheral-sample/client";
import { buildScadaAnomalyInvestigation } from "./peripheral-sample/scada-anomaly-agent-trigger";
import type { PeripheralWorkOrder } from "./peripheral-sample/client";
import { MapDevPanel } from "./components/map-dev-panel";
import { InvestigationPresentationPanel } from "./components/investigation-presentation-panel";
import { MobileWorkbenchSheet } from "./components/mobile-workbench-sheet";
@@ -117,6 +116,9 @@ import type {
} from "./types";
import { getScheduledConditionIdFromAlertId } from "./utils/scheduled-condition-alerts";
import { getOperationalNotificationSeverity } from "./utils/operational-notification-routing";
import { useScadaAlertNotices } from "./hooks/use-scada-alert-notices";
import { useDismissedWorkbenchAlerts } from "./hooks/use-dismissed-workbench-alerts";
import { ScadaAlertAtmosphere, type ScadaAlertIntensity } from "./components/scada-alert-atmosphere";
import { createAlertQueueConversationPrompt } from "./utils/scheduled-condition-prompts";
const WORKBENCH_LAYOUT_CSS_VARIABLES = getWorkbenchLayoutCssVariables();
@@ -159,6 +161,7 @@ function MapWorkbenchContent({
const [taskTickerVisible, setTaskTickerVisible] = useState(true);
const [analysisResultsPanelOpen, setAnalysisResultsPanelOpen] = useState(false);
const [peripheralSamplePanelOpen, setPeripheralSamplePanelOpen] = useState(false);
const [scadaAlertIntensity, setScadaAlertIntensity] = useState<ScadaAlertIntensity>("standard");
const [selectedConditionId, setSelectedConditionId] = useState<string | null>(null);
const [conditionFocusRequest, setConditionFocusRequest] = useState<{
conditionId: string;
@@ -226,11 +229,12 @@ function MapWorkbenchContent({
|| condition.operationalTask?.task_type === "external_work_order"
)
], [operationalTasks.conditions, runtimeScheduledConditions]);
const workbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const allWorkbenchAlerts = useMemo<WorkbenchAlert[]>(() => {
const taskNotifications = operationalTasks.notifications.flatMap((notification) => {
const severity = getOperationalNotificationSeverity(notification.kind);
return severity ? [{
id: `operational-task-notification:${notification.notification_id}`,
notificationVersion: notification.version,
conditionId: operationalTaskConditionId(notification.task_id),
title: notification.title,
description: notification.description,
@@ -241,6 +245,20 @@ function MapWorkbenchContent({
});
return [...taskNotifications, ...runtimeWorkbenchAlerts];
}, [operationalTasks.notifications, runtimeWorkbenchAlerts]);
const { alerts: workbenchAlerts, dismissAlert } = useDismissedWorkbenchAlerts(
allWorkbenchAlerts, projectPresentation.projectId, projectPresentation.user.name
);
const handleRemoveAlert = (alert: WorkbenchAlert) => {
if (!dismissAlert(alert)) {
showMapNotice({ tone: "warning", title: "通知未移除", message: "无法保存移除记录,请确认项目已加载且浏览器允许本地存储后重试。" });
}
};
const { atmosphere: scadaAlertAtmosphere, previewAtmosphere, endAtmospherePreview, dismissAtmosphere } = useScadaAlertNotices(
projectPresentation.projectId,
workbenchSync.snapshot?.runtime.alertAvailability === "ready" ? runtimeWorkbenchAlerts : null,
handleSelectAlert,
operationalTasks.loading ? workbenchSync.snapshot?.tasks ?? null : operationalTasks.tasks
);
const collapseAgentPanel = agent.collapsePanel;
const expandAgentPanel = agent.expandPanel;
const clearActiveTool = useCallback(() => {
@@ -405,44 +423,6 @@ function MapWorkbenchContent({
}
}
async function handleScadaAnomalyAgentTrigger(record: PeripheralTestDataRecord) {
showMapNotice({
id: "scada-anomaly-agent-trigger",
tone: "loading",
title: "SCADA 模拟异常已接收",
message: "正在建立结构化异常调查任务。",
duration: Infinity
});
try {
const task = await operationalTasks.createInvestigation(
buildScadaAnomalyInvestigation(record)
);
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
const conditionId = operationalTaskConditionId(task.task_id);
setSelectedConditionId(conditionId);
setConditionFocusRequest((current) => ({
conditionId,
requestId: (current?.requestId ?? 0) + 1
}));
showMapNotice({
id: "scada-anomaly-agent-trigger",
tone: "success",
title: "已建立 SCADA 异常调查",
message: `关联模拟读数 ${record.record_id.slice(0, 8)},Agent 将按任务上下文核查。`
});
} catch (error) {
showMapNotice({
id: "scada-anomaly-agent-trigger",
tone: "error",
title: "异常调查任务创建失败",
message: error instanceof Error ? error.message : "Agent 事件入口当前不可用。"
});
throw error;
}
}
function openConditionWorkspaceForViewport() {
openConditionFeedForViewport();
}
@@ -589,6 +569,7 @@ function MapWorkbenchContent({
const taskTickerAvailable = runningTickerConditions.length > 0;
const shouldShowTaskTicker =
!readingWorkspace?.active &&
(isLargeScreen || activeToolId !== "geocoding") &&
!artifactActive &&
mobileSheet === null &&
!conditionFeedExpanded &&
@@ -892,6 +873,7 @@ function MapWorkbenchContent({
return;
}
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
setConditionFocusRequest((current) => ({
conditionId,
@@ -1103,6 +1085,7 @@ function MapWorkbenchContent({
const toolbarPanelProps: ToolbarPanelProps = {
geocoding,
featureLocation: { commands: mapController, controllerState: mapControllerState, detailFeature, enabled: mapReady },
activeToolId,
activeMeasureModeId,
activeMeasureUnitId,
@@ -1296,9 +1279,12 @@ function MapWorkbenchContent({
onRejectQuestion: agent.sessionPurpose === "task_execution" ? undefined : agent.rejectQuestion
};
const conditionFeedSuspended = devPanelOpen || peripheralSamplePanelOpen || Boolean(readingWorkspace?.active);
const mobileSheetSuspended = Boolean(readingWorkspace?.active) || (mobileSheet === "condition" && peripheralSamplePanelOpen);
return (
<main
className="relative h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
className="relative isolate h-[100dvh] min-h-[640px] overflow-hidden bg-slate-100 text-slate-900 tabular-nums"
data-basemap-tone={basemapTone}
style={
{
@@ -1308,6 +1294,7 @@ function MapWorkbenchContent({
} as CSSProperties
}
>
<ScadaAlertAtmosphere atmosphere={scadaAlertAtmosphere} onDismiss={dismissAtmosphere} onOpen={handleSelectAlert} intensity={scadaAlertIntensity} />
<WorkbenchTopBar
dataTime={displayedDataTime}
modelName={
@@ -1320,7 +1307,7 @@ function MapWorkbenchContent({
alertDataSource={alertDataSource}
user={projectPresentation.user}
projectSelection={projectSelection}
conditionFeedVisible={isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition"}
conditionFeedVisible={!conditionFeedSuspended && (isLargeScreen ? shouldShowConditionFeed : mobileSheet === "condition")}
timelineVisible={timelineVisible}
taskTickerAvailable={taskTickerAvailable}
taskTickerVisible={taskTickerVisible}
@@ -1329,6 +1316,7 @@ function MapWorkbenchContent({
devPanelEnabled={devPanelEnabled}
devPanelOpen={devPanelOpen}
onSelectAlert={handleSelectAlert}
onRemoveAlert={handleRemoveAlert}
onToggleConditionFeed={toggleConditionWorkspaceForViewport}
onToggleTimeline={toggleTimelineVisibility}
onToggleTaskTicker={() => setTaskTickerVisible((current) => !current)}
@@ -1375,8 +1363,17 @@ function MapWorkbenchContent({
open={peripheralSamplePanelOpen}
onClose={() => setPeripheralSamplePanelOpen(false)}
getAccessToken={getAccessToken}
mapReady={mapReady}
mapRef={mapRef}
onAgentTrigger={handlePeripheralWorkOrderAgentTrigger}
onScadaAnomalyTrigger={handleScadaAnomalyAgentTrigger}
onOpenInvestigation={(taskId) => {
setPeripheralSamplePanelOpen(false);
openConditionWorkspaceForViewport();
const conditionId = operationalTaskConditionId(taskId);
setSelectedConditionId(conditionId);
setConditionFocusRequest((current) => ({ conditionId, requestId: (current?.requestId ?? 0) + 1 }));
operationalTasks.refresh();
}}
/>
<WorkbenchAgentPanels
@@ -1510,7 +1507,7 @@ function MapWorkbenchContent({
</WorkbenchAgentPanels>
{conditionFeedMounted ? (
<div className="absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={devPanelOpen || readingWorkspace?.active ? { display: "none" } : undefined}>
<div className="pointer-events-none absolute bottom-[calc(var(--workbench-timeline-height)+1.25rem)] right-16 top-24 z-50 hidden lg:block 2xl:[--workbench-condition-width:var(--workbench-condition-width-wide)]" style={conditionFeedSuspended ? { display: "none" } : undefined}>
<ScheduledConditionFeed
presentation="desktop-floating"
conditions={scheduledConditions}
@@ -1556,10 +1553,15 @@ function MapWorkbenchContent({
flowDataSource={flowDataSource}
investigationFollow={investigationFollow}
onInvestigationFollowChange={setInvestigationFollow}
onPreviewScadaAlert={previewAtmosphere}
onEndScadaAlertPreview={endAtmospherePreview}
scadaAlertPreviewActive={scadaAlertAtmosphere?.preview === true}
scadaAlertIntensity={scadaAlertIntensity}
onScadaAlertIntensityChange={setScadaAlertIntensity}
investigationTools={{
task: selectedInvestigationTask, mapReady,
charts: previewPresentation.charts.length, overlay: previewPresentation.overlay?.title ?? null,
activity: [...presentation.activity, ...previewPresentation.activity], onSample: previewPresentation.createSample,
onSample: previewPresentation.createSample,
onPreview: previewPresentation.runPreview, onClear: previewPresentation.clearPreview
}}
onClose={() => setDevPanelOpen(false)}
@@ -1569,11 +1571,16 @@ function MapWorkbenchContent({
{mobileSheet === null && !readingWorkspace?.active && artifactWorkspace.surfaceMode !== "focus" ? (
<div
className="absolute left-3 right-3 z-30 transition-[bottom] duration-200 ease-out motion-reduce:transition-none lg:hidden"
style={{ bottom: mobileOverlayLayout.mapControlBottom }}
style={{
bottom: mobileOverlayLayout.mapControlBottom,
"--mobile-tool-available-height": `calc(100dvh - ${mobileOverlayLayout.mapControlBottom}px - 4.5rem)`
} as CSSProperties}
>
<ToolbarPanel
{...toolbarPanelProps}
panelWidthClassName="w-full max-h-[52dvh] overflow-y-auto"
panelWidthClassName={activeToolId === "geocoding"
? "w-full max-h-[var(--mobile-tool-available-height)]"
: "w-full max-h-[52dvh] overflow-y-auto"}
/>
</div>
) : null}
@@ -1624,7 +1631,7 @@ function MapWorkbenchContent({
<MapToolbar
items={toolbarItems}
orientation="horizontal"
className="h-11 min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
className="min-w-0 flex-1 justify-center [&>div]:min-w-0 [&>div]:flex-1 [&>div>div]:hidden [&_button]:w-full"
/>
) : null}
<button
@@ -1642,8 +1649,8 @@ function MapWorkbenchContent({
{mobileSheet ? (
<MobileWorkbenchSheet
className={readingWorkspace?.active ? "hidden" : undefined}
suspended={Boolean(readingWorkspace?.active)}
className={mobileSheetSuspended ? "hidden" : undefined}
suspended={mobileSheetSuspended}
label={
mobileSheet === "agent"
? "Agent 工作台抽屉"
@@ -12,8 +12,8 @@ type OverlayOpacity = "fill-opacity" | "circle-opacity" | "circle-stroke-opacity
type OverlayOpacityValues = Partial<Record<OverlayOpacity, number>>;
const layers: LayerSpecification[] = [
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.supply.waterQuality, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-fill`, source: INVESTIGATION_OVERLAY_SOURCE, type: "fill", filter: ["==", ["geometry-type"], "Polygon"], paint: { "fill-color": MAP_STYLE_TOKENS.state.risk, "fill-opacity": 0.16 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-outline`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "Polygon"], paint: { "line-color": MAP_STYLE_TOKENS.state.risk, "line-width": 2, "line-dasharray": [2, 2] } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-line`, source: INVESTIGATION_OVERLAY_SOURCE, type: "line", filter: ["==", ["geometry-type"], "LineString"], paint: { "line-color": MAP_STYLE_TOKENS.supply.waterQuality, "line-width": 3 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-point`, source: INVESTIGATION_OVERLAY_SOURCE, type: "circle", filter: ["==", ["geometry-type"], "Point"], paint: { "circle-color": MAP_STYLE_TOKENS.supply.waterQuality, "circle-radius": 6, "circle-stroke-color": MAP_STYLE_TOKENS.canvas.casing, "circle-stroke-width": 2 } },
{ id: `${INVESTIGATION_OVERLAY_SOURCE}-heatmap`, source: INVESTIGATION_OVERLAY_SOURCE, type: "heatmap", paint: {
@@ -0,0 +1,9 @@
export const FEATURE_LOCATION_ERROR_LABELS: Record<string, string> = {
MAP_NOT_READY: "地图尚未就绪",
FEATURE_NOT_FOUND: "目标要素不存在",
INVALID_FEATURE_IDS: "请填写有效的要素 ID,单次最多 100 个",
WFS_UNAVAILABLE: "要素查询暂不可用,请稍后重试",
SCADA_API_UNAVAILABLE: "监测点查询暂不可用,请稍后重试",
SCADA_FEATURES_NOT_FOUND: "未找到对应的 SCADA 设备",
ACTION_SUPERSEDED: "地图定位已被新请求替代"
};
+2 -2
View File
@@ -4,8 +4,8 @@ import type { MapToolbarItem } from "@/features/map/core";
export const waterNetworkToolbarItems: MapToolbarItem[] = [
{
id: "geocoding",
label: "地址定位",
description: "查询地址并定位到地图",
label: "定位",
description: "查询地址或管网要素",
icon: Search
},
{
@@ -0,0 +1,12 @@
export type EventGpsCoordinate = [longitude: number, latitude: number];
export function formatGpsCoordinate([longitude, latitude]: EventGpsCoordinate) {
return `${longitude.toFixed(6)}, ${latitude.toFixed(6)}`;
}
export function mergeEventLocation(location: string, coordinate: EventGpsCoordinate | null) {
const text = location.trim();
if (!coordinate) return text;
const gps = `GPS:${formatGpsCoordinate(coordinate)}`;
return text ? `${text}(${gps})` : gps;
}
@@ -0,0 +1,56 @@
import { Marker, type Map as MapLibreMap } from "maplibre-gl";
import type { EventGpsCoordinate } from "./event-location-coordinate";
const arrowDirections: Record<string, [number, number]> = {
ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1]
};
export function createEventLocationMarker({ map, element, coordinate, disabled, onChange, onDragChange }: {
map: MapLibreMap;
element: HTMLButtonElement;
coordinate: EventGpsCoordinate;
disabled: boolean;
onChange: (coordinate: EventGpsCoordinate) => void;
onDragChange: (dragging: boolean) => void;
}) {
const marker = new Marker({ element, anchor: "bottom", draggable: !disabled })
.setLngLat(coordinate)
.addTo(map);
element.setAttribute("aria-label", "事件位置标记,可拖拽调整");
element.setAttribute("title", "拖拽调整事件位置,方向键微调,Shift 加速");
element.setAttribute("aria-keyshortcuts", "ArrowUp ArrowDown ArrowLeft ArrowRight");
marker.on("dragstart", () => {
element.dataset.dragging = "true";
onDragChange(true);
});
marker.on("dragend", () => {
element.dataset.dragging = "false";
onDragChange(false);
const position = marker.getLngLat();
onChange([normalizeLongitude(position.lng), clampLatitude(position.lat)]);
});
// Custom MapLibre markers own their keyboard interaction. Move in screen
// pixels and consume arrows so adjusting the pin cannot also pan the map.
element.onkeydown = (event) => {
if (!marker.isDraggable() || event.altKey || event.ctrlKey || event.metaKey) return;
const direction = arrowDirections[event.key];
if (!direction) return;
event.preventDefault();
event.stopPropagation();
const point = map.project(marker.getLngLat());
const step = event.shiftKey ? 10 : 1;
const position = map.unproject([point.x + direction[0] * step, point.y + direction[1] * step]);
const next: EventGpsCoordinate = [normalizeLongitude(position.lng), clampLatitude(position.lat)];
marker.setLngLat(next);
onChange(next);
};
return marker;
}
export function normalizeLongitude(longitude: number) {
return ((longitude + 180) % 360 + 360) % 360 - 180;
}
export function clampLatitude(latitude: number) {
return Math.max(-90, Math.min(90, latitude));
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { formatGpsCoordinate, mergeEventLocation } from "./event-location-coordinate";
describe("event location GPS formatting", () => {
it("formats coordinates with stable GPS precision", () => {
expect(formatGpsCoordinate([121.4791234, 31.2359876])).toBe("121.479123, 31.235988");
});
it("keeps the written location and persists the selected coordinate", () => {
expect(mergeEventLocation(" 演示路口 ", [121.4791234, 31.2359876]))
.toBe("演示路口(GPS:121.479123, 31.235988)");
expect(mergeEventLocation("", [121.4791234, 31.2359876]))
.toBe("GPS:121.479123, 31.235988");
expect(mergeEventLocation(" 演示路口 ", null)).toBe("演示路口");
});
});
@@ -0,0 +1,241 @@
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Input } from "@/shared/ui/input";
import { RotateCcw } from "lucide-react";
import type { Marker, Map as MapLibreMap } from "maplibre-gl";
import { motion, useReducedMotion } from "motion/react";
import {
useEffect,
useId,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type RefObject
} from "react";
import { createPortal } from "react-dom";
import { formatGpsCoordinate, type EventGpsCoordinate } from "./event-location-coordinate";
import { EventLocationPin } from "./event-location-pin";
import { clampLatitude, createEventLocationMarker, normalizeLongitude } from "./event-location-marker";
import type { EventLocationAddressLookup } from "./use-event-location-address";
export type { EventGpsCoordinate } from "./event-location-coordinate";
type DragPreview = {
x: number;
y: number;
mapRect: DOMRect;
};
export function EventLocationPicker({ location, addressLookup, coordinate, disabled = false, mapReady, mapRef, onChange }: {
location: string;
addressLookup: EventLocationAddressLookup;
coordinate: EventGpsCoordinate | null;
disabled?: boolean;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onChange: (coordinate: EventGpsCoordinate | null) => void;
}) {
const locationId = useId();
const hintId = useId();
const reducedMotion = useReducedMotion();
const markerRef = useRef<Marker | null>(null);
const markerMapRef = useRef<MapLibreMap | null>(null);
const pointerIdRef = useRef<number | null>(null);
const dragPreviewRef = useRef<DragPreview | null>(null);
const onChangeRef = useRef(onChange);
const [dragPreview, setDragPreview] = useState<DragPreview | null>(null);
const [markerElement] = useState(() => {
const element = document.createElement("button");
element.type = "button";
element.className = "size-11 cursor-grab touch-none border-0 bg-transparent p-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[dragging=true]:outline-0! active:cursor-grabbing disabled:cursor-default";
return element;
});
const [markerDragging, setMarkerDragging] = useState(false);
useEffect(() => { onChangeRef.current = onChange; }, [onChange]);
useEffect(() => {
const moveDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
if (event.cancelable) event.preventDefault();
const current = dragPreviewRef.current;
if (!current) return;
const next = {
...current,
x: event.clientX,
y: event.clientY
};
dragPreviewRef.current = next;
setDragPreview(next);
};
const finishDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
const map = mapRef.current;
const preview = dragPreviewRef.current;
pointerIdRef.current = null;
dragPreviewRef.current = null;
setDragPreview(null);
if (!map || !preview || !containsPoint(preview.mapRect, event.clientX, event.clientY)) return;
const position = map.unproject([
event.clientX - preview.mapRect.left,
event.clientY - preview.mapRect.top
]);
onChangeRef.current([normalizeLongitude(position.lng), clampLatitude(position.lat)]);
};
const cancelDrag = (event: PointerEvent) => {
if (pointerIdRef.current !== event.pointerId) return;
pointerIdRef.current = null;
dragPreviewRef.current = null;
setDragPreview(null);
};
window.addEventListener("pointermove", moveDrag, { passive: false });
window.addEventListener("pointerup", finishDrag);
window.addEventListener("pointercancel", cancelDrag);
return () => {
window.removeEventListener("pointermove", moveDrag);
window.removeEventListener("pointerup", finishDrag);
window.removeEventListener("pointercancel", cancelDrag);
};
}, [mapRef]);
useEffect(() => {
const map = mapRef.current;
if (!mapReady || !map || !coordinate) {
if (!coordinate) {
markerRef.current?.remove();
markerRef.current = null;
markerMapRef.current = null;
}
return;
}
if (!markerRef.current || markerMapRef.current !== map) {
markerRef.current?.remove();
const marker = createEventLocationMarker({
map, element: markerElement, coordinate, disabled,
onChange: (next) => onChangeRef.current(next),
onDragChange: setMarkerDragging
});
markerRef.current = marker;
markerMapRef.current = map;
} else {
markerRef.current.setLngLat(coordinate);
}
}, [coordinate, disabled, mapReady, mapRef, markerElement]);
useEffect(() => {
markerRef.current?.setDraggable(!disabled);
markerElement.disabled = disabled;
}, [disabled, markerElement]);
useEffect(() => () => {
markerRef.current?.remove();
markerRef.current = null;
markerMapRef.current = null;
}, []);
const beginDrag = (event: ReactPointerEvent<HTMLButtonElement>) => {
const map = mapRef.current;
if (disabled || !mapReady || !map || !event.isPrimary) return;
event.preventDefault();
event.currentTarget.focus({ preventScroll: true });
pointerIdRef.current = event.pointerId;
const mapRect = map.getContainer().getBoundingClientRect();
const preview = {
x: event.clientX,
y: event.clientY,
mapRect
};
dragPreviewRef.current = preview;
setDragPreview(preview);
};
return (
<section aria-label="事件主地图选点" className="mt-5">
<div className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-2 sm:grid-cols-2 sm:gap-x-4">
<div className="min-w-0">
<label htmlFor={locationId} className="block text-xs font-medium text-muted-foreground">事件位置</label>
<Input
id={locationId}
value={location}
onChange={(event) => addressLookup.changeLocation(event.target.value)}
placeholder={addressLookup.pending ? "正在获取地址…" : "选点获取地址,也可手动填写"}
title={location || undefined}
aria-busy={addressLookup.pending}
disabled={disabled}
className="mt-2 h-10 min-w-0"
/>
</div>
<div className="flex h-10 items-center gap-2 self-end">
<button
type="button"
aria-label="拖拽定位图标到主地图"
aria-describedby={hintId}
disabled={disabled || !mapReady}
data-dragging={dragPreview ? "true" : "false"}
className={cn(
"group relative grid size-11 shrink-0 touch-none place-items-center border-0 bg-transparent p-0",
"cursor-grab active:cursor-grabbing focus-visible:rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring data-[dragging=true]:outline-0! disabled:cursor-not-allowed disabled:opacity-45"
)}
onPointerDown={beginDrag}
>
<motion.span
className={cn("block", dragPreview && "opacity-35")}
whileHover={disabled || reducedMotion ? undefined : { rotate: -3 }}
whileTap={disabled || reducedMotion ? undefined : { scale: 0.95 }}
transition={{ duration: 0.15 }}
style={{ transformOrigin: "50% 100%" }}
>
<EventLocationPin />
</motion.span>
</button>
<p id={hintId} className="sr-only text-[11px] leading-4 text-muted-foreground sm:not-sr-only">拖动图钉到主地图选点</p>
</div>
<div className="col-span-2 flex min-h-8 flex-wrap items-center gap-x-2 gap-y-1 sm:col-span-1">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1 text-[11px] leading-4">
<span className="text-muted-foreground">GPS</span>
<output aria-label="GPS 坐标" aria-live="polite" className={cn(
"font-mono tabular-nums",
coordinate ? "text-foreground" : "text-muted-foreground"
)}>
{coordinate ? formatGpsCoordinate(coordinate) : mapReady ? "等待拖到主地图" : "主地图加载中"}
</output>
</div>
{coordinate ? (
<Button type="button" size="icon-sm" variant="ghost" aria-label="清除坐标" title="清除坐标" disabled={disabled} onClick={() => onChange(null)}>
<RotateCcw size={13} aria-hidden="true" />
</Button>
) : null}
</div>
</div>
{createPortal(<EventLocationPin raised={markerDragging} />, markerElement)}
{addressLookup.pending ? <p role="status" aria-label="地址查询状态" className="mt-1 text-xs leading-5 text-muted-foreground">正在查询地址…</p> : null}
{addressLookup.error ? (
<div role="status" aria-label="地址查询状态" className="mt-2 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground">
<p>{addressLookup.error}</p>
<Button type="button" variant="link" size="sm" className="px-0" disabled={disabled} onClick={addressLookup.retry}>重试地址查询</Button>
</div>
) : null}
{dragPreview ? createPortal(
<div
aria-hidden="true"
data-event-location-drag-preview=""
data-event-location-drag-pin=""
className="pointer-events-none fixed z-[80] size-11 -translate-x-1/2 -translate-y-full"
style={{ left: dragPreview.x, top: dragPreview.y }}
>
<EventLocationPin />
</div>,
document.body
) : null}
</section>
);
}
function containsPoint(rect: DOMRect, x: number, y: number) {
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
}
@@ -0,0 +1,63 @@
import { motion, useReducedMotion } from "motion/react";
import { useId } from "react";
// One drawing for the picker, drag preview and map marker. The needle ends at
// the bottom centre of the viewBox, matching MapLibre's bottom anchor exactly.
export function EventLocationPin({ raised = false }: { raised?: boolean }) {
const id = useId();
const reducedMotion = useReducedMotion();
return (
<motion.svg
aria-hidden="true"
data-event-location-pin=""
viewBox="0 0 80 80"
className="pointer-events-none block size-11 overflow-visible"
initial={false}
animate={{ rotate: raised && !reducedMotion ? -4 : 0 }}
transition={{ duration: reducedMotion ? 0 : 0.18, ease: "easeOut" }}
style={{ transformOrigin: "50% 100%" }}
>
<defs>
<linearGradient id={`${id}-cap`} x1="23" y1="8" x2="53" y2="29" gradientUnits="userSpaceOnUse">
<stop stopColor="#e77c7b" />
<stop offset="0.45" stopColor="#d85157" />
<stop offset="1" stopColor="#b93b48" />
</linearGradient>
<linearGradient id={`${id}-body`} x1="30" y1="27" x2="49" y2="31" gradientUnits="userSpaceOnUse">
<stop stopColor="#df6b6d" />
<stop offset="0.4" stopColor="#d05158" />
<stop offset="1" stopColor="#b33b49" />
</linearGradient>
<linearGradient id={`${id}-foot`} x1="23" y1="35" x2="57" y2="51" gradientUnits="userSpaceOnUse">
<stop stopColor="#e47575" />
<stop offset="0.4" stopColor="#d2525b" />
<stop offset="1" stopColor="#b03b49" />
</linearGradient>
<linearGradient id={`${id}-steel`} x1="36" y1="57" x2="43" y2="59" gradientUnits="userSpaceOnUse">
<stop stopColor="#89949e" />
<stop offset="0.45" stopColor="#d9dfe4" />
<stop offset="1" stopColor="#89949e" />
</linearGradient>
<filter id={`${id}-shadow`} x="-40%" y="-30%" width="190%" height="190%">
<feDropShadow dx="1" dy="1" stdDeviation="1" floodColor="#35151b" floodOpacity="0.12" />
</filter>
<radialGradient id={`${id}-contact`}>
<stop stopColor="#36252b" stopOpacity="0.12" />
<stop offset="1" stopColor="#36252b" stopOpacity="0" />
</radialGradient>
</defs>
<ellipse cx="42" cy="78" rx="12" ry="2" fill={`url(#${id}-contact)`} />
<g transform="rotate(16 40 80)">
<path d="M36.5 47H43.5L41.5 72L40 80L38.5 72Z" fill={`url(#${id}-steel)`} />
<g filter={`url(#${id}-shadow)`}>
<path d="M22 42C22 36 30 32 40 32S58 36 58 42V46C58 52 22 52 22 46Z" fill="#aa3a47" />
<ellipse cx="40" cy="42" rx="18" ry="8" fill={`url(#${id}-foot)`} />
<path d="M29 22H51L47 34C47 38 50 39 52 41C48 46 32 46 28 41C30 39 33 38 33 34Z" fill={`url(#${id}-body)`} />
<path d="M22 17C22 7 58 7 58 17V22C58 32 22 32 22 22Z" fill={`url(#${id}-cap)`} />
<ellipse cx="40" cy="16" rx="18" ry="8" fill={`url(#${id}-cap)`} />
</g>
</g>
</motion.svg>
);
}
@@ -926,6 +926,8 @@ export interface components {
feedback_requirements?: string[] | null;
/** Location */
location?: string | null;
/** Occurred At */
occurred_at?: string | null;
/** Priority */
priority?: ("low" | "normal" | "high" | "urgent") | null;
/** Progress Note */
@@ -1,5 +1,6 @@
import { formatLocalMonthDayTime } from "@/shared/utils/date-time";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
CalendarClock,
LoaderCircle,
@@ -25,12 +26,21 @@ import type {
PeripheralSampleClient,
PeripheralWorkOrder
} from "./client";
import {
EventLocationPicker,
type EventGpsCoordinate
} from "./event-location-picker";
import { mergeEventLocation } from "./event-location-coordinate";
import { useEventLocationAddress, type EventLocationAddressResolver } from "./use-event-location-address";
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, onAgentTrigger }: {
export function ManagedTestResources({ client, canWrite, capabilities, refreshGeneration, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
client: PeripheralSampleClient;
canWrite: boolean;
capabilities: PeripheralContext["managed_resources"];
refreshGeneration?: number;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [orders, setOrders] = useState<PeripheralWorkOrder[]>([]);
@@ -89,6 +99,9 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
busy={busy}
mutate={mutate}
client={client}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveAddress}
onAgentTrigger={onAgentTrigger}
/>
)}
@@ -96,12 +109,15 @@ export function ManagedTestResources({ client, canWrite, capabilities, refreshGe
);
}
function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }: {
function WorkOrders({ orders, canWrite, busy, mutate, client, mapReady, mapRef, resolveAddress, onAgentTrigger }: {
orders: PeripheralWorkOrder[];
canWrite: boolean;
busy: string | null;
mutate: (key: string, operation: () => Promise<void>) => Promise<void>;
client: PeripheralSampleClient;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
resolveAddress: EventLocationAddressResolver;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
}) {
const [editorOpen, setEditorOpen] = useState(false);
@@ -116,9 +132,12 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const [scheduledAt, setScheduledAt] = useState(() => toLocalInput(new Date(Date.now() + 60 * 60_000)));
const [feedbackRequirements, setFeedbackRequirements] = useState("");
const [progressNote, setProgressNote] = useState("");
const [feedbackAt, setFeedbackAt] = useState("");
const [eventEditorOpen, setEventEditorOpen] = useState(false);
const [eventKind, setEventKind] = useState<"water_quality_complaint" | "burst_report">("water_quality_complaint");
const [eventLocation, setEventLocation] = useState("");
const [eventCoordinate, setEventCoordinate] = useState<EventGpsCoordinate | null>(null);
const addressLookup = useEventLocationAddress({ active: eventEditorOpen, coordinate: eventCoordinate, resolveAddress, onChange: setEventLocation });
const [eventOccurredAt, setEventOccurredAt] = useState(toLocalInput(new Date()));
const [eventDescription, setEventDescription] = useState("");
const [agentTriggerFailure, setAgentTriggerFailure] = useState<{ message: string; workOrder: PeripheralWorkOrder } | null>(null);
@@ -140,6 +159,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(toLocalInput(new Date(Date.now() + 60 * 60_000)));
setFeedbackRequirements("");
setProgressNote("");
setFeedbackAt("");
};
const closeEditor = () => {
@@ -182,6 +202,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
setScheduledAt(item.scheduled_at ? toLocalInput(new Date(item.scheduled_at)) : "");
setFeedbackRequirements((item.feedback_requirements ?? []).join("\n"));
setProgressNote("");
setFeedbackAt(completedFeedbackTime(item));
};
const triggerAgent = async (workOrder: PeripheralWorkOrder) => {
@@ -199,26 +220,29 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
const createEventWorkOrder = () => void mutate("create-event-order", async () => {
const waterQuality = eventKind === "water_quality_complaint";
const submittedLocation = mergeEventLocation(eventLocation, eventCoordinate);
const workOrder = await client.createWorkOrder({
title: waterQuality ? `水质投诉:${eventLocation.trim()}` : `爆管上报:${eventLocation.trim()}`,
title: waterQuality ? `水质投诉:${submittedLocation}` : `爆管上报:${submittedLocation}`,
summary: eventDescription.trim(),
status: "open",
priority: waterQuality ? "high" : "urgent",
assignee: null,
scheduled_at: new Date(eventOccurredAt).toISOString(),
work_order_type: eventKind,
location: eventLocation.trim(),
location: submittedLocation,
scenario_code: null,
origin: "system_data",
feedback_requirements: []
});
setEventEditorOpen(false);
setEventLocation("");
setEventCoordinate(null);
setEventDescription("");
await triggerAgent(workOrder);
});
const canSave = canWrite && !busy && title.trim() && summary.trim();
const feedbackTimeChanged = feedbackAt !== (editing ? completedFeedbackTime(editing) : "");
const canSave = canWrite && !busy && title.trim() && summary.trim() && (!feedbackTimeChanged || progressNote.trim());
const editorInputClassName = "mt-1 h-9 rounded-lg border-border bg-card";
const editingEvent = editing?.work_order_type !== undefined && editing.work_order_type !== "general";
@@ -251,6 +275,7 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
<label className="mt-3 block text-xs font-medium text-muted-foreground">回执要求(每行一项)<Textarea className="mt-1 min-h-20" value={feedbackRequirements} onChange={(event) => setFeedbackRequirements(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
{editing ? <label className="mt-3 block text-xs font-medium text-muted-foreground">{editingEvent ? "处置反馈(可选)" : "现场反馈(可选)"}<Textarea className="mt-1 min-h-20" value={progressNote} onChange={(event) => setProgressNote(event.target.value)} disabled={!canWrite || Boolean(busy)} placeholder="记录实际处置、复核或回执;留空则只更新工单信息" /></label> : null}
{editing ? <div className="mt-3"><DateTimeField contentClassName="workbench-shadcn-surface" label="现场反馈时间(可选)" value={feedbackAt} onChange={setFeedbackAt} disabled={!canWrite || Boolean(busy)} /><p className="mt-1 text-[11px] text-muted-foreground">选择实际反馈时间并填写现场反馈。工单完工后,Agent 从该时间起复核 SCADA;留空使用当前时间。</p></div> : null}
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canSave} onClick={() => void mutate("save-order", async () => {
@@ -271,7 +296,8 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
scenario_code: scenarioCode.trim() || null,
clear_scenario_code: !scenarioCode.trim(),
feedback_requirements: requirements,
progress_note: progressNote.trim() || null
progress_note: progressNote.trim() || null,
occurred_at: progressNote.trim() && feedbackAt ? new Date(feedbackAt).toISOString() : null
});
} else {
await client.createWorkOrder({
@@ -334,16 +360,16 @@ function WorkOrders({ orders, canWrite, busy, mutate, client, onAgentTrigger }:
</div>
{eventEditorOpen ? (
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3 sm:p-4">
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-4 sm:p-5">
<EditorHeading title="模拟事件上报" onClose={() => setEventEditorOpen(false)} />
<div className="mt-2 grid gap-2 sm:grid-cols-2">
<div className="mt-4 grid gap-4 sm:grid-cols-2 [&>div>label]:mb-2 [&>div>label]:leading-4">
<FieldSelect contentClassName="workbench-shadcn-surface" label="事件类型" value={eventKind} onValueChange={(value) => setEventKind(value as typeof eventKind)} disabled={!canWrite || Boolean(busy)} options={[{ value: "water_quality_complaint", label: "水质投诉" }, { value: "burst_report", label: "爆管上报" }]} />
<DateTimeField contentClassName="workbench-shadcn-surface" label="发生时间" value={eventOccurredAt} onChange={setEventOccurredAt} disabled={!canWrite || Boolean(busy)} />
</div>
<label className="mt-2 block text-xs font-medium text-muted-foreground">事件位置<Input className="mt-1 h-9" value={eventLocation} onChange={(event) => setEventLocation(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<label className="mt-2 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-1 min-h-16" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-3 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || !eventLocation.trim() || !eventDescription.trim()} onClick={createEventWorkOrder}>
<EventLocationPicker location={eventLocation} addressLookup={addressLookup} coordinate={eventCoordinate} onChange={setEventCoordinate} disabled={!canWrite || Boolean(busy)} mapReady={mapReady} mapRef={mapRef} />
<label className="mt-5 block text-xs font-medium text-muted-foreground">现场描述<Textarea className="mt-2 min-h-20" value={eventDescription} onChange={(event) => setEventDescription(event.target.value)} disabled={!canWrite || Boolean(busy)} /></label>
<div className="mt-5 flex gap-2">
<Button size="sm" disabled={!canWrite || Boolean(busy) || addressLookup.pending || (!eventLocation.trim() && !eventCoordinate) || !eventDescription.trim()} onClick={createEventWorkOrder}>
{busy === "create-event-order" ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}创建事件工单
</Button>
<Button size="sm" variant="ghost" onClick={() => setEventEditorOpen(false)} disabled={Boolean(busy)}>取消</Button>
@@ -574,3 +600,9 @@ function orderStatus(value: PeripheralWorkOrder["status"]) { return ({ draft: "
function orderStatusTone(value: PeripheralWorkOrder["status"]): StatusTone { return ({ draft: "neutral", open: "info", in_progress: "warning", awaiting_reply: "warning", completed: "success", cancelled: "neutral" } satisfies Record<typeof value, StatusTone>)[value]; }
function priorityLabel(value: PeripheralWorkOrder["priority"]) { return ({ low: "低", normal: "普通", high: "高", urgent: "紧急" } satisfies Record<typeof value, string>)[value]; }
function workOrderTypeLabel(value: PeripheralWorkOrder["work_order_type"]) { return ({ general: "预约工单", water_quality_complaint: "投诉工单", burst_report: "爆管上报工单" } satisfies Record<typeof value, string>)[value]; }
function completedFeedbackTime(item: PeripheralWorkOrder): string {
if (item.status !== "completed") return "";
const feedback = (item.activity ?? []).filter((entry) => entry.kind === "feedback").at(-1);
return feedback ? toLocalInput(new Date(feedback.occurred_at)) : "";
}
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import type { Map as MapLibreMap } from "maplibre-gl";
import { Activity, ClipboardList, RadioTower, ReceiptText, RefreshCw, X } from "lucide-react";
import type { WorkbenchAccess } from "../api/request-context";
import { reverseGeocodePoint } from "../api/place-search-client";
import type { EventLocationAddressResolver } from "./use-event-location-address";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/ui/cn";
import { Disclosure } from "@/shared/ui/disclosure";
@@ -12,7 +15,6 @@ import { WorkbenchNotice } from "../components/workbench-panel-ui";
import {
PeripheralSampleClient,
type PeripheralContext,
type PeripheralTestDataRecord,
type PeripheralWorkOrder
} from "./client";
@@ -22,14 +24,18 @@ export function PeripheralSamplePanel({
open,
onClose,
getAccessToken,
mapReady,
mapRef,
onAgentTrigger,
onScadaAnomalyTrigger
onOpenInvestigation
}: {
open: boolean;
onClose: () => void;
getAccessToken?: WorkbenchAccess;
mapReady: boolean;
mapRef: RefObject<MapLibreMap | null>;
onAgentTrigger?: (workOrder: PeripheralWorkOrder) => Promise<void>;
onScadaAnomalyTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new PeripheralSampleClient(getAccessToken));
const client = clientRef.current;
@@ -40,6 +46,10 @@ export function PeripheralSamplePanel({
const [refreshGeneration, setRefreshGeneration] = useState(0);
const [scadaRefreshGeneration, setScadaRefreshGeneration] = useState(0);
const [activeSystem, setActiveSystem] = useState<"work-orders" | "scada">("work-orders");
const resolveEventAddress = useCallback<EventLocationAddressResolver>(async ([longitude, latitude], signal) => {
const result = await reverseGeocodePoint(longitude, latitude, context?.project_id ?? null, getAccessToken, signal);
return result.address.trim() || result.name;
}, [context?.project_id, getAccessToken]);
const refresh = useCallback(async () => {
if (!client.configured) return;
@@ -72,7 +82,7 @@ export function PeripheralSamplePanel({
</span>
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold">外围系统</h2>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 最新数据</p>
<p className="text-xs text-muted-foreground">工单协同与 SCADA 时段演示</p>
</div>
<Button
size="icon"
@@ -123,10 +133,14 @@ export function PeripheralSamplePanel({
{context ? (
<div className="space-y-3">
<ManagedTestResources
key={context.project_id}
client={client}
canWrite={context.can_write}
capabilities={context.managed_resources}
refreshGeneration={refreshGeneration}
mapReady={mapReady}
mapRef={mapRef}
resolveAddress={resolveEventAddress}
onAgentTrigger={onAgentTrigger}
/>
<Disclosure
@@ -171,11 +185,7 @@ export function PeripheralSamplePanel({
active={activeSystem === "scada"}
getAccessToken={getAccessToken}
refreshGeneration={scadaRefreshGeneration}
peripheralClient={client}
canSimulate={Boolean(
context?.can_write && context.managed_resources.test_data === "available"
)}
onAgentTrigger={onScadaAnomalyTrigger}
onOpenInvestigation={onOpenInvestigation}
/>
</TabsContent>
</Tabs>
@@ -1,32 +0,0 @@
import { describe, expect, it } from "vitest";
import type { PeripheralTestDataRecord } from "./client";
import { buildScadaAnomalyInvestigation } from "./scada-anomaly-agent-trigger";
describe("SCADA anomaly Agent trigger", () => {
it("keeps the persisted synthetic reading as the external task source", () => {
const record = {
record_id: "50000000-0000-4000-8000-000000000017",
revision: 3,
point_id: "PRESSURE-17",
observed_at: "2026-10-22T02:03:00Z",
value: 0.08,
unit: "MPa",
notes: "[SCADA_ANOMALY] 压力突降"
} as PeripheralTestDataRecord;
expect(buildScadaAnomalyInvestigation(record)).toMatchObject({
kind: "scada_anomaly",
title: "SCADA 异常调查 · PRESSURE-17",
summary: expect.stringContaining("0.08 MPa"),
occurredAt: "2026-10-22T02:03:00Z",
source: {
kind: "external_system",
external_id: record.record_id,
external_revision: 3,
event_kind: "scada_anomaly"
},
triggerKind: "event"
});
});
});
@@ -1,27 +0,0 @@
import type { CreateInvestigationInput } from "../hooks/use-operational-tasks";
import type { PeripheralTestDataRecord } from "./client";
export function buildScadaAnomalyInvestigation(
record: PeripheralTestDataRecord
): CreateInvestigationInput {
const valueLabel = record.value === null
? "无有效读数"
: `${record.value}${record.unit ? ` ${record.unit}` : ""}`;
const anomalyDetail = record.notes.replace("[SCADA_ANOMALY]", "").trim();
return {
kind: "scada_anomaly",
title: `SCADA 异常调查 · ${record.point_id}`,
summary: `${record.point_id} 在 ${record.observed_at} 记录模拟异常值 ${valueLabel}。${anomalyDetail}`,
detail: `由外围 SCADA 合成读数触发,点位 ${record.point_id},读数 ${valueLabel},发生时间 ${record.observed_at}。`,
occurredAt: record.observed_at,
priority: "normal",
source: {
kind: "external_system",
external_id: record.record_id,
external_revision: record.revision,
event_kind: "scada_anomaly"
},
triggerKind: "event"
};
}
@@ -0,0 +1,82 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, expect, it, vi } from "vitest";
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
const preference = vi.hoisted(() => ({ reduced: false }));
vi.mock("motion/react", async (original) => ({
...await original<typeof import("motion/react")>(), useReducedMotion: () => preference.reduced
}));
const result: ScadaMonitoringResult = {
monitoring_id: "70000000-0000-4000-8000-000000000001", period_start: "2026-07-15T23:45:00Z",
period_end: "2026-07-16T00:00:00Z", knowledge_cutoff: "2026-07-16T00:00:00Z", batch_sequence: 2,
rule_version: "burst-monitor-v2", status: "attention", task_id: "80000000-0000-4000-8000-000000000001",
detail: "详细压力与流量依据应保留在报告中", pressure: {}, outlet_flow: {}, burst_detection: {},
weighted: { score: 0.1, threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
incident: { incident_id: "90000000-0000-4000-8000-000000000001", action: "raised", status: "active",
started_at: "2026-07-16T00:00:00Z", last_observed_at: "2026-07-16T00:00:00Z", recovery_count: 0 }
};
afterEach(() => { cleanup(); vi.useRealTimers(); preference.reduced = false; });
it("keeps notifications short and opens the existing investigation", () => {
const onOpen = vi.fn();
render(<ScadaMonitoringNotice result={result} animationId={null} processing={false} onOpenInvestigation={onOpen} />);
expect(screen.getByText("发现指标异常")).toBeVisible();
expect(screen.getByText("已启动调查")).toBeVisible();
expect(screen.queryByText(result.detail)).not.toBeInTheDocument();
expect(screen.queryByLabelText("综合评分")).not.toBeInTheDocument();
expect(screen.getByRole("status", { name: "最新监测结果" })).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "warning");
fireEvent.click(screen.getByRole("button", { name: "查看调查" }));
expect(onOpen).toHaveBeenCalledWith(result.task_id);
});
it("uses danger color only when the monitoring decision supports suspected burst", () => {
render(<ScadaMonitoringNotice result={{ ...result, status: "suspected_burst" }} animationId={null} processing={false} />);
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "danger");
});
it("finishes the one-time animation without restarting for the next period of the same incident", () => {
vi.useFakeTimers();
const { rerender } = render(<ScadaMonitoringNotice result={result} animationId="accident-1" processing={false} />);
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "running");
act(() => vi.advanceTimersByTime(1000));
rerender(<ScadaMonitoringNotice result={{ ...result, incident: { ...result.incident!, action: "ongoing" } }}
animationId="accident-1" processing={false} />);
act(() => vi.advanceTimersByTime(1600));
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByText("沿用原调查")).toBeVisible();
});
it("retains the anomaly message with reduced motion and suppresses animation", () => {
preference.reduced = true;
render(<ScadaMonitoringNotice result={result} animationId="accident-1" processing={false} />);
expect(screen.getByText("发现指标异常")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-animation-state", "idle");
expect(screen.getByRole("status").querySelector("[data-slot=scada-alert-emphasis]")).toBeNull();
});
it("shows a neutral standby state before monitoring starts", () => {
render(<ScadaMonitoringNotice result={null} animationId={null} processing={false} />);
expect(screen.getByText("监测待机")).toBeVisible();
expect(screen.getByText("等待播放或单步监测")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "neutral");
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
});
it("shows pending progress while waiting for the first monitoring result", () => {
render(<ScadaMonitoringNotice result={null} animationId={null} processing />);
expect(screen.getByText("正在监测")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("aria-busy", "true");
expect(screen.queryByText("监测待机")).not.toBeInTheDocument();
});
it("uses success styling for a normal result and keeps its timestamp during the next check", () => {
render(<ScadaMonitoringNotice result={{ ...result, status: "normal", task_id: null, incident: null }} animationId={null} processing />);
expect(screen.getByText("监测正常")).toBeVisible();
expect(screen.getByText("本周期未发现异常")).toBeVisible();
expect(screen.getByText("正在监测下一周期")).toBeVisible();
expect(screen.getByRole("status")).toHaveAttribute("data-status-tone", "success");
expect(screen.getByText(/07\/16/)).toHaveAttribute("dateTime", result.period_end);
expect(screen.queryByRole("button", { name: "查看调查" })).not.toBeInTheDocument();
});
@@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import { ArrowUpRight, CheckCircle2, CircleHelp, Clock3, LoaderCircle, TriangleAlert } from "lucide-react";
import { cn } from "@/shared/ui/cn";
import { StatusIcon, type StatusTone } from "@/shared/ui/status";
import { Button } from "@/shared/ui/button";
import type { ScadaMonitoringResult } from "../api/scada-replay-client";
export function ScadaMonitoringNotice({ result, animationId, processing, onOpenInvestigation }: {
result: ScadaMonitoringResult | null | undefined;
animationId: string | null;
processing: boolean;
onOpenInvestigation?: (taskId: string) => void;
}) {
const reduced = useReducedMotion();
const [animating, setAnimating] = useState(false);
useEffect(() => {
if (!animationId || reduced) return;
setAnimating(true);
const timer = window.setTimeout(() => setAnimating(false), 2600);
return () => window.clearTimeout(timer);
}, [animationId, reduced]);
const recovered = result?.incident?.action === "resolved";
const ongoing = result?.incident?.action === "ongoing";
const abnormal = Boolean(result && !recovered && (result.task_id || ["attention", "suspected_burst"].includes(result.status)));
const suspectedBurst = abnormal && result?.status === "suspected_burst";
const normal = result?.status === "normal" || recovered;
const tone: StatusTone = suspectedBurst ? "danger" : abnormal ? "warning" : normal ? "success" : processing ? "info" : "neutral";
const Icon = !result ? processing ? LoaderCircle : Clock3 : abnormal ? TriangleAlert : normal ? CheckCircle2 : CircleHelp;
const title = !result ? processing ? "正在监测" : "监测待机"
: recovered ? "异常已恢复" : ongoing ? "异常持续跟踪" : suspectedBurst ? "发现疑似爆管" : abnormal ? "发现指标异常"
: normal ? "监测正常" : "监测数据不足";
const note = !result ? processing ? "正在核对本周期数据" : "等待播放或单步监测"
: result.task_id ? recovered ? "告警已解除" : ongoing ? "沿用原调查" : "已启动调查"
: normal ? "本周期未发现异常" : abnormal ? "请留意后续监测结果" : "暂无法形成监测结论";
const hint = processing ? result ? "正在监测下一周期" : "处理完成后更新结果"
: abnormal ? "诊断详情见调查报告" : recovered ? "可回看本次调查"
: result && !normal ? "待后续周期继续监测" : "异常时自动启动调查";
const emphasis = animating && abnormal && !reduced;
return (
<section aria-label="最新监测结果" role="status" aria-busy={processing}
aria-atomic="true"
data-animation-state={emphasis ? "running" : "idle"} data-announcement-id={animationId ?? undefined}
data-status-tone={tone}
className={cn(
"relative isolate grid h-40 shrink-0 grid-rows-[1.25rem_1fr_2.25rem] gap-2 overflow-hidden rounded-lg bg-[var(--status-soft)] p-3.5 text-foreground ring-1 ring-inset ring-[var(--status-border)]",
`status-tone-${tone}`
)}>
{emphasis ? <motion.div key={`${animationId}-surface`} aria-hidden="true"
data-slot="scada-alert-emphasis" className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
initial={{ opacity: 0 }} animate={{ opacity: [0, 0.55, 0] }}
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
<div data-slot="scada-notice-header" className="flex min-w-0 items-center justify-between gap-2 text-[11px] leading-5 text-foreground/75">
<span>调查监测</span>
{result ? <time dateTime={result.period_end} className="whitespace-nowrap tabular-nums">
<span className="sr-only">最新监测 </span>
{new Date(result.period_end).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false })}
</time> : <span>尚无监测结果</span>}
</div>
<div data-slot="scada-notice-content" className="flex min-w-0 items-center gap-3">
<span className="relative shrink-0" aria-hidden="true">
{emphasis ? <motion.span key={animationId} data-slot="scada-alert-pulse"
className="pointer-events-none absolute inset-0 rounded-lg border border-[var(--status-mark)]"
initial={{ opacity: 0, scale: 0.8 }} animate={{ opacity: [0, 0.65, 0], scale: [0.8, 1.35, 1.65] }}
transition={{ duration: 1.2, repeat: 1, repeatDelay: 0.2, ease: "easeOut" }} /> : null}
<StatusIcon tone={tone} size="md" className="size-9 bg-background/70"
activity={!result && processing ? "loading" : "static"}>
<motion.span key={`${animationId}-icon`} animate={emphasis ? { rotate: [0, -12, 12, -6, 0] } : { rotate: 0 }}
transition={{ duration: 0.5 }}><Icon size={18} /></motion.span>
</StatusIcon>
</span>
<div data-slot="scada-notice-summary" className="min-w-0 flex-1 space-y-0.5 text-xs leading-5">
<p className="font-semibold">{title}</p>
<p className="text-foreground/75">{note}</p>
</div>
</div>
<div data-slot="scada-notice-footer" className="flex min-w-0 items-center justify-between gap-2 border-t border-[var(--status-border)] pt-1.5">
<p className="text-[11px] leading-4 text-foreground/75">{hint}</p>
<div data-slot="scada-notice-action" className="flex h-8 w-[5.5rem] shrink-0 justify-end">
{result?.task_id && onOpenInvestigation ? <Button size="sm" variant="ghost" className="h-8 gap-1 px-2 text-xs"
onClick={() => onOpenInvestigation(result.task_id!)}>
查看调查<ArrowUpRight size={12} aria-hidden="true" />
</Button> : null}
</div>
</div>
</section>
);
}
@@ -0,0 +1,74 @@
import type { ScadaMonitoringCalibration, ScadaMonitoringConfig } from "../api/scada-replay-client";
export function ScadaMonitoringRules({ config, calibration, frozen }: {
config: ScadaMonitoringConfig;
calibration?: ScadaMonitoringCalibration | null;
frozen: boolean;
}) {
const weighted = config.decision_policy === "weighted";
const currentPolicy = calibration?.version === "scada-monitor-calibration@2"
&& calibration.diagnostic_policy === "normal-phase-envelope@1";
const usable = currentPolicy ? calibration : null;
const ready = usable?.status === "ready";
const indicators = [
{ key: "burst_detection", label: "爆管侦测", threshold: ready && usable.burst_detection?.threshold != null
? `得分 ≤ ${usable.burst_detection.threshold.toFixed(4)}` : "由前 15 天正常历史计算" },
{ key: "pressure", label: "压力下降", threshold: thresholdLabel(config.pressure_device_ids, usable?.pressure, "下降") },
{ key: "outlet_flow", label: "出厂流量", threshold: config.outlet_flow_device_ids.length
? thresholdLabel(config.outlet_flow_device_ids, usable?.outlet_flow, "增加") : "未配置,贡献为 0" }
] as const;
return (
<section aria-label="组合监测规则" className="space-y-3 text-xs leading-5">
<div className="space-y-1">
<h4 className="font-semibold text-foreground">SCADA 异常触发规则</h4>
<p className="text-muted-foreground">压力、流量越过历史同期正常范围,或水质越过已配置上下限,即报警并启动调查。</p>
<ol aria-label="诊断流程" className="flex flex-wrap gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<li>数据诊断</li><li aria-hidden="true">→</li><li>指标异常</li><li aria-hidden="true">→</li><li>爆管诊断</li><li aria-hidden="true">→</li><li>报告</li>
</ol>
</div>
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
<h4 className="font-semibold text-foreground">{weighted ? "爆管诊断 · 三指标加权" : "爆管诊断 · 三指标联合判定"}</h4>
<span className="text-[11px] text-muted-foreground">{frozen ? "本轮已固定" : "下一轮使用项目配置"}</span>
</div>
{weighted ? (
<>
<dl className="divide-y divide-border">
{indicators.map(({ key, label, threshold }) => (
<div key={key} className="grid grid-cols-[1fr_auto] gap-x-3 py-2 first:pt-0">
<dt className="text-foreground">{label}</dt>
<dd className="font-medium text-foreground tabular-nums">权重 {(config.weights[key] * 100).toFixed(0)}%</dd>
<dd className="col-span-2 text-[11px] text-muted-foreground tabular-nums">{threshold}</dd>
</div>
))}
</dl>
<p className="font-medium text-foreground tabular-nums">综合分 ≥ {config.alert_threshold.toFixed(2)} 时标记疑似爆管</p>
<p className="text-[11px] text-muted-foreground">加权分用于评估爆管嫌疑,任一指标异常即启动调查。</p>
<p className="text-[11px] text-muted-foreground">压力下降与出厂流量增加需同时越过动态变化阈值和历史同期正常范围。正常日的日内波动由同期范围校验。</p>
<p className="text-[11px] text-muted-foreground">指标命中后计入对应权重;流量缺数按 0 计,不重新分配权重。压力或侦测数据不足时暂不作出组合判定。</p>
<div className="space-y-1 text-[11px] text-muted-foreground">
<p className="font-medium text-foreground">动态阈值 · {ready ? "本轮已冻结" : "等待历史基线"}</p>
{ready ? <p className="tabular-nums">历史区间 {dateTime(usable.history_start)} 至 {dateTime(usable.knowledge_cutoff)}(不含结束时刻)</p> : <p>{calibration && !currentPolicy ? "旧版校准已停用,请重新开始本轮。" : calibration ? "历史基线不足,动态阈值尚未就绪。" : "开始监测后,以演示开始前 15 天的正常历史逐测点计算阈值,并在本轮固定。"}</p>}
</div>
</>
) : (
<p className="text-muted-foreground">本轮使用三指标联合评估爆管嫌疑;任一经历史基线校验的指标异常即启动调查。</p>
)}
<p className="text-[11px] text-muted-foreground">同一异常只报警并建单一次,持续异常沿用原工单;连续两个正常周期且相关测点恢复后解除告警。</p>
<p className="text-[11px] text-muted-foreground tabular-nums">压力 {config.pressure_device_ids.length} 个测点 · 出厂流量 {config.outlet_flow_device_ids.length} 个测点 · {config.rule_version}</p>
</section>
);
}
function thresholdLabel(ids: string[], profiles: ScadaMonitoringCalibration["pressure"] | undefined, direction: string) {
const thresholds = ids.flatMap((id) => profiles?.[id] ? [profiles[id].threshold_percent] : []);
if (!thresholds.length) return "逐测点动态计算变化阈值";
const lower = Math.min(...thresholds).toFixed(2);
const upper = Math.max(...thresholds).toFixed(2);
const range = lower === upper ? `${direction}阈值 ${lower}%` : `${direction}阈值 ${lower}%~${upper}%(逐测点)`;
return thresholds.length < ids.length ? `${range} · 已就绪 ${thresholds.length}/${ids.length} 个测点` : range;
}
function dateTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
@@ -0,0 +1,37 @@
import { Activity, Clock3, Gauge, Timer } from "lucide-react";
import { StatusBadge } from "@/shared/ui/status";
import { SCADA_PLAYBACK_SPEED } from "./use-scada-playback";
import { formatMonitoringInterval } from "./scada-replay-time";
export function ScadaMonitoringStatus({ status, playing, processing, intervalSeconds, count, progress }: {
status: string;
playing: boolean;
processing: boolean;
intervalSeconds?: number | null;
count: number;
progress: number;
}) {
return <section aria-label="SCADA 演示状态" className="space-y-3 rounded-lg bg-muted/60 p-3.5">
<div className="flex flex-wrap items-center justify-between gap-2">
<StatusBadge tone={playing || processing ? "info" : "neutral"} icon={<Activity size={13} />}>
{status}
</StatusBadge>
<span className="text-xs text-muted-foreground tabular-nums">已监测 <strong className="font-medium text-foreground">{count}</strong> 个周期</span>
</div>
<dl className="grid grid-cols-3 gap-2 text-xs">
{[
{ label: "监测周期", value: intervalSeconds ? formatMonitoringInterval(intervalSeconds) : "未配置", Icon: Clock3 },
{ label: "演示倍速", value: `${SCADA_PLAYBACK_SPEED}×`, Icon: Gauge },
{ label: "每步播放", value: intervalSeconds ? `约 ${Number((intervalSeconds / SCADA_PLAYBACK_SPEED).toFixed(1))} 秒` : "待配置", Icon: Timer }
].map(({ label, value, Icon }) => <div key={label} className="min-w-0 space-y-1">
<dt className="flex flex-wrap items-center gap-1 text-[11px] leading-5 text-muted-foreground"><Icon size={12} aria-hidden="true" />{label}</dt>
<dd className="font-semibold leading-6 tabular-nums text-foreground">{value}</dd>
</div>)}
</dl>
<div role="progressbar" aria-label="演示区间进度" aria-valuemin={0} aria-valuemax={100}
aria-valuenow={Math.round(progress * 100)} className="h-1 overflow-hidden rounded-full bg-border">
<div className="h-full origin-left bg-primary" style={{ transform: `scaleX(${progress})` }} />
</div>
<p className="text-[11px] leading-5 text-muted-foreground">每步另需后台处理时间;关闭面板或切到后台会暂停播放。</p>
</section>;
}
@@ -0,0 +1,18 @@
import type { ScadaReplay } from "../api/scada-replay-client";
/** Keep projection identity when polling only advances the replay clock. */
export function reuseScadaReadings(previous: ScadaReplay["latest"] | null | undefined, next: ScadaReplay["latest"]): ScadaReplay["latest"] {
if (previous?.length !== next.length) return next;
const unchanged = next.every((row, index) => {
const old = previous[index];
return old.device_id === row.device_id && old.time === row.time && old.quality === row.quality
&& old.reason === row.reason && Object.is(old.monitored_value, row.monitored_value)
&& Object.is(old.cleaned_value, row.cleaned_value);
});
return unchanged ? previous : next;
}
export function reuseReplayProjection(previous: ScadaReplay | null, next: ScadaReplay | null): ScadaReplay | null {
if (!next || previous?.replay_id !== next.replay_id) return next;
return { ...next, latest: reuseScadaReadings(previous.latest, next.latest) };
}
@@ -0,0 +1,402 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { LatestScadaReadings } from "../api/scada-client";
import type { ScadaMonitoringConfig, ScadaReplay } from "../api/scada-replay-client";
import { reuseReplayProjection, reuseScadaReadings } from "./scada-replay-readings";
import { ScadaReplaySection } from "./scada-replay-section";
const useReplay = vi.hoisted(() => vi.fn());
vi.mock("./use-scada-replay", () => ({ useScadaReplay: useReplay }));
vi.mock("./scada-time-tuner", () => ({
ScadaTimeTuner: ({ value, step, onChange }: { value: number; step: number; onChange: (time: number) => void }) => (
<input
aria-label="回看位置测试输入"
type="number"
data-step-ms={step}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
/>
)
}));
const catalog: LatestScadaReadings = {
items: [{
device_id: "P1",
device_type: "pressure",
measurement_unit: "MPa",
transmission_mode: "realtime",
transmission_frequency: "15min",
observed_at: null,
monitored_value: null,
cleaned_value: null
}],
total: 1,
limit: 500,
offset: 0,
observed_count: 0,
latest_observed_at: "2026-10-22T01:00:00Z"
};
const projectRules: ScadaMonitoringConfig = {
rule_version: "burst-monitor-v2", decision_policy: "weighted",
pressure_device_ids: ["P1"], outlet_flow_device_ids: ["F1"],
weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 }, alert_threshold: 0.8
};
function replay(): ScadaReplay {
return {
replay_id: "60000000-0000-4000-8000-000000000001",
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 1,
status: "paused",
start_time: "2026-10-22T00:00:00Z",
end_time: "2026-10-22T02:00:00Z",
virtual_now: "2026-10-22T01:00:00Z",
delivered_until: "2026-10-22T01:00:00Z",
processed_until: "2026-10-22T01:00:00Z",
processed_count: 1,
anomaly_count: 0,
devices: catalog.items,
rules: [],
monitoring_interval_seconds: 900,
step_pending: false,
monitoring_count: 0,
last_monitoring: null,
latest: [{
time: "2026-10-22T00:15:00Z",
device_id: "P1",
monitored_value: 0,
cleaned_value: 0,
quality: "valid",
reason: "可信零读数"
}],
active_events: [],
error: null,
created_at: "2026-10-22T00:00:00Z",
updated_at: "2026-10-22T01:00:00Z"
};
}
describe("SCADA replay panel", () => {
let current: ScadaReplay;
let command: ReturnType<typeof vi.fn>;
let observations: ReturnType<typeof vi.fn>;
beforeEach(() => {
current = replay();
command = vi.fn();
observations = vi.fn();
useReplay.mockImplementation(() => ({
replay: current,
capabilities: {
available: true,
can_write: true,
monitoring_interval_seconds: 300,
burst_monitoring_ready: true,
burst_monitoring_reason: null
},
busy: false,
error: null,
events: [],
client: { observations },
command,
change: vi.fn()
}));
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
it("changes only the timeline position while browsing", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), {
target: { value: Date.parse("2026-10-22T00:45:00Z") }
});
expect(screen.getByText("已定位到历史监测时间")).toBeVisible();
expect(observations).not.toHaveBeenCalled();
expect(command).not.toHaveBeenCalled();
});
it("returns the pointer to the range beginning without resetting backend progress", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: Date.parse("2026-10-22T01:15:00Z") } });
fireEvent.click(screen.getByRole("button", { name: "回到开始时间" }));
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse(current.start_time));
expect(screen.getByRole("button", { name: "回到开始时间" })).toBeDisabled();
expect(current.virtual_now).toBe("2026-10-22T01:00:00Z");
expect(command).not.toHaveBeenCalled();
});
it("monitors the pointer's exact selected time without sending a next-step command", async () => {
const target = Date.parse("2026-10-22T01:05:00Z");
const backend = useReplay();
const monitorAt = vi.fn(async () => ({ ...current, virtual_now: new Date(target).toISOString() }));
useReplay.mockImplementation(() => ({ ...backend, replay: current, client: { ...backend.client, monitorAt },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: target } });
fireEvent.click(screen.getByRole("button", { name: "监测选定时刻" }));
await waitFor(() => expect(monitorAt).toHaveBeenCalledWith(expect.objectContaining({ replay_id: current.replay_id }), new Date(target).toISOString()));
expect(command).not.toHaveBeenCalled();
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(target);
});
it("prepares a separate causal round before monitoring a historical selection", async () => {
const original = current;
const target = Date.parse("2026-10-22T00:15:00Z");
const backend = useReplay();
const created = { ...original, replay_id: "60000000-0000-4000-8000-000000000002", status: "ready", virtual_now: original.start_time } as ScadaReplay;
const stop = vi.fn(async () => ({ ...original, status: "stopped" }));
const create = vi.fn(async () => created);
const monitorAt = vi.fn(async () => ({ ...created, status: "paused", virtual_now: new Date(target).toISOString() } as ScadaReplay));
useReplay.mockImplementation(() => ({ ...backend, replay: current, client: { ...backend.client, command: stop, create, monitorAt },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.change(screen.getByLabelText("回看位置测试输入"), { target: { value: target } });
expect(screen.getByText("监测此时刻将建立新一轮回放,原有调查记录保留。")).toBeVisible();
fireEvent.click(screen.getByRole("button", { name: "监测选定时刻" }));
await waitFor(() => expect(monitorAt).toHaveBeenCalledWith(created, new Date(target).toISOString()));
expect(stop).toHaveBeenCalledWith(original, "stop");
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]);
expect(create.mock.invocationCallOrder[0]).toBeLessThan(monitorAt.mock.invocationCallOrder[0]);
expect(original.virtual_now).toBe("2026-10-22T01:00:00Z");
expect(command).not.toHaveBeenCalled();
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(target));
});
it("retains new evidence and separates otherwise identical replay runs", () => {
const incoming = structuredClone(current.latest);
expect(reuseScadaReadings(current.latest, incoming)).toBe(current.latest);
const changed = [{ ...incoming[0], reason: "仪表量程待核验" }];
expect(reuseScadaReadings(current.latest, changed)).toBe(changed);
const other = {
...current,
replay_id: "60000000-0000-4000-8000-000000000002",
latest: incoming
};
expect(reuseReplayProjection(current, other)?.latest).toBe(incoming);
});
it("keeps five-minute timeline timestamps separate from the monitoring period", () => {
current.step_pending = true;
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByLabelText("回看位置测试输入")).toHaveAttribute("data-step-ms", "300000");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("15 分钟");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("180×");
expect(screen.getByLabelText("SCADA 演示状态")).toHaveTextContent("5 秒");
expect(screen.getByRole("button", { name: "正在监测本步" })).toBeDisabled();
});
it("actively triggers the next monitoring period", () => {
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "下一步并监测" }));
expect(command).toHaveBeenCalledWith("step");
});
it("shows project weighted rules before starting without claiming learned thresholds", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend, replay: null,
capabilities: { ...backend.capabilities, monitoring_config: projectRules } });
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("权重 50%")).toBeVisible();
expect(screen.getByText("权重 40%")).toBeVisible();
expect(screen.getByText("权重 10%")).toBeVisible();
expect(screen.getByText("综合分 ≥ 0.80 时标记疑似爆管")).toBeVisible();
expect(screen.getByText("动态阈值 · 等待历史基线")).toBeVisible();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("shows the active replay frozen rules and learned thresholds instead of current project settings", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend,
capabilities: { ...backend.capabilities, monitoring_config: { ...projectRules, alert_threshold: 0.7 } } });
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
status: "ready", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: { P1: { threshold_percent: 6.5 } }, outlet_flow: { F1: { threshold_percent: 8.5 } },
burst_detection: { threshold: -0.0843 }
};
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("本轮已固定")).toBeVisible();
expect(screen.getByText("综合分 ≥ 0.80 时标记疑似爆管")).toBeVisible();
expect(screen.getByText("得分 ≤ -0.0843")).toBeVisible();
expect(screen.getByText("下降阈值 6.50%")).toBeVisible();
expect(screen.getByText("增加阈值 8.50%")).toBeVisible();
expect(screen.queryByText("变化阈值 30%")).not.toBeInTheDocument();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("identifies an explicitly configured legacy combined policy", () => {
current.monitoring_config = { ...projectRules, decision_policy: "all_signals" };
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("爆管诊断 · 三指标联合判定", { selector: "h4" })).toBeVisible();
expect(screen.queryByText("权重 50%")).not.toBeInTheDocument();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
expect(screen.queryByLabelText("压力流量变化阈值")).not.toBeInTheDocument();
});
it("defaults a new demonstration to one hour around the Case3 event", () => {
const backend = useReplay();
useReplay.mockReturnValue({ ...backend, replay: null });
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse("2026-07-16T07:30:00+08:00"));
expect(screen.getByText("事件前后各 30 分钟")).toBeVisible();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
});
it("stops an old replay before switching its display to the Case3 hour", async () => {
const backend = useReplay();
const stop = vi.fn(async () => {
current = { ...current, status: "stopped" };
return current;
});
useReplay.mockImplementation(() => ({ ...backend, replay: current,
client: { ...backend.client, command: stop }, change: (operation: () => Promise<ScadaReplay>) => operation() }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "切换到 Case3" }));
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse("2026-07-16T07:30:00+08:00")));
expect(stop).toHaveBeenCalledWith(expect.objectContaining({ status: "paused" }), "stop");
expect(screen.getByText("事件前后各 30 分钟")).toBeVisible();
expect(screen.queryByText("采样范围与基础规则")).not.toBeInTheDocument();
});
it("retains learned flow thresholds while pressure baseline is still incomplete", () => {
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@2", diagnostic_policy: "normal-phase-envelope@1",
status: "insufficient_data", history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: {}, outlet_flow: { F1: { threshold_percent: 8.5 } }
};
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("增加阈值 8.50%")).toBeVisible();
expect(screen.getByText("动态阈值 · 等待历史基线")).toBeVisible();
expect(screen.getByText("加权分用于评估爆管嫌疑,任一指标异常即启动调查。")).toBeVisible();
});
it("requires rebuilding a warmed replay with obsolete calibration", () => {
current.monitoring_config = projectRules;
current.monitoring_calibration = {
version: "scada-monitor-calibration@1", status: "ready",
history_start: "2026-10-07T00:00:00Z", knowledge_cutoff: current.start_time,
pressure: { P1: { threshold_percent: 30 } }, outlet_flow: {}
};
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("本轮使用旧版诊断")).toBeVisible();
expect(screen.getByRole("button", { name: "播放" })).toBeDisabled();
expect(screen.getByRole("button", { name: "下一步并监测" })).toBeDisabled();
fireEvent.click(screen.getByText("检测规则"));
expect(screen.getByText("旧版校准已停用,请重新开始本轮。")).toBeVisible();
expect(screen.queryByText("下降阈值 30.00%")).not.toBeInTheDocument();
});
it("shows the persisted monitoring decision and linked burst investigation", () => {
current.last_monitoring = {
monitoring_id: "70000000-0000-4000-8000-000000000001",
period_start: "2026-10-22T00:45:00Z",
period_end: "2026-10-22T01:00:00Z",
knowledge_cutoff: "2026-10-22T01:00:00Z",
batch_sequence: 9,
rule_version: "burst-monitor-v1",
status: "suspected_burst",
detail: "爆管侦测、压力下降与出厂边界流量增加相互支持",
task_id: "80000000-0000-4000-8000-000000000001",
pressure: {},
outlet_flow: {},
burst_detection: {}
};
current.monitoring_count = 1;
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("发现疑似爆管")).toBeVisible();
expect(screen.queryByText("爆管侦测、压力下降与出厂边界流量增加相互支持")).not.toBeInTheDocument();
expect(screen.getByText("已启动调查")).toBeVisible();
});
it("shows the continuing accident and weighted evidence without offering another investigation", () => {
current.last_monitoring = {
monitoring_id: "70000000-0000-4000-8000-000000000001",
period_start: "2026-10-22T00:45:00Z", period_end: "2026-10-22T01:00:00Z",
knowledge_cutoff: "2026-10-22T01:00:00Z", batch_sequence: 9, rule_version: "burst-monitor-v2",
status: "insufficient_data", detail: "本周期数据不足,继续跟踪原事故",
task_id: "80000000-0000-4000-8000-000000000001",
pressure: { status: "insufficient_data", hit: false }, outlet_flow: { status: "ready", hit: false },
burst_detection: { status: "insufficient_data", hit: false },
weighted: { score: 0, threshold: 0.8, weights: { burst_detection: 0.5, pressure: 0.4, outlet_flow: 0.1 } },
incident: { incident_id: "90000000-0000-4000-8000-000000000001", status: "active", action: "ongoing",
started_at: "2026-10-22T00:30:00Z", last_observed_at: "2026-10-22T01:00:00Z", recovery_count: 0 }
};
render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByText("异常持续跟踪")).toBeVisible();
expect(screen.getByText("沿用原调查")).toBeVisible();
expect(screen.queryByLabelText("综合评分")).not.toBeInTheDocument();
expect(screen.queryByText("判定依据")).not.toBeInTheDocument();
expect(command).not.toHaveBeenCalled();
});
it("separates play and pause controls and keeps the play label after pausing", () => {
render(<ScadaReplaySection active catalog={catalog} />);
const play = screen.getByRole("button", { name: "播放" });
const pause = screen.getByRole("button", { name: "暂停" });
expect(play).toBeEnabled();
expect(pause).toBeDisabled();
fireEvent.click(play);
expect(play).toBeDisabled();
expect(pause).toBeEnabled();
fireEvent.click(pause);
expect(play).toBeEnabled();
expect(pause).toBeDisabled();
expect(command).not.toHaveBeenCalled();
});
it("stops the previous round and prepares a fresh paused round without stepping it", async () => {
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
const original = current;
const backend = useReplay();
const stop = vi.fn(async () => ({ ...original, status: "stopped" }));
const create = vi.fn(async () => ({ ...original, replay_id: "60000000-0000-4000-8000-000000000002",
status: "ready", virtual_now: original.start_time, monitoring_count: 0, processed_count: 0, last_monitoring: null } as ScadaReplay));
useReplay.mockImplementation(() => ({ ...backend, replay: current,
client: { ...backend.client, command: stop, create },
change: async (operation: () => Promise<ScadaReplay>) => { current = await operation(); return current; } }));
render(<ScadaReplaySection active catalog={catalog} />);
fireEvent.click(screen.getByRole("button", { name: "重新开始" }));
await waitFor(() => expect(screen.getByLabelText("回看位置测试输入")).toHaveValue(Date.parse(original.start_time)));
expect(stop).toHaveBeenCalledWith(original, "stop");
expect(stop.mock.invocationCallOrder[0]).toBeLessThan(create.mock.invocationCallOrder[0]);
expect(create).toHaveBeenCalledWith(expect.objectContaining({ start_time: new Date(original.start_time).toISOString(), end_time: new Date(original.end_time).toISOString(), device_ids: ["P1"] }));
expect(command).not.toHaveBeenCalled();
expect(screen.getByRole("button", { name: "播放" })).toBeEnabled();
expect(screen.getByRole("button", { name: "暂停" })).toBeDisabled();
expect(screen.getByRole("status", { name: "最新监测结果" })).toHaveTextContent("监测待机");
});
it("requires an explicit restart after completion and blocks restart during pending processing", () => {
current.rules = [{ rule_id: "p", device_ids: ["P1"], kind: "relative_change", threshold: 30, unit: "MPa" }];
const backend = useReplay();
current = { ...current, status: "completed", start_time: "2026-07-15T23:30:00Z", end_time: "2026-07-16T00:30:00Z", virtual_now: "2026-07-16T00:30:00Z" };
useReplay.mockImplementation(() => ({ ...backend, replay: current }));
const { rerender } = render(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByRole("button", { name: "播放" })).toBeDisabled();
expect(screen.getByRole("button", { name: "暂停" })).toBeDisabled();
expect(screen.getByRole("button", { name: "重新开始" })).toBeEnabled();
current = { ...current, status: "paused", step_pending: true };
rerender(<ScadaReplaySection active catalog={catalog} />);
expect(screen.getByRole("button", { name: "重新开始" })).toBeDisabled();
});
});
@@ -0,0 +1,446 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
Activity,
ChevronDown,
RotateCcw,
Square,
Pause,
Play
} from "lucide-react";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import type { LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context";
import type { ScadaReplay, ScadaReplayRule } from "../api/scada-replay-client";
import { WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { snapTimelineTime } from "./scada-replay-time";
import { ScadaTimeTuner } from "./scada-time-tuner";
import { ScadaMonitoringRules } from "./scada-monitoring-rules";
import { useScadaReplay } from "./use-scada-replay";
import { useScadaPlayback } from "./use-scada-playback";
import { ScadaMonitoringNotice } from "./scada-monitoring-notice";
import { ScadaMonitoringStatus } from "./scada-monitoring-status";
const ACTIVE = new Set(["ready", "paused"]);
const TIMELINE_STEP_MS = 5 * 60_000;
const floorToTimelineStep = (time: number) => Math.floor(time / TIMELINE_STEP_MS) * TIMELINE_STEP_MS;
const CASE3_START = Date.parse("2026-07-16T07:30:00+08:00");
const CASE3_END = Date.parse("2026-07-16T08:30:00+08:00");
const STATUS: Record<ScadaReplay["status"], string> = {
ready: "待监测",
paused: "等待下一步",
completed: "区间已完成",
stopped: "本轮已停止",
failed: "处理失败"
};
export function ScadaReplaySection({
active,
catalog,
getAccessToken,
onOpenInvestigation
}: {
active: boolean;
catalog: LatestScadaReadings;
getAccessToken?: WorkbenchAccess;
onOpenInvestigation?: (taskId: string) => void;
}) {
const backend = useScadaReplay(active, getAccessToken);
const { replay, capabilities, busy } = backend;
const [start, setStart] = useState(() => localTime(CASE3_START));
const [end, setEnd] = useState(() => localTime(CASE3_END));
const [threshold, setThreshold] = useState("30");
const [qualityMetric, setQualityMetric] = useState("");
const [qualityLower, setQualityLower] = useState("");
const [qualityUpper, setQualityUpper] = useState("");
const [selectedTypes, setSelectedTypes] = useState(() => new Set(
catalog.items.map((item) => item.device_type)
));
const [view, setView] = useState<number | null>(null);
const restoredId = useRef<string | undefined>(undefined);
const announced = useRef(new Set<string>());
const [animationId, setAnimationId] = useState<string | null>(null);
const [monitoringSelection, setMonitoringSelection] = useState(false);
const locked = Boolean(replay && ACTIVE.has(replay.status));
const rulesFrozen = Boolean(replay && ["ready", "paused", "failed"].includes(replay.status));
const monitoringConfig = rulesFrozen ? replay?.monitoring_config : capabilities?.monitoring_config;
const obsoleteCalibration = Boolean(rulesFrozen && replay?.monitoring_config?.decision_policy === "weighted"
&& (replay.processed_count > 0 || replay.monitoring_calibration)
&& (replay.monitoring_calibration?.version !== "scada-monitor-calibration@2"
|| replay.monitoring_calibration?.diagnostic_policy !== "normal-phase-envelope@1"));
const interval = locked && replay
? replay.monitoring_interval_seconds
: capabilities?.monitoring_interval_seconds;
const startMs = locked && replay ? Date.parse(replay.start_time) : Date.parse(start);
const endMs = locked && replay ? Date.parse(replay.end_time) : Date.parse(end);
const matchingReplay = replay && Date.parse(replay.start_time) === startMs && Date.parse(replay.end_time) === endMs
? replay : null;
const case3Range = startMs === CASE3_START && endMs === CASE3_END;
const now = matchingReplay ? Date.parse(matchingReplay.virtual_now) : startMs;
const processedDisplayTime = replay?.processed_until
? floorToTimelineStep(Date.parse(replay.processed_until))
: null;
const selectedDevices = catalog.items.filter((item) => selectedTypes.has(item.device_type));
const rules = useMemo(() => {
const groups = new Map<string, typeof catalog.items>();
for (const item of catalog.items) {
if (!selectedTypes.has(item.device_type)) continue;
const key = `${item.device_type}/${item.measurement_unit}/${item.transmission_frequency}`;
groups.set(key, [...groups.get(key) ?? [], item]);
}
const result: ScadaReplayRule[] = [];
for (const devices of groups.values()) {
const device = devices[0];
if (!device.measurement_unit) continue;
if (["pressure", "pipe_flow", "flow"].includes(device.device_type) && Number(threshold) > 0) {
result.push({
rule_id: `change_${result.length}`,
device_ids: devices.map((item) => item.device_id),
kind: "relative_change",
direction: device.device_type === "pressure" ? "decrease" : "both",
threshold: Number(threshold),
unit: device.measurement_unit,
minimum_samples: 3,
window_samples: 4,
consecutive_samples: 1,
recovery_samples: 2,
minimum_devices: 1
});
}
if (device.device_type === "quality" && qualityMetric.trim()
&& (qualityLower.trim() || qualityUpper.trim())) {
result.push({
rule_id: `quality_${result.length}`,
device_ids: devices.map((item) => item.device_id),
kind: "bounds",
unit: device.measurement_unit,
metric_name: qualityMetric.trim(),
lower: qualityLower.trim() ? Number(qualityLower) : null,
upper: qualityUpper.trim() ? Number(qualityUpper) : null,
consecutive_samples: 1,
recovery_samples: 2,
minimum_devices: 1
});
}
}
return result;
}, [catalog, selectedTypes, threshold, qualityMetric, qualityLower, qualityUpper]);
const valid = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs
&& endMs - startMs <= 31 * 24 * 60 * 60_000
&& selectedDevices.length > 0 && selectedDevices.length <= 500
&& Boolean(interval) && rules.length > 0
&& rules.every((rule) => [rule.threshold, rule.lower, rule.upper].every(
(number) => number == null || Number.isFinite(number)
) && (rule.lower == null || rule.upper == null || rule.lower < rule.upper));
useEffect(() => {
if (!replay || restoredId.current === replay.replay_id) return;
restoredId.current = replay.replay_id;
if (!["ready", "paused", "failed"].includes(replay.status)) return;
setView(null);
setStart(localTime(Date.parse(replay.start_time)));
setEnd(localTime(Date.parse(replay.end_time)));
setSelectedTypes(new Set(replay.devices.map((device) => device.device_type)));
const relative = replay.rules.find((rule) => rule.kind === "relative_change");
if (relative?.threshold != null) setThreshold(String(relative.threshold));
const water = replay.rules.find((rule) => rule.kind === "bounds" && rule.metric_name);
setQualityMetric(water?.metric_name ?? "");
setQualityLower(water?.lower == null ? "" : String(water.lower));
setQualityUpper(water?.upper == null ? "" : String(water.upper));
}, [replay]);
const announceMonitoringResult = (next: ScadaReplay | null) => {
const result = next?.last_monitoring;
if (result && (result.task_id || ["attention", "suspected_burst"].includes(result.status))
&& !["ongoing", "resolved"].includes(result.incident?.action ?? "")) {
const id = result.incident?.incident_id ?? result.task_id ?? result.monitoring_id;
if (!announced.current.has(id)) {
announced.current.add(id);
setAnimationId(id);
}
}
};
const advance = async (): Promise<ScadaReplay | null> => {
const prior = matchingReplay?.last_monitoring;
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
let next: ScadaReplay | null;
if (locked && replay && ["ready", "paused"].includes(replay.status)) {
next = await backend.command("step");
} else {
if (!valid) return null;
next = await backend.change(async () => {
const created = await backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
return backend.client.command(created, "step");
});
}
announceMonitoringResult(next);
return next;
};
const canMonitor = Boolean(interval && capabilities?.available && capabilities.can_write && (locked || valid)
&& !backend.error && !obsoleteCalibration
&& (!replay || ["ready", "paused", "completed", "stopped"].includes(replay.status)));
const playback = useScadaPlayback({
active, enabled: canMonitor, busy: busy || Boolean(replay?.step_pending),
start: startMs, end: endMs, now, intervalSeconds: interval,
restart: Boolean(replay && ["completed", "stopped"].includes(replay.status)), advance
});
const displayTime = view === null && playback.started ? playback.time
: snapTimelineTime(view ?? now, startMs, endMs, TIMELINE_STEP_MS);
const future = Boolean(
replay && view !== null && (!replay.processed_until || displayTime > Date.parse(replay.processed_until))
);
const selectionNeedsNewRound = Boolean(matchingReplay && (displayTime < now
|| ["stopped", "failed"].includes(matchingReplay.status) || obsoleteCalibration));
const monitorSelection = async () => {
playback.pause();
const target = new Date(displayTime).toISOString();
const prior = matchingReplay?.last_monitoring;
if (prior) announced.current.add(prior.incident?.incident_id ?? prior.task_id ?? prior.monitoring_id);
setMonitoringSelection(true);
try {
const next = await backend.change(async () => {
let current = matchingReplay;
if (!current || selectionNeedsNewRound) {
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
await backend.client.command(replay, "stop");
}
current = await backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
}
return backend.client.monitorAt(current, target);
});
announceMonitoringResult(next);
} finally {
setMonitoringSelection(false);
}
};
const selectCase3 = () => {
playback.pause();
const apply = () => {
setStart(localTime(CASE3_START));
setEnd(localTime(CASE3_END));
setView(null);
setSelectedTypes(new Set(catalog.items.map((item) => item.device_type)));
};
if (rulesFrozen && replay) {
return backend.change(async () => {
const stopped = await backend.client.command(replay, "stop");
apply();
return stopped;
});
}
apply();
};
const restarting = playback.newRound;
const requiresRestart = Boolean(matchingReplay && ["completed", "stopped"].includes(matchingReplay.status));
const restart = async () => {
playback.pause();
const next = await backend.change(async () => {
if (replay && ["ready", "paused", "failed"].includes(replay.status)) {
await backend.client.command(replay, "stop");
}
return backend.client.create({
start_time: new Date(startMs).toISOString(), end_time: new Date(endMs).toISOString(),
device_ids: selectedDevices.map((item) => item.device_id), rules
});
});
if (next) {
setView(null);
setAnimationId(null);
playback.reset();
}
};
const result = restarting ? null : matchingReplay?.last_monitoring;
const nextTime = interval ? Math.min(endMs,
startMs + (Math.floor(((restarting ? startMs : now) - startMs) / (interval * 1000)) + 1) * interval * 1000
) : null;
const stepDisabled = busy || replay?.step_pending || !canMonitor || requiresRestart;
const restartDisabled = busy || replay?.step_pending || !capabilities?.available || !capabilities.can_write
|| !valid || (!matchingReplay && !playback.started);
const selectionDisabled = busy || replay?.step_pending || playback.playing
|| !capabilities?.available || !capabilities.can_write || !valid;
return (
<WorkbenchSection
title="SCADA 数据诊断"
description="自动播放并按周期监测,发现异常后启动调查。"
icon={<Activity size={15} />}
tone="teal"
bodyClassName="space-y-3"
>
{capabilities && !capabilities.available ? (
<WorkbenchNotice tone="warning" title="后台回放尚未配置">
{capabilities.reason}
</WorkbenchNotice>
) : null}
{backend.error || matchingReplay?.error ? (
<WorkbenchNotice role="alert" tone="danger" title="本步处理未完成">
{backend.error ?? matchingReplay?.error}
</WorkbenchNotice>
) : null}
{obsoleteCalibration ? (
<WorkbenchNotice tone="warning" title="本轮使用旧版诊断">
旧版判断已停用,请重新开始本轮,以使用历史同期正常范围。
</WorkbenchNotice>
) : null}
{capabilities && !interval ? (
<WorkbenchNotice tone="warning" title="监测频率未配置">
{locked ? "本轮未固定监测频率,请停止后重新建立演示。" : "请先在项目配置中设置监测频率。"}
</WorkbenchNotice>
) : null}
{capabilities && interval && !capabilities.burst_monitoring_ready ? (
<WorkbenchNotice tone="neutral" title="爆管组合监测尚未就绪">
{capabilities.burst_monitoring_reason};仍可演示读取、清洗和基础规则检测。
</WorkbenchNotice>
) : null}
<ScadaMonitoringStatus
status={replay?.step_pending || busy ? "正在处理监测数据" : playback.playing ? "自动播放中" : matchingReplay?.status === "completed" && !restarting ? "播放完成" : playback.started ? "播放已暂停" : matchingReplay ? STATUS[matchingReplay.status] : "尚未开始"}
playing={playback.playing}
processing={busy || Boolean(replay?.step_pending)}
intervalSeconds={interval}
count={restarting ? 0 : matchingReplay?.monitoring_count ?? 0}
progress={Number.isFinite(startMs) && endMs > startMs
? Math.max(0, Math.min(1, ((playback.started ? playback.time : now) - startMs) / (endMs - startMs))) : 0}
/>
<ScadaMonitoringNotice result={result} animationId={animationId}
processing={busy || Boolean(replay?.step_pending)}
onOpenInvestigation={onOpenInvestigation ? (taskId) => { playback.pause(); onOpenInvestigation(taskId); } : undefined} />
<section aria-label="SCADA 回放控制" className="overflow-hidden rounded-xl border border-border bg-background">
<div className="space-y-3 p-3">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>Case3 · 事件 07/16 08:00 · 北京时间</span>
{case3Range ? <span>事件前后各 30 分钟</span> : <Button size="sm" variant="ghost"
disabled={busy || replay?.step_pending || !capabilities?.can_write}
onClick={() => void selectCase3()}>切换到 Case3</Button>}
</div>
<div className="grid gap-3 sm:grid-cols-2">
<DateTimeField
label="演示开始时间"
value={locked && replay ? localTime(startMs) : start}
onChange={(value) => { playback.pause(); setStart(value); }}
minuteStep={5}
disabled={locked || busy || playback.playing}
contentClassName="workbench-shadcn-surface"
/>
<DateTimeField
label="演示结束时间"
value={locked && replay ? localTime(endMs) : end}
onChange={(value) => { playback.pause(); setEnd(value); }}
minuteStep={5}
disabled={locked || busy || playback.playing}
contentClassName="workbench-shadcn-surface"
/>
</div>
</div>
<div className="space-y-2 border-t border-border p-3">
{interval && Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs ? (
<ScadaTimeTuner
start={startMs}
end={endMs}
value={Math.max(startMs, Math.min(endMs, displayTime))}
step={TIMELINE_STEP_MS}
following={playback.started && view === null}
browsing={view !== null}
onInteract={playback.pause}
onChange={(value) => { playback.pause(); setView(value); }}
/>
) : null}
<div className="flex min-h-9 flex-wrap items-center justify-between gap-x-2 gap-y-1 text-[11px] leading-5 text-muted-foreground">
<span>{view !== null
? future ? "该时间尚未完成监测" : "已定位到历史监测时间"
: "拨动刻度只定位时间,不会自动触发监测"}</span>
<Button size="sm" variant="ghost" disabled={displayTime === startMs}
onClick={() => { playback.pause(); setView(startMs); }}>回到开始时间</Button>
</div>
<div role="group" aria-label="SCADA 播放控制" className="mx-auto grid w-full max-w-sm grid-cols-3 gap-2">
<Button size="sm" className="h-10 gap-1.5 px-2" disabled={stepDisabled || playback.playing}
onClick={() => { setView(null); playback.play(); }}><Play size={14} />播放</Button>
<Button size="sm" variant="outline" className="h-10 gap-1.5 px-2" disabled={!playback.playing}
onClick={playback.pause}><Pause size={14} />暂停</Button>
<Button size="sm" variant="ghost" className="h-10 px-2" disabled={restartDisabled}
onClick={() => void restart()}>重新开始</Button>
</div>
</div>
<div className="space-y-2 border-t border-border bg-muted/30 p-3">
{selectionNeedsNewRound ? <p className="text-[11px] leading-5 text-muted-foreground">
监测此时刻将建立新一轮回放,原有调查记录保留。
</p> : null}
<div role="group" aria-label="SCADA 单步监测" className="grid grid-cols-2 gap-2">
<Button size="sm" variant="outline" className="h-10 px-2" disabled={selectionDisabled}
onClick={() => void monitorSelection()}>
{monitoringSelection ? "正在监测" : "监测选定时刻"}
</Button>
<Button size="sm" variant="outline" className="h-10 px-2" disabled={stepDisabled || playback.playing}
onClick={() => { playback.pause(); setView(null); void advance(); }}>
{!monitoringSelection && (replay?.step_pending || busy) ? "正在监测本步" : locked ? "下一步并监测" : "单步监测"}
</Button>
</div>
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1">
<div className="space-y-0.5 text-[11px] leading-5 text-muted-foreground tabular-nums">
<p role="status" aria-label="SCADA 监测进度">
已处理至 {matchingReplay && !restarting && processedDisplayTime !== null
? stamp(new Date(processedDisplayTime).toISOString()) : "未开始"}
</p>
{nextTime !== null ? <p>{matchingReplay?.status === "completed" && !restarting
? "本轮监测已完成" : `下次监测 ${clock(new Date(nextTime).toISOString())}`}</p> : null}
</div>
<div className="flex flex-wrap items-center gap-1">
{replay && ["ready", "paused", "failed"].includes(replay.status) ? <Button
size="sm" variant="ghost" className="h-10 px-2" disabled={busy || !capabilities?.can_write}
onClick={() => { playback.pause(); void backend.command("stop"); }}><Square size={13} />停止本轮</Button> : null}
{replay?.status === "failed" ? <Button size="sm" variant="outline" className="h-10 px-2"
disabled={busy || !capabilities?.can_write} onClick={() => { playback.pause(); void backend.command("retry"); }}>
<RotateCcw size={13} />重试本步</Button> : null}
</div>
</div>
</div>
</section>
<details className="border-t border-border pt-3">
<summary className="flex min-h-10 cursor-pointer list-none items-center gap-2 text-xs font-medium text-foreground">
<ChevronDown size={13} />检测规则
<span className="font-normal text-muted-foreground">
{monitoringConfig ? "任一指标异常即调查" : `${selectedDevices.length} 个测点 · ${rules.length} 项基础规则`}
</span>
</summary>
<div className="mt-3 space-y-3">
{monitoringConfig ? <ScadaMonitoringRules
config={monitoringConfig}
calibration={rulesFrozen ? replay?.monitoring_calibration : null}
frozen={rulesFrozen}
/> : <p className="text-xs text-muted-foreground">任一已配置指标异常即启动调查。项目暂未返回组合监测配置。</p>}
</div>
</details>
</WorkbenchSection>
);
}
function stamp(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false });
}
function localTime(time: number) {
const date = new Date(time);
return new Date(time - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 16);
}
function clock(value?: string | null) {
return value
? new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })
: "未开始";
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { formatMonitoringInterval, moveTimelineTime, snapTimelineTime } from "./scada-replay-time";
const minute = 60_000;
const start = Date.parse("2026-10-08T08:07:00+08:00");
describe("SCADA timeline timestamps", () => {
it("quantizes continuous pointer positions to five-minute SCADA timestamps", () => {
const end = start + 60 * minute;
expect(snapTimelineTime(Date.parse("2026-10-08T08:26:00+08:00"), start, end, 5 * minute))
.toBe(Date.parse("2026-10-08T08:25:00+08:00"));
expect(snapTimelineTime(Date.parse("2026-10-08T08:28:00+08:00"), start, end, 5 * minute))
.toBe(Date.parse("2026-10-08T08:30:00+08:00"));
});
it("moves keyboard controls between clock-aligned five-minute timestamps", () => {
const end = start + 60 * minute;
expect(moveTimelineTime(start, start, end, 5 * minute, 1)).toBe(Date.parse("2026-10-08T08:10:00+08:00"));
expect(moveTimelineTime(start + 6 * minute, start, end, 5 * minute, -1)).toBe(Date.parse("2026-10-08T08:10:00+08:00"));
});
it("reaches exact range endpoints and never leaves the selected range", () => {
const end = start + 22 * minute;
expect(moveTimelineTime(end - minute, start, end, 5 * minute, 1)).toBe(end);
expect(moveTimelineTime(start + minute, start, end, 5 * minute, -1)).toBe(start);
});
it("formats the independent project monitoring period", () => {
expect(formatMonitoringInterval(900)).toBe("15 分钟");
expect(formatMonitoringInterval(91)).toBe("91 秒");
});
});
@@ -0,0 +1,17 @@
/** Return the nearest clock-aligned SCADA timestamp inside the selected range. */
export function snapTimelineTime(time: number, start: number, end: number, step: number): number {
return Math.max(start, Math.min(end, Math.round(time / step) * step));
}
/** Keyboard movement follows clock-aligned timestamps. */
export function moveTimelineTime(time: number, start: number, end: number, step: number, slots: number): number {
const slot = (slots > 0 ? Math.floor(time / step) : Math.ceil(time / step)) + slots;
return Math.max(start, Math.min(end, slot * step));
}
export function formatMonitoringInterval(seconds: number): string {
for (const [unit, label] of [[86400, "天"], [3600, "小时"], [60, "分钟"]] as const) {
if (seconds % unit === 0) return `${seconds / unit} ${label}`;
}
return `${seconds} 秒`;
}
@@ -1,16 +1,22 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { render, screen } from "@testing-library/react";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
import { ScadaSystemSection } from "./scada-system-section";
vi.mock("./scada-replay-section", () => ({
ScadaReplaySection: ({ catalog }: { catalog: { items: unknown[] } }) => (
<section data-testid="scada-monitoring-console" data-device-count={catalog.items.length}>
SCADA 监测演示
</section>
)
}));
describe("SCADA peripheral system", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("persists a synthetic abnormal reading before triggering Agent handling", async () => {
it("loads the complete catalog for monitoring without rendering a device list", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify({
@@ -38,55 +44,10 @@ describe("SCADA peripheral system", () => {
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const record: PeripheralTestDataRecord = {
record_id: "50000000-0000-4000-8000-000000000017",
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
revision: 1,
label: "SCADA 异常模拟 · PRESSURE-17",
point_id: "PRESSURE-17",
observed_at: "2026-10-22T02:03:00Z",
value: 0.08,
unit: "MPa",
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降",
origin: "synthetic",
created_at: "2026-10-22T02:03:01Z",
updated_at: "2026-10-22T02:03:01Z",
created_by: "tester",
updated_by: "tester"
};
const createTestData = vi.fn().mockResolvedValue(record);
const peripheralClient = {
listTestData: vi.fn().mockResolvedValue({ items: [], next_cursor: null }),
createTestData
} as unknown as PeripheralSampleClient;
const onAgentTrigger = vi.fn().mockResolvedValue(undefined);
const user = userEvent.setup();
render(<ScadaSystemSection active refreshGeneration={0} />);
render(
<ScadaSystemSection
active
refreshGeneration={0}
peripheralClient={peripheralClient}
canSimulate
onAgentTrigger={onAgentTrigger}
/>
);
await screen.findByText("PRESSURE-17", { exact: true });
await user.click(screen.getByRole("button", { name: "设定读数" }));
await user.type(screen.getByPlaceholderText("输入需要演练的异常值"), "0.08");
await user.type(screen.getByPlaceholderText("例如:压力突降,偏离近一小时稳定区间"), "压力突降");
await user.click(screen.getByRole("button", { name: "保存并触发 Agent" }));
await waitFor(() => expect(createTestData).toHaveBeenCalledOnce());
expect(createTestData).toHaveBeenCalledWith(expect.objectContaining({
point_id: "PRESSURE-17",
value: 0.08,
unit: "MPa",
quality: "valid",
notes: "[SCADA_ANOMALY] 压力突降"
}));
expect(onAgentTrigger).toHaveBeenCalledWith(record);
expect(await screen.findByTestId("scada-monitoring-console")).toHaveAttribute("data-device-count", "1");
expect(screen.queryByRole("table", { name: "SCADA 回放数据表" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "设定读数" })).not.toBeInTheDocument();
});
});
@@ -1,53 +1,28 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Activity, Database, LoaderCircle, Plus, Search, Sparkles, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { ScadaSystemClient, type LatestScadaReadings } from "../api/scada-client";
import type { WorkbenchAccess } from "../api/request-context";
import { WorkbenchEmptyState, WorkbenchNotice, WorkbenchSection } from "../components/workbench-panel-ui";
import { Button } from "@/shared/ui/button";
import { DateTimeField } from "@/shared/ui/date-time-field";
import { FieldSelect } from "@/shared/ui/field-select";
import { Input } from "@/shared/ui/input";
import { StatusBadge } from "@/shared/ui/status";
import { Textarea } from "@/shared/ui/textarea";
import type { PeripheralSampleClient, PeripheralTestDataRecord } from "./client";
const REFRESH_INTERVAL_MS = 15_000;
const SCADA_ANOMALY_MARKER = "[SCADA_ANOMALY]";
import { WorkbenchNotice } from "../components/workbench-panel-ui";
import { ScadaReplaySection } from "./scada-replay-section";
export function ScadaSystemSection({
active,
getAccessToken,
refreshGeneration,
peripheralClient,
canSimulate,
onAgentTrigger
onOpenInvestigation
}: {
active: boolean;
getAccessToken?: WorkbenchAccess;
refreshGeneration: number;
peripheralClient: PeripheralSampleClient;
canSimulate: boolean;
onAgentTrigger?: (record: PeripheralTestDataRecord) => Promise<void>;
onOpenInvestigation?: (taskId: string) => void;
}) {
const clientRef = useRef(new ScadaSystemClient(getAccessToken));
const requestRef = useRef<AbortController | null>(null);
const requestSequenceRef = useRef(0);
const handledRefreshRef = useRef(refreshGeneration);
const [snapshot, setSnapshot] = useState<LatestScadaReadings | null>(null);
const [catalog, setCatalog] = useState<LatestScadaReadings | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [filter, setFilter] = useState("");
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null);
const [simulationEditorOpen, setSimulationEditorOpen] = useState(false);
const [simulationPointId, setSimulationPointId] = useState("");
const [simulationValue, setSimulationValue] = useState("");
const [simulationObservedAt, setSimulationObservedAt] = useState(() => toLocalInput(new Date()));
const [simulationReason, setSimulationReason] = useState("");
const [simulationBusy, setSimulationBusy] = useState(false);
const [simulationError, setSimulationError] = useState<string | null>(null);
const [simulations, setSimulations] = useState<PeripheralTestDataRecord[]>([]);
const [triggerRetry, setTriggerRetry] = useState<PeripheralTestDataRecord | null>(null);
const refresh = useCallback(async () => {
if (!active) return;
@@ -58,434 +33,58 @@ export function ScadaSystemSection({
setBusy(true);
try {
const next = await clientRef.current.getLatestReadings(controller.signal);
if (sequence !== requestSequenceRef.current) return;
setSnapshot(next);
while (next.items.length < next.total) {
const page = await clientRef.current.getLatestReadings(controller.signal, next.items.length);
if (!page.items.length) throw new Error("SCADA 设备目录分页不完整,请重新读取");
next.items.push(...page.items);
if (page.latest_observed_at && (!next.latest_observed_at
|| Date.parse(page.latest_observed_at) > Date.parse(next.latest_observed_at))) {
next.latest_observed_at = page.latest_observed_at;
}
}
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setCatalog(next);
setError(null);
setRefreshedAt(new Date());
} catch (nextError) {
if (controller.signal.aborted || sequence !== requestSequenceRef.current) return;
setError(nextError instanceof Error ? nextError.message : "SCADA 最新数据读取失败");
setError(nextError instanceof Error ? nextError.message : "SCADA 设备目录读取失败");
} finally {
if (sequence === requestSequenceRef.current) setBusy(false);
}
}, [active]);
const refreshSimulations = useCallback(async () => {
if (!active || !canSimulate) return;
try {
const page = await peripheralClient.listTestData();
setSimulations(page.items.filter((item) => item.notes.startsWith(SCADA_ANOMALY_MARKER)));
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数读取失败");
}
}, [active, canSimulate, peripheralClient]);
useEffect(() => {
if (!active) return;
void refresh();
const intervalId = window.setInterval(() => {
if (document.visibilityState === "visible") void refresh();
}, REFRESH_INTERVAL_MS);
const handleVisibilityChange = () => {
if (document.visibilityState === "visible") void refresh();
};
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
window.clearInterval(intervalId);
document.removeEventListener("visibilitychange", handleVisibilityChange);
requestRef.current?.abort();
};
return () => requestRef.current?.abort();
}, [active, refresh]);
useEffect(() => {
if (!active || handledRefreshRef.current === refreshGeneration) return;
handledRefreshRef.current = refreshGeneration;
void refresh();
void refreshSimulations();
}, [active, refresh, refreshGeneration, refreshSimulations]);
useEffect(() => {
void refreshSimulations();
}, [refreshSimulations]);
useEffect(() => {
if (!simulationPointId && snapshot?.items[0]) {
setSimulationPointId(snapshot.items[0].device_id);
}
}, [simulationPointId, snapshot]);
const visibleItems = useMemo(() => {
const query = filter.trim().toLocaleLowerCase("zh-CN");
if (!query) return snapshot?.items ?? [];
return (snapshot?.items ?? []).filter((item) =>
[item.device_id, item.device_type, item.measurement_unit, item.transmission_mode]
.some((value) => value.toLocaleLowerCase("zh-CN").includes(query))
);
}, [filter, snapshot]);
const selectedSimulationDevice = snapshot?.items.find(
(item) => item.device_id === simulationPointId
) ?? null;
const parsedSimulationValue = Number(simulationValue);
const canSaveSimulation = canSimulate && selectedSimulationDevice !== null
&& simulationValue.trim() !== "" && Number.isFinite(parsedSimulationValue)
&& Boolean(simulationObservedAt) && Boolean(simulationReason.trim()) && !simulationBusy;
const saveSimulation = async (triggerAgent: boolean) => {
if (!canSaveSimulation || !selectedSimulationDevice) return;
setSimulationBusy(true);
setSimulationError(null);
setTriggerRetry(null);
try {
const record = await peripheralClient.createTestData({
label: `SCADA 异常模拟 · ${selectedSimulationDevice.device_id}`,
point_id: selectedSimulationDevice.device_id,
observed_at: new Date(simulationObservedAt).toISOString(),
value: parsedSimulationValue,
unit: selectedSimulationDevice.measurement_unit || "未配置",
quality: "valid",
notes: `${SCADA_ANOMALY_MARKER} ${simulationReason.trim()}`
});
setSimulations((current) => [record, ...current.filter((item) => item.record_id !== record.record_id)]);
setSimulationEditorOpen(false);
setSimulationValue("");
setSimulationReason("");
setSimulationObservedAt(toLocalInput(new Date()));
if (triggerAgent) {
if (!onAgentTrigger) throw new Error("Agent 异常处理入口未配置");
try {
await onAgentTrigger(record);
} catch (triggerError) {
setTriggerRetry(record);
setSimulationError(
`模拟读数已保存,但 Agent 任务未建立:${triggerError instanceof Error ? triggerError.message : "触发失败"}`
);
}
}
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "模拟读数保存失败");
} finally {
setSimulationBusy(false);
}
};
const retryAgentTrigger = async () => {
if (!triggerRetry || !onAgentTrigger) return;
setSimulationBusy(true);
setSimulationError(null);
try {
await onAgentTrigger(triggerRetry);
setTriggerRetry(null);
} catch (nextError) {
setSimulationError(nextError instanceof Error ? nextError.message : "Agent 任务触发失败");
} finally {
setSimulationBusy(false);
}
};
}, [active, refresh, refreshGeneration]);
return (
<div className="space-y-3" lang="zh-CN">
<p aria-label="SCADA 数据说明" className="text-xs leading-5 text-muted-foreground">
当前项目各设备最后一条持久化记录,每 15 秒刷新,不据此推断设备在线状态。
</p>
{error ? (
<WorkbenchNotice role="alert" tone="danger" title="最新数据未更新">
{error}。已加载的数据会继续保留。
<WorkbenchNotice role="alert" tone="danger" title="SCADA 演示数据未更新">
{error}。已加载的演示区间仍可继续使用。
</WorkbenchNotice>
) : null}
{simulationError ? (
<WorkbenchNotice role="alert" tone={triggerRetry ? "warning" : "danger"} title="异常演练未完成">
<p>{simulationError}</p>
{triggerRetry ? (
<Button
className="mt-2"
size="sm"
variant="outline"
disabled={simulationBusy}
onClick={() => void retryAgentTrigger()}
>
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
重试触发 Agent
</Button>
) : null}
{!catalog && busy ? (
<WorkbenchNotice role="status" tone="neutral" title="正在准备 SCADA 演示">
正在读取项目测点配置和历史数据范围。
</WorkbenchNotice>
) : null}
<section
aria-label="SCADA 最新数据概览"
className="bg-muted grid grid-cols-2 divide-x divide-[var(--surface-divider)] rounded-xl border border-border px-1 py-3"
>
<div className="px-3">
<p className="text-[11px] leading-4 text-muted-foreground">已取得最新记录</p>
<p className="mt-1 text-xl font-semibold leading-7 text-foreground tabular-nums">
{snapshot ? snapshot.observed_count : "—"}
<span className="ml-1 text-xs font-medium text-muted-foreground">
/ {snapshot ? snapshot.total : "—"} 个设备
</span>
</p>
</div>
<div className="px-3">
<p className="text-[11px] leading-4 text-muted-foreground">最近记录时间</p>
<p className="mt-1 text-sm font-semibold leading-7 text-foreground tabular-nums">
{formatObservedTime(snapshot?.latest_observed_at ?? null)}
</p>
</div>
</section>
<WorkbenchSection
title="最新测点值"
description={refreshedAt
? `本页读取于 ${formatClock(refreshedAt)},按设备编号排序`
: busy ? "正在读取设备目录与最后一条记录" : "尚未读取"}
icon={<Activity size={15} />}
tone="teal"
meta={busy ? <StatusBadge tone="info" activity="loading">更新中</StatusBadge> : null}
bodyClassName="p-0"
>
<div className="border-b border-border p-3">
<div className="relative">
<Search
size={14}
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
aria-label="筛选 SCADA 设备"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="筛选设备编号、类型或单位"
className="h-10 bg-card pl-9 text-xs md:text-xs"
/>
</div>
</div>
{!snapshot && busy ? (
<p role="status" className="px-4 py-8 text-center text-xs text-muted-foreground">
正在读取 SCADA 最新数据
</p>
) : visibleItems.length === 0 ? (
<WorkbenchEmptyState
icon={<Database size={16} />}
title={snapshot?.items.length ? "没有匹配的设备" : "当前项目没有 SCADA 设备"}
description={snapshot?.items.length ? "请调整筛选条件。" : "设备目录为空,未发起历史值推断。"}
className="m-3"
/>
) : (
<div className="max-h-80 overflow-auto overscroll-contain">
<table className="w-full min-w-[620px] border-collapse text-xs" aria-label="SCADA 最新数据表">
<thead className="sticky top-0 z-10 bg-muted text-[11px] text-muted-foreground">
<tr className="border-b border-border">
<th scope="col" className="px-3 py-2.5 text-left font-medium">设备</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">原始值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">清洗值</th>
<th scope="col" className="px-3 py-2.5 text-right font-medium">记录时间</th>
</tr>
</thead>
<tbody>
{visibleItems.map((item) => (
<tr key={item.device_id} className="border-b border-border last:border-b-0">
<th scope="row" className="px-3 py-2.5 text-left font-medium text-foreground">
<span className="block font-mono text-[12px]">{item.device_id}</span>
<span className="mt-0.5 block text-[11px] font-normal text-muted-foreground">
{deviceTypeLabel(item.device_type)} · {item.transmission_frequency || item.transmission_mode}
</span>
</th>
<td className="px-3 py-2.5 text-right font-mono text-foreground tabular-nums">
{formatValue(item.monitored_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right font-mono text-muted-foreground tabular-nums">
{formatValue(item.cleaned_value, item.measurement_unit)}
</td>
<td className="px-3 py-2.5 text-right tabular-nums">
{item.observed_at ? (
<time dateTime={item.observed_at} className="text-foreground">
{formatObservedTime(item.observed_at)}
</time>
) : (
<StatusBadge tone="neutral">无记录</StatusBadge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{snapshot && snapshot.total > snapshot.items.length ? (
<p className="border-t border-border px-3 py-2 text-[11px] text-muted-foreground">
当前显示前 {snapshot.items.length} 个设备,共 {snapshot.total} 个。
</p>
) : null}
</WorkbenchSection>
<WorkbenchSection
title="异常读数演练"
description="读数写入独立合成数据区,可据此建立 Agent 调查任务,不改写正式 SCADA 时序表。"
icon={<Sparkles size={15} />}
tone="warning"
actions={canSimulate ? (
<Button
size="sm"
variant="outline"
aria-expanded={simulationEditorOpen}
onClick={() => setSimulationEditorOpen((current) => !current)}
disabled={simulationBusy || !snapshot?.items.length}
>
{simulationEditorOpen ? <X size={13} /> : <Plus size={13} />}
{simulationEditorOpen ? "收起" : "设定读数"}
</Button>
) : null}
bodyClassName="space-y-3"
>
{!canSimulate ? (
<WorkbenchNotice tone="warning" title="合成读数存储不可用">
需要配置外围样板服务、当前项目写权限以及 test_data 存储,正式 SCADA 数据仍保持只读。
</WorkbenchNotice>
) : null}
{simulationEditorOpen && canSimulate ? (
<div className="border-b border-border pb-4">
<div className="grid gap-3 sm:grid-cols-2">
<FieldSelect
contentClassName="workbench-shadcn-surface"
label="SCADA 设备"
value={simulationPointId}
onValueChange={setSimulationPointId}
disabled={simulationBusy}
options={(snapshot?.items ?? []).map((item) => ({
value: item.device_id,
label: `${item.device_id} · ${deviceTypeLabel(item.device_type)}`
}))}
/>
<DateTimeField
contentClassName="workbench-shadcn-surface"
label="模拟发生时间"
value={simulationObservedAt}
onChange={setSimulationObservedAt}
disabled={simulationBusy}
/>
</div>
<label className="mt-3 block text-xs font-medium text-muted-foreground">
模拟读数{selectedSimulationDevice?.measurement_unit
? `(${selectedSimulationDevice.measurement_unit})`
: ""}
<Input
className="mt-1.5 h-10 bg-card text-sm tabular-nums"
inputMode="decimal"
value={simulationValue}
onChange={(event) => setSimulationValue(event.target.value)}
disabled={simulationBusy}
placeholder="输入需要演练的异常值"
aria-invalid={simulationValue.trim() !== "" && !Number.isFinite(parsedSimulationValue)}
/>
</label>
<label className="mt-3 block text-xs font-medium text-muted-foreground">
异常说明
<Textarea
className="mt-1.5 min-h-20 bg-card"
value={simulationReason}
onChange={(event) => setSimulationReason(event.target.value)}
disabled={simulationBusy}
placeholder="例如:压力突降,偏离近一小时稳定区间"
/>
</label>
<p className="mt-2 text-[11px] leading-5 text-muted-foreground">
读数按“有效但业务异常”保存。保存并触发后,Agent 会收到点位、数值、单位、发生时间和异常说明。
</p>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
disabled={!canSaveSimulation}
onClick={() => void saveSimulation(true)}
>
{simulationBusy ? <LoaderCircle className="animate-spin" /> : <Sparkles size={13} />}
保存并触发 Agent
</Button>
<Button
size="sm"
variant="outline"
disabled={!canSaveSimulation}
onClick={() => void saveSimulation(false)}
>
保存模拟读数
</Button>
</div>
</div>
) : null}
{simulations.length ? (
<ol aria-label="SCADA 模拟异常列表" className="space-y-2">
{simulations.slice(0, 5).map((item) => (
<li
key={item.record_id}
className="flex min-w-0 items-start gap-3 rounded-xl bg-muted px-3 py-2.5 ring-1 ring-inset ring-[var(--surface-divider)]"
>
<StatusBadge tone="warning">模拟异常</StatusBadge>
<div className="min-w-0 flex-1">
<p className="font-mono text-xs font-semibold text-foreground">{item.point_id}</p>
<p className="mt-1 text-[11px] leading-4 text-muted-foreground">
{formatValue(item.value, item.unit)} · {formatObservedTime(item.observed_at)}
</p>
<p className="mt-1 break-words text-[11px] leading-4 text-foreground">
{item.notes.replace(SCADA_ANOMALY_MARKER, "").trim()}
</p>
</div>
</li>
))}
</ol>
) : canSimulate && !simulationEditorOpen ? (
<p className="text-[11px] leading-5 text-muted-foreground">尚未设定 SCADA 模拟异常。</p>
) : null}
</WorkbenchSection>
{catalog ? (
<ScadaReplaySection
active={active}
getAccessToken={getAccessToken}
catalog={catalog}
onOpenInvestigation={onOpenInvestigation}
/>
) : null}
</div>
);
}
function formatValue(value: number | null, unit: string) {
if (value === null) return "—";
const formatted = new Intl.NumberFormat("zh-CN", { maximumFractionDigits: 4 }).format(value);
return `${formatted}${unit ? ` ${unit}` : ""}`;
}
function formatObservedTime(value: string | null) {
if (!value) return "暂无记录";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "时间无效";
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(date);
}
function formatClock(value: Date) {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false
}).format(value);
}
function toLocalInput(value: Date) {
const offset = value.getTimezoneOffset() * 60_000;
return new Date(value.getTime() - offset).toISOString().slice(0, 16);
}
function deviceTypeLabel(value: string) {
return ({
pressure: "压力",
pipe_flow: "流量",
quality: "水质",
demand: "需水量",
reservoir_liquid_level: "水库液位",
tank_liquid_level: "水箱液位",
fixed_pump: "定速泵",
variable_pump: "变频泵"
} as Record<string, string>)[value] ?? value;
}

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