diff --git a/.env.example b/.env.example index 3f0cd78..0b0b65f 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,12 @@ TJWATER_MAP_URL=https://geoserver.waternetwork.cn/geoserver TJWATER_GEOSERVER_WORKSPACE=tjwater_next TJWATER_SERVER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_AGENT_API_BASE_URL=http://127.0.0.1:8787 +TJWATER_WORKBENCH_RUNTIME_MODE=auto +TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL= +TJWATER_PERIPHERAL_SAMPLE_TIME_MODE=demo +TJWATER_PERIPHERAL_SAMPLE_DEMO_START=2026-09-17T00:00:00Z +TJWATER_PERIPHERAL_SAMPLE_DEMO_END=2026-09-17T12:00:00Z +TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS=8 TJWATER_ENABLE_DEV_PANEL=false TJWATER_ENABLE_MSW=false diff --git a/README.md b/README.md index cd0c050..4241adb 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,12 @@ Agent 服务默认地址为 `http://127.0.0.1:8787`。需要完整对话能力 | `TJWATER_GEOSERVER_WORKSPACE` | `tjwater_next` | GeoServer 工作区 | | `TJWATER_SERVER_API_BASE_URL` | `http://127.0.0.1:8000` | 浏览器访问的 TJWater 后端 API 地址 | | `TJWATER_AGENT_API_BASE_URL` | `http://127.0.0.1:8787` | 浏览器访问的 Agent API 地址 | +| `TJWATER_WORKBENCH_RUNTIME_MODE` | `auto` | 工作台时间模式:工况始终使用 mock,预约工单只读取后端投影;`auto` 和 `backend` 使用当天日期;`demo` 与 `history` 定位到最近一个有后端预约工单的日期,并在同一天生成 mock 工况,其中 `demo` 标记为演示时间。分析成果和后端任务状态不作为工况或日期依据 | +| `TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL` | 空 | 可选的外围系统模拟服务地址,留空时不发起连接 | +| `TJWATER_PERIPHERAL_SAMPLE_TIME_MODE` | `demo` | 历史回放默认时间模式:`demo` 使用固定有数据窗口,`recent` 使用最近时段 | +| `TJWATER_PERIPHERAL_SAMPLE_DEMO_START` | `2026-09-17T00:00:00Z` | 演示历史窗口开始时间(UTC、带时区) | +| `TJWATER_PERIPHERAL_SAMPLE_DEMO_END` | `2026-09-17T12:00:00Z` | 演示历史窗口结束时间(UTC、最长 7 天) | +| `TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS` | `8` | 实时时间模式回看小时数,范围 1—168 | | `TJWATER_ENABLE_DEV_PANEL` | `false` | 是否显示开发面板 | | `TJWATER_ENABLE_MSW` | `false` | 是否启用浏览器端 Mock Service Worker | @@ -106,4 +112,8 @@ src/ ## 构建说明 +调查任务支持样板反馈的人工复核草稿。在“外围样板”显式导入反馈证据后,可读取同一来源的完整反馈集合、确认并保存新修订;已保存草稿也可从“调查任务”的历史草稿中再次打开,不依赖外围服务在线。页面区分待重分析、冲突证据及前后修订,不代表已经重新计算。接口与存储边界见 [复核修订说明](../next-tjwater-server/docs/feedback-review-revisions.md)。 + +“分析成果”面板只读取当前项目 BizDB 中已持久化的分析历史,不启动分析任务。外围样板面板可对 synthetic 测试工单和测试数据执行 CRUD,历史监测回放仍为只读。部署所需表和边界见 Server 的[外围样板说明](../next-tjwater-server/docs/peripheral-sample.md)。 + 生产构建可能提示 Shiki、Mermaid 语言包超过 Rollup 默认的 500 kB 阈值。当前构建和浏览器测试可以正常完成,这些提示不影响产物生成。 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index c0817e1..af72d16 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -10,6 +10,12 @@ runtime_config=$(jq -cn \ --arg geoserverWorkspace "${TJWATER_GEOSERVER_WORKSPACE:-tjwater_next}" \ --arg serverApiBaseUrl "${TJWATER_SERVER_API_BASE_URL:-http://127.0.0.1:8000}" \ --arg agentApiBaseUrl "${TJWATER_AGENT_API_BASE_URL:-http://127.0.0.1:8787}" \ + --arg workbenchRuntimeMode "${TJWATER_WORKBENCH_RUNTIME_MODE:-auto}" \ + --arg peripheralSampleApiBaseUrl "${TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL:-}" \ + --arg peripheralSampleTimeMode "${TJWATER_PERIPHERAL_SAMPLE_TIME_MODE:-demo}" \ + --arg peripheralSampleDemoStart "${TJWATER_PERIPHERAL_SAMPLE_DEMO_START:-2026-09-17T00:00:00Z}" \ + --arg peripheralSampleDemoEnd "${TJWATER_PERIPHERAL_SAMPLE_DEMO_END:-2026-09-17T12:00:00Z}" \ + --arg peripheralSampleRecentHours "${TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS:-8}" \ --arg enableDevPanel "${TJWATER_ENABLE_DEV_PANEL:-false}" \ --arg enableMsw "${TJWATER_ENABLE_MSW:-false}" \ '{ @@ -21,6 +27,12 @@ runtime_config=$(jq -cn \ TJWATER_GEOSERVER_WORKSPACE: $geoserverWorkspace, TJWATER_SERVER_API_BASE_URL: $serverApiBaseUrl, TJWATER_AGENT_API_BASE_URL: $agentApiBaseUrl, + TJWATER_WORKBENCH_RUNTIME_MODE: $workbenchRuntimeMode, + TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL: $peripheralSampleApiBaseUrl, + TJWATER_PERIPHERAL_SAMPLE_TIME_MODE: $peripheralSampleTimeMode, + TJWATER_PERIPHERAL_SAMPLE_DEMO_START: $peripheralSampleDemoStart, + TJWATER_PERIPHERAL_SAMPLE_DEMO_END: $peripheralSampleDemoEnd, + TJWATER_PERIPHERAL_SAMPLE_RECENT_HOURS: $peripheralSampleRecentHours, TJWATER_ENABLE_DEV_PANEL: $enableDevPanel, TJWATER_ENABLE_MSW: $enableMsw }') diff --git a/docs/component-design-system.md b/docs/component-design-system.md new file mode 100644 index 0000000..069f40c --- /dev/null +++ b/docs/component-design-system.md @@ -0,0 +1,57 @@ +# 新增业务组件设计规范 + +本规范覆盖调查任务、运行控制、人工复核、外围样板、日期时间与共享表单组件。地图仍是主工作区,侧栏负责定位当前任务、显示状态、给出下一步操作和按需展开证据。 + +## 1. 视觉主题 + +方向为浅色专业调度台。界面保持高信息密度,但使用稳定的标题、状态、操作、证据顺序,减少连续嵌套的彩色卡片。视觉参考取 Linear 的状态聚焦、Grafana 的数据对齐,以及 ArcGIS Experience Builder 的地图优先侧栏。 + +## 2. 色彩角色 + +- `--surface-reading`:主要阅读面。 +- `slate-50`:同一业务区内的次级数据面。 +- `--action-blue`:主操作、焦点和调查任务状态。 +- `cyan-700`:外围样板入口及其来源标识。 +- `emerald`:已完成、已通过。 +- `amber`:需要确认、暂停、结果未知。 +- `rose`:失败、冲突或破坏性操作。 + +彩色区域只表达语义。普通分组使用白色阅读面、灰色边界和轻微背景层级。 + +## 3. 排版规则 + +沿用 Noto Sans SC。侧栏标题使用 14px,业务区标题使用 13px,正文和表单标签使用 12px,辅助信息使用 11px。编号、修订号、时间和数量使用等宽数字。标签左对齐,数值列右对齐。 + +## 4. 组件规则 + +- `WorkbenchSection` 统一业务区标题、说明、状态和操作,不在内部再次堆叠同级标题卡。 +- `WorkbenchNotice` 使用左侧状态线传达信息、警告与错误,避免大面积色块。 +- `WorkbenchEmptyState` 只说明当前为空和下一步,不占用过高空间。 +- 表单控件高度为 40px,按钮使用现有 8px 或 12px 圆角体系。 +- 主操作每个业务区最多一个。其余操作使用 outline 或 ghost。 + +## 5. 布局原则 + +每个业务区按“定位、状态、下一步、证据”排列。侧栏内容使用 12px 至 14px 的紧凑间距。复杂原始对象、请求参数、历史和测试资源按需展开。移动端保持单列,不用横向滚动承载业务字段。 + +## 6. 层级与深度 + +侧栏使用既有 acrylic 容器。一级业务区使用不透明阅读面和 1px 边界。区内次级数据使用背景色差,不叠加投影。弹层可使用较强投影,普通卡片只保留极轻的 1px 投影。 + +## 7. 使用约束 + +- 必须优先显示当前状态和可执行的下一步。 +- 必须明确 synthetic、只读、结果未知和未配置状态。 +- 必须保留完整请求号、对象修订和证据来源的可读入口。 +- 不用颜色代替文字状态。 +- 不在一个业务区内放置多个同等醒目的主按钮。 +- 不用大块空卡显示一句空状态。 +- 不引入第二套字体、主题或 CSS 方案。 + +## 8. 响应式与无障碍 + +支持 1280px 桌面和 320px、375px 窄屏。所有交互控件保留可见焦点,触控目标至少 40px。弹层受视口高度和宽度限制。hover 仅在支持 hover 的设备启用,按压使用轻微缩放,并尊重 reduced motion。 + +## 9. 交互提示 + +任务面板先显示任务状态和下一步,再提供计划、目标、运行与证据。运行暂停时突出检查点校验或恢复中的一个主操作。外围面板先显示连接状态、历史回放和待提交来源,测试资源与原始请求回执默认按需展开。 diff --git a/docs/non-map-ui.md b/docs/non-map-ui.md new file mode 100644 index 0000000..d2f6bbe --- /dev/null +++ b/docs/non-map-ui.md @@ -0,0 +1,115 @@ +# 非地图面板整理 + +2026-09-17,范围限供水前端,不改变地图业务、OpenCode、数据库表或环境配置。 + +## 视觉与交互 + +沿用现有浅色阅读面、Noto Sans SC 中文字体、蓝色操作强调和状态徽标。优先修复可读性、对齐和窄屏裁切,不引入第二套主题、字体或组件库。 + +- `FieldSelect` 复用现有 Radix Select:统一标签、40px 最小高度、长选项换行、禁用态、键盘选择和弹层层级。清空受控值后回到占位提示,不触发额外业务动作。 +- `Checkbox` 保留原生输入与标签语义,统一勾选、焦点和禁用样式。反馈确认仍须用户主动勾选。 +- `Disclosure` 保留原生 details/summary 语义,统一展开箭头、焦点和点击区域。 +- 调查目标和外围历史回放的日期字段单列展示,避免窄面板内日期与时分被裁切。两处均已使用共享自定义日期时间弹窗,保留各自本地时间/UTC 的业务语义。 +- 手机端 Agent 欢迎区改为紧凑横排,避免被底部输入区遮挡。 +- 桌面成果专注视图为 Agent 折叠栏预留空间,标题不再被覆盖,仍可重新展开助手。 +- 成果图表显式使用页面的中文字体栈。此前 ECharts 画布仍以 `11px sans-serif` 绘制中文图例,在测试环境显示方框;浏览器画布探针已验证修复前失败、修复后在 1280/375px 通过,截图可见完整“调度下限”。源码只有这一处 ReactECharts 入口。 + +## 检查覆盖 + +| 面板 | 本次检查 | +| --- | --- | +| 调查任务、生命周期、运行与历史结果 | 表单、确认区、折叠内容、长标识、320/375/1280px | +| 已保存比较结论、Evidence 查看 | 四分支、加载/空/错误态、只读重试、键盘下拉、320/375/1280px | +| 外围样板、模拟数据、历史回放 | 下拉、日期、状态徽标、任务历史、375px | +| 反馈关联、人工复核 | 确认勾选、展开内容、禁用与只读边界、窄屏 | +| Agent 对话与历史 | 桌面抽屉、320/375px 欢迎区与滚动容器边界 | +| 工况列表与详情 | 375px 抽屉布局 | +| 成果预览与文档 | 1280/375px,桌面标题与折叠栏几何边界 | +| 图层、测量、标注、更多、开发面板 | 非地图浮层边界,开发面板表单控件统一 | + +`FeatureInsightPanel` 当前只有定义,没有运行入口,未伪造页面声称已完成运行检查。实际要素详情使用地图上的 `FeaturePopover`,不在本次非地图面板范围。 + +## 验证与限制 + +最终代码检查:`pnpm typecheck`、`pnpm lint`、全量 68 文件 / 350 项 Vitest、`pnpm build` 通过。生产构建仍提示部分 chunk 超过 500kB,本次未扩展打包优化范围。调查/反馈/外围/比较/生命周期的 29 项与非地图布局的 5 项专项浏览器用例通过,比较区新增的弹层不透明度和背景色检查另行重跑 6 项通过。非地图布局用例同时覆盖开发面板键盘选项和成果图表画布字体。 + +浏览器用例使用显式模拟接口,不代表真实账号、Agent 和 PG 的全链路验收。主要回归在 `tests/browser/non-map-panels-layout.e2e.ts`、`run-comparison.e2e.ts` 及既有调查、反馈、外围与生命周期测试中;截图保存在忽略的 `test-results/non-map-panels/`、`test-results/run-comparison/` 等目录,不作为发布资源。 + +本次也运行了完整浏览器套件,当次为 66 通过、33 失败,不能宣称全量通过。已确认的失败包括旧用例依赖 Agent 初始展开或已不存在的“工作台导航”、旧截图基线,以及新移动用例当时选择器不唯一。后者已改为语义化 `role=log` 并在专项回归中通过;其他旧用例与真实地图依赖仍需单独梳理,不通过修改产品默认行为或批量更新截图掩盖失败。 + +运行内容来自现有接口,无需手动替换界面占位数据。人工验收时请在桌面全宽与 375px 下检查长任务名称、日期输入、下拉键盘操作和成果标题。 + +## 后续交互回归 + +继续核查旧失败用例时发现并修复了两处真实缺陷: + +- 窄桌面展开工况详情会自动收起 Agent;此前聊天草稿存在被卸载的面板内部,重新展开时丢失。现在由稳定的工作台 hook 持有草稿,桌面和手机面板共享受控值,新建会话仍清空。此状态只在当前页面内保留,不是刷新后的持久化草稿,也没有新增存储层。面板仍可卸载,避免为了保留文字让隐藏的语音控件继续活动。 +- 新增调查/外围入口后,1536px 顶栏的异常处置与调查按钮发生重叠。现在保留按钮宽度、收紧组间距,仅在 1800px 以上展开次要按钮文字;较窄桌面省略模型只读提示,完整操作名称仍通过无障碍标签提供。手机标题显示短称“供水”,而不是省略号。 + +`workspace-main-frame.e2e.ts` 现在验证默认收起、主动展开后的相邻布局、收起恢复、成果 split/focus 切换,并检查 320/375/1024/1280/1440/1536/1600/1800/1920px 顶栏按钮不重叠、标题不截断。顶栏几何断言修复前失败,修复后通过。截图位于 `test-results/workspace-regression/`。 + +`agent-voice-hydration.e2e.ts` 与 `agent-panel-resize.e2e.ts` 改为先通过真实按钮打开助手,再检查语音、几何和草稿状态;不再假定 Agent 初始展开或折叠后保留旧版 72px 导航宽度。工况自动收起场景使用其实际适用的 1280px 宽度,保留了未发送草稿不丢失的断言。布局测试按实时几何等待入场动画完成,不固定动画中途的坐标,也没有加入固定 sleep。 + +该轮完整浏览器套件为 79 通过、21 失败,共 100 项。仍有旧导航/默认展开假设、截图基线与动画时机相关失败,不能宣称全量通过,也未批量更新基线。 + +## 调查任务日期时间补充 + +根因是原先只统一了 `datetime-local` 输入框外观,其日历和时间弹窗仍由浏览器绘制。调查任务的开始、结束时窗已替换为共享 `DateTimeField`:沿用浅色面板、蓝色选中态和现有 Radix 弹层/下拉,不引入新依赖。 + +- 日历、月份、年份、小时和分钟均在应用内绘制,窄屏弹窗限制宽高并允许纵向滚动。 +- 点击确认才更新值,取消或 Esc 放弃草稿并将焦点返回字段;日历支持方向键和 PageUp/PageDown。 +- 年份编辑保持输入焦点,支持闰年和真实月份天数;无效源值不会被静默截断后保存。 +- 已关闭任务不可编辑。组件仍输出本地 `YYYY-MM-DDTHH:mm`,原有目标保存逻辑负责转换 UTC,不改变业务时区语义。 +- `date-time-field.test.tsx` 覆盖组件边界。 + +调查任务两个日期字段均已替换;同类的外围历史回放已在后续批次统一,见下节。 + +补充后的验证:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项单元测试和 `pnpm build` 全部通过;日期时间、调查面板、非地图布局、运行控制及任务生命周期的 20 项浏览器回归通过。生产构建仍有既有大 chunk 提示。上述浏览器检查使用模拟接口,不替代真实系统验收。 + +日期回归用例做过红绿验证:临时将调查时窗重新接回原生 `datetime-local`,确认/提交用例因缺少自定义字段入口失败;恢复 `DateTimeField` 后重新运行全部 3 项日期用例通过。代码未提交。 + +按用户要求,每轮 Playwright 结束后检查其浏览器和临时 Vite 服务是否退出,不关闭用户已有的 5173 开发服务。 + +## 外围历史回放日期补充 + +外围面板的 `HistoryTime` 同样仍由浏览器绘制日期时间弹窗,已删除该包装并复用 `DateTimeField`。保留“开始(UTC)/结束(UTC)”标签和原来的 UTC 查询拼接,不套用调查任务的本地时间转换。扫描 `src/` 已无原生 date/time/datetime-local 输入入口。 + +`peripheral-sample-panel.e2e.ts` 新增实际交互断言:修改起止日期时间后只有点击“读取历史读数”才发送 GET;浏览器为 Asia/Shanghai 时,08:15 仍按 08:15Z 查询;取消不改值、不发查询,也没有任何外围写请求。320/375/1280px 弹窗边界与无横向溢出通过,截图位于忽略的 `test-results/peripheral-datetime/`。 + +红绿验证:替换前该用例因缺少自定义日期入口失败,替换后外围面板、调查日期时间、反馈导入共 14 项浏览器测试通过。类型检查、Lint、69 文件 / 357 项单元测试与生产构建通过,构建仍提示既有大 chunk。仍使用模拟接口,不代表真实外围服务验收;完整浏览器套件的失败不因本轮专项通过而视为消除。 + +## 问答与流式测试入口修订 + +重新运行确认旧问答/流式用例默认寻找尚未展开的 Agent 输入框;显式展开后还暴露了授权项目模拟缺失。请求失败探针记录 `/api/v1/projects` 连接拒绝,Agent 客户端因此未进入回复与模型查询。现在使用明确的测试运行配置和已有项目/SCADA mock,不改产品授权流程;临时日志已移除。 + +流式用例另外仍断言旧浮动简报和浮动输入区。当前 `desktop-dock` 分支将简报和输入区放入正常布局,已改为验证简报在对话区上方、输入框在滚动区域下方、分析中操作禁用及区域不重叠;进度窗口几何采样、计划卡片、结束滚动、保持展开、选中文本朗读等原断言仍保留。问答回复的精确请求号与 payload 断言未放宽。两个文件共 3 项重跑通过,其中问答桌面和流式用例此前失败。 + +本轮最终完整浏览器套件:103 项中 85 通过、18 失败,耗时 2.7 分钟。失败分布为 `app.e2e.ts` 4 项、`agent-acrylic.e2e.ts` 4 项、开发面板/地图通知/移动转桌面/窄桌面工况/地图相机稳定性各 1 项、视觉基线 5 项。不得将全部失败归因于旧断言,仍需逐项核查实际行为。此前工况动画时机失败在本轮未复现,未作修复,仍作为时序风险保留;问答与流式是本轮明确修订后通过的两项。完整结果与失败上下文位于忽略的 `test-results/non-map-followup-full/`。 + +## 历史菜单、Esc 分层与全量回归收尾 + +继续核查上述 18 个失败后,确认并修复了以下真实行为问题: + +- 手机 Agent 内的 Radix 模型菜单和权限模式菜单已处理 Esc,并将事件标记为 `defaultPrevented`,但外层 `MobileWorkbenchSheet` 的 window 监听仍无条件关闭抽屉。外层现在只处理未被内层消费的 Esc。模型和权限菜单各自关闭后,焦点返回原触发按钮;没有内层菜单时,Esc 仍关闭抽屉。 +- Dev Panel 有相同的事件分层缺陷。`FieldSelect` 打开时第一次 Esc 只关闭选择菜单并返回焦点,第二次 Esc 才关闭开发面板。 +- 手机端已打开 Agent 时跨越 1024px 进入桌面,原逻辑先清空移动抽屉状态,导致用户打开 Agent 的意图丢失。现在先将移动 Agent 状态迁移为桌面展开状态,再清空移动抽屉。 +- Dev Panel 打开时,地图工具栏仍在面板下面渲染。现在开发面板显示期间不挂载桌面地图工具栏与其工具面板。 +- 1024px 下展开工况任务会自动折叠 Agent,但 880px 工况面板与 72px 折叠栏重叠 4px。布局变量现显式记录折叠栏宽度,工况面板在该宽度下收敛到 852px,二者保留 24px 间距。 + +同时修正了三类失真的回归断言。地图容器变宽或变窄时,MapLibre 的 `resize()` 会发出 `movestart`,但经纬度、缩放、方位角和俯仰并未改变;相机稳定性用例现在直接比较这五项参数。地图通知位置测试改用确定会生成通知的“操作参考”入口。工况材质测试等待入场动画结束后再读取 transform 和 will-change,不再采样动画中间帧。焦点、几何和业务请求断言均保留。 + +320px 与 375px 的模型菜单截图位于 `test-results/agent-history-ui/320-model-menu.png` 和 `375-model-menu.png`,长标题重命名截图位于同一目录。模型名称、说明和菜单边界完整,权限模式、模型、语音和发送控件没有重叠。五张过期的工作台视觉基线经逐张检查后更新,覆盖桌面浅色/卫星底图、1920px Agent 收起状态、手机启动器和手机 Agent 半屏;手机工况卫星图无需更新。视觉用例更新后再次无更新参数运行,6/6 通过。 + +最终验证结果:`pnpm typecheck`、`pnpm lint`、69 文件 / 357 项 Vitest、`pnpm build` 全部通过。构建继续报告既有的 500kB 以上 chunk 提示,CSS 校验和 TTS bundle 通过。完整 Playwright 套件为 104/104 通过,耗时 2.4 分钟,结果位于忽略的 `test-results/non-map-followup-final-green/`。这些浏览器用例仍使用模拟接口,不替代真实账号、Agent、PG 和外围服务的全链路验收。 + +## 新增业务组件统一设计 + +2026-09-18 对调查任务与外围样板的新组件完成统一重构。完整规则见 `docs/component-design-system.md`,视觉方向是浅色专业调度台,保留地图为主工作区,并将侧栏内容稳定为“定位、状态、下一步、证据”的阅读顺序。 + +- 新增 `WorkbenchSection`、`WorkbenchNotice` 和 `WorkbenchEmptyState`,统一标题、状态、提示和空态,减少同级彩色卡片与大面积空白。 +- 生命周期确认、定时计划、目标编译、运行控制、运行对比、人工复核和 Evidence 导入统一状态语言与操作层级。 +- 外围样板将连接与场景、历史回放、待提交来源、已有任务放在前面;synthetic 数据、测试工单、测试数据和 request_id 查询移到辅助区并默认折叠。 +- `FieldSelect`、`Checkbox`、`Disclosure` 和 `DateTimeField` 统一焦点、hover、按压和禁用反馈。日期时间弹层继续支持 Esc 取消并返回焦点。 +- 数据表使用等宽数字并将数值右对齐;320、375 和 1280px 下均无横向溢出。 + +本轮最终检查为 `pnpm typecheck`、`pnpm lint`、76 文件 / 395 项 Vitest 和 `pnpm build` 通过。新增组件相关的 17 文件 / 118 项单测通过,核心业务面板 Playwright 为 31/31 通过。完整浏览器套件共 110 项,并发运行 108 项通过;2 项在等待 Toast 退出时超过 15 秒,随后单进程精确复跑 2/2 通过。截图位于忽略的 `test-results/task-lifecycle/`、`run-control/`、`run-comparison/`、`peripheral-datetime/` 和 `/tmp/feedback-*.png`。 diff --git a/public/map/gis-customer-meter.png b/public/map/gis-customer-meter.png new file mode 100644 index 0000000..543ace1 Binary files /dev/null and b/public/map/gis-customer-meter.png differ diff --git a/public/map/gis-customer-meter.svg b/public/map/gis-customer-meter.svg new file mode 100644 index 0000000..c0b400f --- /dev/null +++ b/public/map/gis-customer-meter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map/gis-drain-valve.png b/public/map/gis-drain-valve.png new file mode 100644 index 0000000..9f61305 Binary files /dev/null and b/public/map/gis-drain-valve.png differ diff --git a/public/map/gis-drain-valve.svg b/public/map/gis-drain-valve.svg new file mode 100644 index 0000000..c3b9b2c --- /dev/null +++ b/public/map/gis-drain-valve.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map/gis-flow-meter.png b/public/map/gis-flow-meter.png new file mode 100644 index 0000000..c2ae471 Binary files /dev/null and b/public/map/gis-flow-meter.png differ diff --git a/public/map/gis-flow-meter.svg b/public/map/gis-flow-meter.svg new file mode 100644 index 0000000..89664d1 --- /dev/null +++ b/public/map/gis-flow-meter.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/map/gis-hydrant.png b/public/map/gis-hydrant.png new file mode 100644 index 0000000..2d3ad1a Binary files /dev/null and b/public/map/gis-hydrant.png differ diff --git a/public/map/gis-hydrant.svg b/public/map/gis-hydrant.svg new file mode 100644 index 0000000..7381900 --- /dev/null +++ b/public/map/gis-hydrant.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/map/gis-non-residential-meter.png b/public/map/gis-non-residential-meter.png new file mode 100644 index 0000000..52be79d Binary files /dev/null and b/public/map/gis-non-residential-meter.png differ diff --git a/public/map/gis-non-residential-meter.svg b/public/map/gis-non-residential-meter.svg new file mode 100644 index 0000000..a064bf4 --- /dev/null +++ b/public/map/gis-non-residential-meter.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/map/gis-service-connection.png b/public/map/gis-service-connection.png new file mode 100644 index 0000000..b05fe71 Binary files /dev/null and b/public/map/gis-service-connection.png differ diff --git a/public/map/gis-service-connection.svg b/public/map/gis-service-connection.svg new file mode 100644 index 0000000..f47d96a --- /dev/null +++ b/public/map/gis-service-connection.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/map/gis-valve.png b/public/map/gis-valve.png new file mode 100644 index 0000000..7028d00 Binary files /dev/null and b/public/map/gis-valve.png differ diff --git a/public/map/gis-valve.svg b/public/map/gis-valve.svg new file mode 100644 index 0000000..9a2a6ed --- /dev/null +++ b/public/map/gis-valve.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/map/water-quality-monitoring.png b/public/map/water-quality-monitoring.png new file mode 100644 index 0000000..9c35a41 Binary files /dev/null and b/public/map/water-quality-monitoring.png differ diff --git a/public/map/water-quality-monitoring.svg b/public/map/water-quality-monitoring.svg new file mode 100644 index 0000000..093b92f --- /dev/null +++ b/public/map/water-quality-monitoring.svg @@ -0,0 +1 @@ + diff --git a/public/runtime-config.js b/public/runtime-config.js index d4a46a2..dada733 100644 --- a/public/runtime-config.js +++ b/public/runtime-config.js @@ -2,5 +2,6 @@ globalThis.__TJWATER_CONFIG__ = { TJWATER_AUTH_MODE: "required", TJWATER_KEYCLOAK_ISSUER: "", TJWATER_KEYCLOAK_CLIENT_ID: "next-tjwater", - TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000" + TJWATER_SERVER_API_BASE_URL: "http://127.0.0.1:8000", + TJWATER_WORKBENCH_RUNTIME_MODE: "auto" }; diff --git a/src/app/app.e2e.ts b/src/app/app.e2e.ts index 8448e6d..88640dc 100644 --- a/src/app/app.e2e.ts +++ b/src/app/app.e2e.ts @@ -1,4 +1,11 @@ import { expect, test } from "@playwright/test"; +import { mockAgentApi } from "../../tests/browser/support/mock-agent-api"; +import { mockWorkbenchContext } from "../../tests/browser/support/mock-workbench-context"; + +test.beforeEach(async ({ page }) => { + await mockWorkbenchContext(page); + await mockAgentApi(page); +}); test("renders the migrated WebGIS workbench shell", async ({ page }) => { const riveRequests: string[] = []; @@ -11,6 +18,9 @@ test("renders the migrated WebGIS workbench shell", async ({ page }) => { await page.goto("/"); await expect(page.getByText("供水管网智能调度系统")).toBeVisible(); await expect(page.getByText("调度工作台")).toBeVisible(); + await expect(page.getByLabel("Agent 折叠栏")).toBeVisible(); + await expect(page.getByLabel("Agent 命令面板")).toHaveCount(0); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await expect(page.getByLabel("推荐问题")).toBeVisible(); const runtimeConfig = await page.evaluate(() => globalThis.__TJWATER_CONFIG__); @@ -72,8 +82,11 @@ test("renders map notices through a single toaster", async ({ page }) => { test("preserves compact typography in header and agent controls", async ({ page }) => { await page.setViewportSize({ width: 1440, height: 900 }); await page.goto("/"); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); - const headerControl = page.locator("header.acrylic-navigation button.font-semibold:visible").first(); + const headerControl = page + .locator("header.acrylic-navigation button.font-semibold:visible") + .first(); const approvalControl = page.getByRole("button", { name: "权限批准模式" }); const suggestion = page.locator("[aria-label=推荐问题] button").first(); @@ -86,6 +99,7 @@ test("preserves compact typography in header and agent controls", async ({ page test("lets interactive utilities override surface materials", async ({ page }) => { await page.goto("/"); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); await page.addStyleTag({ content: "*,*::before,*::after{transition:none!important}" }); const suggestion = page.locator("[aria-label=推荐问题] button").first(); @@ -113,18 +127,17 @@ test("keeps menu origin and the solid Main Frame styling through Tailwind v4", a expect(menuTransformOrigin).not.toBe("50% 50%"); await page.keyboard.press("Escape"); - const navigation = page.getByRole("complementary", { name: "工作台导航" }); - const navigationMaterial = await navigation.evaluate( - (element) => { - const style = window.getComputedStyle(element); - return { - backdropFilter: style.backdropFilter, - backgroundColor: style.backgroundColor - }; - } - ); + await page.getByRole("button", { name: /展开 Agent 助手面板/ }).click(); + const navigation = page.getByRole("complementary", { name: "Agent 命令面板" }); + const navigationMaterial = await navigation.evaluate((element) => { + const style = window.getComputedStyle(element); + return { + backdropFilter: style.backdropFilter, + backgroundColor: style.backgroundColor + }; + }); expect(navigationMaterial.backdropFilter).toBe("none"); - expect(navigationMaterial.backgroundColor).toBe("rgb(237, 241, 245)"); + expect(navigationMaterial.backgroundColor).toBe("oklch(0.965 0.012 248)"); await expect(page.locator("#main-workspace")).toHaveAttribute("data-layout-mode", "default"); await expect(page.getByRole("toolbar", { name: "工作区窗口任务栏" })).toHaveCount(0); }); diff --git a/src/features/agent/api/client.test.ts b/src/features/agent/api/client.test.ts index 71d85c0..79b2eb0 100644 --- a/src/features/agent/api/client.test.ts +++ b/src/features/agent/api/client.test.ts @@ -122,6 +122,20 @@ describe("Agent API client sessions", () => { expect(headers.get("Content-Type")).toBe("application/json"); }); + it("sends the authorized project context on Agent requests", async () => { + const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(JSON.stringify({ sessions: [] }), { status: 200 }) + ); + vi.stubGlobal("fetch", fetchMock); + const getProjectId = vi.fn().mockResolvedValue("project-117"); + + await createAgentApiClient("http://agent.local", { getProjectId }).listSessions(); + + expect(getProjectId).toHaveBeenCalledOnce(); + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("X-Project-Id")).toBe("project-117"); + }); + it("streams session events from the backend SSE endpoint", async () => { vi.stubGlobal( "fetch", diff --git a/src/features/agent/api/client.ts b/src/features/agent/api/client.ts index dad9d88..f2674e0 100644 --- a/src/features/agent/api/client.ts +++ b/src/features/agent/api/client.ts @@ -115,6 +115,7 @@ export type AgentApiClient = { export type AgentApiClientOptions = { getAccessToken?: AccessTokenProvider; + getProjectId?: () => Promise; }; const AGENT_API_BASE_URLS = [env.TJWATER_AGENT_API_BASE_URL.replace(/\/$/, "")]; @@ -137,7 +138,8 @@ export function createAgentApiClient( setActiveBaseUrl, path, init, - options.getAccessToken + options.getAccessToken, + options.getProjectId ); const requestJson = async (path: string, init?: RequestInit) => { const response = await request(path, init); @@ -298,18 +300,19 @@ async function fetchWithFallback( setActiveBaseUrl: (baseUrl: string) => void, path: string, init?: RequestInit, - getAccessToken?: AccessTokenProvider + getAccessToken?: AccessTokenProvider, + getProjectId?: () => Promise ) { const orderedBaseUrls = [activeBaseUrl, ...baseUrls.filter((item) => item !== activeBaseUrl)]; let lastError: unknown; let lastResponse: Response | null = null; const accessToken = await getAccessToken?.(); - const requestInit = accessToken - ? { - ...init, - headers: withBearerToken(init?.headers, accessToken) - } - : init; + const projectId = await getProjectId?.(); + const hasContextHeaders = Boolean(accessToken || projectId); + const headers = hasContextHeaders ? new Headers(init?.headers) : undefined; + if (accessToken) headers?.set("Authorization", `Bearer ${accessToken}`); + if (projectId) headers?.set("X-Project-Id", projectId); + const requestInit = hasContextHeaders ? { ...init, headers } : init; for (const baseUrl of orderedBaseUrls) { try { @@ -337,12 +340,6 @@ async function fetchWithFallback( throw lastError instanceof Error ? lastError : new Error("Agent API unavailable"); } -function withBearerToken(headersInit: HeadersInit | undefined, accessToken: string) { - const headers = new Headers(headersInit); - headers.set("Authorization", `Bearer ${accessToken}`); - return headers; -} - function shouldFallbackOnHttpStatus(status: number) { return status === 404 || status === 405 || status === 502 || status === 503 || status === 504; } diff --git a/src/features/agent/components/agent-command-panel.tsx b/src/features/agent/components/agent-command-panel.tsx index a6ed782..d5427c5 100644 --- a/src/features/agent/components/agent-command-panel.tsx +++ b/src/features/agent/components/agent-command-panel.tsx @@ -20,7 +20,17 @@ import { X } from "lucide-react"; import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "motion/react"; -import { useCallback, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react"; +import { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, + type Dispatch, + type ReactNode, + type SetStateAction +} from "react"; import { createPortal } from "react-dom"; import { useStickToBottomContext } from "use-stick-to-bottom"; import { showMapNotice } from "@/features/map/core"; @@ -108,7 +118,9 @@ type AgentCommandPanelProps = { approvalMode?: AgentApprovalMode; uiResults?: AgentUiResult[]; workspaceContext?: AgentWorkspaceContextItem[]; + prompt: string; onCollapse: () => void; + onPromptChange: Dispatch>; onRefreshHistory?: () => Promise | void; onStartNewSession?: () => Promise | void; onLoadHistorySession?: (sessionId: string) => Promise | void; @@ -160,7 +172,9 @@ export function AgentCommandPanel({ approvalMode = "request", uiResults = [], workspaceContext = [], + prompt, onCollapse, + onPromptChange, onRefreshHistory, onStartNewSession, onLoadHistorySession, @@ -174,13 +188,14 @@ export function AgentCommandPanel({ onReplyQuestion, onRejectQuestion }: AgentCommandPanelProps) { - const [prompt, setPrompt] = useState(""); const [historyOpen, setHistoryOpen] = useState(false); const [loadingSessionId, setLoadingSessionId] = useState(null); const [scrollRequestId, setScrollRequestId] = useState(0); const historyPanelId = useId(); const historyTriggerRef = useRef(null); const historyPanelRef = useRef(null); + const promptRestoreGenerationRef = useRef(0); + const mountedRef = useRef(true); const desktopFloating = presentation === "desktop-floating"; const floatingPresentation = desktopFloating || presentation === "mobile-sheet"; const hasConversation = messages.length > 0 || streaming; @@ -195,8 +210,9 @@ export function AgentCommandPanel({ isSupported: isSpeechSupported } = useAgentSpeechSynthesis(); const appendRecognitionResult = useCallback((text: string) => { - setPrompt((current) => `${current}${current.trim() ? " " : ""}${text}`); - }, []); + promptRestoreGenerationRef.current += 1; + onPromptChange((current) => `${current}${current.trim() ? " " : ""}${text}`); + }, [onPromptChange]); const showRecognitionError = useCallback((message: string) => { showMapNotice({ id: "agent-speech-recognition-error", @@ -219,6 +235,14 @@ export function AgentCommandPanel({ } }, [historyOpen, onRefreshHistory]); + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + promptRestoreGenerationRef.current += 1; + }; + }, []); + useEffect(() => { if (!historyOpen) { return; @@ -271,11 +295,16 @@ export function AgentCommandPanel({ if (!nextPrompt || streaming) { return; } - setPrompt(""); + const restoreGeneration = promptRestoreGenerationRef.current + 1; + promptRestoreGenerationRef.current = restoreGeneration; + onPromptChange(""); stopListening(); setScrollRequestId((requestId) => requestId + 1); void Promise.resolve(onSubmitPrompt(nextPrompt)).catch(() => { - setPrompt(nextPrompt); + if (!mountedRef.current || promptRestoreGenerationRef.current !== restoreGeneration) { + return; + } + onPromptChange((current) => current || nextPrompt); }); }; const historyPanel = ( @@ -286,6 +315,7 @@ export function AgentCommandPanel({ sessions={sessionHistory} onRefresh={onRefreshHistory} onSelectSession={(nextSessionId) => { + promptRestoreGenerationRef.current += 1; setLoadingSessionId(nextSessionId); void Promise.resolve(onLoadHistorySession?.(nextSessionId)) .then(() => setHistoryOpen(false)) @@ -369,8 +399,9 @@ export function AgentCommandPanel({ aria-label="新建 Agent 对话" title="新建 Agent 对话" onClick={() => { + promptRestoreGenerationRef.current += 1; setHistoryOpen(false); - setPrompt(""); + onPromptChange(""); void Promise.resolve(onStartNewSession?.()); }} className="agent-panel-icon-button grid h-10 w-10 shrink-0 place-items-center rounded-xl text-slate-600" @@ -436,7 +467,9 @@ export function AgentCommandPanel({ {workspaceContext.length > 0 ? (
); } function TickerProgressBar({ - progressLabel, + progressValue, muted = false }: { - progressLabel: string; + progressValue: number; muted?: boolean; }) { return ( @@ -443,7 +450,7 @@ function TickerProgressBar({ "status-tone-info block h-full rounded-full", muted ? "bg-[var(--status-border)]" : "bg-[var(--status-mark)]" )} - animate={{ width: progressLabel }} + animate={{ width: `${Math.min(100, Math.max(0, progressValue))}%` }} initial={false} transition={{ duration: 0.34, ease: tickerEase }} /> diff --git a/src/features/workbench/components/analysis-history-section.tsx b/src/features/workbench/components/analysis-history-section.tsx new file mode 100644 index 0000000..3ba36b6 --- /dev/null +++ b/src/features/workbench/components/analysis-history-section.tsx @@ -0,0 +1,202 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { Archive, BarChart3, ChevronRight, LoaderCircle, RefreshCw } from "lucide-react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { Button } from "@/shared/ui/button"; +import { Disclosure } from "@/shared/ui/disclosure"; +import { StatusBadge, type StatusTone } from "@/shared/ui/status"; +import { fetchWorkbenchAnalysisRun } from "../api/workbench-runtime-client"; +import { + fetchWorkbenchAnalysisHistory, + type WorkbenchAnalysisHistoryItem +} from "../api/workbench-analysis-history-client"; +import type { AnalysisArtifact } from "../workspace/workspace-model"; +import { + WorkbenchEmptyState, + WorkbenchNotice, + WorkbenchSection +} from "./workbench-panel-ui"; + +const HISTORY_PAGE_SIZE = 10; + +export function AnalysisHistorySection({ + getAccessToken, + onOpenArtifact +}: { + getAccessToken?: AccessTokenProvider; + onOpenArtifact: (artifact: AnalysisArtifact) => void; +}) { + const [items, setItems] = useState([]); + const [nextOffset, setNextOffset] = useState(null); + const [loading, setLoading] = useState(false); + const [openingRunId, setOpeningRunId] = useState(null); + const [error, setError] = useState(null); + const loadedRef = useRef(false); + const listControllerRef = useRef(null); + const artifactControllerRef = useRef(null); + + useEffect(() => () => { + listControllerRef.current?.abort(); + artifactControllerRef.current?.abort(); + }, []); + + const load = useCallback(async (more = false) => { + if (loading) return; + const offset = more ? nextOffset : 0; + if (more && offset === null) return; + listControllerRef.current?.abort(); + const controller = new AbortController(); + listControllerRef.current = controller; + setLoading(true); + setError(null); + try { + const page = await fetchWorkbenchAnalysisHistory({ + limit: HISTORY_PAGE_SIZE, + offset: offset ?? 0, + getAccessToken, + signal: controller.signal + }); + if (controller.signal.aborted) return; + setItems((current) => more + ? [...current, ...page.items.filter( + (item) => !current.some((existing) => existing.runId === item.runId) + )] + : page.items); + setNextOffset(page.nextOffset); + loadedRef.current = true; + } catch (nextError) { + if (!controller.signal.aborted) { + setError(errorMessage(nextError)); + } + } finally { + if (!controller.signal.aborted) setLoading(false); + } + }, [getAccessToken, loading, nextOffset]); + + const openArtifact = async (item: WorkbenchAnalysisHistoryItem) => { + if (openingRunId) return; + artifactControllerRef.current?.abort(); + const controller = new AbortController(); + artifactControllerRef.current = controller; + setOpeningRunId(item.runId); + setError(null); + try { + const projection = await fetchWorkbenchAnalysisRun( + item.runId, + getAccessToken, + controller.signal + ); + if (!controller.signal.aborted) onOpenArtifact(projection.artifact); + } catch (nextError) { + if (!controller.signal.aborted) setError(errorMessage(nextError)); + } finally { + if (!controller.signal.aborted) setOpeningRunId(null); + } + }; + + return ( + } + tone="info" + meta={loadedRef.current ? ( + + 已加载 {items.length} 项 + + ) : null} + > + { + if (event.currentTarget.open && !loadedRef.current && !loading) void load(); + }} + > +
+ {error ? {error} : null} + {loading && !items.length ? ( +
+ 正在读取分析成果历史 +
+ ) : null} + {!loading && loadedRef.current && !items.length ? ( + } + title="暂无持久化分析成果" + description="新的分析运行会在保存到当前项目 BizDB 后出现在这里。" + /> + ) : null} + {items.length ? ( +
    + {items.map((item) => ( +
  1. + +
  2. + ))} +
+ ) : null} +
+ {error && !items.length ? ( + + ) : null} + {nextOffset !== null ? ( + + ) : null} +
+
+
+
+ ); +} + +function statusTone(status: string): StatusTone { + if (["completed", "succeeded"].includes(status)) return "success"; + if (["failed", "error"].includes(status)) return "danger"; + if (["running"].includes(status)) return "info"; + if (["queued", "pending"].includes(status)) return "warning"; + return "neutral"; +} + +function formatHistoryTime(value: string) { + return new Intl.DateTimeFormat("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false + }).format(new Date(value)); +} + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : "分析成果历史请求失败"; +} diff --git a/src/features/workbench/components/analysis-results-panel.tsx b/src/features/workbench/components/analysis-results-panel.tsx new file mode 100644 index 0000000..10de15a --- /dev/null +++ b/src/features/workbench/components/analysis-results-panel.tsx @@ -0,0 +1,42 @@ +import { BarChart3, X } from "lucide-react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; +import { Button } from "@/shared/ui/button"; +import type { AnalysisArtifact } from "../workspace/workspace-model"; +import { AnalysisHistorySection } from "./analysis-history-section"; + +export function AnalysisResultsPanel({ + open, + onClose, + getAccessToken, + onOpenArtifact +}: { + open: boolean; + onClose: () => void; + getAccessToken?: AccessTokenProvider; + onOpenArtifact: (artifact: AnalysisArtifact) => void; +}) { + if (!open) return null; + + return ( + + ); +} diff --git a/src/features/workbench/components/feature-popover.test.tsx b/src/features/workbench/components/feature-popover.test.tsx new file mode 100644 index 0000000..561212f --- /dev/null +++ b/src/features/workbench/components/feature-popover.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { FeaturePopover } from "./feature-popover"; + +const mocks = vi.hoisted(() => ({ + fetchElementGisProperties: vi.fn() +})); + +vi.mock("../api/gis-properties-client", async (importOriginal) => ({ + ...await importOriginal(), + fetchElementGisProperties: mocks.fetchElementGisProperties +})); + +describe("feature GIS properties", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("keeps imported GIS fields folded by dataset", async () => { + mocks.fetchElementGisProperties.mockResolvedValue([{ + dataset_key: "fx_pipe_inventory_20200811", + dataset_title: "奉贤 DN100 及以上管网台账", + source_date: "2020-08-11", + layer_key: "fx_20200811_dn100_pipes", + layer_name: "DN100 及以上管线台账", + source_fid: 117, + match_method: "geometry", + match_distance_m: 0.12, + attributes: { PIPE_D: 300, MATERIAL: "DI" } + }]); + + render( + "read-token"} + onClose={vi.fn()} + /> + ); + + expect(await screen.findByText("1 份")).toBeVisible(); + expect(screen.getByText("PIPE_D")).not.toBeVisible(); + + fireEvent.click(screen.getByText("GIS 台账属性")); + expect(screen.getByText("DN100 及以上管线台账")).toBeVisible(); + expect(screen.getByText("PIPE_D")).not.toBeVisible(); + + fireEvent.click(screen.getByText("DN100 及以上管线台账")); + expect(screen.getByTitle("300 mm")).toBeVisible(); + expect(screen.getByText("材质")).toBeVisible(); + expect(screen.getByTitle("球墨铸铁")).toBeVisible(); + expect(screen.getByText("PIPE_D")).not.toBeVisible(); + + fireEvent.click(screen.getByText("全部原始字段 · 2 项")); + expect(screen.getByText("PIPE_D")).toBeVisible(); + }); + + it("hides the GIS inventory section when the element has no linked records", async () => { + mocks.fetchElementGisProperties.mockResolvedValue([]); + + render( + "read-token"} + onClose={vi.fn()} + /> + ); + + await waitFor(() => expect(mocks.fetchElementGisProperties).toHaveBeenCalledOnce()); + expect(screen.queryByText("GIS 台账属性")).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/workbench/components/feature-popover.tsx b/src/features/workbench/components/feature-popover.tsx index 2440531..9fe4dc3 100644 --- a/src/features/workbench/components/feature-popover.tsx +++ b/src/features/workbench/components/feature-popover.tsx @@ -1,12 +1,14 @@ import { Check, Copy, Database, X } from "lucide-react"; import type { ComponentType } from "react"; import { useEffect, useRef, useState } from "react"; +import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { MAP_FOCUS_SURFACE_CLASS_NAME, MAP_MAJOR_PANEL_RADIUS_CLASS_NAME, showMapNotice } from "@/features/map/core"; import { cn } from "@/shared/ui/cn"; +import { Disclosure } from "@/shared/ui/disclosure"; import { StatusBadge, StatusDot, @@ -14,10 +16,12 @@ import { } from "@/shared/ui/status"; import type { WaterNetworkSourceId } from "../map/sources"; import type { DetailFeature } from "../types"; +import { useElementGisProperties } from "../hooks/use-element-gis-properties"; import { getFeaturePanelModel, type FeaturePanelBadgeTone } from "../utils/feature-properties"; +import { presentGisAttributeGroup } from "../utils/gis-attribute-presentation"; import { PipeFeatureIcon, JunctionFeatureIcon, @@ -31,10 +35,12 @@ import { FeaturePropertyList } from "./feature-property-list"; type FeaturePopoverProps = { feature: DetailFeature | null; + projectId: string | null; + getAccessToken?: AccessTokenProvider; onClose: () => void; }; -const FEATURE_LAYER_META: Record< +const FEATURE_LAYER_META: Partial; @@ -43,14 +49,23 @@ const FEATURE_LAYER_META: Record< labelClassName: string; iconClassName: string; } -> = { +>> = { pipes: { icon: PipeFeatureIcon, label: "管线", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, junctions: { icon: JunctionFeatureIcon, label: "节点", headerClassName: "bg-blue-50/70", labelClassName: "text-blue-800", iconClassName: "bg-sky-700 text-white ring-sky-900/10" }, valves: { icon: ValveFeatureIcon, label: "阀门", headerClassName: "bg-violet-50/70", labelClassName: "text-violet-800", iconClassName: "bg-violet-600 text-white ring-violet-900/10" }, reservoirs: { icon: ReservoirFeatureIcon, label: "水库", headerClassName: "bg-sky-50/70", labelClassName: "text-sky-900", iconClassName: "bg-sky-800 text-white ring-sky-900/10" }, scada: { icon: ScadaFeatureIcon, label: "SCADA", headerClassName: "bg-slate-50/80", labelClassName: "text-slate-800", iconClassName: "bg-slate-700 text-white ring-slate-900/10" }, pumps: { icon: PumpFeatureIcon, label: "泵", headerClassName: "bg-cyan-50/70", labelClassName: "text-cyan-800", iconClassName: "bg-cyan-800 text-white ring-cyan-900/10" }, - tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" } + tanks: { icon: ReservoirFeatureIcon, label: "水箱", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" }, + water_quality_monitoring_points: { icon: ScadaFeatureIcon, label: "水质监测点", headerClassName: "bg-teal-50/70", labelClassName: "text-teal-800", iconClassName: "bg-teal-700 text-white ring-teal-900/10" } +}; + +const SOURCE_GIS_LAYER_META = { + icon: JunctionFeatureIcon, + label: "GIS 交付要素", + headerClassName: "bg-slate-50/80", + labelClassName: "text-slate-700", + iconClassName: "bg-slate-600 text-white ring-slate-900/10" }; const badgeTones = { @@ -61,9 +76,18 @@ const badgeTones = { info: "info" } satisfies Record; -export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { +export function FeaturePopover({ + feature, + projectId, + getAccessToken, + onClose +}: FeaturePopoverProps) { const [copiedId, setCopiedId] = useState(null); + const [expandedGisFeatureKey, setExpandedGisFeatureKey] = useState(null); const copyResetTimeoutRef = useRef | null>(null); + const gisProperties = useElementGisProperties(feature, projectId, getAccessToken); + const featureKey = feature ? `${feature.layer}:${feature.id}` : null; + const gisExpanded = featureKey !== null && expandedGisFeatureKey === featureKey; useEffect(() => () => { if (copyResetTimeoutRef.current) { @@ -76,7 +100,7 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { } const panelModel = getFeaturePanelModel(feature.layer, feature.properties, feature.id); - const layerMeta = FEATURE_LAYER_META[feature.layer]; + const layerMeta = FEATURE_LAYER_META[feature.layer] ?? SOURCE_GIS_LAYER_META; const LayerIcon = layerMeta.icon; const copied = copiedId === panelModel.id; const canCopy = panelModel.id !== "暂无"; @@ -106,8 +130,12 @@ export function FeaturePopover({ feature, onClose }: FeaturePopoverProps) { return (