From f1c54f658d756790268b4957dc28f0b7e3ffca17 Mon Sep 17 00:00:00 2001 From: tianM <46552064+a3384379@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:17:13 +0800 Subject: [PATCH 1/9] feat(ui): implement ice-light workspaces and asset execution links --- docs/design/README.md | 17 +- docs/design/ui-v5/acceptance.csv | 81 ++ docs/design/ui-v5/changed-files.txt | 86 ++ docs/design/ui-v5/implementation.md | 148 +++ docs/design/ui-v5/modules.csv | 27 + docs/design/ui-v5/nodes.csv | 30 + docs/design/ui-v5/source-inventory.csv | 13 + docs/design/ui-v5/visual-deviations.md | 16 + frontend/src/App.test.tsx | 12 +- frontend/src/App.tsx | 10 +- .../src/features/api-console/APIWorkbench.tsx | 30 +- .../api-console/ExecutionResultPanel.test.tsx | 14 +- .../api-console/ExecutionResultPanel.tsx | 98 +- .../features/api-console/use-api-console.ts | 18 +- .../dashboard/DashboardTrendChart.tsx | 24 +- .../evidence/AssertionEvidence.test.tsx | 30 + .../features/evidence/AssertionEvidence.tsx | 52 + .../src/features/evidence/assertion-model.ts | 17 + .../features/navigation/ShellSidebar.test.tsx | 135 ++- .../src/features/navigation/ShellSidebar.tsx | 179 +-- .../navigation/use-navigation-state.ts | 6 +- .../reports/ReportExecutionWorkspace.tsx | 472 ++++++++ .../src/features/reports/ReportTrendChart.tsx | 29 +- .../src/features/reports/report-service.ts | 7 +- frontend/src/features/reports/use-reports.ts | 10 +- .../features/task-plans/task-plan-service.ts | 8 +- .../test-assets/AssetDetailDrawer.tsx | 419 +++++++ .../test-assets/AssetPlanDialog.test.tsx | 82 ++ .../features/test-assets/AssetPlanDialog.tsx | 147 +++ .../features/test-assets/AssetRunEvidence.tsx | 138 +++ .../test-assets/AssetTableContext.tsx | 71 ++ .../test-assets/asset-run-view-model.test.ts | 91 ++ .../test-assets/asset-run-view-model.ts | 59 + .../asset-workspace-service.test.ts | 77 ++ .../test-assets/asset-workspace-service.ts | 140 +++ .../test-assets/test-asset-service.ts | 6 +- .../test-assets/test-asset-view-model.ts | 35 +- .../features/test-assets/use-asset-history.ts | 34 + .../test-assets/use-test-assets.test.tsx | 20 +- .../features/test-assets/use-test-assets.ts | 29 +- .../workflows/execution-navigation.test.ts | 40 + .../workflows/execution-navigation.ts | 31 + .../features/workflows/use-workflow-tabs.ts | 6 + .../src/features/workflows/use-workflows.ts | 37 +- frontend/src/flow/WorkflowDesigner.test.tsx | 85 +- frontend/src/flow/WorkflowDesigner.tsx | 681 ++++++++--- frontend/src/flow/WorkflowInspectorShell.tsx | 10 +- .../src/flow/WorkflowNodeInspector.test.tsx | 1 + frontend/src/flow/WorkflowNodeInspector.tsx | 139 ++- frontend/src/flow/WorkflowNodeLibrary.tsx | 87 +- frontend/src/flow/WorkflowNodeRelations.tsx | 73 ++ frontend/src/flow/WorkflowRegionCanvas.tsx | 16 +- .../src/flow/WorkflowRunInspector.test.tsx | 31 +- frontend/src/flow/WorkflowRunInspector.tsx | 90 +- frontend/src/flow/WorkflowRunTrajectory.tsx | 134 +++ frontend/src/flow/WorkflowRuntimeDock.tsx | 3 + .../src/flow/WorkflowSettingsEditor.test.tsx | 84 ++ frontend/src/flow/WorkflowSettingsEditor.tsx | 219 ++++ .../src/flow/WorkflowWorkspaceShell.test.tsx | 120 +- frontend/src/flow/WorkflowWorkspaceShell.tsx | 84 +- .../flow/editor/layout-preferences.test.ts | 2 +- .../src/flow/editor/layout-preferences.ts | 4 +- .../src/flow/editor/node-presentation.test.ts | 54 + frontend/src/flow/editor/node-presentation.ts | 122 ++ .../flow/editor/use-node-selection-guard.tsx | 73 ++ frontend/src/flow/workflow-editor.css | 575 +++++++-- frontend/src/main.tsx | 22 +- frontend/src/pages/ApiConsolePage.test.tsx | 139 ++- frontend/src/pages/ApiConsolePage.tsx | 240 ++-- frontend/src/pages/DashboardPage.test.tsx | 4 + frontend/src/pages/DashboardPage.tsx | 96 +- frontend/src/pages/ReleaseGatePage.test.tsx | 41 + frontend/src/pages/ReleaseGatePage.tsx | 93 +- frontend/src/pages/ReportsPage.test.tsx | 181 ++- frontend/src/pages/ReportsPage.tsx | 269 +++-- frontend/src/pages/TestAssetsPage.test.tsx | 54 + frontend/src/pages/TestAssetsPage.tsx | 716 +++++++++++- .../src/pages/TestAssetsWorkspace.test.tsx | 270 +++++ frontend/src/pages/WorkflowsPage.test.tsx | 46 +- frontend/src/pages/WorkflowsPage.tsx | 246 +++- frontend/src/styles.css | 1032 +++++++++++++---- .../src/theme/AppearanceSettings.test.tsx | 38 + frontend/src/theme/AppearanceSettings.tsx | 54 + frontend/src/theme/IceThemeProvider.tsx | 15 + frontend/src/theme/ice-theme.ts | 141 +++ frontend/src/theme/use-reduced-motion.ts | 15 + 86 files changed, 8019 insertions(+), 1411 deletions(-) create mode 100644 docs/design/ui-v5/acceptance.csv create mode 100644 docs/design/ui-v5/changed-files.txt create mode 100644 docs/design/ui-v5/implementation.md create mode 100644 docs/design/ui-v5/modules.csv create mode 100644 docs/design/ui-v5/nodes.csv create mode 100644 docs/design/ui-v5/source-inventory.csv create mode 100644 docs/design/ui-v5/visual-deviations.md create mode 100644 frontend/src/features/evidence/AssertionEvidence.test.tsx create mode 100644 frontend/src/features/evidence/AssertionEvidence.tsx create mode 100644 frontend/src/features/evidence/assertion-model.ts create mode 100644 frontend/src/features/reports/ReportExecutionWorkspace.tsx create mode 100644 frontend/src/features/test-assets/AssetDetailDrawer.tsx create mode 100644 frontend/src/features/test-assets/AssetPlanDialog.test.tsx create mode 100644 frontend/src/features/test-assets/AssetPlanDialog.tsx create mode 100644 frontend/src/features/test-assets/AssetRunEvidence.tsx create mode 100644 frontend/src/features/test-assets/AssetTableContext.tsx create mode 100644 frontend/src/features/test-assets/asset-run-view-model.test.ts create mode 100644 frontend/src/features/test-assets/asset-run-view-model.ts create mode 100644 frontend/src/features/test-assets/asset-workspace-service.test.ts create mode 100644 frontend/src/features/test-assets/asset-workspace-service.ts create mode 100644 frontend/src/features/test-assets/use-asset-history.ts create mode 100644 frontend/src/features/workflows/execution-navigation.test.ts create mode 100644 frontend/src/features/workflows/execution-navigation.ts create mode 100644 frontend/src/flow/WorkflowNodeRelations.tsx create mode 100644 frontend/src/flow/WorkflowRunTrajectory.tsx create mode 100644 frontend/src/flow/WorkflowSettingsEditor.test.tsx create mode 100644 frontend/src/flow/WorkflowSettingsEditor.tsx create mode 100644 frontend/src/flow/editor/node-presentation.test.ts create mode 100644 frontend/src/flow/editor/node-presentation.ts create mode 100644 frontend/src/flow/editor/use-node-selection-guard.tsx create mode 100644 frontend/src/pages/TestAssetsWorkspace.test.tsx create mode 100644 frontend/src/theme/AppearanceSettings.test.tsx create mode 100644 frontend/src/theme/AppearanceSettings.tsx create mode 100644 frontend/src/theme/IceThemeProvider.tsx create mode 100644 frontend/src/theme/ice-theme.ts create mode 100644 frontend/src/theme/use-reduced-motion.ts diff --git a/docs/design/README.md b/docs/design/README.md index 28afdf6c..7dd8d249 100644 --- a/docs/design/README.md +++ b/docs/design/README.md @@ -1,11 +1,12 @@ # UI 设计基线 -当前 UI 方向:浅色、高信息密度的企业开发工具风格,参考 Linear、Postman、n8n 与 GitHub Actions。 +当前基线为 v5 冰蓝白:浅色导航、工作区、画布、代码区和弹层,系统深色模式下也使用同一套浅色语义色。 -- V3 主色:`#5b5cf0`;深色导航:`#101936`;成功、失败、运行、跳过使用统一语义色。 -- 侧栏约 220~240px,节点属性面板约 340~400px。 -- API 路径、变量、代码和 JSON 使用等宽字体。 -- Workflow 页面采用左节点库、中画布、右属性、底日志、顶操作栏布局。 -- V3 HTML 设计源与 2560×1440 视觉基准位于 - [`FlowTest_V3_UI_CN_HD`](../../FlowTest_V3_UI_CN_HD/README.md),并补充 1440×900 桌面验收; - 不承诺移动端布局。 +- 主操作色 `#1765E8`,工作区 `#F3F8FF`,画布 `#F7FBFF`。源码颜色入口为 `frontend/src/theme/ice-theme.ts`。 +- 常用任务栏 68px;26 个模块保留在可搜索的模块目录。流程检查器默认 320px,小视口使用抽屉。 +- 流程编排保留真实节点和端口、内联控制区域、应用/保存/发布/执行的生命周期;接口管理采用目录、请求、响应工作区。 +- 报告详情采用执行轨迹与证据工作区,可按执行、节点、请求尝试次数定位历史画布,并从画布返回完整报告。流程设置、来源定位与检查器配置/输入输出/校验均连接已有定义和处理器。 +- 1440×900、1280×800、1600×1000 是桌面核对视口;390px 报告采用单列证据和表格内部滚动。 +- 当前实现、验证范围及必要视觉差异见 [实施记录](ui-v5/implementation.md)、[视觉差异](ui-v5/visual-deviations.md) 和 [80 项验收矩阵](ui-v5/acceptance.csv)。 + +历史 V3 设计源保留在 [`FlowTest_V3_UI_CN_HD`](../../FlowTest_V3_UI_CN_HD/README.md)。其中紫色主色、深色导航和旧面板尺寸已由 v5 替代。 diff --git a/docs/design/ui-v5/acceptance.csv b/docs/design/ui-v5/acceptance.csv new file mode 100644 index 00000000..a9d85894 --- /dev/null +++ b/docs/design/ui-v5/acceptance.csv @@ -0,0 +1,81 @@ +id,category,test,steps,expected,status,evidence,notes +V5-001,视觉与主题,主工作区,1440/1280打开编辑器,系统分别设浅色和深色,始终冰蓝白,无黑色画布/面板,已验证,05-workflow-dark-os-1600.png; 04-workflow-1280.png,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-002,视觉与主题,主图一致性,对照主图01检查节点/工具条/检查器,保留质感与比例,不退成灰线框,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-003,视觉与主题,全局导航,打开常用模块和全部模块,浅色导航、文字可见、原入口和权限仍在,已验证,20-module-directory.png,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-004,视觉与主题,弹出层,逐一打开Tooltip/Popover/Modal/Toast,白/浅蓝底,无默认黑底,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-005,视觉与主题,代码区,请求/响应/历史JSON/diff,浅色语法主题、选中文本可读,已验证,06-api-workbench-1440.png; 07-workflow-history-evidence-1440.png,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-006,视觉与主题,图表,打开tooltip、图例、空数据,浅色、单位清楚、无假0,已验证,14-quality-overview-1440.png,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-007,视觉与主题,无毛玻璃支持,关闭blur/减少透明,实底白色,信息未丢失,已验证,21-appearance-settings.png,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-008,视觉与主题,文字对比,检测正文/说明/控件边界,符合相应4.5/3目标且真实状态可读,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-009,视觉与主题,长文字,长流程名、URL、错误和参数,合理换行或展开,无操作被遮挡,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-010,视觉与主题,响应式,1600/1440/1280/390,主任务可用,无靠缩字硬塞三栏,已验证,05-workflow-dark-os-1600.png; 04-workflow-1280.png; 11-report-evidence-390.png,桌面编辑器在 1280/1440/1600 检查,390 仅报告。 +V5-011,流程编辑与草稿,节点数量,逐项对照当前registry和历史28类,不丢节点,不伪造类型,已验证,frontend/src/flow/WorkflowDesigner.test.tsx,28 类注册项完整;start 为兼容额外映射;不是 28 类引擎实执行验收。 +V5-012,流程编辑与草稿,旧节点兼容,载入condition和legacy ForEach,保持类型与原语义,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-013,流程编辑与草稿,真实端口,对照schema检查handle,无按装饰端点新增真实端口,已验证,frontend/src/flow/WorkflowDesigner.test.tsx,沿用现有真实 handle 模型,非新增装饰性执行端口。 +V5-014,流程编辑与草稿,拖放添加,鼠标与键盘按已有方式添加,真实图新增正确类型,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-015,流程编辑与草稿,复制粘贴,跨选择集复制节点和引用,ID与引用规则按原逻辑,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-016,流程编辑与草稿,撤销重做,添加/移动/删节点/改连线,状态和历史栈一致,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-017,流程编辑与草稿,连线操作,插入节点、重连、非法连接,原校验与行为保持,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-018,流程编辑与草稿,自动布局,手动触发并切换主题,只在显式触发时改坐标,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-019,流程编辑与草稿,节点apply,修改未应用,再apply,图只在apply后dirty,已验证,frontend/src/flow/WorkflowNodeEditSession.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-020,流程编辑与草稿,未应用切节点,修改后选其他节点,应用/丢弃/取消可用,不丢输入,已验证,22-node-switch-confirmation.png; frontend/src/flow/WorkflowDesigner.test.tsx; qa-association-settings-result.log,本轮应用/取消浏览器验证;丢弃有组件回归;保留节点输入与图草稿边界。 +V5-021,流程编辑与草稿,保存成功,apply后发起保存,只确认提交版本,显示真实状态,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-022,流程编辑与草稿,保存失败,模拟超时/错误,保留dirty与数据,可重试,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-023,流程编辑与草稿,保存竞态,保存r12期间编辑到r13,r12成功不能清掉r13 dirty,已验证,frontend/src/features/workflows/workflow-save-concurrency.test.tsx,前端并发保存回归通过;未执行后端/真实网络保存。 +V5-024,流程编辑与草稿,关tab/切项目,两层dirty均存在时离开,未应用和未保存均受保护,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-025,流程编辑与草稿,session隔离,不同用户/项目与旧tab恢复,不串草稿,不因主题清空存储,已验证,frontend/src/features/workflows/workflow-session.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-026,流程编辑与草稿,快捷键上下文,输入框/代码/弹窗中按快捷键,不误删图、不拦截文本操作,已验证,frontend/src/flow/editor/use-canvas-hotkeys.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-027,控制流与数据关系,数据来源,选择可解析变量节点,来源名/路径/作用域正确,已映射,frontend/src/flow/WorkflowNodeRelations.tsx; qa-association-attempt-result.log,真实直接引用可点击定位来源,表达式与变量由原定义展示;复杂表达式和完整作用域真实验收仍待执行。 +V5-028,控制流与数据关系,无法解析引用,复杂表达式,显示未解析,不猜上游,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-029,控制流与数据关系,依赖线,切换依赖可视化后保存,执行edge集合不变,已验证,qa-reference-preservation-final-result.log; frontend/src/flow/editor/node-presentation.test.ts,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-030,控制流与数据关系,if/switch,真假/default/表达式错误,分支语义与原模型一致,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-031,控制流与数据关系,foreach/repeat,不同项/次数的嵌套执行,作用域正确,不变并行,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-032,控制流与数据关系,while/do-while/until,首轮条件与退出条件不同,先后判断保持准确,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-033,控制流与数据关系,循环上限,非法输入与耗尽运行,单位/上限/失败策略不丢失,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-034,控制流与数据关系,parallel,一分支完成、一分支失败,原join策略决定整体状态,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-035,控制流与数据关系,try/return/fail,正常/异常/返回路径,不吞异常、不改终止范围,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-036,控制流与数据关系,SQL/Redis,UI配置与请求尝试写入,现有只读与允许列表不可放宽,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-037,运行与证据,运行对象,执行草稿或发布版本,显示真实执行对象与环境,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-038,运行与证据,历史冻结,修改当前草稿后看旧run,旧预期/版本不变,已验证,24-workflow-history-trajectory-1440.png; qa-association-frozen-complete-result.log; frontend/src/pages/WorkflowsPage.test.tsx,当前草稿变量/名称/设置不覆盖冻结快照;返回编辑后恢复;原流程删除后历史仍可读。隔离夹具验证,真实后端执行未重跑。 +V5-039,运行与证据,节点证据,选择不同node/attempt,请求与结果属于正确节点,已验证,frontend/src/flow/WorkflowRunInspector.test.tsx; frontend/src/pages/ReportsPage.test.tsx; qa-association-attempt-result.log,执行/节点/第1次请求经报告与画布双向跳转、刷新保持一致;同节点再选不重置attempt。真实后端执行样本未新增。 +V5-040,运行与证据,循环证据,切item/index/嵌套路径,不串循环项,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-041,运行与证据,并行证据,切不同分支/子流程,不串branch/subflow,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-042,运行与证据,选择竞态,慢请求A后快速选择B,A回包不覆盖B,已映射,frontend/src/flow/WorkflowRunInspector.tsx;现有选择与查询作用域,慢请求A后选择B的完整竞态条件未在本次浏览器逐项运行;不以节点/attempt测试代替该验收。 +V5-043,运行与证据,HTTP与断言,HTTP200且断言失败,分别显示,运行不能假通过,已验证,06-api-workbench-1440.png; frontend/src/pages/ApiConsolePage.test.tsx,隔离返回 HTTP200 + 金额断言失败;失败 Toast 与失败证据一致。 +V5-044,运行与证据,缺字段,检查点/响应未提供,显示未提供而非0或通过,已验证,frontend/src/features/evidence/AssertionEvidence.test.tsx; frontend/src/flow/WorkflowRunInspector.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-045,运行与证据,重跑资格,不支持或权限不足的重跑,给原因,不发请求,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-046,运行与证据,只读回看,打开历史/证据tabs,不触发有副作用请求,已验证,qa-association-frozen-complete-result.log; qa-association-attempt-result.log,历史/设置只读、来源导航及双向证据跳转实测零后端资产写入或目标请求;浏览器使用隔离契约夹具。 +V5-047,运行与证据,网络中断,运行中断网/恢复,状态待确认,不宣称完成,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-048,运行与证据,新运行,重新运行/重放成功,新run不覆盖历史,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-049,接口资产与报告,继承头,全局/接口头覆盖与禁用,沿用真实合并与遮罩规则,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-050,接口资产与报告,重复参数,同名键多行发送/保存,不被对象化去重,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-051,接口资产与报告,禁用参数,编辑/保存/发送,禁用状态保持,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-052,接口资产与报告,文件引用,已有关联文件,不把路径或秘密当普通文本,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-053,接口资产与报告,认证,密码/token/cURL复制,保持原脱敏权限,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-054,接口资产与报告,导入,文档导入预览/冲突合并,原工作流仍完整,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-055,接口资产与报告,版本冲突,资产服务端版本改变,不静默覆盖,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-056,接口资产与报告,最终目标,切Service/环境Variant,预览与真实请求目标一致,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-057,接口资产与报告,用例发布,绑定已发布流程与套件,版本状态与执行结果分开,已验证,assets-targeted-final.log; assets-frontend-all.log; 28-assets-list-1440.png; 32-suite-fixed-versions-1440.png,本地前端:固定发布绑定/套件成员、独立结果、计划/显式执行/报告关联。真实后端发布执行、全历史聚合和导入导出未验收。 +V5-058,接口资产与报告,批量范围,跨页选择/删除/导出,对象范围明确,已验证,frontend/src/pages/TestAssetsWorkspace.test.tsx; 17-assets-1440.png,目录仅当前已加载记录;切目录清除选择;未改变服务端分页/批量接口。 +V5-059,接口资产与报告,失败筛选,选择仅失败,结果列表无通过记录混入,已验证,frontend/src/pages/ReportsPage.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-060,接口资产与报告,统计分母,后端total与当前页不同,每项指标标范围,分母正确,已验证,frontend/src/pages/ReportsPage.test.tsx; logs/association-mobile-report-tests.log,后端total/本页指标分开,并已连接服务端分页;每页50条;当前页失败筛选沿用标注范围。 +V5-061,接口资产与报告,导出,报告/请求导出,冻结证据与脱敏保持,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-062,接口资产与报告,移动报告,390px长错误/JSON/定位,可阅读,无桌面三栏硬缩,已验证,26-report-workspace-390.png; qa-association-mobile-final-result.log,390px报告证据和冻结上下文单列;轨迹内横向滚动自动显示当前节点;页面宽390,无横向溢出。 +V5-063,环境审核治理,环境语义,打开环境实验室,受控模板/实例/TTL,不是变量页,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-064,环境审核治理,环境安全,创建/清理界面,不增加任意命令/Compose/volume,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-065,环境审核治理,性能默认,打开性能配置,不自动发起负载/改变阈值,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-066,环境审核治理,门禁缺证据,必需证据缺失/过期,不得PASS/假数据,已验证,frontend/src/pages/ReleaseGatePage.test.tsx; 18-release-gate-1440.png,服务端 BLOCK/缺失原因透传;未生成真实发布判断。 +V5-067,环境审核治理,门禁动作,PASS/BLOCK页面操作,不是生产部署,审批按现有支持,已验证,18-release-gate-1440.png,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-068,环境审核治理,AI提案,生成/Review/Apply,先预览/审核再落草稿,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-069,环境审核治理,MCP高风险,批准/逐项接受,不绕过审批与限制,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-070,环境审核治理,组织权限,各角色访问组织/执行面/平台,守卫差异保持,已验证,frontend/src/App.test.tsx; frontend/src/pages/OrganizationGovernancePage.test.tsx; frontend/src/pages/ExecutionFabricPage.test.tsx,26/24 入口及既有治理组件单元回归;真实各角色登录验收未运行。 +V5-071,环境审核治理,一次性秘密,生成token/刷新/返回,只写/一次展示规则不变,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-072,环境审核治理,未开放能力,插件安装等feature关闭,不给假可用按钮,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-073,质量与交付,特殊状态,登录/改密/无权限/空错态,同主题且原认证逻辑不变,已验证,frontend/src/App.test.tsx,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-074,质量与交付,键盘焦点,导航/抽屉/模态/检查器,可达、可见、关闭后返回,已验证,frontend/src/features/navigation/ShellSidebar.test.tsx; frontend/src/flow/WorkflowWorkspaceShell.test.tsx; frontend/src/flow/WorkflowEditorSurfaces.test.tsx,新增目录/列表抽屉关闭焦点恢复及既有检查器测试;不是完整屏幕阅读器审计。 +V5-075,质量与交付,减弱动效,系统减少动效,非必要动画取消,状态仍可见,已验证,frontend/src/theme/use-reduced-motion.ts; qa-appearance-delivery-result.log,Antd/图表/画布响应系统减少动效;非必要 CSS 动画取消。 +V5-076,质量与交付,图规模,同环境小/中/大图前后对照,不凭空宣称性能提升,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 +V5-077,质量与交付,工作区保护,对比修改前后status/diff,不覆盖原有修改/编码,已验证,git-status-delivery.log; git-diff-delivery.patch,原工作区基线干净,改动仅在独立工作树;无 reset/clean/数据库操作。 +V5-078,质量与交付,当前版本,核对lockfile与baseline差异,不为适配设计升级全部依赖,已验证,git-diff-delivery.patch;package.json/锁文件未修改,本地前端验证;不代表远程 CI 或完整发布验收。 +V5-079,质量与交付,真实测试,检查测试日志与截图来源,未测不写通过,不用原型替代,已验证,logs/frontend-delivery-tests.log;浏览器只读隔离夹具,日志记录实际 102 文件 555 测试;不以原型渲染作为实现通过。 +V5-080,质量与交付,回滚,回退本次主题/组件挂接,不清数据库/草稿/session,已映射,source-inventory.csv / modules.csv / nodes.csv;logs/frontend-delivery-tests.log,本次范围为前端呈现与既有回归。该项完整条件未在真实环境逐项执行,待发布验收。 diff --git a/docs/design/ui-v5/changed-files.txt b/docs/design/ui-v5/changed-files.txt new file mode 100644 index 00000000..23eb402f --- /dev/null +++ b/docs/design/ui-v5/changed-files.txt @@ -0,0 +1,86 @@ +docs/design/README.md +docs/design/ui-v5/acceptance.csv +docs/design/ui-v5/changed-files.txt +docs/design/ui-v5/implementation.md +docs/design/ui-v5/modules.csv +docs/design/ui-v5/nodes.csv +docs/design/ui-v5/source-inventory.csv +docs/design/ui-v5/visual-deviations.md +frontend/src/App.test.tsx +frontend/src/App.tsx +frontend/src/features/api-console/APIWorkbench.tsx +frontend/src/features/api-console/ExecutionResultPanel.test.tsx +frontend/src/features/api-console/ExecutionResultPanel.tsx +frontend/src/features/api-console/use-api-console.ts +frontend/src/features/dashboard/DashboardTrendChart.tsx +frontend/src/features/evidence/AssertionEvidence.test.tsx +frontend/src/features/evidence/AssertionEvidence.tsx +frontend/src/features/evidence/assertion-model.ts +frontend/src/features/navigation/ShellSidebar.test.tsx +frontend/src/features/navigation/ShellSidebar.tsx +frontend/src/features/navigation/use-navigation-state.ts +frontend/src/features/reports/ReportExecutionWorkspace.tsx +frontend/src/features/reports/ReportTrendChart.tsx +frontend/src/features/reports/report-service.ts +frontend/src/features/reports/use-reports.ts +frontend/src/features/task-plans/task-plan-service.ts +frontend/src/features/test-assets/AssetDetailDrawer.tsx +frontend/src/features/test-assets/AssetPlanDialog.test.tsx +frontend/src/features/test-assets/AssetPlanDialog.tsx +frontend/src/features/test-assets/AssetRunEvidence.tsx +frontend/src/features/test-assets/AssetTableContext.tsx +frontend/src/features/test-assets/asset-run-view-model.test.ts +frontend/src/features/test-assets/asset-run-view-model.ts +frontend/src/features/test-assets/asset-workspace-service.test.ts +frontend/src/features/test-assets/asset-workspace-service.ts +frontend/src/features/test-assets/test-asset-service.ts +frontend/src/features/test-assets/test-asset-view-model.ts +frontend/src/features/test-assets/use-asset-history.ts +frontend/src/features/test-assets/use-test-assets.test.tsx +frontend/src/features/test-assets/use-test-assets.ts +frontend/src/features/workflows/execution-navigation.test.ts +frontend/src/features/workflows/execution-navigation.ts +frontend/src/features/workflows/use-workflow-tabs.ts +frontend/src/features/workflows/use-workflows.ts +frontend/src/flow/WorkflowDesigner.test.tsx +frontend/src/flow/WorkflowDesigner.tsx +frontend/src/flow/WorkflowInspectorShell.tsx +frontend/src/flow/WorkflowNodeInspector.test.tsx +frontend/src/flow/WorkflowNodeInspector.tsx +frontend/src/flow/WorkflowNodeLibrary.tsx +frontend/src/flow/WorkflowNodeRelations.tsx +frontend/src/flow/WorkflowRegionCanvas.tsx +frontend/src/flow/WorkflowRunInspector.test.tsx +frontend/src/flow/WorkflowRunInspector.tsx +frontend/src/flow/WorkflowRunTrajectory.tsx +frontend/src/flow/WorkflowRuntimeDock.tsx +frontend/src/flow/WorkflowSettingsEditor.test.tsx +frontend/src/flow/WorkflowSettingsEditor.tsx +frontend/src/flow/WorkflowWorkspaceShell.test.tsx +frontend/src/flow/WorkflowWorkspaceShell.tsx +frontend/src/flow/editor/layout-preferences.test.ts +frontend/src/flow/editor/layout-preferences.ts +frontend/src/flow/editor/node-presentation.test.ts +frontend/src/flow/editor/node-presentation.ts +frontend/src/flow/editor/use-node-selection-guard.tsx +frontend/src/flow/workflow-editor.css +frontend/src/main.tsx +frontend/src/pages/ApiConsolePage.test.tsx +frontend/src/pages/ApiConsolePage.tsx +frontend/src/pages/DashboardPage.test.tsx +frontend/src/pages/DashboardPage.tsx +frontend/src/pages/ReleaseGatePage.test.tsx +frontend/src/pages/ReleaseGatePage.tsx +frontend/src/pages/ReportsPage.test.tsx +frontend/src/pages/ReportsPage.tsx +frontend/src/pages/TestAssetsPage.test.tsx +frontend/src/pages/TestAssetsPage.tsx +frontend/src/pages/TestAssetsWorkspace.test.tsx +frontend/src/pages/WorkflowsPage.test.tsx +frontend/src/pages/WorkflowsPage.tsx +frontend/src/styles.css +frontend/src/theme/AppearanceSettings.test.tsx +frontend/src/theme/AppearanceSettings.tsx +frontend/src/theme/IceThemeProvider.tsx +frontend/src/theme/ice-theme.ts +frontend/src/theme/use-reduced-motion.ts diff --git a/docs/design/ui-v5/implementation.md b/docs/design/ui-v5/implementation.md new file mode 100644 index 00000000..e2f1f5a6 --- /dev/null +++ b/docs/design/ui-v5/implementation.md @@ -0,0 +1,148 @@ +# v5 冰蓝白实施记录 + +已在真实前端落实冰蓝白主题及核心工作区布局:接口管理采用“目录—请求—响应”,流程编排采用节点库、画布、检查器和运行证据,用例管理采用目录、完整分页资产列表与详情抽屉,报告和发布门禁优先显示失败证据。后续补齐了报告与编排的双向定位、运行轨迹、来源定位、检查器分区、节点切换确认与流程级配置,并补齐用例/套件的固定版本、计划、执行与报告关联。26 个原有模块继续通过常用任务栏或全部模块目录访问。其余页面共享新的主题与应用壳,业务操作、权限与能力开关沿用原实现。 + +## 基线与工作树 + +- 实施日期:2026-10-02。首轮开始约 12:45,功能联动续作约 15:53,用例功能续作自 18:13(Asia/Shanghai);以日志时间为各次验证时间。 +- 需求来源:[用户指定的共享对话](https://chatgpt.com/share/6abef2e2-07b8-83ea-bfde-691d3d72d13b),使用其最新 v5 冰蓝白完整实施交接包,而非较早的 v3/v4 方向。完整方案、状态契约、全模块/节点矩阵、视觉规范、实施提示与验收材料在编辑前已读取。 +- 原工作区:`/Volumes/雷电/project/FlowTest`,`codex/control-flow-validation`。开始时工作区干净。 +- 实施工作树:`/Users/sqz/Documents/Codex/2026-10-02/task/FlowTest`,`codex/ui-v5-ice-light`。 +- 代码基线:`cfe3594d055e1feb988df18041ed9dbedda8bc6b`;本次改动尚未提交、推送或部署。没有修改后端、数据库、执行引擎、依赖声明或锁文件。 +- Node 22.22.0;复用原工作区相同锁文件安装的依赖。pnpm 启动器为 11.19,仓库声明为 11.16;运行时 `pnpm_config_verify_deps_before_run=warn` 仅阻止工作树路径差异触发重新安装,不修改依赖。 + +## 实际改动 + +完整文件清单见 [changed-files.txt](changed-files.txt)。对应工作树的未提交 diff 可用于审阅全部源码改动。 + +| 工作区 | 主要源码 | 行为与目的 | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 主题与应用壳 | `frontend/src/theme/`、`main.tsx`、`App.tsx`、`features/navigation/`、`styles.css` | 单一颜色入口;浅色画布、代码、弹层、图表;68px 常用导航与可搜索模块目录;用户外观偏好独立存储;减少透明与系统减少动效 | +| 流程布局 | `flow/WorkflowDesigner.tsx`、`WorkflowWorkspaceShell.tsx`、`WorkflowInspectorShell.tsx`、`WorkflowNodeLibrary.tsx`、`workflow-editor.css` | 列表抽屉;宽屏节点库,1280 抽屉;检查器默认 320px;节点显示类型、真实配置来源、所属区域与运行状态;内联区域同步浅色样式 | +| 流程关系与生命周期 | `flow/editor/node-presentation.ts`、`WorkflowNodeRelations.tsx`、`WorkflowSettingsEditor.tsx`、`pages/WorkflowsPage.tsx` | 数据引用为只读展示边;来源/下游可定位;显著呈现保存/发布/执行;历史采用冻结快照;流程级变量与执行设置接入图草稿、撤销与重做 | +| 接口管理 | `pages/ApiConsolePage.tsx`、`features/api-console/APIWorkbench.tsx`、`ExecutionResultPanel.tsx`、`use-api-console.ts` | 左目录与环境卡片;方法/路径行中的发送操作;请求与响应分区和内部滚动;响应、实际请求、断言与历史 tabs;HTTP200 的断言失败仍显示失败提示 | +| 失败证据 | `features/evidence/`、`flow/WorkflowRunInspector.tsx`、`pages/ReportsPage.tsx` | 失败优先排序;期望/实际并排;`undefined` 为“未提供”,保留真实 `null`/`false`/`0`;不存在的尝试次数或耗时不冒充 0 | +| 用例管理 | `pages/TestAssetsPage.tsx`、`features/test-assets/`、`features/task-plans/task-plan-service.ts` | 完整分页目录、当前页选择、详情与发布版本、流程绑定、固定成员版本、创建计划与显式执行、近期结果/报告/冻结编排、项目能力守卫 | +| 总览与报告 | `pages/DashboardPage.tsx`、`pages/ReportsPage.tsx`、`ReportExecutionWorkspace.tsx`、两处趋势图 | 本页统计与后端 total 分开;报告服务端分页;执行轨迹与证据工作区;双向定位冻结画布并保留请求尝试;局部加载错误与重试;390px 证据单列 | +| 发布门禁 | `pages/ReleaseGatePage.tsx` | 显示策略、历史和最新结果;最新冻结判断展示逐项证据与决策上下文,阻断优先;保留策略表、历史表和人工生成判断操作 | + +保存仍由 `useWorkflows.saveDraft`/`saveWorkflowDraftFor` 完成,发布和执行使用原处理器。`WorkflowDefinition` 仍是持久化图的权威;节点编辑先应用再进入图,保存时的并发编辑与版本冲突保护保持。主题切换不清空旧导航、workspace tabs、节点草稿或工作流存储。 + +数据引用展示仅使用已有显式节点引用、类型化 `node_output` 来源和字段映射。无法解析的绑定显示未解析,不猜测上游。展示边不能被选中、重连或写入 `WorkflowDefinition.edges`。浏览器夹具中开启数据引用后,展示边从 0 变为 2,执行边保持 5 条。 + +关联对象通过现有项目路由和查询参数导航,包含执行、节点与请求尝试次数;没有新增持久化对象 tab 模型。未新增接口、执行能力、端口、策略版本或报告检查点。SQL/Redis 只读约束、受控环境模板/实例/TTL、性能默认不自动负载、AI/MCP 人工审批与逐项审核、秘密边界和项目请求级策略均保持。 + +## 功能联动补齐 + +根据用户关于“报告和编排没有关联、编排缺功能”的反馈,本次继续实现以下可操作行为,而非仅调整样式: + +- 报告详情替换为完整页面工作区:左侧逐节点执行轨迹,右侧当次证据;默认选中失败节点,可切换响应、请求、提取、断言和变量映射。报告列表接入已有服务端分页。 +- “定位画布”和“查看完整报告”双向传递项目、执行、节点与请求尝试次数。可刷新直接链接;重复选择同一节点不会清掉已选尝试。原流程被删除时仍可读取执行快照,不回退到其他流程草稿。 +- 运行/历史模式在宽屏以执行轨迹替换节点库,1280px 提供轨迹抽屉。选择轨迹或数据来源可定位画布;来源显示基于真实图引用,不增加执行边。 +- 节点检查器划分配置、输入/输出和校验。API 字段映射只有一个主要编辑入口;节点切换提供应用后继续、丢弃此节点修改和取消,输入校验失败时保留当前表单。 +- 流程设置支持已有初始变量,以及失败即停止、并发上限 1–100、默认超时 1–300 秒。变更进入现有图草稿和撤销/重做链;保留所有其他定义字段及 run_policy。添加变量校验名称格式、长度和重复项,删除可撤销。历史设置只读。 + +对应源码为 `features/reports/ReportExecutionWorkspace.tsx`、`features/workflows/execution-navigation.ts`、`flow/WorkflowRunTrajectory.tsx`、`WorkflowNodeRelations.tsx`、`WorkflowSettingsEditor.tsx`、`editor/use-node-selection-guard.tsx` 及页面/查询层挂接。没有自动保存、发布、执行或重发历史请求。 + +## 阶段与验证范围 + +“已验证”在本记录中指列明的本地前端范围,不能当作完整发布验收通过。 + +| 阶段 | 状态 | 交付与已执行验证 | 剩余验收/回滚范围 | +| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- | +| P0 源码盘点 | 已验证 | 基线/status、依赖、12 项职责、26 模块、28 注册项与额外 start 映射;已有行为测试先于修改执行 | [源码盘点](source-inventory.csv)、[模块映射](modules.csv)、[节点映射](nodes.csv);无业务回滚 | +| P1 主题与应用壳 | 已验证 | 主题与导航、特殊认证状态、用户外观隔离、弹层焦点与深色系统浅色呈现;前端单元与浏览器检查 | 逐页所有弹层/屏幕阅读器审计未完成;回滚 theme/main/App/navigation/CSS 挂接 | +| P2 流程核心 | 已验证 | 既有图编辑/控制字段/草稿竞态/热键回归;桌面三个视口、真实内联区域、未应用提示、冻结历史、数据边不污染执行边 | 28 类节点的真实引擎执行与大图性能未重跑;回滚流程呈现文件,不清草稿/session | +| P3 接口、用例与报告 | 已验证 | 接口发送与断言失败提示、既有请求/版本/文件回归;完整分页目录/清选择、资产版本/计划/执行/报告关联;统计分母/本页筛选;桌面与390px证据 | 真实目标请求、服务器冲突/导出与通知实投未执行;资产导入导出/追加既有计划/全历史统计仍需后端能力;回滚对应前端呈现文件 | +| P4 其他页面族 | 已映射 | 26 模块共享主题、入口与原权限差异;门禁证据分区已实现并测;既有环境/性能/AI/MCP/治理前端回归执行 | 其余模块逐页真实浏览器、多角色、能力开关与安全边界验收未逐项执行;不以共用主题冒充完整逐页重设计验收 | +| P5 最终验收 | 已改未测 | 最终格式/Lint/类型/构建、前端回归和所列浏览器截图;80 项矩阵记录各自范围;修复减少动效下的定位测量 | 完整 `make check`、覆盖率阈值、Compose Playwright、后端、Windows、真实各角色与发布验收未运行;没有 CI/PR merge/GA 结论 | + +## 用例与套件功能补齐 + +首轮只完成了目录与列表布局,未达到方案的“目录—列表—详情、绑定版本—计划—执行—报告”操作链。本轮接入已有 API,没有新增后端契约或自动创建真实资产。 + +- 目录与套件/流程选项逐页读取完整记录,超过 100 条不截断;每页显示 20 条。目录计数包括筛选结果中的用例与套件。切目录、页码、类型和筛选时清除批量选择,操作对象仅限当前页。分页读取发生重复/数量变化时提示重载,不把部分数据标为完整。 +- 名称打开详情,可通过 `type/focus/version` 深链接查看资产。草稿、发布版本和执行结果独立。发布版本展示冻结流程/环境/覆盖项;指定版本缺失时保留错误并禁用计划/执行,不回退到其他版本。 +- 用例可明确选择流程版本,套件可选择成员版本。仅改套件说明时保留原成员版本,避免静默绑定最新版本。保存失败保留表单并显示标准错误。 +- “加入计划”创建含明确 `target_type/target_id/target_version` 的新手动计划;“执行”先展示版本和计划名称,用户点击后创建计划并入队。发布不会自动执行。入队失败重试复用已创建计划与同一 Idempotency-Key。 +- 最近结果从计划运行项及 `source_suite` 冻结来源关联,覆盖最近 20 次项目计划运行,显示当前查询数量与后端 total。无记录只说明这个范围没有记录。混合计划的其他资产不会影响本用例/套件结果;失败、排队、运行、取消和隔离分开。 +- 结果链接进入具体执行报告,详情提供报告与冻结编排回看、固定成员版本和直接引用该资产的计划。项目权限沿用既有 capabilities;无编辑/执行能力时禁用相关操作。只读回看没有写请求。 +- 1440px 列表/详情与 390px 套件详情完成浏览器验证;手机页面/抽屉宽度均为 390px。所有操作使用隔离契约夹具,实际后端写入为零。模拟失败入队后重试:1 次创建计划、2 次入队、同一幂等键。 + +对应源码为 `AssetDetailDrawer.tsx`、`AssetPlanDialog.tsx`、`AssetRunEvidence.tsx`、`asset-workspace-service.ts`、`asset-run-view-model.ts`、`use-asset-history.ts` 及现有页面/服务挂接。导入导出、追加已有计划、按资产全历史聚合尚无现成契约,列为独立后端待办;没有用原型按钮冒充可用功能。 + +## 检查记录 + +工作目录均为上述实施工作树。开发期间的失败及修复日志保留在 `output/playwright/ui-v5/logs/`,最终结论以下表和实际日志为准。 + +| 检查 | 命令与范围 | 结果/日志 | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 修改前表征基线 | `make test-frontend-targeted TARGETS='[...]'`,5 个相关文件 | 65 项通过;`baseline-tests-reuse.log` | +| 全部前端单元/组件测试 | `cd frontend`;`pnpm_config_verify_deps_before_run=warn pnpm test` | 用例补齐后 107 文件、586 项通过(83.76s);`assets-frontend-all.log`。之前 568 项日志保留作历史记录,当前结论以本轮日志为准 | +| 用例与门禁针对性回归 | `make test-frontend-targeted TARGETS='["src/pages/TestAssetsWorkspace.test.tsx","src/pages/TestAssetsPage.test.tsx","src/pages/ReleaseGatePage.test.tsx","src/features/test-assets/use-test-assets.test.tsx"]'` | 4 文件、18 项通过;`assets-gate-final.log` | +| 最终动效/弹层影响范围 | `make test-frontend-targeted`,外观、App、接口、报告、用例目录、门禁6文件 | 6文件、29项通过;`reduced-motion-popup-delivery.log`;浏览器同时检查减少动效下的弹层几何与开关操作 | +| 格式 | `cd frontend`;`pnpm_config_verify_deps_before_run=warn pnpm format:check` | 通过;`association-format-final-complete.log` | +| Lint 与类型 | `cd frontend`;`pnpm_config_verify_deps_before_run=warn pnpm lint` | ESLint(复杂度≤10)及 `tsc -b` 通过;`association-lint-final-complete.log` | +| 生产构建 | `cd frontend`;`pnpm_config_verify_deps_before_run=warn pnpm build` | 通过;`association-build-final-complete.log` | +| 功能联动针对性检查 | `make test-frontend-targeted`,报告/编排页面、检查器、导航及设置 | 6文件45项、设置2项、定位影响范围2文件35项通过;`association-tests-expanded.log`、`settings-variables-final.log`、`association-location-tests.log`。新增缺失入口测试在实现前失败,之后通过 | +| 修改与文档 | `git diff --check`;CSV行数、源码路径与文档链接核对 | 通过记录见 `association-docs-check.log`、`association-git-status-final.log`、`git-diff-delivery.patch` | +| 集中颜色对 | 按相对亮度核算正文/说明/主色/控件边界等10组token色对 | 正文14.08、说明6.13、次级文字5.21、主色/浅蓝背景4.54、控件边界/白底3.15;`contrast-token-check.log`;不是整站WCAG审计 | +| 浏览器 | Playwright CLI;1440×900、1280×800、1600×1000、390×844;系统深色/减少动效 | 真实前端配隔离契约夹具;相关截图与 `qa-*-result.log`;不操作真实后端资产/目标 | + +本次不收集覆盖率,不降低仓库阈值。未运行的后端、全覆盖率、Compose E2E、部署形态与远程门禁属于独立的后续发布验证;没有根据前端本地通过推断其结果。 + +## 本轮功能联动与截图 + +本轮浏览器仍运行真实前端和隔离契约夹具。设置添加/撤销/重做、历史返回恢复草稿和证据导航都检查了网络写入,未发出后端资产变更或真实目标请求。完整脚本和诊断过程留在 `output/playwright/ui-v5/`。 + +| 场景 | 实现截图/证据 | 已观察结果 | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| 节点未应用时切换 | [三种选择](../../../output/playwright/ui-v5/22-node-switch-confirmation.png);`qa-association-settings-result.log` | 取消保留输入,应用后继续进入目标节点;丢弃有组件回归 | +| 流程设置 | [变量与执行设置](../../../output/playwright/ui-v5/23-workflow-settings-1440.png);`qa-association-settings-result.log` | 添加变量可撤销/重做;图定义其他字段和运行策略有回归保护;零后端写入 | +| 历史/草稿隔离 | [历史执行轨迹](../../../output/playwright/ui-v5/24-workflow-history-trajectory-1440.png);`qa-association-frozen-complete-result.log` | 冻结并发20不混入草稿7和新增变量;返回后节点名称/变量/设置恢复;零后端写入 | +| 报告工作区 | [桌面完整报告](../../../output/playwright/ui-v5/25-report-workspace-1440.png) | 轨迹选择、失败金额对照、冻结环境、定位画布和导出入口 | +| 精确请求证据联动 | `qa-association-attempt-result.log` | 来源定位后选择第1次请求,报告→画布→报告及刷新均保留该次503响应;同节点再选不重置尝试;零后端写入 | +| 响应式联动 | [390px完整报告](../../../output/playwright/ui-v5/26-report-workspace-390.png)、[1280px历史画布](../../../output/playwright/ui-v5/27-history-trajectory-1280.png);`qa-association-responsive-result.log`、`qa-association-mobile-final-result.log` | 手机页面宽390、无整体横向溢出,轨迹自动显示选中节点,冻结上下文单列;1280px轨迹抽屉选择后关闭并定位正确节点 | + +## 首轮主题与布局截图 + +| 场景 | 实现截图 | 观察 | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| 流程/1440 | [流程编辑器](../../../output/playwright/ui-v5/03-workflow-1440.png) | 白色节点、蓝色执行关系、节点库/检查器/运行面板 | +| 流程/1280 | [1280工作区](../../../output/playwright/ui-v5/04-workflow-1280.png) | 节点库抽屉,保留可读检查器 | +| 系统深色/1600 | [浅色工作区](../../../output/playwright/ui-v5/05-workflow-dark-os-1600.png) | 主题仍为 ice-light,无深色主画布 | +| 历史证据 | [冻结快照](../../../output/playwright/ui-v5/07-workflow-history-evidence-1440.png) | v1/冻结环境;保存与发布按钮不存在;期望29900与实际29901 | +| 内联控制结构 | [控制块](../../../output/playwright/ui-v5/08-workflow-control-1440.png)、[内部区域](../../../output/playwright/ui-v5/09-workflow-control-region.png) | 真实 foreach 区域与其两个步骤,无装饰端口 | +| 接口 | [1440工作台](../../../output/playwright/ui-v5/06-api-workbench-1440.png)、[1280](../../../output/playwright/ui-v5/13-api-workbench-1280.png) | 请求/响应分区;HTTP200与失败断言同时显示 | +| 用例 | [目录与列表](../../../output/playwright/ui-v5/17-assets-1440.png) | 首轮布局记录;当前版本以本轮 28–34 截图为准 | +| 报告 | [桌面失败证据](../../../output/playwright/ui-v5/10-report-evidence-1440.png)、[手机证据](../../../output/playwright/ui-v5/11-report-evidence-390.png)、[手机列表](../../../output/playwright/ui-v5/12-report-list-390.png) | 后端total120、本页1条;手机页面宽度390,无页面横向溢出 | +| 门禁 | [逐项证据与决策上下文](../../../output/playwright/ui-v5/18-release-gate-1440.png) | BLOCK及2项真实夹具阻断原因;无部署操作 | +| 应用壳与外观 | [总览](../../../output/playwright/ui-v5/14-quality-overview-1440.png)、[模块目录](../../../output/playwright/ui-v5/20-module-directory.png)、[外观](../../../output/playwright/ui-v5/21-appearance-settings.png) | 搜索模块/关闭焦点恢复;减少透明可操作;系统深色和减少动效时弹层白色且在视口内 | + +原型与真实模型不一致处见 [必要视觉差异](visual-deviations.md)。全部80项见 [验收矩阵](acceptance.csv),其中源码映射和部分前端回归不能代替其完整真实环境条件。 + +## 用例续作验证与截图 + +定向回归 `make test-frontend-targeted` 涉及用例页面/工作区、useTestAssets、资产服务/目录/运行模型、计划确认和 TestPlansPage 共 8 文件,33 项通过(`assets-targeted-final.log`)。修改前基线为 4 文件/14 项(`assets-baseline.log`)。 + +本轮最终 `pnpm format:check`、`pnpm lint`(包含 `tsc -b`)和 `pnpm build` 均通过,分别见 `assets-format-check-final.log`、`assets-lint-complete.log`、`assets-build-final.log`。浏览器操作记录在 `assets-browser-retry-stats.log`、`assets-mobile-measurements.log`。 + +| 场景 | 证据 | +| -------------------- | -------------------------------------------------------------------------------------- | +| 完整目录与绑定/结果 | [1440px 列表](../../../output/playwright/ui-v5/28-assets-list-1440.png) | +| 资产详情与冻结定义 | [1440px 详情](../../../output/playwright/ui-v5/29-asset-detail-1440.png) | +| 用例执行与报告 | [执行证据](../../../output/playwright/ui-v5/30-asset-execution-evidence-1440.png) | +| 执行前确认版本与计划 | [可审阅的操作](../../../output/playwright/ui-v5/31-asset-plan-review-1440.png) | +| 套件成员固定版本 | [1440px 套件](../../../output/playwright/ui-v5/32-suite-fixed-versions-1440.png) | +| 套件关联计划 | [390px 关联计划](../../../output/playwright/ui-v5/33-suite-related-plans-390.png) | +| 套件报告/编排回看 | [390px 执行证据](../../../output/playwright/ui-v5/34-suite-execution-evidence-390.png) | + +## 剩余风险与回滚 + +当前核心工作区可供代码与视觉审阅。用例目录已通过现有分页读取完整记录;大项目的目录首载请求量仍随资产数量增长,服务端目录筛选/聚合属于后续优化。近期结果只覆盖最近 20 次项目计划运行,不能替代按资产全历史检索。加入计划当前创建新手动计划,追加已有计划、用例原生导入导出仍需后端能力。报告失败过滤仍是本页过滤,服务端失败分页未实现。未提供的维护状态等字段不会填假数据。 + +减少动效规则使用 `transition-duration: 0s`,避免给所有定位属性引入非零过渡、干扰弹层/画布几何测量。Ant Design 仍使用其正常的系统动效开关;图表和画布也响应系统偏好。深色系统且减少动效时,外观弹层实测位于1440px视口内、背景白色、开关可点击。 + +全站共用主题会影响既有弹层、表单和图表,故正式合并前仍需完整前端覆盖率与风险对应的 PR Required Gate,以及真实权限/后端/Compose验收。现有 Ant Design `maskClosable` 弃用提示未作无关重构;不宣称大图性能提升或完整可访问性合规。 + +回滚只回退本工作树列出的前端主题、工作区与导航改动。不要清数据库、缓存、旧导航、workspace tabs、节点表单或工作流 session。旧偏好存储未删除,外观偏好独立使用 `flowtest:appearance:v5:`;未提交状态可在审阅 diff 后按明确文件范围回退,不使用全局 reset/clean。 diff --git a/docs/design/ui-v5/modules.csv b/docs/design/ui-v5/modules.csv new file mode 100644 index 00000000..bfca85ca --- /dev/null +++ b/docs/design/ui-v5/modules.csv @@ -0,0 +1,27 @@ +number,module,route_baseline,existing_path,permission_guard,handler,new_navigation,test_or_screenshot,status +1,质量总览,dashboard,frontend/src/pages/DashboardPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useDashboard,常用/质量总览,frontend/src/pages/DashboardPage.test.tsx,已验证 +2,接口管理,apis,frontend/src/pages/ApiConsolePage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useApiConsole / useWatch,常用/项目与接口,frontend/src/pages/ApiConsolePage.test.tsx,已验证 +3,服务目录,services,frontend/src/pages/ServiceCatalogPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useServiceCatalog,项目与接口,frontend/src/pages/ServiceCatalogPage.test.tsx,已映射 +4,请求目标,request-targets,frontend/src/pages/RequestTargetsPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useMessage / useEnvironmentSelection,项目与接口,frontend/src/pages/RequestTargetsPage.test.tsx,已映射 +5,多协议工作台,protocols,frontend/src/pages/ProtocolWorkbenchPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useProtocolInventory / useSelectedAsset / useWatch,项目与接口,frontend/src/pages/ProtocolWorkbenchPage.test.tsx,已映射 +6,项目管理,settings,frontend/src/pages/ProjectsPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useProjectsPageState / useApp,项目与接口,frontend/src/pages/ProjectsPage.test.tsx,已映射 +7,流程编排,workflows,frontend/src/pages/WorkflowsPage.tsx,登录与 ProjectProvider;项目 capabilities.edit 控制设计器;执行与发布沿用 useWorkflows,useWorkflows / useWorkflowTabs / useFailedRecordPage,常用/测试设计,frontend/src/pages/WorkflowsPage.test.tsx,已验证 +8,测试用例,assets,frontend/src/pages/TestAssetsPage.tsx,ProjectProvider;既有项目 permissions capabilities;服务端授权保持,useTestAssets / createAssetPlan / runTestPlan / getAssetRun,常用/测试设计,TestAssetsPage / TestAssetsWorkspace / AssetPlanDialog 回归;28–34截图,已验证 +9,数据与 Mock,data,frontend/src/pages/DataMockPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useApp / useWatch,测试设计,frontend/src/pages/DataMockPage.test.tsx,已映射 +10,契约中心,contracts,frontend/src/pages/ContractHubPage.tsx,登录与项目作用域;useContractHub 的 feature flag 与服务端授权,useContractHub,测试设计,frontend/src/pages/ContractHubPage.test.tsx,已映射 +11,测试工程,test-engineering,frontend/src/pages/TestEngineeringPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useTestEngineering / useEndpointVariants / useWatch,测试设计,frontend/src/pages/TestEngineeringPage.test.tsx,已映射 +12,任务执行,tasks,frontend/src/pages/TestPlansPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useTestPlans / useWatch,执行与环境,frontend/src/pages/TestPlansPage.test.tsx,已映射 +13,环境实验室,environments,frontend/src/pages/EnvironmentLabPage.tsx,登录与项目作用域;系统管理员才可注册模板;实例/TTL 沿用 useEnvironmentLab,useEnvironmentLab / useWatch,执行与环境,frontend/src/pages/EnvironmentLabPage.test.tsx,已映射 +14,性能实验室,performance,frontend/src/pages/PerformanceLabPage.tsx,登录与项目作用域;usePerformanceLab;默认不自动产生负载,usePerformanceLab / useWatch,执行与环境,frontend/src/pages/PerformanceLabPage.test.tsx,已映射 +15,测试报告,reports,frontend/src/pages/ReportsPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useReports,常用/质量分析,frontend/src/pages/ReportsPage.test.tsx,已验证 +16,影响分析,impact,frontend/src/pages/ImpactAnalysisPage.tsx,登录与项目作用域;Impact feature flag;沿用 useImpactAnalysis,useImpactAnalysis,质量分析,frontend/src/pages/ImpactAnalysisPage.test.tsx,已映射 +17,变更回归,change-regression,frontend/src/pages/ChangeRegressionPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useChangeRegression / useForm / useWatch,质量分析,frontend/src/pages/ChangeRegressionPage.test.tsx,已映射 +18,质量中心,quality,frontend/src/pages/QualityCenterPage.tsx,登录与项目作用域;Quality feature flag;沿用 useQualityCenter,useQualityCenter,质量分析,frontend/src/pages/QualityCenterPage.test.tsx,已映射 +19,发布门禁,release,frontend/src/pages/ReleaseGatePage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useReleaseGate,常用/质量分析,frontend/src/pages/ReleaseGatePage.test.tsx,已验证 +20,AI 助手,ai,frontend/src/pages/AIAssistantPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useAIReview / useSuggestionReview / useApp,AI 与集成,frontend/src/pages/AIAssistantPage.test.tsx,已映射 +21,上下文检查器,contexts,frontend/src/pages/ContextInspectorPage.tsx,登录与 ProjectProvider;项目作用域及现有服务端授权,useContextInspector,AI 与集成,frontend/src/pages/ContextInspectorPage.test.tsx,已映射 +22,AI 变更集,ai-changes,frontend/src/pages/AIChangeSetsPage.tsx,登录与项目作用域;人工逐项审核后仅修改草稿,useAIChangeSets / useWatch,AI 与集成,frontend/src/pages/AIChangeSetsPage.test.tsx,已映射 +23,MCP 变更集,mcp-changes,frontend/src/pages/MCPChangeSetsPage.tsx,登录;精确 focus ID;manual_approval_required 与 approval ID;服务端授权,useApp,AI 与集成,frontend/src/pages/MCPChangeSetsPage.test.tsx,已映射 +24,组织治理,/organization,frontend/src/pages/OrganizationGovernancePage.tsx,登录;canManage=系统管理员或 owner/admin;canRotate=系统管理员或 owner;创建组织仅系统管理员,useApp / useOrganizationWorkspace / useForm,系统管理,frontend/src/pages/OrganizationGovernancePage.test.tsx,已映射 +25,分布式执行面,/execution-fabric,frontend/src/pages/ExecutionFabricPage.tsx,登录;页面系统管理员守卫;非管理员导航不显示,useApp,系统管理,frontend/src/pages/ExecutionFabricPage.test.tsx,已映射 +26,平台管理,/platform,frontend/src/pages/PlatformCapabilitiesPage.tsx,登录;管理数据查询 enabled=isSystemAdmin;非管理员导航不显示,useSystemAdminStatus / usePlatformInventory / useSelectedCapability,系统管理,frontend/src/pages/PlatformCapabilitiesPage.test.tsx,已映射 diff --git a/docs/design/ui-v5/nodes.csv b/docs/design/ui-v5/nodes.csv new file mode 100644 index 00000000..ec0b8033 --- /dev/null +++ b/docs/design/ui-v5/nodes.csv @@ -0,0 +1,30 @@ +number,group_baseline,display_type_baseline,actual_serialized_type,registry_path,handles,prerequisites,readonly_rules,evidence_model,status +1,协议,API,api,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,已发布接口固定版本,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +2,协议,GraphQL,capability / graphql.request @3.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,GraphQL Schema,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +3,协议,gRPC,capability / grpc.call @3.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,gRPC Descriptor,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +4,协议,Kafka produce,capability / kafka.produce @3.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,Kafka 事件源,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +5,协议,Kafka consume,capability / kafka.consume @3.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,Kafka 事件源,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +6,协议,WebSocket,capability / websocket.exchange @3.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,WebSocket 事件源,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +7,数据与校验,extract,extract,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +8,数据与校验,assert,assert,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +9,数据与校验,condition,condition,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source true/false 右(现有两分支),现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +10,数据与校验,delay,delay,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +11,数据与校验,dataset,dataset,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,已上传数据集;主流程仅一个,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +12,数据与校验,SQL,sql,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,数据库 Credential,历史/提案模式不允许修改;沿用现有 canEdit;只读 SQL 与参数化规则未修改,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +13,数据与校验,Redis,redis,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,Redis Credential,历史/提案模式不允许修改;沿用现有 canEdit;只读命令允许列表未修改,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +14,复用与兼容,subflow,subflow,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,已发布子流程固定版本,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +15,复用与兼容,legacy ForEach,for_each,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,已发布子流程固定版本(旧版语义),历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +16,结构化控制,foreach,capability / flow.control.foreach @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +17,结构化控制,repeat,capability / flow.control.repeat @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +18,结构化控制,if,capability / flow.control.if @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +19,结构化控制,switch,capability / flow.control.switch @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +20,结构化控制,while,capability / flow.control.while @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +21,结构化控制,do-while,capability / flow.control.do_while @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +22,结构化控制,until,capability / flow.control.until @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +23,结构化控制,parallel,capability / flow.control.parallel @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +24,结构化控制,try,capability / flow.control.try @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +25,结构化控制,group,capability / flow.control.group @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +26,结构化控制,fail,capability / flow.control.fail @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +27,结构化控制,return,capability / flow.control.return @1.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;source out 右,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +28,终止,end,end,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,target 左;无 source,现有配置/图校验,历史/提案模式不允许修改;沿用现有 canEdit,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已验证 +29,已有边界节点(不新增到节点库),start,start;兼容 flow.start @2.0.0,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts; frontend/src/flow/editor/control-blocks.ts,无 target;source out 右,主流程恰好一个 start,入口保护规则;不可按节点库新建或删除,WorkflowNodeExecution.result;observations/attempt;控制块内联记录沿用 iteration/branch/scope 查询,已映射 diff --git a/docs/design/ui-v5/source-inventory.csv b/docs/design/ui-v5/source-inventory.csv new file mode 100644 index 00000000..84717ab4 --- /dev/null +++ b/docs/design/ui-v5/source-inventory.csv @@ -0,0 +1,13 @@ +responsibility,existing_path,existing_symbol,current_revision,evidence,status,planned_change +Theme,frontend/src/theme/ice-theme.ts,createIceTheme / applyIceAppearance,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/theme/AppearanceSettings.test.tsx,已验证,集中冰蓝白颜色、Ant Design 与图表配置;减少透明与动效 +AppShell,frontend/src/App.tsx; frontend/src/features/navigation/ShellSidebar.tsx,AppShell / ShellSidebar,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/App.test.tsx; frontend/src/features/navigation/ShellSidebar.test.tsx,已验证,68px 常用任务栏 + 26 模块目录;保留项目、路由与权限差异 +WorkflowEditor,frontend/src/flow/WorkflowDesigner.tsx; frontend/src/flow/WorkflowWorkspaceShell.tsx; frontend/src/flow/WorkflowSettingsEditor.tsx,WorkflowDesigner / WorkflowWorkspaceShell,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/flow/WorkflowDesigner.test.tsx; frontend/src/flow/WorkflowWorkspaceShell.test.tsx,已验证,宽屏节点库/轨迹、列表抽屉、320px 检查器;配置/输入输出/校验;流程设置接入草稿和撤销链 +NodeRegistry,frontend/src/flow/editor/node-registry.ts; frontend/src/flow/workflow-graph.ts,nodeRegistry / addTypedNode / addProtocolNode,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/flow/WorkflowDesigner.test.tsx,已验证,保留 28 类注册项和已有 start;卡片仅增加真实配置摘要 +NodeEditSession,frontend/src/flow/WorkflowNodeEditSession.tsx; frontend/src/flow/editor/node-edit-session.ts; frontend/src/flow/editor/use-node-selection-guard.tsx,WorkflowNodeEditSession,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/flow/WorkflowNodeEditSession.test.tsx,已验证,节点未应用时提供应用后继续、丢弃和取消;保留节点表单与图草稿边界 +DraftSave,frontend/src/features/workflows/use-workflows.ts,saveDraft / saveWorkflowDraftFor,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/features/workflows/workflow-save-concurrency.test.tsx,已验证,将已有保存操作放在显著位置;保留提交版本与竞态保护 +PublishAndRun,frontend/src/features/workflows/use-workflows.ts; frontend/src/pages/WorkflowsPage.tsx,publish / execute / selectedPublishedVersion,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/pages/WorkflowsPage.test.tsx,已验证,发布确认和运行对象不合并;显示冻结快照版本与环境 +RunEvidenceQuery,frontend/src/flow/WorkflowRunInspector.tsx; frontend/src/pages/WorkflowsPage.tsx; frontend/src/features/workflows/execution-navigation.ts; frontend/src/flow/WorkflowRunTrajectory.tsx,WorkflowRunInspector / useFailedRecordPage,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/flow/WorkflowRunInspector.test.tsx,已验证,节点/尝试深链接、来源/轨迹定位;历史删除流程兼容;失败证据与局部错误保留 +APIWorkbench,frontend/src/pages/ApiConsolePage.tsx; frontend/src/features/api-console/APIWorkbench.tsx,ApiConsolePage / APIWorkbench / APIResponseTabs,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/pages/ApiConsolePage.test.tsx; frontend/src/features/api-console/APIWorkbench.test.tsx,已验证,目录、请求、响应分区;HTTP 状态与执行断言结论分开 +ReportAggregation,frontend/src/pages/ReportsPage.tsx; frontend/src/features/reports/use-reports.ts; frontend/src/features/reports/ReportExecutionWorkspace.tsx,ReportCounts / ExecutionCard / useReports,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/pages/ReportsPage.test.tsx,已验证,完整报告轨迹与证据工作区、双向定位画布、请求尝试选择;服务端分页与本页统计范围 +PermissionGuards,frontend/src/features/navigation/navigation-config.tsx; frontend/src/pages/OrganizationGovernancePage.tsx; frontend/src/pages/ExecutionFabricPage.tsx; frontend/src/pages/PlatformCapabilitiesPage.tsx,visibleSections / canManage / canRotate / useSystemAdminStatus,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/App.test.tsx; frontend/src/pages/OrganizationGovernancePage.test.tsx,已验证,保留组织治理与系统管理员入口差异;未开放能力不增加假操作 +CodeEditorAndCharts,frontend/src/styles.css; frontend/src/features/dashboard/DashboardTrendChart.tsx; frontend/src/features/reports/ReportTrendChart.tsx,iceChartTheme / DashboardTrendChart / ReportTrendChart,cfe3594d055e1feb988df18041ed9dbedda8bc6b + 当前未提交改动,frontend/src/pages/DashboardPage.test.tsx; frontend/src/pages/ReportsPage.test.tsx,已验证,白色代码区/Tooltip、图例/单位、无数据状态与减少动效 diff --git a/docs/design/ui-v5/visual-deviations.md b/docs/design/ui-v5/visual-deviations.md new file mode 100644 index 00000000..7ab56f7e --- /dev/null +++ b/docs/design/ui-v5/visual-deviations.md @@ -0,0 +1,16 @@ +# v5 必要视觉差异记录 + +以下截图来自修改后的真实前端,数据为浏览器中隔离的 API 契约夹具。原型图片只作为对照,不作为实现证据。截图、运行脚本和完整日志保存在工作树的 `output/playwright/ui-v5/`,该目录被 Git 忽略。 + +| 编号 | 场景/视口 | 与主图的差异 | 源码或模型依据 | 保留的视觉目标 | 实现截图 | 功能影响与审核状态 | +| ---- | --------------- | ------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| D01 | 应用壳/桌面 | 保留现有模块 tabs;报告内增加流程/执行关联对象导航,编排增加执行报告链接 | `App.tsx`、项目路由和 `execution-navigation.ts`;通过真实对象 ID 关联,未引入全局持久化对象 tab 模型 | 68px 浅色任务栏、项目选择、全部模块入口;流程与报告互相定位 | [模块目录](../../../output/playwright/ui-v5/20-module-directory.png) | 保留权限入口;执行/节点/请求尝试传递有前端回归;待用户视觉审阅 | +| D02 | 流程/1440、1600 | 控制块在节点卡片展示所属区域名称/步骤,双击或配置进入真实内联区域;不把原型中括号端点变成执行端口 | `WorkflowDefinition.regions`、`control-blocks.ts`、原有 Handle 模型 | 可见控制结构、冰蓝画布、白色节点、类型图标、状态、320px 检查器 | [控制块](../../../output/playwright/ui-v5/08-workflow-control-1440.png)、[内联区域](../../../output/playwright/ui-v5/09-workflow-control-region.png) | 无序列化类型/引擎修改;28 类注册项保留;待用户视觉审阅 | +| D03 | 流程/1280×800 | 节点库改为抽屉,工作流列表也为抽屉;检查器保留可读宽度 | 编辑器有效画布宽度与 `layout-preferences.ts`;原列表和搜索处理器不变 | 主任务可读,不缩小文字强塞三栏;开关可恢复焦点 | [1280 编辑器](../../../output/playwright/ui-v5/04-workflow-1280.png) | 抽屉与检查器测试通过;待用户视觉审阅 | +| D04 | 接口/1440、1280 | 原型中的静态按钮只映射到已有发送、版本、导入、环境与文件操作;请求内高级表单保留 | `APIWorkbench`、`api-service.ts`、资产版本和 request target 模型 | 左目录、方法/路径/发送、请求/响应工作区;失败证据优先;HTTP 与断言分开 | [接口工作台](../../../output/playwright/ui-v5/06-api-workbench-1440.png)、[1280](../../../output/playwright/ui-v5/13-api-workbench-1280.png) | 固定版本执行、重复参数、禁用项、认证/文件沿用原实现;真实网络验收未运行 | +| D05 | 用例/1440、390 | 用例与套件仍各自 tab;新增详情、固定版本、计划/显式执行与报告关联。近期结果明示最近 20 次计划运行范围 | 沿用现有分页、版本、计划与运行详情接口;套件来源取自 source_suite 冻结信息;全历史聚合/导入导出/追加既有计划尚无接口 | 目录—列表—详情,发布与结果分开;批量当前页;执行前可审阅;不使用当前草稿替代发布版本 | 见本轮 28–34 截图及 assets-targeted-final.log | 33 项定向回归与隔离浏览器验证;真实角色/后端执行仍待验收;原生导入导出和全量聚合独立待办 | +| D06 | 报告/1440、390 | 详情现为完整工作区:左执行轨迹、右节点证据、定位画布与关联对象入口。手机将轨迹改为可横向浏览的区域,证据单列 | `ReportExecutionWorkspace`、既有报告/执行快照接口;没有新增 checkpoint 或重跑能力 | 期望/实际对照、失败优先;节点/请求尝试精确关联历史画布;统计分母明确 | 见实施记录中的本轮联动截图;首轮 Modal 截图仅作历史记录 | 服务端分页、双向导航、直接链接与尝试选择有回归;导出/通知沿用原处理器 | +| D07 | 发布门禁/1440 | 保留真实策略与历史表,最新结论展示逐项证据和决策上下文;不显示模型不存在的策略语义版本 | `ReleaseDecision.policy_snapshot`/`evidence_snapshot`/`fingerprint` | BLOCK 优先、缺证据明确、冻结决策可读;重新判断创建新记录 | [门禁工作区](../../../output/playwright/ui-v5/18-release-gate-1440.png) | 只读显示,不增加部署操作;真实证据收集与生成判断未执行 | +| D08 | 其他页面族 | 26 个模块共享主题、导航、表格、代码和弹层;没有用原型假数据替换页面业务 | 当前路由、权限、feature flags、服务处理器 | 冰蓝白的一致表面与层次;保留受控环境、性能默认和人工审核边界 | [总览](../../../output/playwright/ui-v5/14-quality-overview-1440.png)、[外观](../../../output/playwright/ui-v5/21-appearance-settings.png) | 共性组件与既有前端回归通过;其余页面逐个真实浏览器/角色验收待运行 | + +本次未进行整站像素差异自动评分、完整 WCAG 审计或大图性能对比,因此不宣称这些项目已完成。 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index a5b2354d..db8231ff 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -33,7 +33,8 @@ describe('App authentication', () => { useAuthStore.setState({ user: { ...user, is_system_admin: isAdmin } }) renderApp(`/projects/${project.id}/dashboard`) await screen.findByRole('heading', { name: '质量指挥中心' }) - const navigation = within(document.querySelector('.sidebar') as HTMLElement) + await userEvent.setup().click(screen.getByRole('button', { name: '全部模块' })) + const navigation = within(await screen.findByRole('dialog')) const sections = { dashboard: '质量总览', settings: '项目管理', @@ -134,7 +135,6 @@ describe('App authentication', () => { expect(close).toBeInstanceOf(HTMLElement) fireEvent.click(close as HTMLElement) expect(screen.queryByRole('tab', { name: '接口管理' })).not.toBeInTheDocument() - await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) await browser.click(screen.getByRole('link', { name: '接口管理' })) expect(await screen.findByRole('tab', { name: '接口管理' })).toBeVisible() await browser.click(screen.getByRole('tab', { name: '质量总览' })) @@ -231,11 +231,11 @@ describe('App authentication', () => { expect(await screen.findByRole('heading', { name: '质量指挥中心' })).toBeVisible() expect((await screen.findAllByText(project.name)).length).toBeGreaterThanOrEqual(2) + await userEvent.setup().click(screen.getByRole('button', { name: '全部模块' })) await userEvent.setup().click(screen.getByRole('menuitem', { name: '项目与接口' })) - expect(screen.getByRole('link', { name: '接口管理' })).toHaveAttribute( - 'href', - `/projects/${project.id}/apis`, - ) + expect( + within(screen.getByRole('dialog')).getByRole('link', { name: '接口管理' }), + ).toHaveAttribute('href', `/projects/${project.id}/apis`) expect(screen.getByRole('link', { name: '服务目录' })).toHaveAttribute( 'href', `/projects/${project.id}/services`, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 63723a92..4b7faac5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -29,6 +29,7 @@ import { useProjectContext } from './features/projects/use-project-context' import { groupForSection, sectionLabels } from './features/navigation/navigation-config' import ShellSidebar from './features/navigation/ShellSidebar' import GlobalSearch from './features/search/GlobalSearch' +import AppearanceSettings from './theme/AppearanceSettings' const DashboardPage = lazy(() => import('./pages/DashboardPage')) const ServiceCatalogPage = lazy(() => import('./pages/ServiceCatalogPage')) @@ -100,10 +101,12 @@ function AuthenticatedShell() {
+ + FlowTest + 接口自动化测试平台 - + + {props.requestActions} + +
+ + 发送与预览使用已保存版本 v{props.detail.version.version};编辑后请先保存新版本。 +
{ expect(screen.getByText('上游服务返回错误')).toBeVisible() expect(screen.getByText('upstream failed')).toBeVisible() - await browser.click(screen.getByRole('tab', { name: '断言' })) - expect(screen.getByText('状态码期望 200,实际 500')).toBeVisible() + await browser.click(screen.getByRole('tab', { name: '断言(1)' })) + expect( + within(screen.getByRole('tabpanel', { name: '断言(1)' })).getByText( + '状态码期望 200,实际 500', + ), + ).toBeVisible() await browser.click(screen.getByRole('tab', { name: '执行历史' })) - expect(screen.getAllByText('failed').at(-1)).toBeVisible() + expect( + within(screen.getByRole('tabpanel', { name: '执行历史' })).getByText('失败'), + ).toBeVisible() }) }) diff --git a/frontend/src/features/api-console/ExecutionResultPanel.tsx b/frontend/src/features/api-console/ExecutionResultPanel.tsx index 8d7464d6..ea8636cc 100644 --- a/frontend/src/features/api-console/ExecutionResultPanel.tsx +++ b/frontend/src/features/api-console/ExecutionResultPanel.tsx @@ -1,5 +1,6 @@ -import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons' import { Alert, Descriptions, Empty, Table, Tabs, Tag, Typography } from 'antd' +import { useId } from 'react' +import AssertionEvidence from '../evidence/AssertionEvidence' import type { Execution, ExecutionDetail } from '../../lib/api' @@ -9,11 +10,46 @@ type Props = { } export default function ExecutionResultPanel({ result, history }: Props) { + const tabsId = useId() return ( }, - { key: 'assertions', label: '断言', children: }, + { key: 'response', label: '响应结果', children: }, + { + key: 'headers', + label: '响应头', + children: result ? ( +
{formatJson(result.execution.response_headers)}
+ ) : ( + + ), + }, + { + key: 'request', + label: '实际请求', + children: result ? ( + <> + + {result.execution.request_method} {result.execution.request_url} + +
+                {formatJson({
+                  headers: result.execution.request_headers,
+                  body: result.execution.request_body,
+                  target_snapshot: result.execution.target_snapshot,
+                })}
+              
+ + ) : ( + + ), + }, + { + key: 'assertions', + label: `断言${result ? `(${result.assertions.length})` : ''}`, + children: , + }, { key: 'history', label: '执行历史', children: }, ]} /> @@ -35,10 +71,16 @@ function ResponseView({ result }: { result: ExecutionDetail | null }) { - 请求目标快照 -
{formatJson(execution.target_snapshot)}
- 响应 Body -
{formatJson(execution.response_body)}
+
+
+ 响应 Body +
{formatJson(execution.response_body)}
+
+ +
) } @@ -46,30 +88,11 @@ function ResponseView({ result }: { result: ExecutionDetail | null }) { function AssertionsView({ result }: { result: ExecutionDetail | null }) { if (!result) return return ( - - passed ? ( - } color="success"> - 通过 - - ) : ( - } color="error"> - 失败 - - ), - }, - { title: '类型', dataIndex: 'kind' }, - { title: '目标', dataIndex: 'target', render: (value: string | null) => value ?? '—' }, - { title: '说明', dataIndex: 'message' }, - ]} + ({ + ...item, + name: item.target ? `${item.kind} · ${item.target}` : item.kind, + }))} /> ) } @@ -99,10 +122,21 @@ function HistoryView({ history }: { history: Execution[] }) { function ExecutionStatus({ status }: { status: Execution['status'] }) { const passed = status === 'passed' - return {status} + const labels: Record = { + passed: '通过', + running: '运行中', + failed: '失败', + error: '执行错误', + } + return ( + + {labels[status]} + + ) } function formatJson(value: unknown) { + if (value === undefined) return '未提供' if (typeof value === 'string') return value return JSON.stringify(value, null, 2) ?? '' } diff --git a/frontend/src/features/api-console/use-api-console.ts b/frontend/src/features/api-console/use-api-console.ts index 5b8aa1dd..a0809492 100644 --- a/frontend/src/features/api-console/use-api-console.ts +++ b/frontend/src/features/api-console/use-api-console.ts @@ -127,7 +127,15 @@ export function useApiConsole(initialApiId?: string) { onSuccess: async (value) => { setResult(value) await queryClient.invalidateQueries({ queryKey: ['executions', projectId] }) - void message.success(executionMessage(value)) + void message.open({ + type: + value.execution.status === 'passed' + ? 'success' + : value.execution.status === 'running' + ? 'info' + : 'error', + content: executionMessage(value), + }) }, onError: (error) => void message.error(apiErrorMessage(error)), }) @@ -362,7 +370,13 @@ function requiredId(value: string | null): string { } function executionMessage(result: ExecutionDetail): string { - return result.execution.status === 'passed' ? '接口执行通过' : '接口执行完成' + const labels: Record = { + passed: '接口执行通过', + running: '接口正在执行', + failed: '接口执行失败,请查看断言和响应', + error: '接口执行出错,请查看诊断', + } + return labels[result.execution.status] } async function withErrorMessage( diff --git a/frontend/src/features/dashboard/DashboardTrendChart.tsx b/frontend/src/features/dashboard/DashboardTrendChart.tsx index 31364c91..fe6369db 100644 --- a/frontend/src/features/dashboard/DashboardTrendChart.tsx +++ b/frontend/src/features/dashboard/DashboardTrendChart.tsx @@ -3,29 +3,33 @@ import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/compon import { init, use as registerECharts } from 'echarts/core' import { SVGRenderer } from 'echarts/renderers' import { useEffect, useRef } from 'react' +import { Empty } from 'antd' import type { DashboardTrendPoint } from '../../lib/api' +import { iceChartTheme } from '../../theme/ice-theme' +import { useReducedMotion } from '../../theme/use-reduced-motion' registerECharts([LineChart, GridComponent, LegendComponent, TooltipComponent, SVGRenderer]) export function DashboardTrendChart({ points }: { points: DashboardTrendPoint[] }) { const container = useRef(null) + const reduced = useReducedMotion() useEffect(() => { - if (!container.current || navigator.userAgent.includes('jsdom')) return - const chart = init(container.current, undefined, { renderer: 'svg' }) + if (!container.current || !points.length || navigator.userAgent.includes('jsdom')) return + const chart = init(container.current, iceChartTheme, { renderer: 'svg' }) chart.setOption({ + animation: !reduced, tooltip: { trigger: 'axis' }, - legend: { data: ['通过', '失败', '运行中'] }, + legend: { top: 0, data: ['通过', '失败', '运行中'] }, grid: { left: 40, right: 20, top: 38, bottom: 28 }, xAxis: { type: 'category', data: points.map((point) => point.date.slice(5)) }, - yAxis: { type: 'value', minInterval: 1 }, + yAxis: { type: 'value', name: '次', minInterval: 1 }, series: [ { name: '通过', type: 'line', smooth: true, data: points.map((point) => point.passed) }, { name: '失败', type: 'line', smooth: true, data: points.map((point) => point.failed) }, { name: '运行中', type: 'line', smooth: true, data: points.map((point) => point.running) }, ], - color: ['#22a06b', '#dc4446', '#2563eb'], }) const resize = () => chart.resize() window.addEventListener('resize', resize) @@ -33,7 +37,13 @@ export function DashboardTrendChart({ points }: { points: DashboardTrendPoint[] window.removeEventListener('resize', resize) chart.dispose() } - }, [points]) + }, [points, reduced]) - return
+ return ( +
+ {!points.length && ( + + )} +
+ ) } diff --git a/frontend/src/features/evidence/AssertionEvidence.test.tsx b/frontend/src/features/evidence/AssertionEvidence.test.tsx new file mode 100644 index 00000000..988993ad --- /dev/null +++ b/frontend/src/features/evidence/AssertionEvidence.test.tsx @@ -0,0 +1,30 @@ +import { render, screen, within } from '@testing-library/react' +import { expect, it } from 'vitest' +import AssertionEvidence from './AssertionEvidence' +import { reportAssertionEvidence } from './assertion-model' + +it('shows failed assertions first and preserves false, zero, null and missing values', () => { + render( + , + ) + const cards = within(screen.getByLabelText('断言证据')).getAllByRole('article') + expect(within(cards[0]).getByText('金额校验')).toBeVisible() + expect(within(cards[0]).getByText('0')).toBeVisible() + expect(within(cards[0]).getByText('false')).toBeVisible() + expect(within(cards[1]).getAllByText('null')).toHaveLength(2) + expect(within(cards[2]).getByText('未提供结果')).toBeVisible() + expect(within(cards[2]).getAllByText('未提供')).toHaveLength(2) +}) + +it('keeps incomplete report assertions unknown and does not invent evidence from arbitrary payloads', () => { + expect(reportAssertionEvidence({ expected: 0 }, '金额')).toEqual([ + { name: '金额', passed: null, expected: 0, actual: undefined, message: undefined }, + ]) + expect(reportAssertionEvidence({ result: 'passed' }, '金额')).toEqual([]) +}) diff --git a/frontend/src/features/evidence/AssertionEvidence.tsx b/frontend/src/features/evidence/AssertionEvidence.tsx new file mode 100644 index 00000000..aaa3337e --- /dev/null +++ b/frontend/src/features/evidence/AssertionEvidence.tsx @@ -0,0 +1,52 @@ +import { Empty, Tag, Typography } from 'antd' + +export type AssertionEvidenceItem = { + name: string + passed: boolean | null + expected: unknown + actual: unknown + message?: string +} + +export default function AssertionEvidence({ items }: { items: AssertionEvidenceItem[] }) { + if (!items.length) + return + const ordered = [...items].sort( + (left, right) => Number(left.passed !== false) - Number(right.passed !== false), + ) + return ( +
+ {ordered.map((item, index) => ( +
+
+ {item.name} + + {item.passed === true ? '通过' : item.passed === false ? '失败' : '未提供结果'} + +
+
+
+ 期望值 +
{displayValue(item.expected)}
+
+
+ 实际值 +
{displayValue(item.actual)}
+
+
+ {item.message &&

{item.message}

} +
+ ))} +
+ ) +} + +function displayValue(value: unknown): string { + if (value === undefined) return '未提供' + return JSON.stringify(value, null, 2) ?? '未提供' +} diff --git a/frontend/src/features/evidence/assertion-model.ts b/frontend/src/features/evidence/assertion-model.ts new file mode 100644 index 00000000..86d80c90 --- /dev/null +++ b/frontend/src/features/evidence/assertion-model.ts @@ -0,0 +1,17 @@ +import type { AssertionEvidenceItem } from './AssertionEvidence' + +export function reportAssertionEvidence(value: unknown, name: string): AssertionEvidenceItem[] { + if (Array.isArray(value)) return value.flatMap((item) => reportAssertionEvidence(item, name)) + if (!value || typeof value !== 'object') return [] + const item = value as Record + if (!('expected' in item) && !('actual' in item)) return [] + return [ + { + name: typeof item.name === 'string' ? item.name : name, + passed: typeof item.passed === 'boolean' ? item.passed : null, + expected: item.expected, + actual: item.actual, + message: typeof item.message === 'string' ? item.message : undefined, + }, + ] +} diff --git a/frontend/src/features/navigation/ShellSidebar.test.tsx b/frontend/src/features/navigation/ShellSidebar.test.tsx index c8a1f62b..902d867d 100644 --- a/frontend/src/features/navigation/ShellSidebar.test.tsx +++ b/frontend/src/features/navigation/ShellSidebar.test.tsx @@ -68,119 +68,117 @@ function mockViewport(width: number) { }) } -describe('grouped shell navigation', () => { +describe('task rail and full module directory', () => { beforeEach(() => localStorage.clear()) afterEach(() => vi.restoreAllMocks()) - it('restores a manually expanded dashboard group on reload while an unconfigured dashboard stays closed', async () => { + async function openDirectory(browser: ReturnType) { + await browser.click(screen.getByRole('button', { name: '全部模块' })) + return within(await screen.findByRole('dialog')) + } + + it('keeps the six task shortcuts and preserves deep links and drafts while searching the directory', async () => { const browser = userEvent.setup() - const view = renderWorkspace('/dashboard') - const group = screen.getByRole('menuitem', { name: '质量分析' }) - expect(group).toHaveAttribute('aria-expanded', 'false') - await browser.click(group) - view.unmount() - renderWorkspace('/dashboard') - expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( - 'aria-expanded', - 'true', + renderWorkspace() + expect( + within(screen.getByRole('navigation', { name: '常用任务' })).getAllByRole('link'), + ).toHaveLength(6) + const directory = await openDirectory(browser) + await browser.type(directory.getByLabelText('搜索模块'), '协议') + expect(directory.getByRole('link', { name: '多协议工作台' })).toHaveAttribute( + 'href', + '/protocols', ) - await browser.click(screen.getByRole('button', { name: '收起侧栏' })) - await browser.click(screen.getByRole('button', { name: '展开侧栏' })) - expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( - 'aria-expanded', - 'true', + expect(directory.queryByRole('link', { name: '平台管理' })).not.toBeInTheDocument() + expect(screen.getByTestId('location')).toHaveTextContent( + '/workflows?focus=node&proposal=p#draft', ) + expect(screen.getByLabelText('本地草稿')).toHaveValue('未保存草稿') }) - it('keeps URL, local draft and manually closed active group stable across ordinary renders', async () => { + it('restores a manually expanded dashboard group without overwriting the old collapse preference', async () => { const browser = userEvent.setup() localStorage.setItem( 'flowtest:navigation:v1:alice', - JSON.stringify({ openKeys: ['nav:system'] }), + JSON.stringify({ collapsed: true, openKeys: [] }), ) - renderWorkspace() - const group = screen.getByRole('menuitem', { name: '测试设计' }) - expect(group).toHaveAttribute('aria-expanded', 'true') - await browser.click(group) - await browser.click(screen.getByRole('button', { name: '重新渲染' })) + const view = renderWorkspace('/dashboard') + const directory = await openDirectory(browser) + const group = directory.getByRole('menuitem', { name: '质量分析' }) expect(group).toHaveAttribute('aria-expanded', 'false') - await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) - expect(screen.getByTestId('location')).toHaveTextContent( - '/workflows?focus=node&proposal=p#draft', - ) - expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') - await browser.click(screen.getByRole('button', { name: '收起侧栏' })) - await browser.click(screen.getByRole('button', { name: '展开侧栏' })) - expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( + await browser.click(group) + view.unmount() + renderWorkspace('/dashboard') + const restored = await openDirectory(browser) + expect(restored.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( 'aria-expanded', 'true', ) - expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') + expect(JSON.parse(localStorage.getItem('flowtest:navigation:v1:alice')!).collapsed).toBe(true) }) - it('navigates via row and collapsed dashboard icon activation without a text Link', async () => { + it('keeps a manually closed group stable across ordinary renders', async () => { const browser = userEvent.setup() renderWorkspace() - await browser.click(screen.getByRole('button', { name: '收起侧栏' })) - await browser.click(screen.getByRole('menuitem', { name: '质量总览' })) - expect(screen.getByTestId('location')).toHaveTextContent('/dashboard') + const directory = await openDirectory(browser) + const group = directory.getByRole('menuitem', { name: '测试设计' }) + await browser.click(group) + await browser.click(screen.getByRole('button', { name: '重新渲染' })) + expect(group).toHaveAttribute('aria-expanded', 'false') + expect(screen.getByLabelText('本地草稿')).toHaveValue('普通 render') }) - it('opens the matching parent on Tab navigation and browser history, and uses an accordion', async () => { + it('tracks task navigation and browser history without discarding drafts', async () => { const browser = userEvent.setup() renderWorkspace() - await browser.click(screen.getByRole('menuitem', { name: '项目与接口' })) - expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( - 'aria-expanded', - 'false', - ) - await browser.click(screen.getByRole('button', { name: '打开报告 Tab' })) - expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( - 'aria-expanded', - 'true', - ) + await browser.click(screen.getByRole('link', { name: '测试报告' })) + expect(screen.getByTestId('location')).toHaveTextContent('/reports') + expect(screen.getByRole('link', { name: '测试报告' })).toHaveAttribute('aria-current', 'page') await browser.click(screen.getByRole('button', { name: '后退' })) - expect(screen.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( - 'aria-expanded', - 'true', + expect(screen.getByTestId('location')).toHaveTextContent( + '/workflows?focus=node&proposal=p#draft', ) - expect(screen.getByRole('menuitem', { name: '质量分析' })).toHaveAttribute( + const directory = await openDirectory(browser) + expect(directory.getByRole('menuitem', { name: '测试设计' })).toHaveAttribute( 'aria-expanded', - 'false', + 'true', ) + expect(screen.getByLabelText('本地草稿')).toHaveValue('未保存草稿') }) - it('defaults the dashboard to closed parents and filters admin leaves after role changes', async () => { + it('filters administrator modules from both browsing and search after role changes', async () => { const browser = userEvent.setup() const view = renderWorkspace('/dashboard') - await browser.click(screen.getByRole('menuitem', { name: '系统管理' })) - await waitFor(() => expect(screen.getByRole('link', { name: '平台管理' })).toBeVisible()) + const directory = await openDirectory(browser) + await browser.type(directory.getByLabelText('搜索模块'), '平台') + expect(directory.getByRole('link', { name: '平台管理' })).toBeVisible() view.rerender( , ) - await browser.click(screen.getByRole('menuitem', { name: '系统管理' })) - await waitFor(() => expect(screen.getByRole('link', { name: '组织治理' })).toBeVisible()) - expect(screen.queryByRole('link', { name: '平台管理' })).not.toBeInTheDocument() - expect(screen.queryByRole('link', { name: '分布式执行面' })).not.toBeInTheDocument() + const filtered = await openDirectory(browser) + expect(filtered.queryByRole('link', { name: '平台管理' })).not.toBeInTheDocument() + await browser.clear(filtered.getByLabelText('搜索模块')) + await browser.click(filtered.getByRole('menuitem', { name: '系统管理' })) + await waitFor(() => expect(filtered.getByRole('link', { name: '组织治理' })).toBeVisible()) + expect(filtered.queryByRole('link', { name: '分布式执行面' })).not.toBeInTheDocument() }) - it('defaults compact screens to collapsed without overwriting the explicit desktop preference', async () => { + it('uses the same compact rail at desktop widths without changing saved preferences', () => { const resize = mockViewport(1100) - const browser = userEvent.setup() + localStorage.setItem( + 'flowtest:navigation:v1:alice', + JSON.stringify({ collapsed: true, openKeys: [] }), + ) renderWorkspace('/dashboard') - expect(screen.getByRole('button', { name: '展开侧栏' })).toBeVisible() - expect(localStorage.getItem('flowtest:navigation:v1:alice')).toBeNull() - resize(1440) - await browser.click(screen.getByRole('button', { name: '收起侧栏' })) - resize(1100) + expect(screen.getByRole('button', { name: '全部模块' })).toBeVisible() resize(1920) - expect(screen.getByRole('button', { name: '展开侧栏' })).toBeVisible() + expect(screen.getByRole('button', { name: '全部模块' })).toBeVisible() expect(JSON.parse(localStorage.getItem('flowtest:navigation:v1:alice')!).collapsed).toBe(true) }) - it('uses a narrow-screen drawer, closes after choosing a leaf and restores trigger focus', async () => { + it('closes the mobile directory on navigation or Escape and restores trigger focus', async () => { mockViewport(390) const browser = userEvent.setup() renderWorkspace('/dashboard') @@ -194,6 +192,5 @@ describe('grouped shell navigation', () => { await browser.click(trigger) fireEvent.keyDown(await screen.findByRole('dialog'), { key: 'Escape', keyCode: 27 }) await waitFor(() => expect(trigger).toHaveAttribute('aria-expanded', 'false')) - expect(localStorage.getItem('flowtest:navigation:v1:alice')).toEqual(expect.any(String)) }) }) diff --git a/frontend/src/features/navigation/ShellSidebar.tsx b/frontend/src/features/navigation/ShellSidebar.tsx index 81b40c17..ec9d0567 100644 --- a/frontend/src/features/navigation/ShellSidebar.tsx +++ b/frontend/src/features/navigation/ShellSidebar.tsx @@ -1,10 +1,24 @@ -import { ApiOutlined, MenuFoldOutlined, MenuOutlined, MenuUnfoldOutlined } from '@ant-design/icons' -import { Button, ConfigProvider, Drawer, Layout, Menu, type MenuProps } from 'antd' -import { useRef } from 'react' -import { useNavigate } from 'react-router-dom' +import { + ApiOutlined, + ApartmentOutlined, + AppstoreOutlined, + BarChartOutlined, + DashboardOutlined, + FolderOutlined, + MenuOutlined, + SafetyCertificateOutlined, +} from '@ant-design/icons' +import { Button, Drawer, Input, Layout, Menu, type MenuProps } from 'antd' +import { useRef, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' import type { ProjectSection } from '../projects/project-routing' -import { navigationPath, shellNavigationItems, visibleSections } from './navigation-config' +import { + navigationPath, + sectionLabels, + shellNavigationItems, + visibleSections, +} from './navigation-config' import { useNavigationState } from './use-navigation-state' interface ShellSidebarProps { @@ -14,95 +28,106 @@ interface ShellSidebarProps { pathFor: (section: ProjectSection) => string } +const shortcuts = [ + { section: 'dashboard', label: '总览', icon: }, + { section: 'apis', label: '接口', icon: }, + { section: 'workflows', label: '编排', icon: }, + { section: 'assets', label: '用例', icon: }, + { section: 'reports', label: '报告', icon: }, + { section: 'release', label: '门禁', icon: }, +] as const + export default function ShellSidebar({ userId, isSystemAdmin, section, pathFor, }: ShellSidebarProps) { - const state = useNavigationState(userId, section, isSystemAdmin) + const state = useNavigationState(userId, section, isSystemAdmin, 'directory') const navigate = useNavigate() + const trigger = useRef(null) + const [query, setQuery] = useState('') const handleLeafClick: NonNullable = ({ key, domEvent }) => { - if (state.mobile) state.setDrawerOpen(false) const target = visibleSections(isSystemAdmin).find((item) => item === key) + if (!target) return + state.setDrawerOpen(false) const clickedLink = domEvent.target instanceof Element && domEvent.target.closest('a') - // Links keep native browser behavior; row/icon/keyboard activation uses the same router guard. - if (target && !clickedLink) navigate(navigationPath(target, pathFor)) + if (!clickedLink) navigate(navigationPath(target, pathFor)) } - const drawerTrigger = useRef(null) - // Each Menu mode owns its popup lifecycle; the workspace keeps the same instance. - const navigation = ( -
-
- - {!state.collapsed && FlowTest} -
-
- ) - if (state.mobile) { - return ( - <> - } + /> + )} + {detail && ( + void snapshot.refetch()} + /> + )} + + ) +} + +function ReportWorkflowObjectLink({ + workflowPath, + detail, +}: { + workflowPath: string + detail: ReportExecutionDetail | undefined +}) { + return {detail?.summary.workflow_name ?? '流程快照'} +} + +function ReportExecutionHeading({ + props, + environment, +}: { + props: WorkspaceProps + environment: string +}) { + const { detail, executionId } = props + return ( +
+
+ 执行报告详情 · 历史只读 + {detail?.summary.workflow_name ?? '执行报告'} + + 执行 {executionId.slice(0, 8)} + {detail && 执行版本 v{detail.summary.workflow_version}} + 环境:{environment} + {detail && } + +
+ + + + +
+ ) +} + +function ReportExecutionBody({ + props, + detail, + node, + workflowPath, + snapshotError, + onRetrySnapshot, +}: { + props: WorkspaceProps + detail: ReportExecutionDetail + node: ReportNode | undefined + workflowPath: string + snapshotError: boolean + onRetrySnapshot: () => void +}) { + const { executionId, nodeId } = props + return ( +
+ +
+ {nodeId && !detail.nodes.some((item) => item.node_id === nodeId) && ( + + )} + {node ? ( + props.onSelectAttempt(node.node_id, attempt)} + /> + ) : ( + + + + )} + + + + 这里展示当次执行保存的证据。定位画布会打开同一次执行的只读快照,当前草稿不会覆盖历史数据。 + + {snapshotError && ( + + 重试环境信息 + + } + /> + )} + + {detail.dataset_children.length > 0 && ( + + + {detail.dataset_children.map((child) => ( + + {child.workflow_name} · {child.status} + + ))} + + + )} +
+
+ ) +} + +function selectReportNode(nodes: ReportNode[], nodeId: string | null): ReportNode | undefined { + return ( + nodes.find((node) => node.node_id === nodeId) ?? + nodes.find((node) => node.status === 'failed') ?? + nodes[0] + ) +} + +function ReportTrajectory({ + nodes, + selectedId, + onSelect, +}: { + nodes: ReportNode[] + selectedId?: string + onSelect: (nodeId: string) => void +}) { + const navigation = useRef(null) + useEffect(() => { + const nav = navigation.current + const selected = nav?.querySelector('[aria-pressed="true"]') + if (!nav || !selected || nav.scrollWidth <= nav.clientWidth) return + const bounds = nav.getBoundingClientRect() + const target = selected.getBoundingClientRect() + nav.scrollLeft += target.left - bounds.left - (nav.clientWidth - target.width) / 2 + }, [selectedId]) + return ( + + ) +} + +function ReportNodeEvidence({ + node, + workflowPath, + initialAttempt, + onSelectAttempt, +}: { + node: ReportNode + workflowPath: string + initialAttempt?: number + onSelectAttempt: (attempt: number) => void +}) { + const observations = node.observations ?? [] + const [attempt, setAttempt] = useRouteScopedState( + null, + String(initialAttempt), + initialAttempt ?? observations.at(-1)?.attempt, + ) + const observation = observations.find((item) => item.attempt === attempt) ?? observations.at(-1) + const assertions = reportAssertionEvidence(node.assertion, node.name) + return ( + + {node.name} + + + } + extra={ + + 定位画布 + + } + > + {node.error_message && ( + + )} + {assertions.length > 0 && } + + + {node.node_type} + 尝试次数 {node.attempts} + 节点耗时 {node.duration_ms === null ? '未提供' : `${node.duration_ms} ms`} + {observations.length > 0 && ( + ({ + value: item.version, + label: `已发布 v${item.version}`, + })), + ]} + /> + + {props.selected === 'draft' + ? '草稿仅用于编辑;先发布后才能加入计划或执行。' + : `正在查看不可变的发布版本 v${props.selected}。`} + + + + + + +
+ ) +} + +function CaseBinding({ + projectId, + definition, + workflows, + environments, + draft, +}: { + projectId: string + definition: TestCaseDefinition + workflows: Workflow[] + environments: Environment[] + draft: boolean +}) { + const workflow = workflows.find((item) => item.id === definition.workflow_id) + const environment = environments.find((item) => item.id === definition.environment_id) + return ( + <> + + {workflow?.name ?? definition.workflow_id} + + 打开流程 + + + ), + }, + { + key: 'version', + label: '流程版本', + children: definition.workflow_version + ? `v${definition.workflow_version}` + : '发布用例时固定最新已发布版本', + }, + { + key: 'environment', + label: '运行环境', + children: environment?.name ?? definition.environment_id, + }, + ]} + /> + + 打开流程用于查看或编辑当前流程;历史执行证据请从“执行与报告”进入冻结回看。 + + 运行变量 + + 运行请求头 + + + ) +} + +function OverrideValues({ values }: { values: Record }) { + return ( +
({ name, value }))} + locale={{ emptyText: '未设置覆盖项' }} + columns={[ + { title: '名称', dataIndex: 'name' }, + { + title: '值', + dataIndex: 'value', + render: (value: string) => {value}, + }, + ]} + /> + ) +} + +function SuiteMembers(props: AssetDetailProps & { items: TestSuiteItem[]; draft: boolean }) { + return ( + <> + + {props.draft ? '当前套件草稿;发布时固定成员版本。' : '这些用例版本已随套件发布冻结。'} + +
( + + {props.cases.find((asset) => asset.id === item.test_case_id)?.name ?? + item.test_case_id} + + ), + }, + { + title: '固定版本', + render: (_, item) => + item.test_case_version ? `v${item.test_case_version}` : '发布套件时固定', + }, + ]} + /> + + ) +} + +function RelatedPlans(props: Pick) { + const plans = useQuery({ + queryKey: ['asset-plans', props.projectId], + queryFn: () => listAssetPlans(props.projectId), + }) + const related = + plans.data?.items.filter((plan) => + plan.items.some((item) => item.target_type === props.kind && item.target_id === props.id), + ) ?? [] + return ( + <> + {plans.error && void plans.refetch()} />} +
( + + {plan.name} + + ), + }, + { + title: '固定版本', + render: (_, plan) => + plan.items + .filter((item) => item.target_type === props.kind && item.target_id === props.id) + .map((item) => `v${item.target_version}`) + .join(' / '), + }, + ]} + /> + + ) +} + +function AssetQueryError({ error, onRetry }: { error: unknown; onRetry: () => void }) { + return ( + + 重试 + + } + /> + ) +} diff --git a/frontend/src/features/test-assets/AssetPlanDialog.test.tsx b/frontend/src/features/test-assets/AssetPlanDialog.test.tsx new file mode 100644 index 00000000..66665c14 --- /dev/null +++ b/frontend/src/features/test-assets/AssetPlanDialog.test.tsx @@ -0,0 +1,82 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { App as AntdApp } from 'antd' +import { MemoryRouter } from 'react-router-dom' +import { beforeEach, expect, it, vi } from 'vitest' +import type { CreatedTestPlan, TestPlanRun } from '../../lib/api' +import { runTestPlan } from '../task-plans/task-plan-service' +import { createAssetPlan } from './asset-workspace-service' +import AssetPlanDialog from './AssetPlanDialog' + +vi.mock('./asset-workspace-service') +vi.mock('../task-plans/task-plan-service') + +const targets = [{ kind: 'case' as const, id: 'case-1', name: '订单校验', version: 1 }] +const plan = { + id: 'plan-1', + name: '固定版本回归', + webhook_secret: 'once-only-secret', +} as CreatedTestPlan + +beforeEach(() => { + vi.resetAllMocks() + vi.mocked(createAssetPlan).mockResolvedValue(plan) + vi.mocked(runTestPlan).mockResolvedValue({ id: 'run-1' } as TestPlanRun) +}) + +function renderDialog(execute: boolean) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + render( + + + + + + + , + ) + fireEvent.change(screen.getByLabelText('计划名称'), { target: { value: '固定版本回归' } }) +} + +it('does not write during review and creates a frozen plan without automatically running it', async () => { + renderDialog(false) + expect(createAssetPlan).not.toHaveBeenCalled() + expect(runTestPlan).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button', { name: '创建计划' })) + await screen.findByText('测试计划已创建') + expect(createAssetPlan).toHaveBeenCalledWith('project-1', '固定版本回归', targets) + expect(runTestPlan).not.toHaveBeenCalled() + expect(screen.queryByText(plan.webhook_secret)).not.toBeInTheDocument() + expect(screen.getByRole('link', { name: `查看 ${plan.name}` })).toHaveAttribute( + 'href', + '/projects/project-1/tasks?focus=plan-1', + ) +}) + +it('retries enqueue on the same created plan and idempotency key instead of creating duplicate plans', async () => { + vi.mocked(runTestPlan).mockRejectedValueOnce(new Error('队列暂不可用')) + renderDialog(true) + fireEvent.click(screen.getByRole('button', { name: '创建计划并执行' })) + await screen.findByText('计划已创建,执行入队失败') + fireEvent.click(screen.getByRole('button', { name: '重试执行' })) + await screen.findByText('计划已进入执行队列') + expect(createAssetPlan).toHaveBeenCalledTimes(1) + expect(runTestPlan).toHaveBeenCalledTimes(2) + expect(vi.mocked(runTestPlan).mock.calls[0]).toEqual(vi.mocked(runTestPlan).mock.calls[1]) + expect(runTestPlan).toHaveBeenCalledWith('project-1', plan.id, expect.any(String)) +}) + +it('keeps a failed create reviewable and never enqueues it', async () => { + vi.mocked(createAssetPlan).mockRejectedValueOnce(new Error('版本不存在')) + renderDialog(true) + fireEvent.click(screen.getByRole('button', { name: '创建计划并执行' })) + await screen.findByText('创建计划失败') + expect(runTestPlan).not.toHaveBeenCalled() + await waitFor(() => expect(screen.getByRole('button', { name: '创建计划并执行' })).toBeEnabled()) + expect(screen.getByLabelText('计划名称')).toHaveValue('固定版本回归') +}) diff --git a/frontend/src/features/test-assets/AssetPlanDialog.tsx b/frontend/src/features/test-assets/AssetPlanDialog.tsx new file mode 100644 index 00000000..d2eeafde --- /dev/null +++ b/frontend/src/features/test-assets/AssetPlanDialog.tsx @@ -0,0 +1,147 @@ +import { useQueryClient } from '@tanstack/react-query' +import { Alert, Button, Form, Input, Modal, Space, Tag, Typography } from 'antd' +import { useState } from 'react' +import { Link } from 'react-router-dom' +import { apiErrorMessage, type TestPlan } from '../../lib/api' +import { projectPath } from '../projects/project-routing' +import { runTestPlan } from '../task-plans/task-plan-service' +import { createAssetPlan, type PublishedAssetTarget } from './asset-workspace-service' + +export default function AssetPlanDialog({ + projectId, + targets, + execute, + onClose, +}: { + projectId: string + targets: PublishedAssetTarget[] + execute: boolean + onClose: () => void +}) { + const client = useQueryClient() + const [form] = Form.useForm<{ name: string }>() + const [created, setCreated] = useState(null) + const [queued, setQueued] = useState(false) + const [pending, setPending] = useState(false) + const [error, setError] = useState(null) + const [idempotencyKey] = useState(() => crypto.randomUUID()) + async function submit() { + if (pending || queued) return + const values = await form.validateFields().catch(() => null) + if (!values) return + setPending(true) + setError(null) + try { + const plan = created ?? (await createAssetPlan(projectId, values.name.trim(), targets)) + setCreated(plan) + await client.invalidateQueries({ queryKey: ['test-plans', projectId] }) + await client.invalidateQueries({ queryKey: ['asset-plans', projectId] }) + if (execute) { + await runTestPlan(projectId, plan.id, idempotencyKey) + setQueued(true) + await client.invalidateQueries({ queryKey: ['asset-recent-runs', projectId] }) + await client.invalidateQueries({ queryKey: ['test-plan-runs', projectId] }) + } + } catch (failure) { + setError(apiErrorMessage(failure)) + } finally { + setPending(false) + } + } + const done = Boolean(created) && (!execute || queued) + return ( + + + {!done && ( + + )} + + } + > + + {execute + ? '为所选版本创建手动计划并进入执行队列。' + : '创建包含所选版本的手动计划;创建后可到任务执行页面运行。'} + 计划固定已发布版本,后续草稿修改不会改变这次执行资产。 + +
+ {targets.map((target) => ( +
+ {target.kind === 'case' ? '用例' : '套件'} + {target.name} v{target.version} +
+ ))} +
+
+ + + + + +
+ ) +} + +function PlanFeedback({ + projectId, + created, + queued, + error, +}: { + projectId: string + created: TestPlan | null + queued: boolean + error: string | null +}) { + return ( + <> + {error && ( + + )} + {created && ( + + 查看 {created.name} + + } + /> + )} + + ) +} + +function planActionLabel(execute: boolean, created: boolean): string { + if (!execute) return '创建计划' + return created ? '重试执行' : '创建计划并执行' +} diff --git a/frontend/src/features/test-assets/AssetRunEvidence.tsx b/frontend/src/features/test-assets/AssetRunEvidence.tsx new file mode 100644 index 00000000..5381fbbc --- /dev/null +++ b/frontend/src/features/test-assets/AssetRunEvidence.tsx @@ -0,0 +1,138 @@ +import { Alert, Button, Empty, Space, Table, Tag, Typography } from 'antd' +import { Link } from 'react-router-dom' +import { apiErrorMessage, type TestCase } from '../../lib/api' +import { projectPath } from '../projects/project-routing' +import { reportExecutionPath, workflowExecutionPath } from '../workflows/execution-navigation' +import { assetRunLabels, reportItem, type AssetRunEntry } from './asset-run-view-model' +import type { AssetKind, AssetRunItem } from './asset-workspace-service' +import type { useAssetHistory } from './use-asset-history' + +export function AssetResultTag({ status }: { status: AssetRunItem['status'] }) { + const colors: Partial> = { + passed: 'success', + failed: 'error', + running: 'processing', + queued: 'warning', + } + return {assetRunLabels[status]} +} + +export function AssetRecentResult({ + projectId, + entry, +}: { + projectId: string + entry?: AssetRunEntry +}) { + if (!entry) return 此范围无记录 + const item = reportItem(entry) + return ( + + + + {entry.versions.map((version) => `v${version}`).join(' / ')} + + {item?.workflow_execution_id && ( + + 报告 → + + )} + + ) +} + +export function AssetHistoryNotice({ history }: { history: ReturnType }) { + if (history.error) + return ( + void history.reload()}> + 重新加载 + + } + /> + ) + return ( + + 执行结果范围:最近 {history.count} 次计划运行 / 全部 {history.total ?? '—'} 次; 最多查询 20 + 次,范围内无记录不表示从未执行。批量选择仅限当前页。 + + ) +} + +export default function AssetRunEvidence({ + projectId, + kind, + id, + history, + cases, +}: { + projectId: string + kind: AssetKind + id: string + history: ReturnType + cases: TestCase[] +}) { + const entries = history.history(kind, id) + return ( +
+ + {!entries.length ? ( + + ) : ( + entries.map((entry) => ( +
+ + + + {new Date(entry.run.created_at).toLocaleString('zh-CN')} + + {entry.versions.map((version) => `v${version}`).join(' / ')} + + 查看计划 + + +
( + + {cases.find((asset) => asset.id === item.target_id)?.name ?? item.target_id} · + v{item.target_version} + + ), + }, + { title: '结果', render: (_, item) => }, + { + title: '证据', + render: (_, item) => , + }, + ]} + /> + + )) + )} + + ) +} + +function RunItemLinks({ projectId, item }: { projectId: string; item: AssetRunItem }) { + if (!item.workflow_execution_id) + return 尚无流程报告 + const location = { executionId: item.workflow_execution_id, workflowId: item.workflow_id } + return ( + + 报告 + 编排回看 + + ) +} diff --git a/frontend/src/features/test-assets/AssetTableContext.tsx b/frontend/src/features/test-assets/AssetTableContext.tsx new file mode 100644 index 00000000..d65bdae3 --- /dev/null +++ b/frontend/src/features/test-assets/AssetTableContext.tsx @@ -0,0 +1,71 @@ +import { useQuery } from '@tanstack/react-query' +import { Typography } from 'antd' +import type { TestCase, Workflow } from '../../lib/api' +import { AssetRecentResult } from './AssetRunEvidence' +import type { AssetKind, PublishedAssetTarget } from './asset-workspace-service' +import { listTestCaseVersions } from './test-asset-service' +import type { useAssetHistory } from './use-asset-history' + +export type AssetTableWorkspace = { + projectId: string + page: number + onPage: (page: number) => void + onFocus: (kind: AssetKind, id: string) => void + onPlan: (target: PublishedAssetTarget, execute: boolean) => void + workflows: Workflow[] + history: ReturnType + canEdit: boolean + canExecute: boolean +} + +export function PublishedCaseBinding({ + item, + workspace, +}: { + item: TestCase + workspace: AssetTableWorkspace +}) { + const versions = useQuery({ + queryKey: ['test-case-versions', workspace.projectId, item.id], + queryFn: () => listTestCaseVersions(workspace.projectId, item.id), + enabled: item.current_version !== null, + }) + if (!item.current_version) return 草稿尚未发布 + if (versions.error) return 绑定读取失败 + const version = versions.data?.find((candidate) => candidate.version === item.current_version) + if (!version) + return ( + + {versions.isPending ? '读取绑定中' : '发布版本不存在'} + + ) + const workflow = workspace.workflows.find( + (candidate) => candidate.id === version.definition.workflow_id, + ) + return ( + + {workflow?.name ?? version.definition.workflow_id} · v{version.definition.workflow_version} + + ) +} + +export function RecentAssetCell({ + kind, + id, + workspace, +}: { + kind: AssetKind + id: string + workspace: AssetTableWorkspace +}) { + if (workspace.history.loading) + return 读取结果中 + if (workspace.history.error) + return 结果未完整加载 + return ( + + ) +} diff --git a/frontend/src/features/test-assets/asset-run-view-model.test.ts b/frontend/src/features/test-assets/asset-run-view-model.test.ts new file mode 100644 index 00000000..2903169c --- /dev/null +++ b/frontend/src/features/test-assets/asset-run-view-model.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { assetRunHistory, assetRunStatus, reportItem } from './asset-run-view-model' +import type { AssetRunDetail, AssetRunItem } from './asset-workspace-service' + +const item = (overrides: Partial = {}): AssetRunItem => ({ + id: 'item-1', + target_type: 'case', + target_id: 'case-1', + target_version: 1, + target_snapshot: { target_type: 'case', target_id: 'case-1', target_version: 1 }, + workflow_id: 'flow-1', + workflow_version: 2, + environment_id: 'env-1', + position: 0, + max_retries: 0, + attempts: 1, + status: 'passed', + workflow_execution_id: 'execution-1', + error_message: null, + ...overrides, +}) +const detail = (items: AssetRunItem[], created = '2026-10-02T10:00:00Z'): AssetRunDetail => ({ + run: { id: `run-${created}`, created_at: created, status: 'failed' } as AssetRunDetail['run'], + items, +}) + +describe('asset execution linkage', () => { + it('uses actual case targets and never attributes a workflow run or another case to this asset', () => { + const records = [ + detail([ + item(), + item({ id: 'other', target_id: 'case-2', status: 'failed' }), + item({ id: 'flow', target_type: 'workflow', target_id: 'case-1', status: 'failed' }), + ]), + ] + const history = assetRunHistory(records, 'case', 'case-1') + expect(history).toHaveLength(1) + expect(history[0].status).toBe('passed') + expect(history[0].versions).toEqual([1]) + expect(history[0].items).toEqual([item()]) + }) + + it('aggregates only frozen suite members and chooses failed execution evidence', () => { + const source_suite = { id: 'suite-1', version: 3 } + const records = [ + detail([ + item({ target_snapshot: { ...item().target_snapshot, source_suite } }), + item({ + id: 'failed', + target_id: 'case-2', + target_version: 2, + status: 'failed', + workflow_execution_id: 'execution-failed', + target_snapshot: { ...item().target_snapshot, source_suite }, + }), + item({ id: 'unrelated', target_id: 'case-3', status: 'cancelled' }), + ]), + ] + const entry = assetRunHistory(records, 'suite', 'suite-1')[0] + expect(entry.status).toBe('failed') + expect(entry.versions).toEqual([3]) + expect(entry.items).toHaveLength(2) + expect(reportItem(entry)?.workflow_execution_id).toBe('execution-failed') + expect(assetRunHistory(records, 'suite', 'suite-2')).toEqual([]) + }) + + it('orders actual run time and preserves historical asset versions', () => { + const entries = assetRunHistory( + [ + detail([item({ target_version: 4 })], '2026-10-02T10:10:00Z'), + detail([item({ target_version: 1 })]), + ], + 'case', + 'case-1', + ) + expect(entries.map((entry) => entry.versions)).toEqual([[4], [1]]) + }) + + it('keeps unfinished, cancelled and quarantined items from becoming a pass', () => { + expect(assetRunStatus([item(), item({ status: 'queued' })])).toBe('queued') + expect(assetRunStatus([item(), item({ status: 'running' })])).toBe('running') + expect(assetRunStatus([item(), item({ status: 'cancelled' })])).toBe('cancelled') + expect(assetRunStatus([item(), item({ status: 'quarantined' })])).toBe('quarantined') + const entry = assetRunHistory( + [detail([item({ status: 'queued', workflow_execution_id: null })])], + 'case', + 'case-1', + )[0] + expect(reportItem(entry)).toBeUndefined() + }) +}) diff --git a/frontend/src/features/test-assets/asset-run-view-model.ts b/frontend/src/features/test-assets/asset-run-view-model.ts new file mode 100644 index 00000000..d665a89f --- /dev/null +++ b/frontend/src/features/test-assets/asset-run-view-model.ts @@ -0,0 +1,59 @@ +import type { AssetKind, AssetRunDetail, AssetRunItem } from './asset-workspace-service' + +export type AssetRunEntry = { + run: AssetRunDetail['run'] + items: AssetRunItem[] + status: AssetRunItem['status'] + versions: number[] +} + +export function assetRunHistory( + details: AssetRunDetail[], + kind: AssetKind, + assetId: string, +): AssetRunEntry[] { + return details + .flatMap((detail) => { + const items = detail.items.filter((item) => matchesAsset(item, kind, assetId)) + if (!items.length) return [] + const versions = items.map((item) => + kind === 'case' ? item.target_version : item.target_snapshot.source_suite!.version, + ) + return [ + { run: detail.run, items, status: assetRunStatus(items), versions: [...new Set(versions)] }, + ] + }) + .sort((a, b) => Date.parse(b.run.created_at) - Date.parse(a.run.created_at)) +} + +function matchesAsset(item: AssetRunItem, kind: AssetKind, id: string): boolean { + if (kind === 'case') return item.target_type === 'case' && item.target_id === id + return item.target_snapshot.source_suite?.id === id +} + +export function assetRunStatus(items: AssetRunItem[]): AssetRunItem['status'] { + const priority: AssetRunItem['status'][] = [ + 'failed', + 'running', + 'queued', + 'cancelled', + 'quarantined', + ] + return priority.find((status) => items.some((item) => item.status === status)) ?? 'passed' +} + +export function reportItem(entry: AssetRunEntry): AssetRunItem | undefined { + return ( + entry.items.find((item) => item.status === 'failed' && item.workflow_execution_id) ?? + entry.items.find((item) => item.workflow_execution_id) + ) +} + +export const assetRunLabels: Record = { + queued: '排队中', + running: '运行中', + passed: '通过', + failed: '失败', + cancelled: '已取消', + quarantined: '已隔离', +} diff --git a/frontend/src/features/test-assets/asset-workspace-service.test.ts b/frontend/src/features/test-assets/asset-workspace-service.test.ts new file mode 100644 index 00000000..e9b582df --- /dev/null +++ b/frontend/src/features/test-assets/asset-workspace-service.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { apiClient } from '../../lib/api' +import { + createAssetPlan, + getAsset, + getAssetRun, + listAssetPlans, + listAssetWorkflows, + listCaseCatalog, + listRecentAssetRuns, + listSuiteCatalog, +} from './asset-workspace-service' + +afterEach(() => vi.restoreAllMocks()) + +describe('asset workspace service', () => { + it('loads later pages for directories and pickers instead of truncating at 100', async () => { + const get = vi.spyOn(apiClient, 'get').mockImplementation(async (_url, config) => ({ + data: + (config?.params as { page: number }).page === 1 + ? { + items: Array.from({ length: 100 }, (_, index) => ({ id: `item-${index}` })), + total: 101, + page: 1, + page_size: 100, + } + : { items: [{ id: 'item-100' }], total: 101, page: 2, page_size: 100 }, + })) + expect((await listCaseCatalog('project-1', '订单', 'regression')).items).toHaveLength(101) + expect((await listSuiteCatalog('project-1', '', '')).items).toHaveLength(101) + expect((await listAssetWorkflows('project-1')).items).toHaveLength(101) + expect((await listAssetPlans('project-1')).items).toHaveLength(101) + expect(get).toHaveBeenCalledWith('/projects/project-1/test-cases', { + params: { page: 2, page_size: 100, search: '订单', tag: 'regression' }, + }) + }) + + it('rejects a stalled catalog rather than claiming a partial directory is complete', async () => { + vi.spyOn(apiClient, 'get').mockResolvedValue({ + data: { items: [{ id: 'item-1' }], total: 180 }, + }) + await expect(listCaseCatalog('project-1', '', '')).rejects.toThrow( + '资产列表在分页读取时发生变化', + ) + }) + + it('keeps every read project-scoped and limits the declared execution range to 20 runs', async () => { + const get = vi.spyOn(apiClient, 'get').mockResolvedValue({ data: {} }) + await getAsset('project-1', 'case', 'case-1') + await getAsset('project-1', 'suite', 'suite-1') + await getAssetRun('project-1', 'run-1') + await listRecentAssetRuns('project-1') + expect(get).toHaveBeenCalledWith('/projects/project-1/test-cases/case-1') + expect(get).toHaveBeenCalledWith('/projects/project-1/test-suites/suite-1') + expect(get).toHaveBeenCalledWith('/projects/project-1/test-plan-runs/run-1') + expect(get).toHaveBeenCalledWith('/projects/project-1/test-plan-runs', { + params: { page: 1, page_size: 20 }, + }) + }) + + it('creates plans with explicit selected asset versions and performs no execution', async () => { + const post = vi.spyOn(apiClient, 'post').mockResolvedValue({ data: {} }) + await createAssetPlan('project-1', '固定版本回归', [ + { kind: 'case', id: 'case-1', name: '订单校验', version: 1 }, + { kind: 'suite', id: 'suite-1', name: '核心回归', version: 3 }, + ]) + expect(post).toHaveBeenCalledTimes(1) + expect(post).toHaveBeenCalledWith('/projects/project-1/test-plans', { + name: '固定版本回归', + enabled: true, + items: [ + { target_type: 'case', target_id: 'case-1', target_version: 1 }, + { target_type: 'suite', target_id: 'suite-1', target_version: 3 }, + ], + }) + }) +}) diff --git a/frontend/src/features/test-assets/asset-workspace-service.ts b/frontend/src/features/test-assets/asset-workspace-service.ts new file mode 100644 index 00000000..ebde2f8d --- /dev/null +++ b/frontend/src/features/test-assets/asset-workspace-service.ts @@ -0,0 +1,140 @@ +import { + apiClient, + type CreatedTestPlan, + type Page, + type TestCase, + type TestCaseDefinition, + type TestPlan, + type TestPlanRun, + type TestSuite, + type Workflow, +} from '../../lib/api' +import { listTestCases, listTestSuites } from './test-asset-service' + +export type AssetKind = 'case' | 'suite' +export type PublishedAssetTarget = { + kind: AssetKind + id: string + name: string + version: number +} +export type AssetCatalog = { items: T[]; total: number } +export type AssetRunItem = { + id: string + target_type: 'workflow' | 'case' + target_id: string + target_version: number + target_snapshot: { + target_type: 'workflow' | 'case' + target_id: string + target_version: number + definition?: TestCaseDefinition + source_suite?: { id: string; version: number } + } + workflow_id: string + environment_id: string + workflow_version: number + position: number + max_retries: number + attempts: number + status: 'queued' | 'running' | 'passed' | 'failed' | 'cancelled' | 'quarantined' + workflow_execution_id: string | null + error_message: string | null +} +export type AssetRunDetail = { run: TestPlanRun; items: AssetRunItem[] } + +// These three existing resources share the same page contract. Complete catalogs +// let directory filters and version pickers include records beyond page one. +export function listCaseCatalog(projectId: string, search: string, tag: string) { + return collectPages((page) => listTestCases(projectId, search, tag, page)) +} + +export function listSuiteCatalog(projectId: string, search: string, tag: string) { + return collectPages((page) => listTestSuites(projectId, search, tag, page)) +} + +export function listAssetWorkflows(projectId: string) { + return collectPages( + async (page) => + ( + await apiClient.get>(`/projects/${projectId}/workflows`, { + params: { page, page_size: 100 }, + }) + ).data, + ) +} + +async function collectPages( + fetchPage: (page: number) => Promise>, +): Promise> { + const first = await fetchPage(1) + const items = new Map(first.items.map((item) => [item.id, item])) + let total = first.total + let page = 1 + while (items.size < total) { + const next = await fetchPage(++page) + const before = items.size + next.items.forEach((item) => items.set(item.id, item)) + total = next.total + if (items.size === before && items.size < total) { + throw new Error('资产列表在分页读取时发生变化,请重新加载') + } + } + if (items.size !== total) throw new Error('资产数量在读取时发生变化,请重新加载') + return { items: [...items.values()], total } +} + +export async function getAsset(projectId: string, kind: 'case', id: string): Promise +export async function getAsset(projectId: string, kind: 'suite', id: string): Promise +export async function getAsset( + projectId: string, + kind: AssetKind, + id: string, +): Promise +export async function getAsset(projectId: string, kind: AssetKind, id: string) { + const resource = kind === 'case' ? 'test-cases' : 'test-suites' + return (await apiClient.get(`/projects/${projectId}/${resource}/${id}`)) + .data +} + +export async function listAssetPlans(projectId: string): Promise> { + return collectPages( + async (page) => + ( + await apiClient.get>(`/projects/${projectId}/test-plans`, { + params: { page, page_size: 100 }, + }) + ).data, + ) +} + +export async function listRecentAssetRuns(projectId: string): Promise> { + return ( + await apiClient.get>(`/projects/${projectId}/test-plan-runs`, { + params: { page: 1, page_size: 20 }, + }) + ).data +} + +export async function getAssetRun(projectId: string, runId: string): Promise { + return (await apiClient.get(`/projects/${projectId}/test-plan-runs/${runId}`)) + .data +} + +export async function createAssetPlan( + projectId: string, + name: string, + targets: PublishedAssetTarget[], +): Promise { + return ( + await apiClient.post(`/projects/${projectId}/test-plans`, { + name, + enabled: true, + items: targets.map((target) => ({ + target_type: target.kind, + target_id: target.id, + target_version: target.version, + })), + }) + ).data +} diff --git a/frontend/src/features/test-assets/test-asset-service.ts b/frontend/src/features/test-assets/test-asset-service.ts index b4efbbfe..2a937ca5 100644 --- a/frontend/src/features/test-assets/test-asset-service.ts +++ b/frontend/src/features/test-assets/test-asset-service.ts @@ -31,9 +31,10 @@ export async function listTestCases( projectId: string, search: string, tag: string, + page = 1, ): Promise> { const response = await apiClient.get>(`/projects/${projectId}/test-cases`, { - params: { page: 1, page_size: 100, search: search || undefined, tag: tag || undefined }, + params: { page, page_size: 100, search: search || undefined, tag: tag || undefined }, }) return response.data } @@ -127,9 +128,10 @@ export async function listTestSuites( projectId: string, search: string, tag: string, + page = 1, ): Promise> { const response = await apiClient.get>(`/projects/${projectId}/test-suites`, { - params: { page: 1, page_size: 100, search: search || undefined, tag: tag || undefined }, + params: { page, page_size: 100, search: search || undefined, tag: tag || undefined }, }) return response.data } diff --git a/frontend/src/features/test-assets/test-asset-view-model.ts b/frontend/src/features/test-assets/test-asset-view-model.ts index 5359771b..5426e149 100644 --- a/frontend/src/features/test-assets/test-asset-view-model.ts +++ b/frontend/src/features/test-assets/test-asset-view-model.ts @@ -1,4 +1,4 @@ -import type { Folder, TestCase } from '../../lib/api' +import type { Folder, TestCase, TestSuiteItem } from '../../lib/api' import type { TestCaseDraftInput, TestSuiteDraftInput } from './test-asset-service' export type CaseFormValues = { @@ -8,6 +8,7 @@ export type CaseFormValues = { tags: string[] isTemplate: boolean workflowId: string + workflowVersion?: number | null environmentId: string } @@ -17,6 +18,7 @@ export type SuiteFormValues = { folderId?: string tags: string[] caseIds: string[] + caseVersions?: Record } export function caseInput( @@ -31,8 +33,7 @@ export function caseInput( isTemplate: values.isTemplate, definition: { workflow_id: values.workflowId, - workflow_version: - previous?.workflow_id === values.workflowId ? previous.workflow_version : null, + workflow_version: workflowVersion(values, previous), environment_id: values.environmentId, runtime_variables: previous?.runtime_variables ?? {}, runtime_headers: previous?.runtime_headers ?? {}, @@ -40,7 +41,19 @@ export function caseInput( } } -export function suiteInput(values: SuiteFormValues, cases: TestCase[]): TestSuiteDraftInput { +function workflowVersion( + values: CaseFormValues, + previous: TestCase['draft_definition'] | undefined, +) { + if ('workflowVersion' in values) return values.workflowVersion ?? null + return previous?.workflow_id === values.workflowId ? previous.workflow_version : null +} + +export function suiteInput( + values: SuiteFormValues, + cases: TestCase[], + previous: TestSuiteItem[] = [], +): TestSuiteDraftInput { return { name: values.name, description: values.description, @@ -48,11 +61,23 @@ export function suiteInput(values: SuiteFormValues, cases: TestCase[]): TestSuit tags: values.tags ?? [], items: values.caseIds.map((caseId) => ({ test_case_id: caseId, - test_case_version: cases.find((item) => item.id === caseId)?.current_version ?? null, + test_case_version: suiteCaseVersion(caseId, values.caseVersions, previous, cases), })), } } +function suiteCaseVersion( + caseId: string, + selected: SuiteFormValues['caseVersions'], + previous: TestSuiteItem[], + cases: TestCase[], +): number | null { + if (selected && caseId in selected) return selected[caseId] + const saved = previous.find((item) => item.test_case_id === caseId) + if (saved) return saved.test_case_version + return cases.find((item) => item.id === caseId)?.current_version ?? null +} + export function pageItems(page: { items: T[] } | undefined): T[] { return page?.items ?? [] } diff --git a/frontend/src/features/test-assets/use-asset-history.ts b/frontend/src/features/test-assets/use-asset-history.ts new file mode 100644 index 00000000..9cc37f39 --- /dev/null +++ b/frontend/src/features/test-assets/use-asset-history.ts @@ -0,0 +1,34 @@ +import { useQueries, useQuery } from '@tanstack/react-query' +import { getAssetRun, listRecentAssetRuns, type AssetKind } from './asset-workspace-service' +import { assetRunHistory } from './asset-run-view-model' + +export function useAssetHistory(projectId: string | null) { + const runs = useQuery({ + queryKey: ['asset-recent-runs', projectId], + queryFn: () => listRecentAssetRuns(projectId!), + enabled: Boolean(projectId), + refetchInterval: (query) => (hasActiveRun(query.state.data?.items) ? 1000 : false), + }) + const details = useQueries({ + queries: (runs.data?.items ?? []).map((run) => ({ + queryKey: ['test-plan-run-detail', projectId, run.id], + queryFn: () => getAssetRun(projectId!, run.id), + staleTime: 10_000, + refetchInterval: (query: { state: { data?: Awaited> } }) => + hasActiveRun(query.state.data ? [query.state.data.run] : []) ? 1000 : false, + })), + }) + const records = details.flatMap((query) => (query.data ? [query.data] : [])) + return { + total: runs.data?.total, + count: records.length, + loading: runs.isPending || details.some((query) => query.isPending), + error: runs.error ?? details.find((query) => query.error)?.error, + history: (kind: AssetKind, id: string) => assetRunHistory(records, kind, id), + reload: () => Promise.all([runs.refetch(), ...details.map((query) => query.refetch())]), + } +} + +function hasActiveRun(items: Array<{ status: string }> = []): boolean { + return items.some((item) => ['queued', 'running'].includes(item.status)) +} diff --git a/frontend/src/features/test-assets/use-test-assets.test.tsx b/frontend/src/features/test-assets/use-test-assets.test.tsx index 710349ac..4fa18cd2 100644 --- a/frontend/src/features/test-assets/use-test-assets.test.tsx +++ b/frontend/src/features/test-assets/use-test-assets.test.tsx @@ -6,7 +6,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { TestCase, TestSuite, VersionDiff } from '../../lib/api' import { listFolders } from '../projects/asset-service' import { useProjectContext } from '../projects/use-project-context' -import { listEnvironments, listWorkflows } from '../workflows/workflow-service' +import { getProjectPermission } from '../projects/project-service' +import { listEnvironments } from '../workflows/workflow-service' +import { listAssetWorkflows, listCaseCatalog, listSuiteCatalog } from './asset-workspace-service' import { cloneTestCase, cloneTestSuite, @@ -14,9 +16,7 @@ import { createTestSuite, diffTestCaseVersions, diffTestSuiteVersions, - listTestCases, listTestCaseVersions, - listTestSuites, listTestSuiteVersions, moveTestCases, moveTestSuites, @@ -30,6 +30,8 @@ import { useTestAssets } from './use-test-assets' vi.mock('../projects/use-project-context') vi.mock('../projects/asset-service') vi.mock('../workflows/workflow-service') +vi.mock('../projects/project-service') +vi.mock('./asset-workspace-service') vi.mock('./test-asset-service') const testCase = { @@ -52,9 +54,12 @@ describe('useTestAssets', () => { beforeEach(() => { vi.resetAllMocks() vi.mocked(useProjectContext).mockReturnValue({ projectId: 'project-1' } as never) - vi.mocked(listTestCases).mockResolvedValue(page([testCase])) - vi.mocked(listTestSuites).mockResolvedValue(page([testSuite])) - vi.mocked(listWorkflows).mockResolvedValue(page([])) + vi.mocked(listCaseCatalog).mockResolvedValue(page([testCase])) + vi.mocked(listSuiteCatalog).mockResolvedValue(page([testSuite])) + vi.mocked(listAssetWorkflows).mockResolvedValue(page([])) + vi.mocked(getProjectPermission).mockResolvedValue({ + capabilities: ['read', 'edit', 'execute'], + } as never) vi.mocked(listEnvironments).mockResolvedValue([]) vi.mocked(listFolders).mockResolvedValue([]) vi.mocked(createTestCase).mockResolvedValue(testCase) @@ -79,9 +84,10 @@ describe('useTestAssets', () => { result.current.setSearch('登录') result.current.setTag('smoke') }) - await waitFor(() => expect(listTestCases).toHaveBeenCalledWith('project-1', '登录', 'smoke')) + await waitFor(() => expect(listCaseCatalog).toHaveBeenCalledWith('project-1', '登录', 'smoke')) await waitFor(() => expect(result.current.cases.data?.items).toEqual([testCase])) await waitFor(() => expect(result.current.suites.data?.items).toEqual([testSuite])) + expect(result.current.caseOptions.data?.items).toEqual([testCase]) }) it('creates and updates case and suite drafts through one stable hook', async () => { diff --git a/frontend/src/features/test-assets/use-test-assets.ts b/frontend/src/features/test-assets/use-test-assets.ts index 28189261..35b3e068 100644 --- a/frontend/src/features/test-assets/use-test-assets.ts +++ b/frontend/src/features/test-assets/use-test-assets.ts @@ -2,8 +2,9 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useState } from 'react' import { listFolders } from '../projects/asset-service' -import { listEnvironments, listWorkflows } from '../workflows/workflow-service' +import { listEnvironments } from '../workflows/workflow-service' import { useProjectContext } from '../projects/use-project-context' +import { getProjectPermission } from '../projects/project-service' import type { TestCase, TestSuite, VersionDiff } from '../../lib/api' import { cloneTestCase, @@ -12,9 +13,7 @@ import { createTestSuite, diffTestCaseVersions, diffTestSuiteVersions, - listTestCases, listTestCaseVersions, - listTestSuites, listTestSuiteVersions, moveTestCases, moveTestSuites, @@ -25,6 +24,7 @@ import { updateTestCase, updateTestSuite, } from './test-asset-service' +import { listAssetWorkflows, listCaseCatalog, listSuiteCatalog } from './asset-workspace-service' export function useTestAssets() { const queryClient = useQueryClient() @@ -35,17 +35,17 @@ export function useTestAssets() { const enabled = Boolean(projectId) const cases = useQuery({ queryKey: ['test-cases', projectId, search, tag], - queryFn: () => listTestCases(projectId!, search, tag), + queryFn: () => listCaseCatalog(projectId!, search, tag), enabled, }) const suites = useQuery({ queryKey: ['test-suites', projectId, search, tag], - queryFn: () => listTestSuites(projectId!, search, tag), + queryFn: () => listSuiteCatalog(projectId!, search, tag), enabled, }) const workflows = useQuery({ queryKey: ['workflows', projectId, 'asset-options'], - queryFn: () => listWorkflows(projectId!), + queryFn: () => listAssetWorkflows(projectId!), enabled, }) const environments = useQuery({ @@ -58,11 +58,24 @@ export function useTestAssets() { queryFn: () => listFolders(projectId!), enabled, }) + const caseOptions = useQuery({ + queryKey: ['test-cases', projectId, '', ''], + queryFn: () => listCaseCatalog(projectId!, '', ''), + enabled, + }) + const permissions = useQuery({ + queryKey: ['project-permission', projectId], + queryFn: () => getProjectPermission(projectId!), + enabled, + }) async function invalidateAssets() { await Promise.all([ queryClient.invalidateQueries({ queryKey: ['test-cases', projectId] }), queryClient.invalidateQueries({ queryKey: ['test-suites', projectId] }), + queryClient.invalidateQueries({ queryKey: ['test-asset', projectId] }), + queryClient.invalidateQueries({ queryKey: ['test-case-versions', projectId] }), + queryClient.invalidateQueries({ queryKey: ['test-suite-versions', projectId] }), ]) } @@ -130,6 +143,10 @@ export function useTestAssets() { workflows, environments, folders, + caseOptions, + permissions, + canEdit: permissions.data?.capabilities.includes('edit') ?? false, + canExecute: permissions.data?.capabilities.includes('execute') ?? false, diff, setDiff, saveCase: saveCaseMutation.mutateAsync, diff --git a/frontend/src/features/workflows/execution-navigation.test.ts b/frontend/src/features/workflows/execution-navigation.test.ts new file mode 100644 index 00000000..7f4b3118 --- /dev/null +++ b/frontend/src/features/workflows/execution-navigation.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { + executionAttempt, + reportExecutionPath, + workflowExecutionPath, +} from './execution-navigation' + +describe('execution navigation', () => { + it('keeps the project, execution, node and request attempt across both views', () => { + const location = { + workflowId: 'workflow-2', + executionId: 'run-3', + nodeId: '订单 & 校验', + attempt: 2, + } + const workflowUrl = new URL( + workflowExecutionPath('project-1', location), + 'https://flowtest.test', + ) + const reportUrl = new URL(reportExecutionPath('project-1', location), 'https://flowtest.test') + expect(workflowUrl.pathname).toBe('/projects/project-1/workflows') + expect(workflowUrl.searchParams.get('focus')).toBe('workflow-2') + for (const url of [workflowUrl, reportUrl]) { + expect(url.searchParams.get('execution')).toBe('run-3') + expect(url.searchParams.get('node')).toBe(location.nodeId) + expect(executionAttempt(url.searchParams)).toBe(2) + } + }) + + it('allows frozen snapshots without a current workflow and ignores invalid attempts', () => { + expect(workflowExecutionPath('project-1', { executionId: 'run-3', workflowId: null })).toBe( + '/projects/project-1/workflows?execution=run-3', + ) + expect(reportExecutionPath('project-1', { executionId: 'run-3', attempt: 2 })).toBe( + '/projects/project-1/reports?execution=run-3', + ) + for (const value of ['0', '-1', '1.2', 'abc', '9007199254740992']) + expect(executionAttempt(new URLSearchParams({ attempt: value }))).toBeUndefined() + }) +}) diff --git a/frontend/src/features/workflows/execution-navigation.ts b/frontend/src/features/workflows/execution-navigation.ts new file mode 100644 index 00000000..a602ac06 --- /dev/null +++ b/frontend/src/features/workflows/execution-navigation.ts @@ -0,0 +1,31 @@ +import { projectPath } from '../projects/project-routing' + +export type ExecutionLocation = { + executionId: string + workflowId?: string | null + nodeId?: string | null + attempt?: number +} + +export function workflowExecutionPath(projectId: string, location: ExecutionLocation): string { + const params = new URLSearchParams() + if (location.workflowId) params.set('focus', location.workflowId) + params.set('execution', location.executionId) + if (location.nodeId) params.set('node', location.nodeId) + if (location.nodeId && location.attempt) params.set('attempt', String(location.attempt)) + return `${projectPath(projectId, 'workflows')}?${params}` +} + +export function reportExecutionPath(projectId: string, location: ExecutionLocation): string { + const params = new URLSearchParams({ execution: location.executionId }) + if (location.nodeId) params.set('node', location.nodeId) + if (location.nodeId && location.attempt) params.set('attempt', String(location.attempt)) + return `${projectPath(projectId, 'reports')}?${params}` +} + +export function executionAttempt(params: URLSearchParams): number | undefined { + const raw = params.get('attempt') + if (!raw || !/^[1-9]\d*$/.test(raw)) return undefined + const value = Number(raw) + return Number.isSafeInteger(value) ? value : undefined +} diff --git a/frontend/src/features/workflows/use-workflow-tabs.ts b/frontend/src/features/workflows/use-workflow-tabs.ts index 05c62fc8..3ab518bc 100644 --- a/frontend/src/features/workflows/use-workflow-tabs.ts +++ b/frontend/src/features/workflows/use-workflow-tabs.ts @@ -149,6 +149,9 @@ export function useWorkflowTabs(input: WorkflowTabHookInput) { input.selectWorkflow(workflowId) const next = new URLSearchParams(input.searchParams) next.set('focus', workflowId) + next.delete('execution') + next.delete('node') + next.delete('attempt') input.setSearchParams(next, { replace: true }) } @@ -175,6 +178,9 @@ export function useWorkflowTabs(input: WorkflowTabHookInput) { input.selectWorkflow(null) const next = new URLSearchParams(input.searchParams) next.delete('focus') + next.delete('execution') + next.delete('node') + next.delete('attempt') input.setSearchParams(next, { replace: true }) } diff --git a/frontend/src/features/workflows/use-workflows.ts b/frontend/src/features/workflows/use-workflows.ts index 54069edd..a3979316 100644 --- a/frontend/src/features/workflows/use-workflows.ts +++ b/frontend/src/features/workflows/use-workflows.ts @@ -37,7 +37,7 @@ import { writeWorkflowDraft, type WorkflowDraftKey, } from './workflow-draft-store' -import { useRouteScopedSelection } from '../../lib/use-route-scoped-state' +import { useRouteScopedSelection, useRouteScopedState } from '../../lib/use-route-scoped-state' import { createWorkflow, deleteWorkflow, @@ -73,7 +73,7 @@ export type WorkflowDraftEdit = { editVersion: number } -export function useWorkflows(initialWorkflowId?: string) { +export function useWorkflows(initialWorkflowId?: string, initialExecutionId?: string) { const { message, modal } = App.useApp() const queryClient = useQueryClient() const token = useAuthStore((store) => store.token) @@ -110,8 +110,8 @@ export function useWorkflows(initialWorkflowId?: string) { const [eventHistoryIncompleteId, setEventHistoryIncompleteId] = useState(null) const [liveNodes, setLiveNodes] = useState>({}) const [executionDefinition, setExecutionDefinition] = useState(null) - const [workspaceMode, setWorkspaceMode] = useState('draft') - const [historyExecutionId, setHistoryExecutionId] = useState(null) + const { workspaceMode, setWorkspaceMode, historyExecutionId, setHistoryExecutionId } = + useExecutionSelection(projectId, initialExecutionId) const [breakpointSelection, setBreakpointSelection] = useState(null) const [debugResult, setDebugResult] = useState(null) const [versionDiff, setVersionDiff] = useState(null) @@ -170,6 +170,7 @@ export function useWorkflows(initialWorkflowId?: string) { workflowSelection, workflowSelectionCleared, workflows.data, + Boolean(initialExecutionId), ) const setDraftStorageError = useCallback( (error: string | null, targetId = workflowId) => { @@ -774,6 +775,8 @@ export function useWorkflows(initialWorkflowId?: string) { showHistory, historyExecutionId, historyLoading: historyExecution.isLoading, + historyError: historyErrorText(historyExecution.error), + reloadHistory: () => void historyExecution.refetch(), breakpointNodes, breakpointNodeId, setBreakpointSelection, @@ -805,18 +808,34 @@ export function useWorkflows(initialWorkflowId?: string) { } } +function useExecutionSelection(projectId: string | null, executionId?: string) { + const [workspaceMode, setWorkspaceMode] = useRouteScopedState( + projectId, + executionId ?? null, + executionId ? 'history' : 'draft', + ) + const [historyExecutionId, setHistoryExecutionId] = useRouteScopedSelection( + projectId, + executionId ?? null, + ) + return { workspaceMode, setWorkspaceMode, historyExecutionId, setHistoryExecutionId } +} + +function historyErrorText(error: Error | null): string | null { + return error ? apiErrorMessage(error) : null +} + function useSelectedWorkflow( projectId: string | null, workflowSelection: string | null, workflowSelectionCleared: boolean, workflows: Page | undefined, + preserveExplicitSelection: boolean, ) { const listed = workflows?.items - const workflowId = resolveSelectedWorkflowId( - workflowSelection, - workflowSelectionCleared, - workflows, - ) + const workflowId = preserveExplicitSelection + ? workflowSelection + : resolveSelectedWorkflowId(workflowSelection, workflowSelectionCleared, workflows) const listedWorkflow = listed?.find((item) => item.id === workflowId) ?? null const workflowDetail = useQuery({ queryKey: ['workflow', projectId, workflowId], diff --git a/frontend/src/flow/WorkflowDesigner.test.tsx b/frontend/src/flow/WorkflowDesigner.test.tsx index 1c269be0..88ec2e8d 100644 --- a/frontend/src/flow/WorkflowDesigner.test.tsx +++ b/frontend/src/flow/WorkflowDesigner.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { ConfigProvider } from 'antd' import { useState } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -32,6 +33,44 @@ describe('WorkflowDesigner', () => { vi.clearAllMocks() }) + it('edits workflow settings in the graph draft and supports undo', async () => { + const browser = userEvent.setup() + render() + await browser.click(screen.getByRole('button', { name: '流程设置' })) + fireEvent.change(await screen.findByRole('spinbutton', { name: '并发上限' }), { + target: { value: '7' }, + }) + expect(screen.getByRole('spinbutton', { name: '并发上限' })).toHaveValue('7') + await browser.click(screen.getByRole('button', { name: '关闭流程设置' })) + await browser.click(screen.getByRole('button', { name: 'undo 撤销' })) + await browser.click(screen.getByRole('button', { name: '流程设置' })) + expect(await screen.findByRole('spinbutton', { name: '并发上限' })).toHaveValue( + String(workflowDefinition.settings.concurrency), + ) + }) + + it('only displays frozen workflow settings in historical mode', async () => { + const onChange = vi.fn() + const browser = userEvent.setup() + render( + , + ) + await browser.click(screen.getByRole('button', { name: '流程设置' })) + expect(await screen.findByRole('spinbutton', { name: '并发上限' })).toBeDisabled() + expect(screen.getByRole('textbox', { name: '变量 order_id 的初始值' })).toBeDisabled() + expect(screen.queryByRole('button', { name: '添加变量' })).not.toBeInTheDocument() + expect(onChange).not.toHaveBeenCalled() + }) + it('adds an inline control block with versioned regions as one editor change', async () => { const onChange = vi.fn() const browser = userEvent.setup() @@ -347,6 +386,7 @@ describe('WorkflowDesigner', () => { render() fireEvent.click(screen.getByText('请求用户')) + fireEvent.click(screen.getByRole('tab', { name: '输入 / 输出' })) expect(screen.getByLabelText('映射源表达式')).toHaveValue('row.email') fireEvent.change(screen.getByLabelText('映射源表达式'), { target: { value: 'row.user.email' }, @@ -355,7 +395,7 @@ describe('WorkflowDesigner', () => { expect(screen.getAllByLabelText('映射源表达式')).toHaveLength(2) await browser.click(screen.getAllByRole('button', { name: '删除映射' })[1]) - fireEvent.click(screen.getByText('提取邮箱')) + fireEvent.click(screen.getByTestId('rf__node-extract')) expect(screen.getByDisplayValue('selected_email')).toBeVisible() fireEvent.change(screen.getByDisplayValue('selected_email'), { target: { value: 'mapped_email' }, @@ -671,6 +711,49 @@ describe('WorkflowDesigner', () => { await waitFor(() => expect(screen.getByDisplayValue('尚未应用的名称')).toBeVisible()) expect(screen.queryAllByText('节点配置尚未应用')).toHaveLength(0) }) + it('offers apply, discard and cancel when selecting another node without changing the graph on discard', async () => { + const session = new DraftSession() + const onChange = vi.fn() + render( + + + + + , + ) + fireEvent.click(screen.getByTestId('rf__node-api')) + fireEvent.change(screen.getByLabelText('名称'), { target: { value: '未应用名称' } }) + expect(session.nodeEditors.size).toBe(1) + expect([...session.nodeEditors.values()][0].dirty).toBe(true) + fireEvent.click(screen.getByTestId('rf__node-end')) + const dialog = (await screen.findByText('节点配置尚未应用')).closest('.ant-modal')! + await waitFor(() => + expect(within(dialog).getByRole('button', { name: '应用配置后继续' })).toBeVisible(), + ) + fireEvent.click(within(dialog).getByRole('button', { name: '取消切换' })) + expect(screen.getByDisplayValue('未应用名称')).toBeVisible() + fireEvent.click(screen.getByTestId('rf__node-end')) + fireEvent.click( + within( + (await screen.findByText('节点配置尚未应用')).closest('.ant-modal')!, + ).getByRole('button', { + name: '丢弃此节点修改', + }), + ) + await waitFor(() => expect(screen.getByDisplayValue('结束')).toBeVisible()) + expect(onChange).not.toHaveBeenCalled() + expect(session.nodeEditors.size).toBe(0) + fireEvent.click(screen.getByTestId('rf__node-api')) + expect(screen.getByDisplayValue('查询用户')).toBeVisible() + }) it('uses the right-click target and guards editable input and proposal actions', async () => { const onChange = vi.fn() const view = render( diff --git a/frontend/src/flow/WorkflowDesigner.tsx b/frontend/src/flow/WorkflowDesigner.tsx index 072531f9..55db75ab 100644 --- a/frontend/src/flow/WorkflowDesigner.tsx +++ b/frontend/src/flow/WorkflowDesigner.tsx @@ -9,6 +9,14 @@ import { useAuthStore } from '../features/auth/auth-store' import { useDraftSession } from '../features/drafts/draft-session' import { nodeEditorScope } from './editor/editor-identity' import { nodeRegistryItem, type NodeRegistryKey } from './editor/node-registry' +import { + dataReferences, + nodePresentation, + unresolvedReferenceCount, + type RegionPreview, +} from './editor/node-presentation' +import { iceColors } from '../theme/ice-theme' +import { reducedMotionEnabled } from '../theme/use-reduced-motion' import { addControlBlock, controlCapabilityLabel, @@ -83,6 +91,7 @@ import '@xyflow/react/dist/style.css' import { Alert, Button, + Checkbox, Dropdown, Empty, Input, @@ -107,6 +116,9 @@ import type { import type { EventSource, SchemaArtifact } from '../features/protocols/protocol-service' import WorkflowNodeInspector from './WorkflowNodeInspector' import WorkflowRunInspector from './WorkflowRunInspector' +import WorkflowRunTrajectory from './WorkflowRunTrajectory' +import WorkflowSettingsEditor from './WorkflowSettingsEditor' +import { useNodeSelectionGuard } from './editor/use-node-selection-guard' import { getApiDetail } from '../features/api-console/api-service' import { listApis } from '../features/workflows/workflow-service' import { @@ -142,6 +154,10 @@ type DesignerProps = { runtimeExecutionId?: string runtimeNodes?: WorkflowNodeExecution[] runtimeContext?: Record + focusNodeId?: string + onNodeFocus?: (nodeId: string | null) => void + runtimeAttempt?: number + onAttemptFocus?: (nodeId: string, attempt: number) => void focusActions?: ReactNode onChange: (definition: WorkflowDefinition) => void } @@ -154,6 +170,8 @@ type NodeData = Record & { typeLabel: string status: string runtimeLabel: string + summary?: string + regions?: RegionPreview[] canCopy?: boolean canDelete?: boolean onConfigure?: () => void @@ -172,8 +190,8 @@ type CanvasEdge = Edge const nodeTypes = { workflowNode: WorkflowNodeCard } const edgeTypes = { workflowEdge: WorkflowCanvasEdge } -const NODE_INITIAL_WIDTH = 210 -const NODE_INITIAL_HEIGHT = 72 +const NODE_INITIAL_WIDTH = 232 +const NODE_INITIAL_HEIGHT = 110 export default function WorkflowDesigner(props: DesignerProps) { return ( @@ -222,6 +240,10 @@ function WorkflowDesignerReady({ runtimeExecutionId, runtimeNodes, runtimeContext, + focusNodeId, + onNodeFocus, + runtimeAttempt, + onAttemptFocus, focusActions, onChange, }: ReadyDesignerProps) { @@ -233,16 +255,33 @@ function WorkflowDesignerReady({ definition, canvasEditable, onChange, - recoverNodeSelection(draftSession, scope, definition), + designerInitialSelection( + draftSession, + scope, + definition, + runtimeNodes, + runtimeMode, + focusNodeId, + ), ) const selectedId = primaryNodeId(editor.selection) const selectedEdge = primaryEdge(definition, editor.selection) const canvasRef = useRef(null) const flowRef = useRef | null>(null) useCanvasAutoFrame(canvasRef, flowRef, definition, editor.selection, scope) + useRuntimeSelection( + editor.select, + definition, + runtimeNodes, + runtimeMode, + runtimeExecutionId, + focusNodeId, + ) const dropPosition = useRef<{ x: number; y: number } | null>(null) const [dimensions, setDimensions] = useState(() => new Map()) const [focusMode, setFocusMode] = useState(false) + const [libraryContainer, setLibraryContainer] = useState(null) + const [showDataReferences, setShowDataReferences] = useState(false) const [shortcutHelp, setShortcutHelp] = useState(false) const [confirming, setConfirming] = useState(false) const [wrapPathOpen, setWrapPathOpen] = useState(false) @@ -250,6 +289,7 @@ function WorkflowDesignerReady({ const [wrapPathEnd, setWrapPathEnd] = useState(null) const [menuPoint, setMenuPoint] = useState<{ x: number; y: number } | null>(null) const [modal, modalHolder] = Modal.useModal() + const selectionGuard = useNodeSelectionGuard(draftSession, scope, canvasEditable) const wrapChoices = useMemo( () => mainWrapChoices(definition, selectedId ?? ''), [definition, selectedId], @@ -297,13 +337,14 @@ function WorkflowDesignerReady({ runtimeByNode.get(node.id), ).data, canCopy: canvasEditable && resolveEffectiveNodeType(node) !== 'start', + ...nodePresentation(node, definition), canDelete: canvasEditable && canDeleteNode(definition, node), onConfigure: configureInspector, onCopy: copySelectedNode, onDelete: () => void requestDelete(), }, })) - const edges = definition.edges.map((edge) => ({ + const executionEdges = definition.edges.map((edge) => ({ ...toCanvasEdge(edge, proposalEdgeStatuses[edge.id]), selected: editor.selection?.kind === 'edge' && editor.selection.id === edge.id, sourceHandle: edge.condition ?? 'out', @@ -316,6 +357,10 @@ function WorkflowDesignerReady({ }, ariaLabel: `从 ${definition.nodes.find((node) => node.id === edge.source)?.name ?? edge.source} 到 ${definition.nodes.find((node) => node.id === edge.target)?.name ?? edge.target} 的连线`, })) + const edges: CanvasEdge[] = [ + ...executionEdges, + ...canvasReferenceEdges(definition, showDataReferences), + ] const selected = selectedNode(definition, selectedId) const selectedApi = resolveSelectedApi( apiSelection, @@ -347,25 +392,22 @@ function WorkflowDesignerReady({ requestAnimationFrame(() => focusInspector(canvasRef.current)) } async function finishSelectionChange(): Promise { - const keys = draftSession.dirtyNodeEditorKeys(scope) - if (!keys.length) return true - const approved = await modal.confirm({ - title: '节点配置尚未应用', - content: '切换前应用当前配置,或返回继续编辑。', - okText: '应用配置后继续', - cancelText: '返回编辑', - }) - if (!approved) return false - for (const key of keys) { - if (!(await draftSession.nodeEditorActions.get(key)?.())) return false - } - return true + return selectionGuard.request() } async function selectObject(kind: 'node' | 'edge', id: string) { const selection = editor.latest.current.selection - if (selection?.kind === kind && selection.id === id) return - if (draftSession.dirtyNodeEditorKeys(scope).length && !(await finishSelectionChange())) return + if (jsonEqual(selection, { kind, id })) { + if (kind === 'node') onNodeFocus?.(id) + return + } + if ( + canvasEditable && + draftSession.dirtyNodeEditorKeys(scope).length && + !(await finishSelectionChange()) + ) + return editor.click(kind, id) + onNodeFocus?.(kind === 'node' ? id : null) } async function openObjectMenu(kind: 'node' | 'edge', id: string, event: React.MouseEvent) { await selectObject(kind, id) @@ -374,7 +416,10 @@ function WorkflowDesignerReady({ canvasRef.current?.focus() } async function clearSelection() { - if (await finishSelectionChange()) editor.select(emptySelection()) + if (await finishSelectionChange()) { + editor.select(emptySelection()) + onNodeFocus?.(null) + } } async function requestDelete() { if (!canvasEditable || confirming) return @@ -667,6 +712,7 @@ function WorkflowDesignerReady({ if (!definition.nodes.length) return return (
+ {selectionGuard.dialog} {modalHolder} setShortcutHelp(false)} /> + } + libraryContainer={libraryContainer} + showDataReferences={showDataReferences} + onDataReferences={setShowDataReferences} projectId={projectId} runtimeMode={runtimeMode} + runtimeNavigation={ + { + setFocusMode(false) + void selectObject('node', nodeId) + }} + /> + } apiSelection={selectedApiId} apiSelectionOverride={apiSelectionOverride} apis={apis} @@ -777,10 +851,15 @@ function WorkflowDesignerReady({ onFocus={() => setFocusMode((value) => !value)} onHelp={() => setShortcutHelp(true)} onFitView={() => - void flowRef.current?.fitView({ duration: 200, padding: 0.18, maxZoom: 1 }) + void flowRef.current?.fitView({ + duration: reducedMotionEnabled() ? 0 : 200, + padding: 0.18, + maxZoom: 1, + }) } /> + - void clearSelection()} - canvas={ -
{ - if (canvasEditable) { - event.preventDefault() - event.dataTransfer.dropEffect = 'copy' - } - }} - onKeyDown={hotkeys} - onBlur={(event) => { - if (!event.currentTarget.contains(event.relatedTarget)) editor.cancelDrag() - }} - > - - fitView - fitViewOptions={{ maxZoom: 1, padding: 0.18 }} - onInit={(instance) => { - flowRef.current = instance - }} - multiSelectionKeyCode={null} - deleteKeyCode={null} - nodes={nodes} - edges={edges} - nodeTypes={nodeTypes} - edgeTypes={edgeTypes} - nodesDraggable={canvasEditable} - nodesConnectable={canvasEditable} - edgesReconnectable={canvasEditable} - onNodeClick={(_event, node) => { - void selectObject('node', node.id) - canvasRef.current?.focus() - }} - onEdgeClick={(_event, edge) => { - void selectObject('edge', edge.id) - canvasRef.current?.focus() +
+
+ void clearSelection()} + canvas={ +
{ + if (canvasEditable) { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } }} - onNodeContextMenu={(event, node) => { - event.preventDefault() - void openObjectMenu('node', node.id, event) + onKeyDown={hotkeys} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) editor.cancelDrag() }} - onEdgeContextMenu={(event, edge) => { - event.preventDefault() - void openObjectMenu('edge', edge.id, event) - }} - onPaneClick={() => { - void clearSelection() - canvasRef.current?.focus() - }} - onNodeDragStart={editor.beginDrag} - onNodeDragStop={(_event, _node, moved) => - editor.endDrag(moved.map((node) => ({ id: node.id, position: node.position }))) - } - onNodesChange={(changes) => { - setDimensions((current) => - updateCanvasDimensions( - current, - changes, - new Set(definition.nodes.map((node) => node.id)), - ), - ) - const updates = changes.flatMap((change) => - change.type === 'position' && change.position - ? [{ id: change.id, position: change.position }] - : [], - ) - const change = changes.find((item) => item.type === 'position') - editor.positionsChanged( - updates, - change?.type === 'position' ? change.dragging : undefined, - ) - }} - onConnect={(connection) => - editor.accept( - connectGraphNodes(editor.latest.current.definition, { + > + + colorMode="light" + fitView + fitViewOptions={{ maxZoom: 1, padding: 0.18 }} + onInit={(instance) => { + flowRef.current = instance + }} + multiSelectionKeyCode={null} + deleteKeyCode={null} + nodes={nodes} + edges={edges} + nodeTypes={nodeTypes} + edgeTypes={edgeTypes} + nodesDraggable={canvasEditable} + nodesConnectable={canvasEditable} + edgesReconnectable={canvasEditable} + onNodeClick={(_event, node) => { + void selectObject('node', node.id) + canvasRef.current?.focus() + }} + onEdgeClick={(_event, edge) => { + if (!definition.edges.some((item) => item.id === edge.id)) return + void selectObject('edge', edge.id) + canvasRef.current?.focus() + }} + onNodeContextMenu={(event, node) => { + event.preventDefault() + void openObjectMenu('node', node.id, event) + }} + onEdgeContextMenu={(event, edge) => { + if (!definition.edges.some((item) => item.id === edge.id)) return + event.preventDefault() + void openObjectMenu('edge', edge.id, event) + }} + onPaneClick={() => { + void clearSelection() + canvasRef.current?.focus() + }} + onNodeDragStart={editor.beginDrag} + onNodeDragStop={(_event, _node, moved) => + editor.endDrag(moved.map((node) => ({ id: node.id, position: node.position }))) + } + onNodesChange={(changes) => { + setDimensions((current) => + updateCanvasDimensions( + current, + changes, + new Set(definition.nodes.map((node) => node.id)), + ), + ) + const updates = changes.flatMap((change) => + change.type === 'position' && change.position + ? [{ id: change.id, position: change.position }] + : [], + ) + const change = changes.find((item) => item.type === 'position') + editor.positionsChanged( + updates, + change?.type === 'position' ? change.dragging : undefined, + ) + }} + onConnect={(connection) => + editor.accept( + connectGraphNodes(editor.latest.current.definition, { + sourceId: connection.source, + targetId: connection.target, + branch: + connection.sourceHandle === 'true' || connection.sourceHandle === 'false' + ? connection.sourceHandle + : null, + edgeId: `edge-${crypto.randomUUID()}`, + }), + ) + } + onReconnect={(edge, connection) => + void reconnect(edge.id, { sourceId: connection.source, targetId: connection.target, branch: connection.sourceHandle === 'true' || connection.sourceHandle === 'false' ? connection.sourceHandle : null, - edgeId: `edge-${crypto.randomUUID()}`, - }), - ) + edgeId: edge.id, + }) + } + > + + + { + if (issue.nodeId) void selectObject('node', issue.nodeId) + else if (issue.edgeId) void selectObject('edge', issue.edgeId) + }} + /> + + + + + + + +
+ } + > + {selectedEdge ? ( + void requestDelete()} + onSwap={() => + editor.accept(swapBranches(editor.latest.current.definition, selectedEdge.source)) } - onReconnect={(edge, connection) => - void reconnect(edge.id, { - sourceId: connection.source, - targetId: connection.target, - branch: - connection.sourceHandle === 'true' || connection.sourceHandle === 'false' - ? connection.sourceHandle - : null, - edgeId: edge.id, + onUpdate={(edge) => + applyChange({ + ...editor.latest.current.definition, + edges: editor.latest.current.definition.edges.map((item) => + item.id === edge.id ? edge : item, + ), }) } - > - - - { - if (issue.nodeId) void selectObject('node', issue.nodeId) - else if (issue.edgeId) void selectObject('edge', issue.edgeId) - }} - /> - - - - -
- } - > - {selectedEdge ? ( - void requestDelete()} - onSwap={() => - editor.accept(swapBranches(editor.latest.current.definition, selectedEdge.source)) - } - onUpdate={(edge) => - applyChange({ - ...editor.latest.current.definition, - edges: editor.latest.current.definition.edges.map((item) => - item.id === edge.id ? edge : item, - ), - }) - } - /> - ) : ( - editor.select(emptySelection())} - onDelete={() => void requestDelete()} - /> - )} - + /> + ) : ( + void selectObject('node', nodeId)} + selected={selected} + definition={definition} + apis={apis} + artifacts={artifacts} + workflows={publishedWorkflows} + credentials={credentials} + graphqlSchemas={graphqlSchemas} + grpcDescriptors={grpcDescriptors} + eventSources={eventSources} + editable={canvasEditable} + onChange={applyChange} + onClearSelection={() => editor.select(emptySelection())} + onDelete={() => void requestDelete()} + /> + )} + +
+
+ ) +} + +function MissingRuntimeNode({ + requestedId, + definition, + mode, +}: { + requestedId?: string + definition: WorkflowDefinition + mode?: string +}) { + if (!mode || !requestedId || definition.nodes.some((node) => node.id === requestedId)) return null + return ( + + ) +} + +function ReferenceLegend({ + visible, + definition, +}: { + visible: boolean + definition: WorkflowDefinition +}) { + if (!visible) return null + const unresolved = unresolvedReferenceCount(definition) + return ( +
+ 实线:执行顺序 + 虚线:数据引用 · 只读 + {unresolved > 0 && ( + {unresolved} 项引用/表达式未解析,请在配置中核对 + )}
) } @@ -1016,6 +1147,9 @@ function DesignerInspector({ runtimeNodes, runtimeContext, runtimeByNode, + onLocateNode, + runtimeAttempt, + onAttemptFocus, selected, definition, apis, @@ -1037,6 +1171,9 @@ function DesignerInspector({ runtimeNodes: WorkflowNodeExecution[] runtimeContext: Record runtimeByNode: Map + onLocateNode: (nodeId: string) => void + runtimeAttempt?: number + onAttemptFocus?: (nodeId: string, attempt: number) => void selected: WorkflowNode | null definition: WorkflowDefinition apis: ApiDefinition[] @@ -1062,6 +1199,11 @@ function DesignerInspector({ execution={selected ? runtimeByNode.get(selected.id) : undefined} nodes={runtimeNodes} context={runtimeContext} + onLocateNode={onLocateNode} + initialAttempt={runtimeAttempt} + onSelectAttempt={(attempt) => { + if (selected) onAttemptFocus?.(selected.id, attempt) + }} /> ) } @@ -1092,6 +1234,7 @@ function DesignerInspector({ editable={editable} onChange={update} onDelete={onDelete} + onLocateNode={onLocateNode} /> )} @@ -1165,8 +1308,13 @@ function workflowReference(workflow: Workflow | undefined) { } function DesignerToolbar({ + workflowSettings, + libraryContainer, + showDataReferences, + onDataReferences, projectId, runtimeMode, + runtimeNavigation, apiSelection, apiSelectionOverride, apis, @@ -1224,8 +1372,13 @@ function DesignerToolbar({ onHelp, onFitView, }: { + workflowSettings: ReactNode + libraryContainer: HTMLElement | null + showDataReferences: boolean + onDataReferences: (value: boolean) => void projectId?: string | null runtimeMode?: 'run' | 'history' + runtimeNavigation?: ReactNode apiSelection?: string apiSelectionOverride?: ApiDefinition apis: ApiDefinition[] @@ -1292,6 +1445,7 @@ function DesignerToolbar({ data-testid={focusMode ? 'workflow-focus-toolbar' : 'workflow-canvas-toolbar'} > + {runtimeNavigation} {runtimeMode === 'history' ? '历史快照 · 只读' : '实时运行视图'} @@ -1300,6 +1454,13 @@ function DesignerToolbar({ + {workflowSettings} + onDataReferences(event.target.checked)} + > + 数据引用 + @@ -1347,6 +1508,13 @@ function DesignerToolbar({ + {workflowSettings} + onDataReferences(event.target.checked)} + > + 数据引用 + setLibraryOpen(false)} items={createNodeLibraryItems({ @@ -1878,15 +2048,16 @@ function WorkflowNodeCard({ data, selected }: NodeProps) { />
{!start && } - {nodeIcon(data.nodeType)} - - {data.label} - {data.typeLabel} - - - {statusLabel(data.status)} - {data.runtimeLabel && {data.runtimeLabel}} - +
+ {nodeIcon(data.nodeType)} + + {data.label} + {data.typeLabel} + +
+
{data.summary}
+ + {!terminal && (data.nodeType === 'condition' ? ( <> @@ -1901,6 +2072,52 @@ function WorkflowNodeCard({ data, selected }: NodeProps) { ) } +function NodeRegionPreview({ regions }: { regions: RegionPreview[] }) { + if (!regions.length) return null + return ( +
+ {regions.map((region) => ( +
+ + {region.label} · {region.steps.length} 步 + + {region.steps.join(' → ') || '尚未配置步骤'} +
+ ))} +
+ ) +} + +function NodeFooter({ data }: { data: NodeData }) { + return ( +
+ + {statusLabel(data.status)} + {data.runtimeLabel && {data.runtimeLabel}} + + {data.regions?.length ? '内部步骤见配置' : '查看详情'} +
+ ) +} + +function canvasReferenceEdges(definition: WorkflowDefinition, visible: boolean): CanvasEdge[] { + if (!visible) return [] + const executionIds = new Set(definition.edges.map((edge) => edge.id)) + return dataReferences(definition) + .filter((reference) => !executionIds.has(reference.id)) + .map((reference) => ({ + ...reference, + type: 'workflowEdge', + sourceHandle: 'out', + selectable: false, + reconnectable: false, + focusable: false, + className: 'workflow-data-reference', + style: { stroke: iceColors.brandDecorative, strokeDasharray: '5 5' }, + ariaLabel: `${reference.label}(只读,不影响执行)`, + })) +} + function WorkflowCanvasEdge({ id, sourceX, @@ -1996,7 +2213,7 @@ function toCanvasEdge( target: edge.target, markerEnd: { type: MarkerType.ArrowClosed }, label: edge.condition ? (edge.condition === 'true' ? '是' : '否') : undefined, - animated: status === 'rewired', + animated: false, style: color ? { stroke: color, strokeWidth: 3 } : undefined, data: { branch: edge.condition, @@ -2010,10 +2227,10 @@ function toCanvasEdge( function proposalEdgeColor(status: ProposalGraphStatus | undefined): string | undefined { if (!status) return undefined const colors: Record = { - added: '#16a34a', - modified: '#d97706', - removed: '#dc2626', - rewired: '#7c3aed', + added: iceColors.success, + modified: iceColors.warning, + removed: iceColors.danger, + rewired: iceColors.iconExtract, } return colors[status] } @@ -2231,6 +2448,69 @@ function canMutateGraph(editable: boolean, mode: string, runtimeMode?: string): return editable && mode === 'edit' && runtimeMode === undefined } +function designerInitialSelection( + session: ReturnType, + scope: string, + definition: WorkflowDefinition, + nodes: WorkflowNodeExecution[], + mode?: string, + requestedId?: string, +) { + return mode + ? runtimeNodeSelection(definition, nodes, requestedId) + : recoverNodeSelection(session, scope, definition) +} + +function RuntimeTrajectoryEntry({ + mode, + surface, + focused, + ...props +}: Omit[0], 'mode' | 'docked'> & { + mode?: 'run' | 'history' + surface: 'workspace' | 'embedded' + focused: boolean +}) { + if (!mode) return null + return ( + + ) +} + +function runtimeNodeSelection( + definition: WorkflowDefinition, + nodes: WorkflowNodeExecution[], + requestedId?: string, +): WorkflowSelection { + const requested = definition.nodes.find((node) => node.id === requestedId) + const failed = nodes.find( + (node) => node.status === 'failed' && definition.nodes.some((item) => item.id === node.node_id), + ) + const node = + requested ?? + definition.nodes.find((item) => item.id === failed?.node_id) ?? + definition.nodes.find((item) => item.type !== 'start' && item.type !== 'end') + return node ? { kind: 'node', id: node.id } : emptySelection() +} + +function useRuntimeSelection( + select: (selection: WorkflowSelection) => void, + definition: WorkflowDefinition, + nodes: WorkflowNodeExecution[], + mode?: string, + executionId?: string, + requestedId?: string, +) { + const applied = useRef(null) + useEffect(() => { + if (!mode || !definition.nodes.length) return + const key = JSON.stringify([mode, executionId, requestedId]) + if (applied.current === key) return + applied.current = key + select(runtimeNodeSelection(definition, nodes, requestedId)) + }, [definition, executionId, mode, nodes, requestedId, select]) +} + function useCanvasAutoFrame( canvasRef: React.RefObject, flowRef: React.RefObject | null>, @@ -2242,6 +2522,21 @@ function useCanvasAutoFrame( useEffect(() => { latest.current = { definition, selection } }, [definition, selection]) + useEffect(() => { + const frame = requestAnimationFrame(() => { + const canvas = canvasRef.current + const instance = flowRef.current + if (!canvas || !instance) return + keepSelectionVisible( + instance, + definition, + selection, + { width: canvas.clientWidth, height: canvas.clientHeight }, + selectionToolbarSize(canvas), + ) + }) + return () => cancelAnimationFrame(frame) + }, [canvasRef, definition, flowRef, selection]) useEffect(() => { const canvas = canvasRef.current if (!canvas) return diff --git a/frontend/src/flow/WorkflowInspectorShell.tsx b/frontend/src/flow/WorkflowInspectorShell.tsx index 779e8140..bd52ad62 100644 --- a/frontend/src/flow/WorkflowInspectorShell.tsx +++ b/frontend/src/flow/WorkflowInspectorShell.tsx @@ -35,25 +35,25 @@ export default function WorkflowInspectorShell({ observer.observe(container.current) return () => observer.disconnect() }, []) - const overlay = width > 0 && width < 880 + const overlay = width > 0 && width < 760 const docked = visible && !overlay function saveWidth(sizes: number[]) { const size = sizes[1] - if (!Number.isFinite(size) || size < 320) return + if (!Number.isFinite(size) || size < 304) return setStorageError(!writeLayoutPreferences(preferenceKey, { inspectorWidth: size })) } return (
{ - if (sizes[1] >= 320) setInspectorWidth(sizes[1]) + if (sizes[1] >= 304) setInspectorWidth(sizes[1]) }} onResizeEnd={saveWidth} > {canvas} @@ -122,5 +122,5 @@ function inspectorShellClassName(overlay: boolean, maximized: boolean): string { function canvasMinimumWidth(overlay: boolean, containerWidth: number): number { if (overlay) return 0 - return Math.max(480, Math.ceil(containerWidth * 0.52)) + return Math.max(440, Math.ceil(containerWidth * 0.5)) } diff --git a/frontend/src/flow/WorkflowNodeInspector.test.tsx b/frontend/src/flow/WorkflowNodeInspector.test.tsx index 0c5cef6d..9035637f 100644 --- a/frontend/src/flow/WorkflowNodeInspector.test.tsx +++ b/frontend/src/flow/WorkflowNodeInspector.test.tsx @@ -180,6 +180,7 @@ it('EDGE09 requires an explicit incoming edge and updates only that edge', async />, ) const browser = userEvent.setup() + await browser.click(screen.getByRole('tab', { name: '输入 / 输出' })) const add = screen.getByRole('button', { name: 'plus 添加' }) expect(add).toBeDisabled() await browser.click(screen.getByRole('combobox', { name: '选择入站连线' })) diff --git a/frontend/src/flow/WorkflowNodeInspector.tsx b/frontend/src/flow/WorkflowNodeInspector.tsx index bfd13f08..9e47370e 100644 --- a/frontend/src/flow/WorkflowNodeInspector.tsx +++ b/frontend/src/flow/WorkflowNodeInspector.tsx @@ -1,4 +1,4 @@ -import { editorNode, restoreEditedNode } from './editor/graph-analysis' +import { analyzeGraph, editorNode, restoreEditedNode } from './editor/graph-analysis' import { getApiDetail } from '../features/api-console/api-service' import WorkflowJsonInput from './WorkflowJsonInput' import WorkflowControlFields from './WorkflowControlFields' @@ -14,6 +14,7 @@ import { Select, Space, Tag, + Tabs, Typography, } from 'antd' import { useEffect, useMemo, useState, type ReactNode } from 'react' @@ -37,6 +38,7 @@ import { listServiceEndpoints, } from '../features/service-targets/service-target-service' import WorkflowApiRequestEditor from './WorkflowApiRequestEditor' +import WorkflowNodeRelations from './WorkflowNodeRelations' import { useInspectorPresentation } from './editor/inspector-presentation' import type { WorkflowNodeEditKind } from './editor/node-edit-session' @@ -55,6 +57,7 @@ type InspectorProps = { editable: boolean onChange: (definition: WorkflowDefinition, kind: WorkflowNodeEditKind) => void onDelete: () => void + onLocateNode?: (nodeId: string) => void } export default function WorkflowNodeInspector({ @@ -72,6 +75,7 @@ export default function WorkflowNodeInspector({ editable, onChange, onDelete, + onLocateNode, }: InspectorProps) { const presentation = useInspectorPresentation() if (!originalNode) return @@ -93,39 +97,69 @@ export default function WorkflowNodeInspector({ {nodeTypeName(node)}
- onChange(next, 'structure')} - onRegionUpdate={(region) => - onChange( - { - ...definition, - regions: definition.regions?.map((item) => (item.id === region.id ? region : item)), - }, - 'regions', - ) - } + onChange(next, 'structure')} + onRegionUpdate={(region) => + onChange( + { + ...definition, + regions: definition.regions?.map((item) => + item.id === region.id ? region : item, + ), + }, + 'regions', + ) + } + /> + ), + }, + { + key: 'io', + label: '输入 / 输出', + children: ( + <> + + {node.type === 'api' && ( + onChange(next, 'edges')} + /> + )} + + ), + }, + { + key: 'validation', + label: '校验', + children: , + }, + ]} /> - {node.type === 'api' && ( - onChange(next, 'edges')} - /> - )}
+ + + {item.resourceControl && ( +
+ 选择资源 + {item.resourceControl} +
+ )} +
+ ) +} + function NodeLibraryCard({ item }: { item: NodeLibraryItem }) { return (
void +}) { + const updated = { + ...definition, + nodes: definition.nodes.map((item) => (item.id === node.id ? node : item)), + } + const sources = dataReferences(updated).filter((reference) => reference.target === node.id) + const config = effectiveConfig(node) + const outgoing = definition.edges.filter((edge) => edge.source === node.id) + return ( +
+ 数据来源 + {sources.map((reference) => ( +
+ + {reference.label} +
+ ))} + {typeof config.expression === 'string' && ( + {config.expression} + )} + {!sources.length && ( + + )} + 输出与下游 + {typeof config.variable === 'string' && ( + + 输出变量:{config.variable} + + )} + + {outgoing.map((edge) => ( + + ))} + + {!outgoing.length && 暂无下游执行连线} + + 这里展示图定义中的引用关系,实际输出在执行后查看。点击来源或下游可定位节点。 + +
+ ) +} diff --git a/frontend/src/flow/WorkflowRegionCanvas.tsx b/frontend/src/flow/WorkflowRegionCanvas.tsx index e87d62d3..f0a329eb 100644 --- a/frontend/src/flow/WorkflowRegionCanvas.tsx +++ b/frontend/src/flow/WorkflowRegionCanvas.tsx @@ -1,5 +1,14 @@ -import { Controls, MarkerType, Position, ReactFlow, type Edge, type Node } from '@xyflow/react' +import { + Background, + Controls, + MarkerType, + Position, + ReactFlow, + type Edge, + type Node, +} from '@xyflow/react' import type { WorkflowRegion } from '../lib/api' +import { iceColors } from '../theme/ice-theme' import { linearRegionPath } from './editor/control-blocks' type Props = { @@ -31,7 +40,8 @@ function canvasEdges(region: WorkflowRegion): Edge[] { id: edge.id, source: edge.source, target: edge.target, - markerEnd: { type: MarkerType.ArrowClosed }, + markerEnd: { type: MarkerType.ArrowClosed, color: iceColors.edge }, + style: { stroke: iceColors.edge, strokeWidth: 1.5 }, animated: false, })) } @@ -48,6 +58,7 @@ export default function WorkflowRegionCanvas({ return (
+
diff --git a/frontend/src/flow/WorkflowRunInspector.test.tsx b/frontend/src/flow/WorkflowRunInspector.test.tsx index 4ac881e0..8c3d01f7 100644 --- a/frontend/src/flow/WorkflowRunInspector.test.tsx +++ b/frontend/src/flow/WorkflowRunInspector.test.tsx @@ -1,7 +1,7 @@ import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { http, HttpResponse } from 'msw' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import WorkflowRunInspector from './WorkflowRunInspector' import { workflowDefinition } from '../test/fixtures' @@ -9,6 +9,32 @@ import type { WorkflowNodeExecution } from '../lib/api' import { server } from '../test/server' describe('WorkflowRunInspector', () => { + it('opens the linked attempt and reports a later attempt selection', async () => { + const browser = userEvent.setup() + const execution = apiNodeExecution() + const onSelectAttempt = vi.fn() + render( + node.id === 'api') ?? null} + definition={workflowDefinition} + execution={execution} + nodes={[execution]} + context={{}} + initialAttempt={1} + onSelectAttempt={onSelectAttempt} + />, + ) + expect(screen.getByText('31.2 ms')).toBeVisible() + await browser.click(screen.getByRole('tab', { name: '响应' })) + expect(screen.getByText(/"busy"/)).toBeVisible() + expect(screen.queryByText(/"Ada"/)).not.toBeInTheDocument() + await selectAttempt(browser, /第 2 次/) + expect(onSelectAttempt).toHaveBeenCalledWith(2) + expect(screen.getAllByText('82.35 ms')[0]).toBeVisible() + expect(screen.getByText(/"Ada"/)).toBeVisible() + }) + it('shows an authorized download action for a stored response body', async () => { const browser = userEvent.setup() const execution = apiNodeExecution() @@ -284,7 +310,8 @@ describe('WorkflowRunInspector', () => { />, ) expect(screen.getByText('pending')).toBeVisible() - expect(screen.getByText('计时中')).toBeVisible() + expect(screen.queryByText('计时中')).not.toBeInTheDocument() + expect(screen.getAllByText('未提供').length).toBeGreaterThanOrEqual(2) expect(screen.queryByText('历史快照')).not.toBeInTheDocument() await browser.click(screen.getByRole('tab', { name: '请求' })) diff --git a/frontend/src/flow/WorkflowRunInspector.tsx b/frontend/src/flow/WorkflowRunInspector.tsx index d22e0fc8..2df9bc08 100644 --- a/frontend/src/flow/WorkflowRunInspector.tsx +++ b/frontend/src/flow/WorkflowRunInspector.tsx @@ -12,6 +12,9 @@ import { Typography, } from 'antd' import { useEffect, useState } from 'react' +import { useRouteScopedState } from '../lib/use-route-scoped-state' +import AssertionEvidence from '../features/evidence/AssertionEvidence' +import WorkflowNodeRelations from './WorkflowNodeRelations' import type { ExecutionCheckpoint, @@ -40,6 +43,9 @@ type RuntimeInspectorProps = { execution: WorkflowNodeExecution | undefined nodes: WorkflowNodeExecution[] context: Record + onLocateNode?: (nodeId: string) => void + initialAttempt?: number + onSelectAttempt?: (attempt: number) => void } export default function WorkflowRunInspector({ @@ -51,6 +57,9 @@ export default function WorkflowRunInspector({ execution, nodes, context, + onLocateNode, + initialAttempt, + onSelectAttempt, }: RuntimeInspectorProps) { if (!node) return return ( @@ -64,6 +73,9 @@ export default function WorkflowRunInspector({ execution={execution} nodes={nodes} context={context} + onLocateNode={onLocateNode} + initialAttempt={initialAttempt} + onSelectAttempt={onSelectAttempt} /> ) } @@ -77,9 +89,18 @@ function RuntimeNodeDetail({ execution, nodes, context, + onLocateNode, + initialAttempt, + onSelectAttempt, }: RuntimeInspectorProps & { node: WorkflowNode }) { const observations = execution?.result?.observations ?? [] - const [attempt, setAttempt] = useState(observations.at(-1)?.attempt) + const [attempt, setAttempt] = useRuntimeAttempt( + projectId, + executionId, + node.id, + initialAttempt, + observations, + ) const observation = selectedObservation(observations, attempt) const input = upstreamOutputs(node.id, definition, nodes) return ( @@ -89,13 +110,26 @@ function RuntimeNodeDetail({ + {execution?.result?.assertions.length ? ( + + ) : null} + + + - + { + setAttempt(nextAttempt) + onSelectAttempt?.(nextAttempt) + }} + /> ( + projectId ?? null, + `${executionId}:${nodeId}:${initialAttempt}`, + initialAttempt ?? observations.at(-1)?.attempt, + ) +} + +function MissingAttempt({ + initialAttempt, + observations, +}: { + initialAttempt?: number + observations: WorkflowNodeObservation[] +}) { + if (!initialAttempt || observations.some((item) => item.attempt === initialAttempt)) return null + return ( + + ) +} + type ControlItem = { input_index?: number definition_index?: number @@ -568,11 +634,16 @@ function RuntimeSummary({ children: , }, { key: 'type', label: '节点类型', children: execution?.node_type ?? node.type }, - { key: 'attempts', label: '尝试次数', children: execution?.attempts ?? 0 }, + { key: 'attempts', label: '尝试次数', children: execution?.attempts ?? '未提供' }, { key: 'duration', label: '耗时', - children: , + children: ( + + ), }, ]} /> @@ -618,6 +689,7 @@ function RuntimeTabs({ }) { return ( - - + 断言 + + {execution?.result?.assertions.length ? '期望值和实际值见上方断言证据。' : '未提供断言证据'} + ) @@ -873,8 +947,8 @@ function nodeDuration( return new Date(execution.completed_at).getTime() - new Date(execution.started_at).getTime() } -function Duration({ value }: { value: number | null }) { - return {value === null ? '计时中' : formatDuration(value)} +function Duration({ value, running }: { value: number | null; running: boolean }) { + return {value === null ? (running ? '计时中' : '未提供') : formatDuration(value)} } function formatDuration(value: number): string { diff --git a/frontend/src/flow/WorkflowRunTrajectory.tsx b/frontend/src/flow/WorkflowRunTrajectory.tsx new file mode 100644 index 00000000..2c50d060 --- /dev/null +++ b/frontend/src/flow/WorkflowRunTrajectory.tsx @@ -0,0 +1,134 @@ +import { HistoryOutlined } from '@ant-design/icons' +import { Button, Drawer, Empty, Tag, Typography } from 'antd' +import { useState, useSyncExternalStore } from 'react' +import { createPortal } from 'react-dom' +import type { WorkflowDefinition, WorkflowNode, WorkflowNodeExecution } from '../lib/api' + +type TrajectoryProps = { + mode: 'run' | 'history' + executionId?: string + definition: WorkflowDefinition + nodes: WorkflowNodeExecution[] + selectedId: string | null + dockContainer: HTMLElement | null + docked: boolean + onSelect: (nodeId: string) => void +} + +export default function WorkflowRunTrajectory(props: TrajectoryProps) { + const [open, setOpen] = useState(false) + const wide = useSyncExternalStore(subscribeViewport, wideViewport) + const content = ( + { + props.onSelect(nodeId) + setOpen(false) + }} + /> + ) + const inline = props.docked && wide && Boolean(props.dockContainer) + return ( + <> + {!inline && ( + + )} + {inline && props.dockContainer && createPortal(content, props.dockContainer)} + setOpen(false)} + size={300} + placement="left" + > + {!inline && content} + + + ) +} + +function TrajectoryContent({ + mode, + executionId, + definition, + nodes, + selectedId, + onSelect, +}: TrajectoryProps) { + const byId = new Map(nodes.map((node) => [node.node_id, node])) + return ( + + ) +} + +function TrajectoryNode({ + node, + execution, + selected, + onSelect, +}: { + node: WorkflowNode + execution?: WorkflowNodeExecution + selected: boolean + onSelect: (nodeId: string) => void +}) { + const labels: Readonly> = { + pending: '待执行', + running: '执行中', + passed: '通过', + failed: '失败', + skipped: '已跳过', + cancelled: '已取消', + } + const status = execution?.status + const duration = execution?.result?.observations?.at(-1)?.duration_ms + return ( + + ) +} + +function subscribeViewport(listener: () => void): () => void { + const media = window.matchMedia('(min-width: 1360px)') + media.addEventListener('change', listener) + return () => media.removeEventListener('change', listener) +} +function wideViewport(): boolean { + return window.matchMedia('(min-width: 1360px)').matches +} diff --git a/frontend/src/flow/WorkflowRuntimeDock.tsx b/frontend/src/flow/WorkflowRuntimeDock.tsx index 4bccc4d9..82949d94 100644 --- a/frontend/src/flow/WorkflowRuntimeDock.tsx +++ b/frontend/src/flow/WorkflowRuntimeDock.tsx @@ -14,10 +14,12 @@ export default function WorkflowRuntimeDock({ mode, status, children, + summary, }: { mode: RuntimeDockMode status?: ReactNode children: ReactNode + summary?: ReactNode }) { const [collapsed, setCollapsed] = useState(false) return ( @@ -41,6 +43,7 @@ export default function WorkflowRuntimeDock({ onClick={() => setCollapsed((value) => !value)} /> + {collapsed && summary &&
{summary}
} {!collapsed &&
{children}
} ) diff --git a/frontend/src/flow/WorkflowSettingsEditor.test.tsx b/frontend/src/flow/WorkflowSettingsEditor.test.tsx new file mode 100644 index 00000000..984c6da7 --- /dev/null +++ b/frontend/src/flow/WorkflowSettingsEditor.test.tsx @@ -0,0 +1,84 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { useState } from 'react' +import { describe, expect, it, vi } from 'vitest' +import WorkflowSettingsEditor from './WorkflowSettingsEditor' +import { workflowDefinition } from '../test/fixtures' +import type { WorkflowDefinition } from '../lib/api' + +describe('WorkflowSettingsEditor', () => { + it('validates new variables and preserves execution policy and graph content', async () => { + const browser = userEvent.setup() + const onChange = vi.fn() + const initial: WorkflowDefinition = { + ...workflowDefinition, + variables: { order_id: 'initial' }, + run_policy: { + request_budget: 30, + max_runtime_seconds: 90, + cleanup_request_budget: 2, + force_cancel_skips_cleanup: false, + }, + } + function Harness() { + const [definition, setDefinition] = useState(initial) + return ( + Promise.resolve(true)} + onChange={(next) => { + onChange(next) + setDefinition(next) + }} + /> + ) + } + render() + await browser.click(screen.getByRole('button', { name: '流程设置' })) + fireEvent.change(await screen.findByRole('textbox', { name: '新变量名' }), { + target: { value: 'order_id' }, + }) + await browser.click(screen.getByRole('button', { name: '添加变量' })) + await waitFor(() => expect(screen.getByText('变量名已存在')).toBeVisible()) + expect(onChange).not.toHaveBeenCalled() + fireEvent.change(screen.getByRole('textbox', { name: '新变量名' }), { + target: { value: '1invalid' }, + }) + await browser.click(screen.getByRole('button', { name: '添加变量' })) + await waitFor(() => expect(screen.getByText(/使用字母或下划线开头/)).toBeVisible()) + expect(onChange).not.toHaveBeenCalled() + fireEvent.change(screen.getByRole('textbox', { name: '新变量名' }), { + target: { value: 'expected.amount' }, + }) + fireEvent.change(screen.getByRole('textbox', { name: '新变量初始值' }), { + target: { value: '29900' }, + }) + await browser.click(screen.getByRole('button', { name: '添加变量' })) + await waitFor(() => expect(onChange).toHaveBeenCalledOnce()) + const next = onChange.mock.calls[0][0] as WorkflowDefinition + expect(next.variables).toEqual({ order_id: 'initial', 'expected.amount': '29900' }) + expect(next.run_policy).toEqual(initial.run_policy) + expect(next.nodes).toEqual(initial.nodes) + expect(next.edges).toEqual(initial.edges) + await browser.click(screen.getByRole('button', { name: '删除变量 expected.amount' })) + await browser.click(await screen.findByRole('button', { name: '确认删除变量' })) + expect(onChange.mock.lastCall?.[0].variables).toEqual({ order_id: 'initial' }) + }) + + it('keeps the settings closed when unapplied node edits cancel the transition', async () => { + const browser = userEvent.setup() + render( + Promise.resolve(false)} + onChange={vi.fn()} + />, + ) + await browser.click(screen.getByRole('button', { name: '流程设置' })) + expect(screen.queryByRole('spinbutton', { name: '并发上限' })).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/flow/WorkflowSettingsEditor.tsx b/frontend/src/flow/WorkflowSettingsEditor.tsx new file mode 100644 index 00000000..a9a61a7d --- /dev/null +++ b/frontend/src/flow/WorkflowSettingsEditor.tsx @@ -0,0 +1,219 @@ +import { DeleteOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons' +import { + Alert, + Button, + Drawer, + Empty, + Form, + Input, + InputNumber, + Popconfirm, + Switch, + Typography, +} from 'antd' +import { useState } from 'react' +import type { WorkflowDefinition } from '../lib/api' + +type SettingsProps = { + definition: WorkflowDefinition + editable: boolean + historical: boolean + beforeOpen: () => Promise + onChange: (definition: WorkflowDefinition) => void +} + +export default function WorkflowSettingsEditor(props: SettingsProps) { + const [open, setOpen] = useState(false) + async function show() { + if (await props.beforeOpen()) setOpen(true) + } + return ( + <> + + setOpen(false)} + footer={ + + } + > + + + + + + ) +} + +function ExecutionSettings({ definition, editable, onChange }: SettingsProps) { + function change(settings: Partial) { + if (editable) onChange({ ...definition, settings: { ...definition.settings, ...settings } }) + } + return ( +
+ 执行设置 +
+ + { + if (validInteger(value, 100)) change({ concurrency: value }) + }} + /> + + + { + if (validInteger(value, 300)) change({ default_timeout_seconds: value }) + }} + /> + + + change({ fail_fast: value })} + /> + + +
+ ) +} + +function validInteger(value: number | null, maximum: number): value is number { + return value !== null && Number.isInteger(value) && value >= 1 && value <= maximum +} + +function WorkflowVariables(props: SettingsProps) { + const { definition, editable, onChange } = props + function change(name: string, value: string) { + if (editable) onChange({ ...definition, variables: { ...definition.variables, [name]: value } }) + } + function remove(name: string) { + if (!editable) return + const variables = Object.fromEntries( + Object.entries(definition.variables).filter(([key]) => key !== name), + ) + onChange({ ...definition, variables }) + } + return ( +
+ 流程初始变量 + + 值按字符串保存。变量属于流程定义,执行时的实际变量值在运行证据中查看。 + + {Object.entries(definition.variables).map(([name, value]) => ( +
+ {name} + change(name, event.target.value)} + /> + {editable && ( + remove(name)} + > +
+ ))} + {!Object.keys(definition.variables).length && ( + + )} + {editable && } +
+ ) +} + +function NewWorkflowVariable({ + variables, + onAdd, +}: { + variables: WorkflowDefinition['variables'] + onAdd: (name: string, value: string) => void +}) { + const [form] = Form.useForm<{ name: string; value: string }>() + return ( +
{ + onAdd(name, value) + form.resetFields() + }} + > + + Object.hasOwn(variables, value) + ? Promise.reject(new Error('变量名已存在')) + : Promise.resolve(), + }, + ]} + > + + + + + + + 新变量在点击添加后进入图草稿。 + + ) +} diff --git a/frontend/src/flow/WorkflowWorkspaceShell.test.tsx b/frontend/src/flow/WorkflowWorkspaceShell.test.tsx index b01ab6ac..91a67c1a 100644 --- a/frontend/src/flow/WorkflowWorkspaceShell.test.tsx +++ b/frontend/src/flow/WorkflowWorkspaceShell.test.tsx @@ -1,25 +1,14 @@ -import { act, fireEvent, render, screen } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' import WorkflowWorkspaceShell from './WorkflowWorkspaceShell' + afterEach(() => { localStorage.clear() - vi.unstubAllGlobals() vi.restoreAllMocks() }) -it('collapses by workspace width without changing preferences, preserves input, and accepts explicit expansion', () => { - let callback: ResizeObserverCallback = () => undefined - vi.stubGlobal( - 'ResizeObserver', - class { - constructor(observe: ResizeObserverCallback) { - callback = observe - } - observe() {} - unobserve() {} - disconnect() {} - }, - ) - render( + +function renderWorkspace() { + return render( 工作台 Header
} preferenceKey="layout" @@ -28,77 +17,40 @@ it('collapses by workspace width without changing preferences, preserves input, , ) - act(() => - callback([{ contentRect: { width: 1050 } }] as ResizeObserverEntry[], {} as ResizeObserver), - ) - expect(screen.getByRole('button', { name: '切换工作流列表' })).toHaveAttribute( - 'aria-expanded', - 'false', - ) - expect(localStorage.getItem('layout')).toBeNull() - fireEvent.click(screen.getByRole('button', { name: '切换工作流列表' })) - expect(screen.getByRole('button', { name: '切换工作流列表' })).toHaveAttribute( - 'aria-expanded', - 'true', - ) - expect(screen.getByLabelText('列表搜索')).toHaveValue('工作流') +} + +it('opens a workflow directory without squeezing the canvas or losing its local draft', async () => { + renderWorkspace() + const trigger = screen.getByRole('button', { name: '切换工作流列表' }) + expect(trigger).toHaveAttribute('aria-expanded', 'false') + fireEvent.click(trigger) + expect(trigger).toHaveAttribute('aria-expanded', 'true') + fireEvent.change(screen.getByLabelText('列表搜索'), { target: { value: '订单' } }) + fireEvent.click(screen.getByRole('button', { name: 'Close' })) + await waitFor(() => expect(trigger).toHaveFocus()) + fireEvent.click(trigger) + expect(screen.getByLabelText('列表搜索')).toHaveValue('订单') expect(screen.getByLabelText('节点草稿')).toHaveValue('未保存') - expect(JSON.parse(localStorage.getItem('layout')!).listCollapsed).toBe(false) - fireEvent.click(screen.getByRole('button', { name: '切换工作流列表' })) - expect(JSON.parse(localStorage.getItem('layout')!).listCollapsed).toBe(true) + expect(localStorage.getItem('layout')).toBeNull() }) -it('forces the list closed only when the workspace cannot fit its minimum surfaces', () => { - let callback: ResizeObserverCallback = () => undefined - vi.stubGlobal( - 'ResizeObserver', - class { - constructor(observe: ResizeObserverCallback) { - callback = observe - } - observe() {} - unobserve() {} - disconnect() {} - }, - ) - localStorage.setItem('layout', JSON.stringify({ listCollapsed: false })) - render( - Header
} - preferenceKey="layout" - list={
列表
} - > -
画布
- , - ) - act(() => - callback([{ contentRect: { width: 880 } }] as ResizeObserverEntry[], {} as ResizeObserver), - ) - expect(screen.getByRole('button', { name: '切换工作流列表' })).toHaveAttribute( - 'aria-expanded', - 'false', - ) - act(() => - callback([{ contentRect: { width: 1180 } }] as ResizeObserverEntry[], {} as ResizeObserver), - ) - expect(screen.getByRole('button', { name: '切换工作流列表' })).toHaveAttribute( - 'aria-expanded', - 'true', - ) + +it('preserves existing layout and draft storage when switching to the new directory presentation', () => { + const stored = JSON.stringify({ listWidth: 300, inspectorWidth: 480, listCollapsed: false }) + localStorage.setItem('layout', stored) + localStorage.setItem('draft', 'retain') + renderWorkspace() + fireEvent.click(screen.getByRole('button', { name: '切换工作流列表' })) + expect(screen.getByRole('dialog')).toBeVisible() + expect(localStorage.getItem('layout')).toBe(stored) + expect(localStorage.getItem('draft')).toBe('retain') }) -it('keeps an explicit layout usable when preference storage fails', () => { - vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { - throw new Error('quota') + +it('keeps the directory usable when stored preferences cannot be read', () => { + vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { + throw new DOMException('disabled') }) - render( - Header
} - preferenceKey="layout" - list={
列表内容
} - > -
画布内容
- , - ) + renderWorkspace() fireEvent.click(screen.getByRole('button', { name: '切换工作流列表' })) - expect(screen.getByRole('status')).toHaveTextContent('布局偏好未保存') - expect(screen.getByText('画布内容')).toBeVisible() + expect(screen.getByLabelText('列表搜索')).toHaveValue('工作流') + expect(screen.getByLabelText('节点草稿')).toHaveValue('未保存') }) diff --git a/frontend/src/flow/WorkflowWorkspaceShell.tsx b/frontend/src/flow/WorkflowWorkspaceShell.tsx index 5212c818..269d2e13 100644 --- a/frontend/src/flow/WorkflowWorkspaceShell.tsx +++ b/frontend/src/flow/WorkflowWorkspaceShell.tsx @@ -1,6 +1,6 @@ -import { Button, Splitter } from 'antd' -import { useEffect, useRef, useState, type ReactNode } from 'react' -import { readLayoutPreferences, writeLayoutPreferences } from './editor/layout-preferences' +import { Button, Drawer } from 'antd' +import { useRef, useState, type ReactNode } from 'react' +import { readLayoutPreferences } from './editor/layout-preferences' export default function WorkflowWorkspaceShell({ header, list, @@ -15,70 +15,40 @@ export default function WorkflowWorkspaceShell({ preferenceKey: string }) { const root = useRef(null) - const [preferences, setPreferences] = useState(() => readLayoutPreferences(preferenceKey)) - const [width, setWidth] = useState(0) - const [storageError, setStorageError] = useState(false) - useEffect(() => { - if (!root.current) return - const observer = new ResizeObserver((entries) => setWidth(entries[0]?.contentRect.width ?? 0)) - observer.observe(root.current) - return () => observer.disconnect() - }, []) - const collapsed = listCollapsed(preferences.listCollapsed, width) - function toggleList() { - const patch = { listCollapsed: !collapsed } - setPreferences((current) => ({ ...current, ...patch })) - setStorageError(!writeLayoutPreferences(preferenceKey, patch)) - } - function saveWidth(sizes: number[]) { - if (collapsed || !Number.isFinite(sizes[0])) return - setStorageError(!writeLayoutPreferences(preferenceKey, { listWidth: sizes[0] })) - } + const trigger = useRef(null) + const [listOpen, setListOpen] = useState(false) + const [preferences] = useState(() => readLayoutPreferences(preferenceKey)) return (
{header} - {storageError && ( - - 布局偏好未保存 - - )} - { - if (sizes[0] >= 180) setPreferences((current) => ({ ...current, listWidth: sizes[0] })) - }} - onResizeEnd={saveWidth} + setListOpen(false)} + placement="left" + size={Math.max(280, preferences.listWidth)} + getContainer={false} + rootClassName="workflow-catalog-drawer" + styles={{ root: { position: 'absolute' }, body: { padding: 0 } }} + afterOpenChange={(open) => !open && trigger.current?.focus()} > - - - - -
- {children} - {runtimeDock} -
-
-
+
+ {list} +
+ +
+ {children} + {runtimeDock} +
) } -function listCollapsed(preference: boolean | null, width: number): boolean { - if (preference === true) return true - if (width > 0 && width < 920) return true - if (preference === false) return false - return width > 0 && width < 1120 -} diff --git a/frontend/src/flow/editor/layout-preferences.test.ts b/frontend/src/flow/editor/layout-preferences.test.ts index 11e292a0..bf9f0a9f 100644 --- a/frontend/src/flow/editor/layout-preferences.test.ts +++ b/frontend/src/flow/editor/layout-preferences.test.ts @@ -38,7 +38,7 @@ it('separates server/user/project preferences and sanitizes malformed dimensions }) for (const raw of ['{', 'null', '[]', '3']) { localStorage.setItem(key, raw) - expect(readLayoutPreferences(key).inspectorWidth).toBe(400) + expect(readLayoutPreferences(key).inspectorWidth).toBe(320) } }) it('reports write failure without throwing or clearing draft storage', () => { diff --git a/frontend/src/flow/editor/layout-preferences.ts b/frontend/src/flow/editor/layout-preferences.ts index 0db73384..a5d967a7 100644 --- a/frontend/src/flow/editor/layout-preferences.ts +++ b/frontend/src/flow/editor/layout-preferences.ts @@ -8,7 +8,7 @@ export function workflowLayoutKey(userId?: string | null, projectId?: string | n return `flowtest:workflow-layout:v1:${[window.location.origin, userId ?? 'anonymous', projectId ?? 'embedded'].map(encodeURIComponent).join(':')}` } function defaults(): WorkflowLayoutPreferences { - return { inspectorWidth: 400, listWidth: 256, requestWidth: 760, listCollapsed: null } + return { inspectorWidth: 320, listWidth: 256, requestWidth: 760, listCollapsed: null } } export function readLayoutPreferences(key: string): WorkflowLayoutPreferences { try { @@ -16,7 +16,7 @@ export function readLayoutPreferences(key: string): WorkflowLayoutPreferences { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return defaults() const value = raw as Record return { - inspectorWidth: dimension(value.inspectorWidth, 400, 320, 640), + inspectorWidth: dimension(value.inspectorWidth, 320, 304, 640), listWidth: dimension(value.listWidth, 256, 180, 320), requestWidth: dimension(value.requestWidth, 760, 480, 4096), listCollapsed: typeof value.listCollapsed === 'boolean' ? value.listCollapsed : null, diff --git a/frontend/src/flow/editor/node-presentation.test.ts b/frontend/src/flow/editor/node-presentation.test.ts new file mode 100644 index 00000000..896c78a9 --- /dev/null +++ b/frontend/src/flow/editor/node-presentation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { workflowDefinition } from '../../test/fixtures' +import { dataReferences, nodePresentation } from './node-presentation' + +describe('workflow presentation data', () => { + it('derives references from saved contracts without changing execution edges or interpreting literal values', () => { + const definition = structuredClone(workflowDefinition) + const target = { + ...definition.nodes[0], + id: 'assert', + type: 'assert' as const, + name: '金额断言', + } + definition.nodes.push(target) + target.config = { source_node_id: 'api', expected_source_node_id: 'missing' } + target.configuration = { + inputs: { + actual: { kind: 'node_output', node_id: 'api', path: ['body'] }, + literal: { kind: 'literal', value: { kind: 'node_output', node_id: 'start', path: [] } }, + }, + } + const before = structuredClone(definition) + expect(dataReferences(definition)).toEqual([ + { id: 'data-reference:api:assert', source: 'api', target: 'assert', label: '数据引用' }, + ]) + expect(definition).toEqual(before) + }) + + it('uses owned region contents for previews and leaves empty regions explicit', () => { + const definition = structuredClone(workflowDefinition) + const owner = definition.nodes.find((node) => node.id === 'api')! + owner.type = 'capability' + owner.capability_id = 'flow.control.repeat' + owner.configuration = { count: 3, policy: { concurrency: 1, max_iterations: 10 } } + definition.regions = [ + { + id: 'body', + owner_node_id: owner.id, + role: 'body', + nodes: [], + edges: [], + entry_node_id: null, + exit_node_ids: [], + inputs: {}, + outputs: {}, + }, + ] + expect(nodePresentation(owner, definition)).toEqual({ + summary: '重复 3 次 · 上限 10 轮 · 并发 1', + regions: [{ id: 'body', label: '内部步骤', steps: [] }], + }) + expect(definition.regions[0].nodes).toEqual([]) + }) +}) diff --git a/frontend/src/flow/editor/node-presentation.ts b/frontend/src/flow/editor/node-presentation.ts new file mode 100644 index 00000000..466a9eea --- /dev/null +++ b/frontend/src/flow/editor/node-presentation.ts @@ -0,0 +1,122 @@ +import type { WorkflowDefinition, WorkflowNode } from '../../lib/api' +import { effectiveConfig, resolveEffectiveNodeType } from './graph-analysis' +import { parseValueSource } from './control-source-browser' + +export type RegionPreview = { id: string; label: string; steps: string[] } +export type DataReference = { id: string; source: string; target: string; label: string } + +const regionLabels: Readonly> = { + body: '内部步骤', + true: '条件成立', + false: '条件不成立', + try: '尝试', + catch: '异常处理', + finally: '最终处理', + default: '默认分支', +} + +export function nodePresentation( + node: WorkflowNode, + definition: WorkflowDefinition, +): { + summary: string + regions: RegionPreview[] +} { + const regions = (definition.regions ?? []) + .filter((region) => region.owner_node_id === node.id) + .map((region) => ({ + id: region.id, + label: regionLabels[region.role] ?? region.role, + steps: region.nodes.map((child) => child.name), + })) + return { summary: nodeSummary(node, definition), regions } +} + +function nodeSummary(node: WorkflowNode, definition: WorkflowDefinition): string { + const config = effectiveConfig(node) + const source = sourceSummary(config, definition) + if (source) return source + const type = resolveEffectiveNodeType(node) + if (type === 'api') + return typeof config.api_version === 'number' + ? `接口资产 · v${config.api_version}` + : '选择接口并配置请求' + if (type === 'start') return '流程入口' + if (type === 'end') return '流程出口' + if (type === 'delay' && typeof config.seconds === 'number') return `等待 ${config.seconds} 秒` + if (node.capability_id?.startsWith('flow.control.')) return controlSummary(config) + return '点击节点查看配置' +} + +function sourceSummary( + config: Record, + definition: WorkflowDefinition, +): string | null { + if (typeof config.source_node_id !== 'string' || !config.source_node_id) return null + const source = definition.nodes.find((item) => item.id === config.source_node_id) + return source ? `来源:${source.name}` : '来源未解析 · 请核对配置' +} + +function controlSummary(config: Record): string { + const policy = record(config.policy) + const parts: string[] = [] + if (typeof config.count === 'number') parts.push(`重复 ${config.count} 次`) + if (typeof policy.max_iterations === 'number') parts.push(`上限 ${policy.max_iterations} 轮`) + if (typeof policy.concurrency === 'number') parts.push(`并发 ${policy.concurrency}`) + return parts.join(' · ') || '配置条件与内部步骤' +} + +// These are view data only. They never enter WorkflowDefinition.edges or graph commands. +export function dataReferences(definition: WorkflowDefinition): DataReference[] { + const known = new Set(definition.nodes.map((node) => node.id)) + const references = new Map() + const add = (source: unknown, target: string, label: string) => { + if (typeof source !== 'string' || !known.has(source) || !known.has(target) || source === target) + return + const id = `data-reference:${source}:${target}` + references.set(id, { id, source, target, label }) + } + for (const node of definition.nodes) { + const config = effectiveConfig(node) + add(config.source_node_id, node.id, '数据引用') + add(config.expected_source_node_id, node.id, '期望值引用') + for (const source of typedSourceIds(node.configuration)) add(source, node.id, '数据引用') + } + for (const edge of definition.edges) { + for (const mapping of edge.mappings) + add(mapping.source.node_id, mapping.target.node_id, '字段映射') + } + return [...references.values()] +} + +export function unresolvedReferenceCount(definition: WorkflowDefinition): number { + const known = new Set(definition.nodes.map((node) => node.id)) + return definition.nodes.reduce((count, node) => { + const config = effectiveConfig(node) + const sources = [ + config.source_node_id, + config.expected_source_node_id, + ...typedSourceIds(node.configuration), + ] + const missing = new Set(sources.filter((id) => typeof id === 'string' && id && !known.has(id))) + return count + missing.size + (node.bindings?.length ?? 0) + }, 0) +} + +function typedSourceIds(value: unknown, depth = 0): string[] { + if (depth > 12) return [] + if (Array.isArray(value)) return value.flatMap((item) => typedSourceIds(item, depth + 1)) + const item = record(value) + if (item.kind === 'literal') return [] + const source = parseValueSource(item) + if (source?.kind === 'node_output') return [source.node_id] + return Object.values(item).flatMap((child) => + child && typeof child === 'object' ? typedSourceIds(child, depth + 1) : [], + ) +} + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} diff --git a/frontend/src/flow/editor/use-node-selection-guard.tsx b/frontend/src/flow/editor/use-node-selection-guard.tsx new file mode 100644 index 00000000..0a7d3df0 --- /dev/null +++ b/frontend/src/flow/editor/use-node-selection-guard.tsx @@ -0,0 +1,73 @@ +import { Button, Modal, Space } from 'antd' +import { useEffect, useRef, useState } from 'react' +import type { DraftSession } from '../../features/drafts/draft-session' + +export function useNodeSelectionGuard(session: DraftSession, scope: string, editable: boolean) { + const [open, setOpen] = useState(false) + const [applying, setApplying] = useState(false) + const pending = useRef<((value: boolean) => void) | null>(null) + useEffect( + () => () => { + pending.current?.(false) + }, + [], + ) + + function request(): Promise { + if (!editable || !session.dirtyNodeEditorKeys(scope).length) return Promise.resolve(true) + if (pending.current) return Promise.resolve(false) + setOpen(true) + return new Promise((resolve) => { + pending.current = resolve + }) + } + + async function finish(action: 'apply' | 'discard' | 'cancel') { + if (applying) return + const keys = session.dirtyNodeEditorKeys(scope) + let proceed = action !== 'cancel' + if (action === 'apply') { + setApplying(true) + for (const key of keys) { + if (!(await session.nodeEditorActions.get(key)?.())) { + proceed = false + break + } + } + setApplying(false) + } + if (action === 'discard') keys.forEach((key) => session.clearNodeEditor(key)) + const resolve = pending.current + pending.current = null + setOpen(false) + resolve?.(proceed) + } + + return { + request, + dialog: ( + void finish('cancel')} + closable={!applying} + footer={ + + + + + + } + > + 切换节点前,选择将当前输入应用到图草稿、丢弃本次节点输入,或留在当前节点继续编辑。 + + ), + } +} diff --git a/frontend/src/flow/workflow-editor.css b/frontend/src/flow/workflow-editor.css index 22b32af0..d4b9cf6d 100644 --- a/frontend/src/flow/workflow-editor.css +++ b/frontend/src/flow/workflow-editor.css @@ -22,7 +22,7 @@ min-height: clamp(420px, 62dvh, 640px); } .workflow-canvas:focus-visible { - outline: 2px solid #5b5cf0; + outline: 2px solid var(--ft-brand); outline-offset: -2px; } .workflow-editor-workspace .workflow-canvas { @@ -50,19 +50,19 @@ } .workflow-inspector-controls { padding: 8px 12px; - border-bottom: 1px solid #e5eaf2; + border-bottom: 1px solid var(--ft-border-subtle); justify-content: flex-end; } .workflow-view-actions { padding: 6px 12px; - border-bottom: 1px solid #e5eaf2; + border-bottom: 1px solid var(--ft-border-subtle); } .workflow-session-actions { padding: 12px 16px; position: sticky; bottom: 0; background: #fff; - border-top: 1px solid #e5eaf2; + border-top: 1px solid var(--ft-border-subtle); } .workflow-split-body.inspector-overlay .workflow-inspector-shell { position: absolute; @@ -71,7 +71,7 @@ bottom: 0; width: min(440px, 100%); z-index: 10; - box-shadow: -6px 0 20px #17203320; + box-shadow: var(--ft-shadow-overlay); } .workflow-split-body.inspector-maximized .workflow-inspector-shell { position: fixed; @@ -79,20 +79,20 @@ width: auto; height: auto; z-index: 950; - box-shadow: 0 0 0 100vmax #17203360; + box-shadow: 0 0 0 100vmax rgb(122 158 205 / 24%); border-radius: 12px; } .workflow-split-body.inspector-maximized .workflow-inspector-body { padding: 18px; - background: #f5f7fb; + background: var(--ft-app); } .workflow-split-body.inspector-maximized .workflow-inspector-body > div, .workflow-split-body.inspector-maximized .workflow-session-actions { width: min(1120px, 100%); margin-inline: auto; - border: 1px solid #e3e8f1; + border: 1px solid var(--ft-border-subtle); border-radius: 12px; background: #fff; } @@ -121,20 +121,19 @@ } .workflow-editor-workspace .react-flow__edge.selected .react-flow__edge-path { stroke-width: 4; - filter: drop-shadow(0 0 2px #5b5cf0); } .workflow-editor-embedded .react-flow__edge.selected .react-flow__edge-path { stroke-width: 4; } .workflow-designer .react-flow__node.selected .flow-node { - outline: 2px solid #5b5cf0; + outline: 2px solid var(--ft-brand); outline-offset: 3px; } .workflow-selection-toolbar { overflow: hidden; padding: 4px; - border: 1px solid #d8dfeb; + border: 1px solid var(--ft-border-subtle); border-radius: 10px; background: rgb(255 255 255 / 98%); box-shadow: 0 10px 28px rgb(31 42 68 / 16%); @@ -161,7 +160,7 @@ } .workflow-library-group { padding: 12px 0; - border-bottom: 1px solid #e5eaf2; + border-bottom: 1px solid var(--ft-border-subtle); } .workflow-library-group .workflow-api-select { width: 100%; @@ -215,6 +214,8 @@ } .workflow-list-pane, .workflow-workspace-main { + flex: 1; + height: auto; height: 100%; min-width: 0; min-height: 0; @@ -273,6 +274,7 @@ z-index: 20; background: white; } + .workflow-workspace-page > .workflow-tabs, .workflow-workbench-header { flex-shrink: 0; @@ -309,21 +311,21 @@ /* Workflow workbench visual system. Keep these rules scoped to the workflow route. */ .workflow-workspace-page { - --workflow-accent: #4f46e5; - --workflow-accent-soft: #eef2ff; - --workflow-border: #dfe6f1; - --workflow-muted: #64748b; + --workflow-accent: var(--ft-brand); + --workflow-accent-soft: var(--ft-brand-soft); + --workflow-border: var(--ft-border-subtle); + --workflow-muted: var(--ft-text-secondary); --workflow-surface: #ffffff; } .content:has(.workflow-workspace-page) { padding: 16px 20px 12px; - background: radial-gradient(circle at 70% 0%, rgb(79 70 229 / 5%), transparent 34%), #f6f8fc; + background: var(--ft-app); } .content:has(.workflow-workspace-page) .page-breadcrumb { margin-bottom: 8px; - color: #7b879c; + color: var(--ft-text-tertiary); } .workflow-workbench-header .ant-btn, @@ -409,7 +411,7 @@ flex: 0 0 auto; margin: 0; padding-right: 11px; - color: #172033; + color: var(--ft-text); font-size: 16px; line-height: 24px; } @@ -429,7 +431,7 @@ min-width: 0; max-width: 190px; overflow: hidden; - color: #172033; + color: var(--ft-text); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; @@ -463,7 +465,7 @@ .workflow-list-pane { border-right: 1px solid var(--workflow-border); - background: #fbfcff; + background: var(--ft-chrome); } .workflow-list-pane > .workflow-list-card { @@ -508,8 +510,8 @@ } .workflow-list-item.selected-row { - border-color: #c9c7ff; - background: linear-gradient(90deg, #eeedff, #f6f5ff); + border-color: var(--ft-node-border); + background: linear-gradient(90deg, var(--ft-brand-soft), var(--ft-chrome)); box-shadow: inset 3px 0 var(--workflow-accent); } @@ -545,8 +547,8 @@ } .workflow-toolbar-primary .ant-btn-primary { - background: linear-gradient(135deg, #5b5cf0, #4f46e5); - box-shadow: 0 4px 12px rgb(79 70 229 / 20%); + background: linear-gradient(135deg, var(--ft-brand), var(--ft-brand)); + box-shadow: none; } .workflow-toolbar-primary .ant-btn-primary:disabled, @@ -562,12 +564,12 @@ } .workflow-split-body { - background: #f8faff; + background: var(--ft-canvas); } .workflow-split-body .workflow-canvas { min-height: 360px; - background: #f8faff; + background: var(--ft-canvas); } .workflow-canvas .react-flow__background { @@ -575,7 +577,7 @@ } .workflow-canvas .react-flow__edge-path { - stroke: #8ca0bf; + stroke: var(--ft-edge); stroke-width: 1.8; } @@ -586,7 +588,7 @@ .workflow-canvas .react-flow__controls { display: flex; overflow: hidden; - border: 1px solid #dbe3ef; + border: 1px solid var(--ft-border-subtle); border-radius: 10px; box-shadow: 0 6px 16px rgb(31 42 68 / 10%); } @@ -595,7 +597,7 @@ width: 34px; height: 34px; border-bottom: 0; - border-right: 1px solid #e5eaf2; + border-right: 1px solid var(--ft-border-subtle); } .workflow-canvas .react-flow__controls-button:last-child { @@ -606,7 +608,7 @@ width: 180px; height: 112px; overflow: hidden; - border: 1px solid #dbe3ef; + border: 1px solid var(--ft-border-subtle); border-radius: 10px; background: rgb(255 255 255 / 92%); box-shadow: 0 6px 16px rgb(31 42 68 / 8%); @@ -636,7 +638,7 @@ display: flex; align-items: center; justify-content: space-between; - background: #fbfcff; + background: var(--ft-chrome); } .workflow-inspector-controls .ant-btn { @@ -651,7 +653,7 @@ .workflow-inspector h5 { margin-bottom: 18px; - color: #172033; + color: var(--ft-text); font-size: 17px; } @@ -667,7 +669,7 @@ min-width: 0; margin-block: 14px; padding: 14px; - border: 1px solid #e5eaf2; + border: 1px solid var(--ft-border-subtle); border-radius: 10px; background: #fff; } @@ -683,7 +685,7 @@ padding: 10px; border-left: 2px solid #c7d8ee; border-radius: 6px; - background: #f8fafc; + background: var(--ft-canvas); } .workflow-control-condition > .ant-space { @@ -701,8 +703,38 @@ .workflow-region-canvas { height: min(62vh, 560px); min-height: 360px; - border: 1px solid #e5eaf2; + border: 1px solid var(--ft-border-subtle); border-radius: 10px; + overflow: hidden; + background: var(--ft-canvas); +} + +.workflow-region-canvas .react-flow__node-default { + width: 232px; + min-height: 90px; + display: grid; + align-items: center; + padding: 18px; + border: 1px solid var(--ft-node-border); + border-radius: 14px; + box-shadow: var(--ft-shadow-node); + background: var(--ft-surface); + color: var(--ft-text); + font-size: 15px; + font-weight: 600; + text-align: left; +} + +.workflow-region-canvas .react-flow__node-default.selected { + border-color: var(--ft-brand); + box-shadow: 0 0 0 3px var(--ft-brand-soft); +} + +.workflow-region-canvas .react-flow__handle { + background: var(--ft-surface); + border: 1.5px solid var(--ft-brand); + width: 8px; + height: 8px; } .workflow-region-canvas-toolbar { @@ -725,7 +757,7 @@ .workflow-config-danger { padding-top: 14px; - border-top: 1px solid #edf0f5; + border-top: 1px solid var(--ft-border-subtle); } .workflow-request-inline { @@ -754,7 +786,7 @@ .workflow-split-body.inspector-maximized .workflow-node-configuration-main:not(:has(> .workflow-config-section)) { padding: 18px; - border: 1px solid #e3e8f1; + border: 1px solid var(--ft-border-subtle); border-radius: 12px; background: #fff; box-shadow: 0 4px 14px rgb(31 42 68 / 4%); @@ -794,7 +826,7 @@ .workflow-inspector label { margin-bottom: 6px; - color: #344054; + color: var(--ft-text-secondary); font-weight: 600; } @@ -823,14 +855,14 @@ align-items: center; justify-content: space-between; padding: 0 14px; - border-bottom: 1px solid #edf0f5; + border-bottom: 1px solid var(--ft-border-subtle); } .workflow-runtime-dock-header .ant-tag { margin-inline-end: 0; border-radius: 999px; - color: #4338ca; - background: #eef2ff; + color: var(--ft-brand); + background: var(--ft-brand-soft); } .workflow-runtime-dock-body { @@ -852,14 +884,14 @@ display: flex; align-items: end; gap: 8px; - border-bottom: 1px solid #edf0f5; + border-bottom: 1px solid var(--ft-border-subtle); } .workflow-runtime-tab-list .ant-btn { height: 38px; padding: 0 6px; border-radius: 0; - color: #667085; + color: var(--ft-text-secondary); } .workflow-runtime-tab-list .ant-btn.is-active { @@ -904,7 +936,7 @@ } .workflow-editor-workspace.is-focus { - background: #f8faff; + background: var(--ft-canvas); } .workflow-editor-workspace.is-focus .workflow-toolbar { @@ -944,16 +976,19 @@ margin-top: 82px !important; } -.workflow-editor-workspace .flow-node { - width: 210px; - min-height: 72px; - padding: 12px 14px; - grid-template-columns: 34px minmax(0, 1fr) auto; - gap: 10px; +.workflow-designer .flow-node { + width: 232px; + min-height: 110px; + padding: 0; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0; border-width: 1px; - border-color: #b8c4d6; - border-radius: 10px; - box-shadow: 0 8px 20px rgb(31 42 68 / 9%); + border-color: var(--ft-node-border); + border-radius: 14px; + box-shadow: var(--ft-shadow-node); + background: var(--ft-surface); } .workflow-editor-workspace .flow-node-icon { @@ -963,65 +998,64 @@ align-items: center; justify-content: center; border-radius: 9px; - background: #eef4ff; - color: #356dff; + background: var(--ft-brand-soft); + color: var(--ft-brand); font-size: 17px; } -.workflow-editor-workspace .flow-node strong { +.workflow-designer .flow-node strong { overflow: hidden; - color: #172033; - font-size: 13px; + color: var(--ft-text); + font-size: 15px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } .workflow-editor-workspace .flow-node small { - color: #7a879b; - font-size: 11px; + color: var(--ft-text-tertiary); + font-size: 12px; } .workflow-editor-workspace .flow-node-status { - padding: 2px 6px; - border-radius: 999px; - background: #f3f5f8; - font-size: 10px; + padding: 0; + background: transparent; + font-size: 12px; } .workflow-editor-workspace .flow-node-start { - border-color: #62c783; - background: linear-gradient(135deg, #f4fff7, #fff); + border-color: var(--ft-node-border); + background: var(--ft-surface); } .workflow-editor-workspace .flow-node-start .flow-node-icon { - color: #16a34a; - background: #dcfce7; + color: var(--ft-success); + background: var(--ft-success-bg); } .workflow-editor-workspace .flow-node-end { - border-color: #fb7185; - background: linear-gradient(135deg, #fff5f5, #fff); + border-color: var(--ft-node-border); + background: var(--ft-surface); } .workflow-editor-workspace .flow-node-end .flow-node-icon { - color: #ef4444; - background: #ffe4e6; + color: var(--ft-text-secondary); + background: var(--ft-surface-subtle); } .workflow-designer .react-flow__node.selected .flow-node { border-color: var(--workflow-accent); - outline: 2px solid rgb(79 70 229 / 18%); + outline: 2px solid rgb(23 101 232 / 18%); outline-offset: 3px; - box-shadow: 0 10px 26px rgb(79 70 229 / 16%); + box-shadow: var(--ft-shadow-selected); } .workflow-editor-workspace .react-flow__handle { width: 10px; height: 10px; border: 2px solid #fff; - background: #617395; - box-shadow: 0 0 0 1px #617395; + background: var(--ft-brand); + box-shadow: 0 0 0 1px var(--ft-brand); } .workflow-editor-workspace .react-flow__node.selected .react-flow__handle { @@ -1052,7 +1086,7 @@ .workflow-node-library .ant-drawer-title, .workflow-shortcut-help .ant-drawer-title { - color: #172033; + color: var(--ft-text); font-size: 18px; } @@ -1087,7 +1121,7 @@ flex: 1; min-height: 0; overflow: hidden; - border: 1px solid #e6eaf2; + border: 1px solid var(--ft-border-subtle); border-radius: 12px; background: #fff; } @@ -1098,7 +1132,7 @@ flex-direction: column; gap: 4px; padding: 9px 7px; - border-right: 1px solid #e6eaf2; + border-right: 1px solid var(--ft-border-subtle); background: #f7f8fc; } @@ -1112,9 +1146,9 @@ } .workflow-node-library .workflow-library-categories .ant-btn-primary { - color: #4f46e5; + color: var(--ft-brand); background: #ebe9ff; - box-shadow: inset 3px 0 #5b50f4; + box-shadow: inset 3px 0 var(--ft-brand); } .workflow-node-library .workflow-library-results { @@ -1122,7 +1156,7 @@ min-width: 0; padding: 10px; overflow: auto; - background: #fbfcff; + background: var(--ft-chrome); } .workflow-library-card { @@ -1140,13 +1174,13 @@ .workflow-library-card:hover:not(.is-disabled) { border-color: #8d83ff; - box-shadow: 0 7px 18px rgb(79 70 229 / 10%); + box-shadow: var(--ft-shadow-node); transform: translateY(-1px); } .workflow-library-card.is-disabled { border-color: #e5e9f0; - background: #f8fafc; + background: var(--ft-canvas); } .workflow-library-card-main { @@ -1163,13 +1197,13 @@ align-items: center; justify-content: center; border-radius: 9px; - color: #4f46e5; - background: #eeedff; + color: var(--ft-brand); + background: var(--ft-brand-soft); font-size: 16px; } .workflow-library-card.is-disabled .workflow-library-card-icon { - color: #667085; + color: var(--ft-text-secondary); background: #eef1f5; } @@ -1189,7 +1223,7 @@ .workflow-library-card-title .ant-tag { margin: 0; flex: 0 0 auto; - font-size: 10px; + font-size: 12px; line-height: 20px; } @@ -1228,7 +1262,7 @@ } .workflow-shortcut-help .ant-table-wrapper { - border: 1px solid #e5eaf2; + border: 1px solid var(--ft-border-subtle); border-radius: 10px; overflow: hidden; } @@ -1272,3 +1306,364 @@ padding-block: 7px; } } + +/* v5 uses one object tab row and a directory drawer, leaving space for the graph. */ +.content:has(.workflow-workspace-page) .workspace-navigation-tabs, +.content:has(.workflow-workspace-page) .page-breadcrumb { + display: none; +} +.content:has(.workflow-workspace-page) { + padding: 0 16px 12px; +} +.workflow-tabs { + height: 40px; + margin: 0; +} +.workflow-workbench-header { + min-height: 80px; + grid-template-columns: minmax(0, 1fr) max-content; + grid-template-rows: auto auto; + padding: 10px 14px 10px 76px; +} +.workflow-workbench-header-left { + grid-column: 1; + grid-row: 1; +} +.workflow-workbench-header-center { + grid-column: 1; + grid-row: 2; + justify-self: start; +} +.workflow-workbench-header-right { + grid-column: 2; + grid-row: 1 / 3; +} +.workflow-workspace-title { + flex-wrap: wrap; + row-gap: 4px; +} +.workflow-workspace-name { + font-size: 20px; + max-width: 320px; + flex-basis: auto; +} +.workflow-workspace .workflow-list-toggle { + top: 23px; +} +.workflow-canvas-stage { + display: flex; + flex: 1; + min-width: 0; + min-height: 0; +} +.workflow-canvas-stage > .workflow-split-body { + flex: 1; + height: auto; + min-width: 0; +} +.workflow-library-slot:empty { + display: none; +} +.workflow-library-slot { + flex: 0 0 208px; + min-height: 0; + border-right: 1px solid var(--ft-border-subtle); +} +.workflow-node-library-docked { + display: flex; + flex-direction: column; + height: 100%; + padding: 12px; + gap: 10px; + background: var(--ft-surface); +} +.workflow-node-library-docked header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.workflow-run-trajectory { + display: flex; + flex-direction: column; + height: 100%; + gap: 12px; + padding: 16px 12px; + background: var(--ft-surface); + font-size: 13px; +} +.workflow-run-trajectory header { + display: flex; + align-items: center; + justify-content: space-between; +} +.workflow-run-trajectory nav { + flex: 1; + min-height: 0; + overflow: auto; +} +.workflow-trajectory-node { + display: flex; + flex-direction: column; + width: 100%; + gap: 6px; + padding: 12px 10px; + margin-bottom: 6px; + text-align: left; + background: var(--ft-surface); + color: var(--ft-text); + border: 1px solid transparent; + border-radius: 8px; + cursor: pointer; +} +.workflow-trajectory-node span { + color: var(--ft-text-secondary); + font-size: 12px; +} +.workflow-trajectory-node.is-failed { + background: var(--ft-danger-bg); +} +.workflow-trajectory-node.is-failed span { + color: var(--ft-danger); +} +.workflow-trajectory-node[aria-pressed='true'] { + border-color: var(--ft-brand); + box-shadow: 0 0 0 2px var(--ft-brand-soft); +} +.workflow-trajectory-node:focus-visible { + outline: 2px solid var(--ft-brand); + outline-offset: 2px; +} +.workflow-node-relations, +.workflow-node-validation { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 16px; +} +.workflow-node-relations .ant-typography { + margin-bottom: 0; +} +.workflow-source-card { + display: flex; + flex-direction: column; + gap: 4px; + padding: 12px; + border: 1px solid var(--ft-border-subtle); + border-radius: 10px; + background: var(--ft-brand-soft); +} +.workflow-source-card .ant-btn { + justify-content: flex-start; + padding: 0; + height: auto; + white-space: normal; + text-align: left; +} +.workflow-library-inline-results { + flex: 1; + min-height: 0; + overflow: auto; + scrollbar-width: thin; +} +.workflow-library-inline-results h3 { + margin: 14px 6px 6px; + color: var(--ft-text-secondary); + font-size: 12px; + font-weight: 600; +} +.workflow-library-row .ant-btn { + justify-content: flex-start; + height: 34px; + padding-inline: 8px; +} +.workflow-library-row .ant-btn:not(:disabled):hover { + background: var(--ft-brand-soft); +} +.workflow-library-row .ant-btn .anticon { + color: var(--ft-brand); +} +.workflow-library-row details { + margin: 0 8px 8px; +} +.workflow-library-row summary { + color: var(--ft-text-secondary); + font-size: 12px; + cursor: pointer; +} +.workflow-library-row .ant-select { + max-width: 170px; +} +.workflow-node-library-docked > .ant-typography { + font-size: 12px; +} +.workflow-toolbar { + min-height: 48px; + padding-block: 6px; +} +.workflow-split-body .workflow-canvas { + min-height: 0; +} +.workflow-inspector-controls { + min-height: 44px; +} +.workflow-inspector-controls .ant-btn { + font-size: 12px; + padding-inline: 4px; +} +.workflow-inspector { + padding: 14px; +} +.flow-node-heading { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px 6px; +} +.flow-node-copy { + min-width: 0; + flex: 1; +} +.flow-node-summary { + padding: 0 14px 10px; + color: var(--ft-text-secondary); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.flow-node-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 12px; + border-top: 1px solid var(--ft-border-subtle); + border-radius: 0 0 14px 14px; + background: var(--ft-chrome); + color: var(--ft-text-secondary); + font-size: 12px; +} +.flow-node-footer .flow-node-status { + display: flex; + align-items: center; + gap: 6px; +} +.flow-node-footer .flow-node-status small { + margin: 0; +} +.flow-node.is-passed { + border-color: var(--ft-success); +} +.flow-node.is-passed .flow-node-footer { + color: var(--ft-success); + background: var(--ft-success-bg); +} +.flow-node.is-failed { + border-color: var(--ft-danger); +} +.flow-node.is-failed .flow-node-footer { + color: var(--ft-danger); + background: var(--ft-danger-bg); +} +.flow-node.is-running .flow-node-footer { + color: var(--ft-brand); + background: var(--ft-brand-soft); +} +.flow-node-regions { + margin: 0 10px 10px; + padding: 8px; + border: 1px dashed var(--ft-node-border); + border-radius: 9px; + background: var(--ft-canvas); +} +.workflow-designer .flow-node-regions strong { + font-size: 12px; +} +.flow-node-regions small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.workflow-data-reference { + pointer-events: none; +} +.workflow-data-reference .react-flow__edge-path { + stroke: var(--ft-brand-decorative); + stroke-dasharray: 5 5; +} +.workflow-canvas .react-flow__edge-textbg { + fill: var(--ft-surface); +} +.workflow-canvas .react-flow__edge-text { + fill: var(--ft-text-secondary); + font-size: 12px; +} +.workflow-canvas .react-flow__minimap-mask { + fill: rgb(216 232 251 / 35%); +} +.workflow-canvas .react-flow__minimap-node { + fill: var(--ft-node-border); +} +.workflow-runtime-dock { + flex-basis: 224px; + box-shadow: none; +} +.workflow-runtime-dock.is-collapsed { + flex-basis: 112px; +} +.workflow-runtime-dock-summary { + display: flex; + align-items: center; + height: 68px; + padding: 12px 16px; + background: var(--ft-canvas); +} +@media (max-width: 1100px) { + .workflow-workbench-header { + grid-template-columns: minmax(0, 1fr); + padding-left: 72px; + } + .workflow-workbench-header-right { + grid-column: 1; + grid-row: 3; + justify-self: start; + } + .workflow-toolbar-primary .ant-btn { + padding-inline: 8px; + } + .workflow-toolbar { + gap: 8px; + flex-wrap: wrap; + } +} +.workflow-environment-select { + min-width: 150px; +} +.workflow-reference-legend { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + max-width: 340px; + font-size: 12px; +} +.workflow-reference-legend .ant-tag { + margin: 0; + white-space: normal; +} +.workflow-settings-section { + margin-top: 24px; +} + +.workflow-variable-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + margin-bottom: 16px; +} + +.workflow-variable-row > .ant-typography { + grid-column: 1 / -1; + overflow-wrap: anywhere; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 2faf8800..1ff2324d 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -1,9 +1,10 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' -import { App as AntdApp, ConfigProvider } from 'antd' +import { App as AntdApp } from 'antd' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { createBrowserRouter, RouterProvider } from 'react-router-dom' -import zhCN from 'antd/locale/zh_CN' +import IceThemeProvider from './theme/IceThemeProvider' +import { applyIceAppearance } from './theme/ice-theme' import App from './App' import './styles.css' @@ -16,25 +17,16 @@ const queryClient = new QueryClient({ }, }) +applyIceAppearance(document.documentElement) + createRoot(document.getElementById('root')!).render( - + - + , ) diff --git a/frontend/src/pages/ApiConsolePage.test.tsx b/frontend/src/pages/ApiConsolePage.test.tsx index e02945c1..7fdc4adb 100644 --- a/frontend/src/pages/ApiConsolePage.test.tsx +++ b/frontend/src/pages/ApiConsolePage.test.tsx @@ -1,14 +1,16 @@ +import { createIceTheme } from '../theme/ice-theme' import { useAuthStore } from '../features/auth/auth-store' import { user as authenticatedUser } from '../test/fixtures' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen, within } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { App as AntdApp } from 'antd' +import { App as AntdApp, ConfigProvider } from 'antd' import { http, HttpResponse } from 'msw' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import ApiConsolePage from './ApiConsolePage' import { apiDefinition, environment, executionDetail, project } from '../test/fixtures' +import type { ExecutionDetail } from '../lib/api' import { server } from '../test/server' import ProjectTestProvider from '../test/ProjectTestProvider' @@ -19,48 +21,87 @@ afterEach(() => { }) describe('ApiConsolePage', () => { - it('runs an API and renders its assertion and history', async () => { - server.use( - http.get('/api/v1/projects', () => - HttpResponse.json({ items: [project], total: 1, page: 1, page_size: 100 }), - ), - http.get(`/api/v1/projects/${project.id}/environments`, () => - HttpResponse.json([environment]), - ), - http.get(`/api/v1/projects/${project.id}/apis`, () => - HttpResponse.json({ items: [apiDefinition], total: 1, page: 1, page_size: 100 }), - ), - http.get(`/api/v1/projects/${project.id}/executions`, () => - HttpResponse.json({ - items: [executionDetail.execution], - total: 1, - page: 1, - page_size: 20, - }), - ), - http.get(`/api/v1/projects/${project.id}/files`, () => - HttpResponse.json({ items: [], total: 0, page: 1, page_size: 100 }), - ), - http.post( - `/api/v1/projects/${project.id}/apis/${apiDefinition.id}/execute`, - async ({ request }) => { - expect(await request.json()).toMatchObject({ environment_id: environment.id }) - return HttpResponse.json(executionDetail) - }, - ), - ) - renderPage() - const browser = userEvent.setup() + it.each([ + { verdict: 'passed', result: executionDetail, notification: '接口执行通过' }, + { + verdict: 'failed despite HTTP 200', + result: { + execution: { ...executionDetail.execution, status: 'failed' }, + assertions: [{ ...executionDetail.assertions[0], passed: false, actual: 201 }], + } satisfies ExecutionDetail, + notification: '接口执行失败,请查看断言和响应', + }, + ])( + 'runs an API and renders its assertion and history: $verdict', + async ({ result, notification }) => { + server.use( + http.get('/api/v1/projects', () => + HttpResponse.json({ items: [project], total: 1, page: 1, page_size: 100 }), + ), + http.get(`/api/v1/projects/${project.id}/environments`, () => + HttpResponse.json([environment]), + ), + http.get(`/api/v1/projects/${project.id}/apis`, () => + HttpResponse.json({ items: [apiDefinition], total: 1, page: 1, page_size: 100 }), + ), + http.get(`/api/v1/projects/${project.id}/apis/${apiDefinition.id}`, () => + HttpResponse.json({ + definition: apiDefinition, + version: { + id: 'api-v1', + api_definition_id: apiDefinition.id, + version: 1, + method: 'GET', + path: '/users/me', + query_parameters: [], + headers: {}, + body_kind: 'none', + body: null, + auth_kind: 'none', + auth_config: {}, + extraction_rules: [], + assertions: [], + created_at: '2026-08-09T08:00:00Z', + }, + }), + ), + http.get(`/api/v1/projects/${project.id}/executions`, () => + HttpResponse.json({ + items: [result.execution], + total: 1, + page: 1, + page_size: 20, + }), + ), + http.get(`/api/v1/projects/${project.id}/files`, () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 100 }), + ), + http.post( + `/api/v1/projects/${project.id}/apis/${apiDefinition.id}/execute`, + async ({ request }) => { + expect(await request.json()).toMatchObject({ environment_id: environment.id }) + return HttpResponse.json(result) + }, + ), + ) + renderPage() + const browser = userEvent.setup() - expect(await screen.findByText(apiDefinition.name)).toBeVisible() - await browser.click(screen.getByRole('button', { name: /发送请求/ })) - expect(await screen.findByText(/测试用户/)).toBeVisible() + await screen.findByRole('button', { name: /保存新版本/ }) + await browser.click(screen.getByRole('button', { name: /发送请求/ })) + expect(await screen.findByText(/测试用户/)).toBeVisible() + const notice = await screen.findByText(notification) + const noticeType = result.execution.status === 'passed' ? 'success' : 'error' + expect(notice.closest(`.ant-message-${noticeType}`)).not.toBeNull() - await browser.click(screen.getByRole('tab', { name: '断言' })) - expect(screen.getByText('状态码等于 200')).toBeVisible() - await browser.click(screen.getByRole('tab', { name: '执行历史' })) - expect(screen.getByText(executionDetail.execution.request_url)).toBeVisible() - }) + await browser.click(screen.getByRole('tab', { name: '断言(1)' })) + expect( + within(screen.getByRole('tabpanel', { name: '断言(1)' })).getByText('状态码等于 200'), + ).toBeVisible() + await browser.click(screen.getByRole('tab', { name: '执行历史' })) + expect(screen.getByText(executionDetail.execution.request_url)).toBeVisible() + }, + ) it('renames an API and refreshes both the list and workbench title', async () => { let definition = apiDefinition @@ -132,12 +173,14 @@ function renderPage() { defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) return render( - - - - - - - , + + + + + + + + + , ) } diff --git a/frontend/src/pages/ApiConsolePage.tsx b/frontend/src/pages/ApiConsolePage.tsx index 0f68464e..b37a13bf 100644 --- a/frontend/src/pages/ApiConsolePage.tsx +++ b/frontend/src/pages/ApiConsolePage.tsx @@ -2,6 +2,7 @@ import { DownloadOutlined, EditOutlined, ImportOutlined, + FolderOutlined, PlayCircleOutlined, PlusOutlined, } from '@ant-design/icons' @@ -9,6 +10,7 @@ import { Button, Card, Dropdown, + Drawer, Empty, Form, Input, @@ -47,6 +49,7 @@ export default function ApiConsolePage() { const [importOpen, setImportOpen] = useState(false) const [renameTarget, setRenameTarget] = useState(null) const [environmentManagerOpen, setEnvironmentManagerOpen] = useState(false) + const [artifactsOpen, setArtifactsOpen] = useState(false) const consoleState = useApiConsole(searchParams.get('focus') ?? undefined) const userId = useAuthStore((state) => state.user?.id) const currentDefinition = selectedApiDefinition(consoleState) @@ -77,27 +80,37 @@ export default function ApiConsolePage() { ]) return ( - <> +
setImportOpen(true)} - onManageEnvironment={() => setEnvironmentManagerOpen(true)} + onArtifacts={() => setArtifactsOpen(true)} /> -
- +
+ +
+ setRenameTarget(currentDefinition)} + artifacts={artifacts} + redactionMode={consoleState.redactionMode} + draftScope={apiDraftScope(userId, consoleState.projectId)} + requestActions={} /> - - - setRenameTarget(currentDefinition)} - artifacts={artifacts} - redactionMode={consoleState.redactionMode} - draftScope={apiDraftScope(userId, consoleState.projectId)} - /> + + 预期状态码 + consoleState.setExpectedStatus(value ?? 200)} + /> + + } + > + + +
- - } + setArtifactsOpen(false)} + size={760} > - - - - - + + - +
) } @@ -222,32 +272,23 @@ function ApiConsoleHeading({ canCreateAssets, onDialog, onImport, - onManageEnvironment, + onArtifacts, }: { state: ConsoleState canCreateAssets: boolean onDialog: (dialog: DialogState) => void onImport: () => void - onManageEnvironment: () => void + onArtifacts: () => void }) { return (
接口管理 - 创建接口、发送真实请求,并检查断言和历史记录。 + 编辑请求,发送已保存版本,并查看响应与断言证据。
- - -
@@ -297,25 +320,19 @@ function ApiConsoleHeading({ function RunnerActions({ state, enabled }: { state: ConsoleState; enabled: boolean }) { return ( - - 预期状态码 - state.setExpectedStatus(value ?? 200)} - /> +
- +
) } @@ -361,6 +378,8 @@ function ApiTable({
(record.id === selectedId ? 'selected-row' : '')} onRow={(record) => ({ onClick: () => onSelect(record.id) })} columns={[ - { title: '名称', dataIndex: 'name' }, + { + title: '名称', + dataIndex: 'name', + render: (name: string, definition: ApiDefinition) => ( + + ), + }, { title: '版本', dataIndex: 'current_version', - width: 80, + width: 50, render: (version: number) => v{version}, }, { diff --git a/frontend/src/pages/DashboardPage.test.tsx b/frontend/src/pages/DashboardPage.test.tsx index 3e95b6c0..3b679e4a 100644 --- a/frontend/src/pages/DashboardPage.test.tsx +++ b/frontend/src/pages/DashboardPage.test.tsx @@ -293,6 +293,8 @@ describe('DashboardPage', () => { expect(await screen.findByText('质量总览加载失败')).toBeVisible() expect(screen.getByText('最近执行暂不可用')).toBeVisible() + expect(screen.getByText('未提供终态通过率')).toBeVisible() + expect(screen.queryByText('0%')).not.toBeInTheDocument() expect(screen.getByText('暂无执行记录')).toBeVisible() }) @@ -311,6 +313,8 @@ describe('DashboardPage', () => { renderDashboard() expect(await screen.findByText('统计暂不可用')).toBeVisible() + expect(screen.getByText('未提供执行数据')).toBeVisible() + expect(screen.queryByText('0%')).not.toBeInTheDocument() expect(screen.getByText('暂无执行记录')).toBeVisible() }) }) diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 4ad6cd01..1e460f8d 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -55,17 +55,6 @@ const riskColors: Record = { critical: 'error', } -const emptySummary: DashboardSummary = { - project_count: 0, - api_count: 0, - workflow_count: 0, - today_total: 0, - today_passed: 0, - today_failed: 0, - pass_rate: 0, - trend: [], -} - type DashboardState = ReturnType export default function DashboardPage() { @@ -128,10 +117,9 @@ function DashboardHeading({ } function ProjectQualityCommandCenter({ dashboard }: { dashboard: DashboardState }) { - const summary = valueOr(dashboard.summary.data, emptySummary) + const summary = dashboard.summary.data const risk = firstItem(dashboard.risks.data) const impact = firstItem(dashboard.impactRuns.data) - const flaky = pageItems(dashboard.flaky.data) const decision = firstItem(dashboard.decisions.data) const projectId = required(dashboard.projectId) return ( @@ -140,7 +128,7 @@ function ProjectQualityCommandCenter({ dashboard }: { dashboard: DashboardState summary={summary} risk={risk} impact={impact} - flaky={flaky} + flaky={dashboard.flaky.data?.items} decision={decision} qualityEnabled={dashboard.qualityEnabled} impactEnabled={dashboard.impactEnabled} @@ -179,42 +167,42 @@ function ProjectQualityCommandCenter({ dashboard }: { dashboard: DashboardState function GlobalQualityOverview({ dashboard }: { dashboard: DashboardState }) { if (dashboard.projects.data?.items.length === 0) return - const summary = dashboard.summary.data ?? emptySummary + const metrics = globalSummaryMetrics(dashboard.summary.data) return ( <>
} loading={dashboard.summary.isLoading} /> } loading={dashboard.summary.isLoading} /> } loading={dashboard.summary.isLoading} /> } loading={dashboard.summary.isLoading} /> } loading={dashboard.summary.isLoading} /> @@ -239,16 +227,17 @@ function ProjectQualityStats({ impactEnabled, loading, }: { - summary: DashboardSummary + summary?: DashboardSummary risk?: ReleaseRiskSummary impact?: ImpactRunSummary - flaky: FlakyRecord[] + flaky?: FlakyRecord[] decision?: ReleaseDecision qualityEnabled: boolean impactEnabled: boolean loading: boolean }) { - const quarantined = flaky.filter((item) => item.quarantined).length + const executionState = executionSummaryMetric(summary) + const quarantined = flaky?.filter((item) => item.quarantined).length const riskState = riskMetric(risk, qualityEnabled) const impactState = impactMetric(impact, impactEnabled) const decisionState = decisionMetric(decision) @@ -272,17 +261,17 @@ function ProjectQualityStats({ /> } loading={loading} /> } loading={loading} /> @@ -298,6 +287,37 @@ function ProjectQualityStats({ ) } +function executionSummaryMetric(summary?: DashboardSummary): { + value: string + detail: string + tone: string +} { + if (!summary) return { value: '—', detail: '未提供执行数据', tone: 'default' } + const total = summary.today_passed + summary.today_failed + return { + value: total ? `${summary.pass_rate}%` : '—', + detail: `今日 ${summary.today_passed} / ${total} 个终态通过`, + tone: executionTone(summary.today_failed), + } +} + +function globalSummaryMetrics(summary?: DashboardSummary) { + const execution = executionSummaryMetric(summary) + return { + projects: metricCount(summary?.project_count, '个'), + apis: metricCount(summary?.api_count, '个'), + workflows: metricCount(summary?.workflow_count, '个'), + executions: metricCount(summary?.today_total, '次'), + executionDetail: summary ? `失败或异常 ${summary.today_failed} 次` : '未提供执行数据', + passRate: execution.value, + passDetail: execution.detail, + } +} + +function metricCount(value: number | undefined, unit: string): string { + return value === undefined ? '—' : `${value} ${unit}` +} + function QualityMetric({ title, value, @@ -331,7 +351,11 @@ function TrendCard({ summary, loading }: { summary?: DashboardSummary; loading:
今日终态通过率 - + {summary && summary.today_passed + summary.today_failed > 0 ? ( + + ) : ( + 未提供终态通过率 + )}
) @@ -569,14 +593,6 @@ function firstItem(page: Page | undefined): T | undefined { return page?.items.at(0) } -function pageItems(page: Page | undefined): T[] { - return page?.items ?? [] -} - -function valueOr(value: T | undefined, fallback: T): T { - return value ?? fallback -} - function anyLoading(...values: boolean[]): boolean { return values.some(Boolean) } diff --git a/frontend/src/pages/ReleaseGatePage.test.tsx b/frontend/src/pages/ReleaseGatePage.test.tsx index bd3d144f..e632547d 100644 --- a/frontend/src/pages/ReleaseGatePage.test.tsx +++ b/frontend/src/pages/ReleaseGatePage.test.tsx @@ -99,6 +99,10 @@ describe('ReleaseGatePage', () => { expect(await screen.findByRole('heading', { name: '发布门禁' })).toBeVisible() expect(screen.getByText(/不可变快照/)).toBeVisible() expect(await screen.findByText('v3.0.0-rc.1')).toBeVisible() + const latest = screen.getByRole('region', { name: '最新判断的冻结证据' }) + expect(within(latest).getByText('Quality Gate 已通过')).toBeVisible() + expect(within(latest).getByText(/不会触发生产部署/)).toBeVisible() + expect(within(latest).queryByRole('button', { name: /编辑|删除|部署/ })).not.toBeInTheDocument() await userEvent.click(screen.getByRole('button', { name: '查看证据' })) const dialog = await screen.findByRole('dialog', { name: '发布判断证据' }) @@ -108,6 +112,43 @@ describe('ReleaseGatePage', () => { expect(within(dialog).queryByRole('button', { name: /编辑|删除/ })).not.toBeInTheDocument() }) + it('shows blocking evidence before passing evidence in the frozen decision workspace', async () => { + handlers() + server.use( + http.get(`/api/v1/projects/${project.id}/release-decisions`, () => + HttpResponse.json({ + items: [ + { + ...decision, + status: 'block', + reasons: [ + ...decision.reasons, + { + code: 'CONTRACT_EVIDENCE_MISSING', + evidence_type: 'contract_compatibility', + status: 'blocked', + message: '必需契约证据未提供', + expected: 'present', + actual: null, + }, + ], + }, + ], + total: 1, + page: 1, + page_size: 100, + }), + ), + ) + renderPage() + await screen.findByText('必需契约证据未提供') + const latest = screen.getByRole('region', { name: '最新判断的冻结证据' }) + const rows = within(latest).getAllByRole('row') + expect(rows[1]).toHaveTextContent('必需契约证据未提供') + expect(rows[1]).toHaveTextContent('阻断') + expect(within(latest).getByText('BLOCK')).toBeVisible() + }) + it('creates a typed release policy with the configured thresholds', async () => { let created: Record | null = null handlers() diff --git a/frontend/src/pages/ReleaseGatePage.tsx b/frontend/src/pages/ReleaseGatePage.tsx index 2fed9aa3..ed3d8730 100644 --- a/frontend/src/pages/ReleaseGatePage.tsx +++ b/frontend/src/pages/ReleaseGatePage.tsx @@ -17,11 +17,13 @@ import { Typography, } from 'antd' import { useState } from 'react' +import { iceColors } from '../theme/ice-theme' import type { ReleaseDecision, ReleaseDecisionInput, ReleasePolicyInput, + ReleaseReason, } from '../features/release-gate/release-gate-service' import { useReleaseGate } from '../features/release-gate/use-release-gate' @@ -44,6 +46,9 @@ export default function ReleaseGatePage() { decisionCount={state.decisions.data?.total} latest={state.decisions.data?.items.at(0)} /> + {state.decisions.data?.items[0] && ( + + )} - - - +
+ + - +
+ + {latest + ? `候选版本 ${latest.candidate_ref} · ${latest.reasons.filter((reason) => reason.status === 'blocked').length} 项阻断 · 判断只读` + : '尚无发布判断。按策略收集必需证据后,才能生成 PASS/BLOCK。'} + ) } +function LatestDecisionEvidence({ decision }: { decision: ReleaseDecision }) { + const reasons = [...decision.reasons].sort( + (left, right) => Number(left.status !== 'blocked') - Number(right.status !== 'blocked'), + ) + return ( +
+ + + rowKey="code" + size="small" + pagination={false} + scroll={{ x: 540 }} + dataSource={reasons} + locale={{ emptyText: '本次判断未提供逐项原因' }} + columns={[ + { + title: '结果', + dataIndex: 'status', + width: 80, + render: (status: ReleaseReason['status']) => ( + + {status === 'blocked' ? '阻断' : '通过'} + + ), + }, + { + title: '证据', + dataIndex: 'evidence_type', + width: 140, + render: (type: ReleaseReason['evidence_type']) => releaseEvidenceLabels[type], + }, + { title: '判断依据', dataIndex: 'message' }, + ]} + /> + + + + 候选版本 {decision.candidate_ref} + 策略:{decision.policy_snapshot.name} + + 生成时间:{new Date(decision.created_at).toLocaleString('zh-CN', { hour12: false })} + + + 证据指纹: + + {decision.fingerprint} + + + + 策略与证据来自本次冻结快照。重新判断会创建新记录;该结论不会触发生产部署。 + + +
+ ) +} + +const releaseEvidenceLabels: Readonly> = { + quality_gate: '质量门禁', + contract_compatibility: '契约兼容', + impact: '变更影响', + release_risk: '发布风险', + performance: '性能结果', + runner: 'Runner 执行', +} + function PoliciesCard({ policies, loading, diff --git a/frontend/src/pages/ReportsPage.test.tsx b/frontend/src/pages/ReportsPage.test.tsx index e16541e7..8b3a5863 100644 --- a/frontend/src/pages/ReportsPage.test.tsx +++ b/frontend/src/pages/ReportsPage.test.tsx @@ -1,7 +1,8 @@ +import { createIceTheme } from '../theme/ice-theme' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { App as AntdApp } from 'antd' +import { App as AntdApp, ConfigProvider } from 'antd' import { http, HttpResponse } from 'msw' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -13,8 +14,9 @@ import type { ReportExecution, ReportExecutionDetail, ReportTrend, + WorkflowNodeObservation, } from '../lib/api' -import { project } from '../test/fixtures' +import { project, workflowExecutionDetail } from '../test/fixtures' import { server } from '../test/server' import ProjectTestProvider from '../test/ProjectTestProvider' import ReportsPage from './ReportsPage' @@ -112,6 +114,25 @@ const artifact: Artifact = { describe('ReportsPage', () => { beforeEach(() => { + server.use( + http.get('/api/v1/projects', () => + HttpResponse.json({ items: [project], total: 1, page: 1, page_size: 100 }), + ), + http.get(`/api/v1/projects/${project.id}/reports/executions`, () => + HttpResponse.json({ items: [execution], total: 1, page: 1, page_size: 50 }), + ), + http.get(`/api/v1/projects/${project.id}/reports/trends`, () => HttpResponse.json(trend)), + http.get(`/api/v1/projects/${project.id}/notification-webhooks`, () => HttpResponse.json([])), + http.get(`/api/v1/projects/${project.id}/notification-deliveries`, () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + http.get(`/api/v1/projects/${project.id}/workflow-executions/${execution.id}`, () => + HttpResponse.json({ + ...workflowExecutionDetail, + execution: { ...workflowExecutionDetail.execution, id: execution.id }, + }), + ), + ) Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:report'), @@ -123,6 +144,42 @@ describe('ReportsPage', () => { vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined) }) + it('labels page statistics and filters only the loaded page when the server total is larger', async () => { + server.use( + http.get('/api/v1/projects', () => + HttpResponse.json({ items: [project], total: 1, page: 1, page_size: 100 }), + ), + http.get(`/api/v1/projects/${project.id}/reports/executions`, () => + HttpResponse.json({ + items: [ + execution, + { ...execution, id: 'passed-execution', workflow_name: '通过流程', status: 'passed' }, + ], + total: 120, + page: 1, + page_size: 50, + }), + ), + http.get(`/api/v1/projects/${project.id}/reports/trends`, () => HttpResponse.json(trend)), + http.get(`/api/v1/projects/${project.id}/notification-webhooks`, () => HttpResponse.json([])), + http.get(`/api/v1/projects/${project.id}/notification-deliveries`, () => + HttpResponse.json({ items: [], total: 0, page: 1, page_size: 20 }), + ), + ) + renderPage() + const browser = userEvent.setup() + expect(await screen.findByText('1/2 条本页记录')).toBeVisible() + expect(screen.getByText('本页通过率')).toBeVisible() + expect(screen.getByText('当前页 2 / 2 条 · 全部记录 120 条')).toBeVisible() + await browser.click(screen.getByRole('combobox', { name: '本页执行状态' })) + await browser.click( + screen.getByText('本页失败', { selector: '.ant-select-item-option-content' }), + ) + expect(screen.getByText('当前页 1 / 2 条 · 全部记录 120 条')).toBeVisible() + expect(screen.queryByText('通过流程 · v3')).not.toBeInTheDocument() + expect(screen.getByText('订单回归流程 · v3')).toBeVisible() + }) + it('drills into reports, exports HTML and manages signed notifications', async () => { const calls = { detail: 0, export: 0, created: 0, toggled: 0 } const createdWebhook: CreatedNotificationWebhook = { @@ -183,10 +240,12 @@ describe('ReportsPage', () => { expect(screen.getByText('workflow.completed')).toBeVisible() await browser.click(screen.getByRole('button', { name: /详情/ })) - expect(await screen.findByText('执行报告详情')).toBeInTheDocument() - expect(await screen.findByText('校验订单状态')).toBeInTheDocument() + expect(await screen.findByRole('region', { name: '执行报告详情' })).toBeInTheDocument() + expect((await screen.findAllByText('校验订单状态')).length).toBeGreaterThan(0) + await waitFor(() => expect(screen.getByText('期望值')).toBeVisible()) + expect(screen.getByText('实际值')).toBeVisible() expect(calls.detail).toBe(1) - await browser.keyboard('{Escape}') + await browser.click(screen.getByRole('button', { name: '返回执行列表' })) await browser.click(screen.getByRole('button', { name: /HTML/ })) await waitFor(() => expect(calls.export).toBe(1)) @@ -201,19 +260,113 @@ describe('ReportsPage', () => { expect(await screen.findByText(createdWebhook.secret)).toBeInTheDocument() expect(calls.created).toBe(1) }) + + it('links a report node to the same execution snapshot and preserves the execution in its URL', async () => { + server.use( + http.get(`/api/v1/projects/${project.id}/reports/executions/${execution.id}`, () => + HttpResponse.json(detail), + ), + ) + renderPage(`/projects/${project.id}/reports?execution=${execution.id}&node=assert-order`) + const link = await screen.findByRole('link', { name: '定位画布' }) + expect(link).toHaveAttribute( + 'href', + `/projects/${project.id}/workflows?focus=${execution.workflow_id}&execution=${execution.id}&node=assert-order`, + ) + expect(screen.getByText('期望值')).toBeVisible() + expect(screen.getByText('"pending"')).toBeVisible() + }) + + it('loads the next report page from the server', async () => { + const pages: string[] = [] + server.use( + http.get(`/api/v1/projects/${project.id}/reports/executions`, ({ request }) => { + const page = new URL(request.url).searchParams.get('page')! + pages.push(page) + return HttpResponse.json({ + items: [{ ...execution, workflow_name: page === '2' ? '第二页流程' : '第一页流程' }], + total: 120, + page: Number(page), + page_size: 50, + }) + }), + ) + renderPage() + await screen.findByText('第一页流程 · v3') + fireEvent.click(screen.getByTitle('2')) + expect(await screen.findByText('第二页流程 · v3')).toBeVisible() + expect(pages).toEqual(['1', '2']) + }) + + it('selects the exact request attempt and keeps it in the canvas link', async () => { + const observation = (attempt: number): WorkflowNodeObservation => ({ + kind: 'http', + attempt, + request: { + method: 'GET', + url: `https://orders.example.test/${attempt}`, + headers: {}, + body: null, + }, + response: { + status_code: attempt === 1 ? 503 : 200, + headers: {}, + body: { selected_attempt: attempt }, + size_bytes: 20, + }, + mappings: [], + duration_ms: 20, + started_at: execution.started_at, + completed_at: '2026-08-15T08:00:01Z', + error_code: null, + error_message: null, + }) + const apiNode = { + ...detail.nodes[0], + id: 'api-node', + node_id: 'api', + node_type: 'api', + name: '查询订单', + status: 'passed' as const, + assertion: null, + attempts: 2, + observations: [observation(1), observation(2)], + } + server.use( + http.get(`/api/v1/projects/${project.id}/reports/executions/${execution.id}`, () => + HttpResponse.json({ ...detail, nodes: [apiNode, detail.nodes[0]] }), + ), + ) + renderPage(`/projects/${project.id}/reports?execution=${execution.id}&node=api&attempt=1`) + const link = await screen.findByRole('link', { name: '定位画布' }) + expect(link).toHaveAttribute( + 'href', + `/projects/${project.id}/workflows?focus=${execution.workflow_id}&execution=${execution.id}&node=api&attempt=1`, + ) + expect(screen.getByText(/"selected_attempt": 1/)).toBeVisible() + fireEvent.click(screen.getByRole('button', { name: /校验订单状态/ })) + expect(await screen.findByText('期望值')).toBeVisible() + expect(screen.getByRole('link', { name: '定位画布' })).toHaveAttribute( + 'href', + `/projects/${project.id}/workflows?focus=${execution.workflow_id}&execution=${execution.id}&node=assert-order`, + ) + expect(screen.queryByText(/"selected_attempt": 1/)).not.toBeInTheDocument() + }) }) -function renderPage() { +function renderPage(initialEntry?: string) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }) return render( - - - - - - - , + + + + + + + + + , ) } diff --git a/frontend/src/pages/ReportsPage.tsx b/frontend/src/pages/ReportsPage.tsx index f209296a..fbde18cd 100644 --- a/frontend/src/pages/ReportsPage.tsx +++ b/frontend/src/pages/ReportsPage.tsx @@ -8,7 +8,6 @@ import { Alert, Button, Card, - Descriptions, Form, Input, Modal, @@ -22,6 +21,10 @@ import { Typography, } from 'antd' import { useSearchParams } from 'react-router-dom' +import { useState } from 'react' +import ReportExecutionWorkspace from '../features/reports/ReportExecutionWorkspace' +import { executionAttempt } from '../features/workflows/execution-navigation' +import { iceColors } from '../theme/ice-theme' import { ReportTrendChart } from '../features/reports/ReportTrendChart' import type { CreateNotificationWebhookInput } from '../features/reports/report-service' @@ -31,20 +34,57 @@ import type { NotificationDelivery, NotificationWebhook, ReportExecution, - ReportNode, } from '../lib/api' export default function ReportsPage() { - const [searchParams] = useSearchParams() - const state = useReports(searchParams.get('execution') ?? undefined) + const [searchParams, setSearchParams] = useSearchParams() + const reports = useReports(searchParams.get('execution') ?? undefined) + const state = { + ...reports, + selectExecution: (executionId: string | null) => { + const next = new URLSearchParams(searchParams) + next.delete('node') + next.delete('attempt') + if (executionId) next.set('execution', executionId) + else next.delete('execution') + setSearchParams(next) + }, + } + if (state.selectedExecutionId && state.projectId) + return ( + { + const next = new URLSearchParams(searchParams) + next.set('node', nodeId) + next.delete('attempt') + setSearchParams(next, { replace: true }) + }} + onSelectAttempt={(nodeId, attempt) => { + const next = new URLSearchParams(searchParams) + next.set('node', nodeId) + next.set('attempt', String(attempt)) + setSearchParams(next, { replace: true }) + }} + onBack={() => state.selectExecution(null)} + onRetry={() => void state.detail.refetch()} + onExport={() => void state.exportHtml(state.selectedExecutionId!)} + /> + ) return ( - <> +
- +
) } @@ -82,56 +122,129 @@ function ReportHeading({ state }: { state: ReportState }) { } function ReportOverview({ state }: { state: ReportState }) { - const items = state.reports.data?.items ?? [] - const passed = items.filter((item) => item.status === 'passed').length - const failed = items.filter((item) => item.status === 'failed').length - const passRate = items.length ? Math.round((passed * 1000) / items.length) / 10 : 0 return ( <> -
- - - - - - - - - - - - - -
+
- - {(state.trend.data?.failures ?? []).map((item) => ( - - {failureLabel(item.category)} {item.count} - - ))} - {!state.trend.data?.failures.length && ( - 暂无失败 - )} - +
) } +function ReportCounts({ data }: { data: ReportState['reports']['data'] }) { + const items = data?.items ?? [] + const passed = items.filter((item) => item.status === 'passed').length + const failed = items.filter((item) => item.status === 'failed').length + const passRate = items.length ? Math.round((passed * 1000) / items.length) / 10 : null + return ( +
+ + + + + + + + + + +
+ ) +} + +function ReportPassRate({ + rate, + passed, + count, +}: { + rate: number | null + passed: number + count: number | null +}) { + return ( + + + + {count === null ? '未提供本页记录' : `${passed}/${count} 条本页记录`} + + {rate !== null && ( + + )} + + ) +} + +function FailureCategories({ trend }: { trend: ReportState['trend']['data'] }) { + return ( + + {trend?.failures.map((item) => ( + + {failureLabel(item.category)} {item.count} + + ))} + {!trend?.failures.length && ( + + {trend ? '最近 7 日暂无失败' : '未提供分类数据'} + + )} + + ) +} + function ExecutionCard({ state }: { state: ReportState }) { + const [status, setStatus] = useState('all') + const items = state.reports.data?.items ?? [] + const visible = status === 'all' ? items : items.filter((item) => item.status === status) return ( - + + } + > + + 当前页 {visible.length} / {items.length} 条 · 全部记录 {state.reports.data?.total ?? '—'} 条 +
@@ -143,6 +256,7 @@ function executionColumns(state: ReportState) { return [ { title: '工作流', + width: 220, render: (_: unknown, item: ReportExecution) => `${item.workflow_name} · v${item.workflow_version}`, }, @@ -168,7 +282,8 @@ function executionColumns(state: ReportState) { title: '耗时', dataIndex: 'duration_ms', width: 100, - render: (value: number | null) => (value === null ? '运行中' : `${value} ms`), + render: (value: number | null, item: ReportExecution) => + value === null ? (item.status === 'running' ? '运行中' : '未提供') : `${value} ms`, }, { title: '开始时间', @@ -254,7 +369,6 @@ function NotificationCard({ state }: { state: ReportState }) { function ReportDialogs({ state }: { state: ReportState }) { return ( <> - state.selectExecution(null)} - > - {detail && ( - <> - , - }, - { key: 'duration', label: '耗时', children: `${detail.summary.duration_ms ?? 0} ms` }, - ]} - /> -
}} - columns={[ - { title: '步骤', dataIndex: 'name' }, - { title: '类型', dataIndex: 'node_type' }, - { - title: '状态', - dataIndex: 'status', - render: (value: string) => , - }, - { title: '尝试', dataIndex: 'attempts' }, - { - title: '耗时', - dataIndex: 'duration_ms', - render: (value: number | null) => `${value ?? 0} ms`, - }, - { title: '错误', dataIndex: 'error_message' }, - ]} - /> - - )} - - ) -} - -function NodePayload({ node }: { node: ReportNode }) { - return ( -
- - - - -
- ) -} - -function Payload({ title, value }: { title: string; value: unknown }) { - return ( -
- {title} -
{JSON.stringify(value, null, 2) ?? '—'}
-
- ) -} - function WebhookDialog({ open, submitting, diff --git a/frontend/src/pages/TestAssetsPage.test.tsx b/frontend/src/pages/TestAssetsPage.test.tsx index 9f6551e1..09052335 100644 --- a/frontend/src/pages/TestAssetsPage.test.tsx +++ b/frontend/src/pages/TestAssetsPage.test.tsx @@ -284,6 +284,60 @@ describe('TestAssetsPage', () => { expect(screen.getByDisplayValue(testSuite.name)).toHaveValue(testSuite.name) }) + it('keeps a suite member on its saved version when only metadata changes', async () => { + const onSave = vi.fn().mockResolvedValue(undefined) + const older = { + ...testSuite, + draft_definition: { items: [{ test_case_id: testCase.id, test_case_version: 1 }] }, + } + render( + + + , + ) + const dialog = screen.getByRole('dialog') + fireEvent.change(within(dialog).getByLabelText('说明'), { target: { value: '更新说明' } }) + fireEvent.click(within(dialog).getByRole('button', { name: 'OK' })) + await waitFor(() => + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + description: '更新说明', + items: [{ test_case_id: testCase.id, test_case_version: 1 }], + }), + ), + ) + }) + + it('allows an explicit member-version change without changing other members', () => { + const previous = [{ test_case_id: testCase.id, test_case_version: 1 }] + const values = { name: '版本测试', description: '', tags: [], caseIds: [testCase.id] } + expect(suiteInput(values, [testCase], previous).items).toEqual(previous) + expect( + suiteInput({ ...values, caseVersions: { [testCase.id]: 2 } }, [testCase], previous).items, + ).toEqual([{ test_case_id: testCase.id, test_case_version: 2 }]) + expect( + caseInput( + { + name: '用例', + description: '', + tags: [], + isTemplate: false, + workflowId: workflow.id, + workflowVersion: null, + environmentId: environment.id, + }, + testCase.draft_definition, + ).definition.workflow_version, + ).toBeNull() + }) + it('dispatches suite row actions and renders a structured version diff', async () => { const state = assetState() const diff = { diff --git a/frontend/src/pages/TestAssetsPage.tsx b/frontend/src/pages/TestAssetsPage.tsx index 66609dbe..c64753ca 100644 --- a/frontend/src/pages/TestAssetsPage.tsx +++ b/frontend/src/pages/TestAssetsPage.tsx @@ -3,15 +3,21 @@ import { DiffOutlined, EditOutlined, FolderOpenOutlined, + MoreOutlined, + PlayCircleOutlined, PlusOutlined, RocketOutlined, } from '@ant-design/icons' import { + Alert, + App, Button, Card, Checkbox, + Dropdown, Form, Input, + InputNumber, Modal, Select, Space, @@ -24,6 +30,20 @@ import { useState } from 'react' import { useSearchParams } from 'react-router-dom' import ContractAutomationPanel from '../features/contracts/ContractAutomationPanel' +import { useProjectContext } from '../features/projects/use-project-context' +import AssetDetailDrawer from '../features/test-assets/AssetDetailDrawer' +import AssetPlanDialog from '../features/test-assets/AssetPlanDialog' +import { AssetHistoryNotice } from '../features/test-assets/AssetRunEvidence' +import { + PublishedCaseBinding, + RecentAssetCell, + type AssetTableWorkspace, +} from '../features/test-assets/AssetTableContext' +import type { + AssetKind, + PublishedAssetTarget, +} from '../features/test-assets/asset-workspace-service' +import { useAssetHistory } from '../features/test-assets/use-asset-history' import { useTestAssets } from '../features/test-assets/use-test-assets' import type { TestCaseDraftInput, @@ -38,41 +58,148 @@ import { type CaseFormValues, type SuiteFormValues, } from '../features/test-assets/test-asset-view-model' -import type { Environment, Folder, TestCase, TestSuite, Workflow } from '../lib/api' +import { + apiErrorMessage, + type Environment, + type Folder, + type TestCase, + type TestSuite, + type Workflow, +} from '../lib/api' export default function TestAssetsPage() { - const [searchParams] = useSearchParams() + const { projectId } = useProjectContext() + return +} + +function TestAssetsWorkspace() { + const [searchParams, setSearchParams] = useSearchParams() + const { message } = App.useApp() const state = useTestAssets() + const history = useAssetHistory(state.projectId) const [caseEditor, setCaseEditor] = useState(undefined) const [suiteEditor, setSuiteEditor] = useState(undefined) const [selectedCases, setSelectedCases] = useState([]) const [selectedSuites, setSelectedSuites] = useState([]) const [folderId, setFolderId] = useState(null) + const [casePage, setCasePage] = useState(1) + const [suitePage, setSuitePage] = useState(1) + const [plan, setPlan] = useState<{ targets: PublishedAssetTarget[]; execute: boolean } | null>( + null, + ) const cases = pageItems(state.cases.data) const suites = pageItems(state.suites.data) - const publishedCases = cases.filter((item) => item.current_version) + const publishedCases = pageItems(state.caseOptions.data).filter((item) => item.current_version) + const browseFolder = searchParams.get('folder') ?? 'all' + function resetSelection() { + setSelectedCases([]) + setSelectedSuites([]) + setCasePage(1) + setSuitePage(1) + } + function browse(folder: string): void { + resetSelection() + const next = new URLSearchParams(searchParams) + next.set('folder', folder) + next.delete('focus') + next.delete('version') + setSearchParams(next) + } const focusedId = searchParams.get('focus') ?? undefined const focusedType = searchParams.get('type') + function focus(kind: AssetKind, id: string) { + const next = new URLSearchParams(searchParams) + next.set('focus', id) + next.set('type', kind) + next.delete('version') + setSearchParams(next) + } + function setFocusVersion(version: number | 'draft') { + const next = new URLSearchParams(searchParams) + next.set('version', String(version)) + setSearchParams(next) + } + function closeDetail() { + const next = new URLSearchParams(searchParams) + next.delete('focus') + next.delete('version') + setSearchParams(next) + } + async function action(operation: () => Promise) { + try { + await operation() + } catch (error) { + void message.error(apiErrorMessage(error)) + } + } + function changeTab(key: string) { + resetSelection() + const next = new URLSearchParams(searchParams) + next.set('type', key === 'suites' ? 'suite' : key === 'contracts' ? 'contracts' : 'case') + next.delete('focus') + next.delete('version') + setSearchParams(next) + } + const workspace: AssetTableWorkspace = { + projectId: state.projectId ?? '', + page: casePage, + onPage: (page) => { + setCasePage(page) + setSelectedCases([]) + }, + onFocus: focus, + onPlan: (target, execute) => setPlan({ targets: [target], execute }), + workflows: pageItems(state.workflows.data), + history, + canEdit: state.canEdit, + canExecute: state.canExecute, + } return ( - <> - - +
+ resetSelection()} /> +
+ +
+ + + { + setSuitePage(page) + setSelectedSuites([]) + }, + }} + onChangeTab={changeTab} + onAction={action} + onPlan={(targets) => setPlan({ targets, execute: false })} + /> +
+
{state.diff && state.setDiff(null)} />} - + setPlan({ targets: [target], execute })} + /> + setPlan(null)} /> +
+ ) +} + +function AssetCatalogScope({ state }: { state: AssetState }) { + const loading = state.cases.isPending || state.suites.isPending + return ( + + {loading + ? '正在分页读取完整资产目录…' + : `筛选命中 ${state.cases.data?.total ?? '—'} 个用例、${state.suites.data?.total ?? '—'} 个套件。`} + + ) +} + +function WorkspaceAssetDetail( + props: Pick< + Parameters[0], + 'history' | 'onVersion' | 'onClose' | 'onEditCase' | 'onEditSuite' | 'onPlan' + > & { + state: AssetState + params: URLSearchParams + }, +) { + const { state, params } = props + const id = params.get('focus') + const type = params.get('type') + if (!state.projectId || !id || type === 'contracts') return null + return ( + + ) +} + +function WorkspaceAssetPlan({ + projectId, + plan, + onClose, +}: { + projectId: string | null + plan: { targets: PublishedAssetTarget[]; execute: boolean } | null + onClose: () => void +}) { + if (!projectId || !plan) return null + return ( + + ) +} + +function assetRouteVersion(params: URLSearchParams): number | 'draft' | undefined { + const raw = params.get('version') + if (raw === 'draft') return raw + if (!raw || !/^[1-9]\d*$/.test(raw)) return undefined + const version = Number(raw) + return Number.isSafeInteger(version) ? version : undefined +} + +function filterLoadedAssets( + items: T[], + folder: string, +): T[] { + if (folder === 'all') return items + return items.filter((item) => item.folder_id === (folder === 'unfiled' ? null : folder)) +} + +function AssetDirectory({ + folders, + selected, + cases, + suites, + loaded, + onBrowse, +}: { + folders: Folder[] + selected: string + cases: TestCase[] + suites: TestSuite[] + loaded: boolean + onBrowse: (folder: string) => void +}) { + const entries = [ + { id: 'all', name: '全部测试资产' }, + { id: 'unfiled', name: '未分类' }, + ...folders + .map((folder) => ({ ...folder, name: directoryPath(folder, folders) })) + .sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')), + ] + return ( + + + + 数量覆盖全部筛选结果。批量移动目标在列表工具栏选择。 + + ) } +function directoryPath(folder: Folder, folders: Folder[]): string { + const names = [folder.name] + const visited = new Set([folder.id]) + let parentId = folder.parent_id + while (parentId && !visited.has(parentId)) { + visited.add(parentId) + const parent = folders.find((item) => item.id === parentId) + if (!parent) break + names.unshift(parent.name) + parentId = parent.parent_id + } + return names.join(' / ') +} + type AssetState = ReturnType type EditorSetter = (value: T | null | undefined) => void -function AssetHeading({ state }: { state: AssetState }) { +function AssetHeading({ state, onFilter }: { state: AssetState; onFilter: () => void }) { return (
@@ -103,14 +382,20 @@ function AssetHeading({ state }: { state: AssetState }) { aria-label="搜索测试资产" allowClear placeholder="搜索名称或描述" - onSearch={state.setSearch} + onSearch={(value) => { + onFilter() + state.setSearch(value) + }} /> state.setTag(event.target.value)} + onChange={(event) => { + onFilter() + state.setTag(event.target.value) + }} />
@@ -132,12 +417,25 @@ function AssetTabs(props: { setFolderId: (id: string | null) => void focusedId?: string focusedType: string | null + workspace: AssetTableWorkspace + suiteWorkspace: AssetTableWorkspace + onChangeTab: (key: string) => void + onAction: (operation: () => Promise) => Promise + onPlan: (targets: PublishedAssetTarget[]) => void }) { return ( + query.error) + if (!failed) return null + return ( + void Promise.all(queries.map((query) => query.refetch()))}> + 重新加载 + + } + /> + ) +} + function caseTab(props: Parameters[0]) { const { state, cases, selectedCases, folderId, setCaseEditor, setSelectedCases, setFolderId } = props @@ -161,14 +486,21 @@ function caseTab(props: Parameters[0]) { folderId={folderId} folders={folderItems(state)} createDisabled={ + !state.canEdit || !pageItems(state.workflows.data).some((workflow) => workflow.current_version !== null) || !state.environments.data?.length } onFolderChange={setFolderId} onCreate={() => setCaseEditor(null)} onMove={() => - void state.moveCases({ ids: selectedCases, folderId }).then(() => setSelectedCases([])) + void props.onAction(async () => { + await state.moveCases({ ids: selectedCases, folderId }) + setSelectedCases([]) + }) } + moveDisabled={!state.canEdit} + onPlan={() => props.onPlan(selectedTargets('case', cases, selectedCases))} + planDisabled={!state.canEdit || !allPublished(cases, selectedCases)} > [0]) { selected={selectedCases} onSelect={setSelectedCases} onEdit={setCaseEditor} - onPublish={(item) => void state.publishCase(item.id)} - onClone={(item) => void state.cloneCase(item)} - onDiff={(item) => void state.loadCaseDiff(item)} + onPublish={(item) => void props.onAction(() => state.publishCase(item.id))} + onClone={(item) => void props.onAction(() => state.cloneCase(item))} + onDiff={(item) => void props.onAction(() => state.loadCaseDiff(item))} + workspace={props.workspace} /> ), @@ -206,12 +539,18 @@ function suiteTab(props: Parameters[0]) { selected={selectedSuites} folderId={folderId} folders={folderItems(state)} - createDisabled={!publishedCases.length} + createDisabled={!state.canEdit || !publishedCases.length} onFolderChange={setFolderId} onCreate={() => setSuiteEditor(null)} onMove={() => - void state.moveSuites({ ids: selectedSuites, folderId }).then(() => setSelectedSuites([])) + void props.onAction(async () => { + await state.moveSuites({ ids: selectedSuites, folderId }) + setSelectedSuites([]) + }) } + moveDisabled={!state.canEdit} + onPlan={() => props.onPlan(selectedTargets('suite', suites, selectedSuites))} + planDisabled={!state.canEdit || !allPublished(suites, selectedSuites)} > [0]) { selected={selectedSuites} onSelect={setSelectedSuites} onEdit={setSuiteEditor} - onPublish={(item) => void state.publishSuite(item.id)} - onClone={(item) => void state.cloneSuite(item)} - onDiff={(item) => void state.loadSuiteDiff(item)} + onPublish={(item) => void props.onAction(() => state.publishSuite(item.id))} + onClone={(item) => void props.onAction(() => state.cloneSuite(item))} + onDiff={(item) => void props.onAction(() => state.loadSuiteDiff(item))} + workspace={props.suiteWorkspace} /> ), } } +function allPublished(items: Array, ids: string[]): boolean { + return ( + ids.length > 0 && + ids.every((id) => items.some((item) => item.id === id && item.current_version !== null)) + ) +} + +function selectedTargets( + kind: AssetKind, + items: Array, + ids: string[], +): PublishedAssetTarget[] { + return items + .filter((item) => ids.includes(item.id) && item.current_version !== null) + .map((item) => ({ kind, id: item.id, name: item.name, version: item.current_version! })) +} + function AssetDialogs({ state, caseEditor, @@ -290,6 +647,9 @@ export function AssetPane({ onFolderChange, onCreate, onMove, + moveDisabled = false, + onPlan, + planDisabled = false, children, }: { title: string @@ -300,6 +660,9 @@ export function AssetPane({ onFolderChange: (value: string | null) => void onCreate: () => void onMove: () => void + moveDisabled?: boolean + onPlan?: () => void + planDisabled?: boolean children: React.ReactNode }) { return ( @@ -316,9 +679,23 @@ export function AssetPane({ options={folders.map((folder) => ({ value: folder.id, label: folder.name }))} onChange={(value?: string) => onFolderChange(value ?? null)} /> - + {onPlan && ( + + )} + 仅选择当前页 {children} @@ -335,6 +712,7 @@ export function CaseTable({ onClone, onDiff, focusedId, + workspace, }: { items: TestCase[] loading: boolean @@ -345,18 +723,36 @@ export function CaseTable({ onClone: (item: TestCase) => void onDiff: (item: TestCase) => void focusedId?: string + workspace?: AssetTableWorkspace }) { return (
(item.id === focusedId ? 'selected-row' : '')} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => onSelect(keys.map(String)) }} columns={[ - { title: '名称', dataIndex: 'name' }, + { + title: '用例', + dataIndex: 'name', + render: (name: string, item) => + workspace ? ( + + ) : ( + name + ), + }, + ...caseContextColumns(workspace), { title: '标签', dataIndex: 'tags', @@ -369,7 +765,7 @@ export function CaseTable({ { title: '版本', render: (_, item) => versionLabel(item.current_version) }, { title: '操作', - width: 310, + width: workspace ? 200 : 310, render: (_, item) => ( onPublish(item)} onClone={() => onClone(item)} onDiff={() => onDiff(item)} + compact={Boolean(workspace)} + canEdit={workspace?.canEdit ?? true} /> ), }, @@ -395,6 +793,7 @@ export function SuiteTable({ onClone, onDiff, focusedId, + workspace, }: { items: TestSuite[] loading: boolean @@ -405,20 +804,38 @@ export function SuiteTable({ onClone: (item: TestSuite) => void onDiff: (item: TestSuite) => void focusedId?: string + workspace?: AssetTableWorkspace }) { return (
(item.id === focusedId ? 'selected-row' : '')} rowSelection={{ selectedRowKeys: selected, onChange: (keys) => onSelect(keys.map(String)) }} columns={[ - { title: '名称', dataIndex: 'name' }, { - title: '用例数', + title: '套件', + dataIndex: 'name', + render: (name: string, item) => + workspace ? ( + + ) : ( + name + ), + }, + ...suiteContextColumns(workspace), + { + title: '草稿用例数', render: (_, item) => item.draft_definition.items.length, }, { @@ -429,7 +846,7 @@ export function SuiteTable({ { title: '版本', render: (_, item) => versionLabel(item.current_version) }, { title: '操作', - width: 310, + width: workspace ? 200 : 310, render: (_, item) => ( onPublish(item)} onClone={() => onClone(item)} onDiff={() => onDiff(item)} + compact={Boolean(workspace)} + canEdit={workspace?.canEdit ?? true} /> ), }, @@ -445,38 +864,114 @@ export function SuiteTable({ ) } +function assetPagination(workspace: AssetTableWorkspace | undefined) { + if (!workspace) return false as const + return { + current: workspace.page, + pageSize: 20, + showSizeChanger: false, + onChange: workspace.onPage, + showTotal: (total: number) => `共 ${total} 条`, + } +} + +function caseContextColumns(workspace: AssetTableWorkspace | undefined) { + if (!workspace) return [] + return [ + { + title: '已发布绑定流程', + width: 220, + render: (_: unknown, item: TestCase) => ( + + ), + }, + { + title: '最近结果(近 20 次计划运行)', + width: 200, + render: (_: unknown, item: TestCase) => ( + + ), + }, + ] +} + +function suiteContextColumns(workspace: AssetTableWorkspace | undefined) { + if (!workspace) return [] + return [ + { + title: '最近结果(近 20 次计划运行)', + width: 200, + render: (_: unknown, item: TestSuite) => ( + + ), + }, + ] +} + function RowActions({ version, onEdit, onPublish, onClone, onDiff, + compact = false, + canEdit = true, }: { version: number | null onEdit: () => void onPublish: () => void onClone: () => void onDiff: () => void + compact?: boolean + canEdit?: boolean }) { return ( - - - - + {compact ? ( + , + disabled: !canEdit, + onClick: onClone, + }, + { + key: 'diff', + label: '版本比较', + icon: , + disabled: !version || version < 2, + onClick: onDiff, + }, + ], + }} + trigger={['click']} + > + + + + )} ) } @@ -500,6 +995,17 @@ export function CaseDialog({ }) { const [form] = Form.useForm() const definition = current?.draft_definition + const [error, setError] = useState(null) + async function save() { + const values = await form.validateFields().catch(() => null) + if (!values) return + setError(null) + try { + await onSave(caseInput(values, definition)) + } catch (failure) { + setError(apiErrorMessage(failure)) + } + } return ( - void form.validateFields().then((values) => onSave(caseInput(values, definition))) - } + onOk={() => void save()} >
@@ -519,8 +1023,14 @@ export function CaseDialog({ - form.setFieldValue('workflowVersion', null)} + /> + + @@ -580,10 +1103,75 @@ export function SuiteDialog({ + + {state.breakpointNodeId && (