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..595e2942 --- /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; github-new-case-actions-passed.log,完整分页目录与当前页 20 条选择已验证;切目录/页码/筛选清空选择。批量删除/原生导出未实现,不能把选择范围回归当作完整批量验收。 +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差异,不为适配设计升级全部依赖,已验证,backend/uv.lock; frontend/package.json; frontend/pnpm-lock.yaml; backend/Dockerfile; frontend/Dockerfile; mock-target/Dockerfile; implementation.md,未为设计升级全部依赖;安全依赖与运行库及固定夹具修复独立提交于 PR #126,执行受控 Bootstrap 后同步主干。工具链与扫描规则保持,UI 普通门禁按最终新 Head 确认。 +V5-079,质量与交付,真实测试,检查测试日志与截图来源,未测不写通过,不用原型替代,已验证,github-3969269-local-full-coverage.log; github-3969269-frontend-full.log; github-3969269-backend-full.log; github-3969269-compose.log; github-ui-s23-final-targeted.log; frontend/e2e/workflow-editor-visual.spec.ts-snapshots/README.md,本地及远程前端均 111 文件 / 653 测试通过;远程后端 1693 passed / 11 skipped,覆盖率 90.63%;Compose 非 S29 浏览器 62 passed / 1 flaky,S23 重试通过并补齐动画等待。Linux DOM 基线保留 0.003 阈值与取景几何。旧 Head 安全夹具阻塞独立处理,最终新 Head 门禁与发布验收单独确认。 +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..56226116 --- /dev/null +++ b/docs/design/ui-v5/changed-files.txt @@ -0,0 +1,133 @@ +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/e2e/grouped-navigation.spec.ts +frontend/e2e/s15-test-assets.spec.ts +frontend/e2e/s16-advanced-workflow.spec.ts +frontend/e2e/s21-ai-review.spec.ts +frontend/e2e/s23-multi-protocol.spec.ts +frontend/e2e/s30-failure-intelligence.spec.ts +frontend/e2e/s31-release-gate.spec.ts +frontend/e2e/s58-failure-repair.spec.ts +frontend/e2e/support/auth.ts +frontend/e2e/support/navigation.ts +frontend/e2e/support/workflow-editor.ts +frontend/e2e/v1-acceptance.spec.ts +frontend/e2e/workflow-editor-audit.spec.ts +frontend/e2e/workflow-editor-interactions.spec.ts +frontend/e2e/workflow-editor-layout.spec.ts +frontend/e2e/workflow-editor-visual.spec.ts +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-02-add-node-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-06-fullscreen-config-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-07-focus-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-02-add-node-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-06-fullscreen-config-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-07-focus-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-02-add-node-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-06-fullscreen-config-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-07-focus-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png +frontend/e2e/workflow-editor-visual.spec.ts-snapshots/README.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/FlowProposalReviewDialog.test.tsx +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.test.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/WorkflowViewport.test.ts +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..eadec3c8 --- /dev/null +++ b/docs/design/ui-v5/implementation.md @@ -0,0 +1,190 @@ +# 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`。提交准备阶段已同步 `main@f7c3459`,保留其认证会话修复、用例直接运行和依赖更新;独立安全修复 #126 合并后又同步 `main@9a918fa`。本功能改动集中于前端、浏览器验收和设计记录,没有新增后端业务、数据库、执行引擎或 CI 治理改动。安全门禁发现的 urllib3、Axios 依赖漏洞、Debian / Alpine 运行层漏洞与固定夹具阻断已由独立 PR #126 修复,见下方记录。GitHub 合并结果以 PR 和最新提交的 Required Gate 为准。 +- 本地 Node 22.22.0。主干同步后在实施工作树独立执行冻结锁文件安装,保留仓库的 pnpm 11.16 声明。Linux 浏览器验证使用 Playwright 1.62.1;构建与远程门禁按仓库声明执行。 + +## 实际改动 + +完整文件清单见 [changed-files.txt](changed-files.txt)。PR 相对最新主干的 diff 可用于审阅全部源码改动。日志和隔离浏览器截图保存在本地忽略目录,不提供无法在 GitHub 打开的链接。 + +| 工作区 | 主要源码 | 行为与目的 | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| 主题与应用壳 | `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 项矩阵记录各自范围;修复减少动效下的定位测量 | 本轮远程后端、前端、Compose Playwright、Windows 和升级已执行;最终新 Head 门禁与真实各角色 / 发布验收单独确认,不能推断 GA | + +## 合并前完整检查快照(2026-10-03) + +UI Head `3969269f17a828fc6107559ca299527bff267b4c` 的[完整 PR 运行](https://github.com/a3384379/FlowTest/actions/runs/37038461921)已执行:后端 1693 passed / 11 skipped,覆盖率 90.63%;前端 111 文件 / 653 项及全部覆盖率阈值通过;基础设施集成、Windows、历史升级回滚、Quick 和 Skills 均通过。Compose 完成业务、Runner Fabric、浏览器、环境 Seed / 重启后清理及备份恢复检查;非 S29 浏览器结果为 62 passed / 1 flaky(S23 重试后通过),保留该记录,不称首次全部通过。本地相同 Head 的单工作线程完整覆盖率也为 111 文件 / 653 项通过,四项覆盖率为 84.88% / 80.41% / 84.41% / 87.19%(语句 / 分支 / 函数 / 行)。 + +此运行的正式 Required Gate 未通过:Security CI 阻塞于固定 Nginx 夹具中的 PCRE2 10.48。该阻塞及运行库 / 源码依赖修复由[独立 PR #126](https://github.com/a3384379/FlowTest/pull/126)处理:[完整 Bootstrap 运行 37071215766](https://github.com/a3384379/FlowTest/actions/runs/37071215766)全部所需检查通过后,按受控流程合并为 `main@9a918fa`,原严格 Required Gate 已恢复并验证。本 UI 分支已同步修复后的主干,以新 Head 重新验证普通门禁;最终远程结果与合并证据以 PR #125 为准,以上旧 Head 不作为新提交的门禁证明。 + +S23 的首轮失败定位到动画期间点击 Schema 格式下拉后未进入 Reflection 表单。验收脚本限定可见下拉,等待弹层 / 下拉动画完成,并验证 Reflection 字段出现;未增加重试或放宽超时。对应协议页面 17 项定向测试、脚本格式 / ESLint 与 Playwright 发现检查通过,实际修复后的浏览器行为由最终 Compose 运行验证。 + +## 用例与套件功能补齐 + +首轮只完成了目录与列表布局,未达到方案的“目录—列表—详情、绑定版本—计划—执行—报告”操作链。本轮接入已有 API,没有新增后端契约或自动创建真实资产。 + +- 目录与套件/流程选项逐页读取完整记录,超过 100 条不截断;每页显示 20 条。目录计数包括筛选结果中的用例与套件。切目录、页码、类型和筛选时清除批量选择,操作对象仅限当前页。分页读取发生重复/数量变化时提示重载,不把部分数据标为完整。 +- 名称打开详情,可通过 `type/focus/version` 深链接查看资产。草稿、发布版本和执行结果独立。发布版本展示冻结流程/环境/覆盖项;指定版本缺失时保留错误并禁用计划/执行,不回退到其他版本。 +- 用例可明确选择流程版本,套件可选择成员版本。仅改套件说明时保留原成员版本,避免静默绑定最新版本。保存失败保留表单并显示标准错误。 +- 用例“执行”接入主干直接运行接口,详情选择的旧发布版本会作为固定版本提交,不创建隐式计划;草稿运行仍需明确确认发布。用例“加入计划”可以追加到已有计划,并保留所选发布版本,也可以明确新建计划。套件和批量操作先创建可审阅的手动计划,再由用户显式执行。发布不会自动执行;新计划入队失败重试复用已创建计划与同一 Idempotency-Key。 +- 用例最近结果接入主干按资产查询接口,包含直接执行与计划执行;每次只查询当前页的最多 20 个用例,满足接口 100 ID 上限。套件记录仍从计划项及 `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` 及现有页面/服务挂接。主干新增的用例直接执行、最近结果与追加已有计划能力已保留并接入新版工作区。导入导出、按资产全历史检索/聚合仍列为独立后端待办。 + +## 主干同步后的提交前验证 + +主干同步保留认证 epoch 变化时清理查询缓存、草稿运行确认、运行变量/headers 和套件成员固定版本。用例工作区新增回归验证:跨 100 条记录的旧版用例直接运行、将指定旧版加入已有计划,两项均校验精确 API 请求且不触发隐式计划或执行。最终前端覆盖率复核为 111 文件、651 测试通过:语句 84.88%、分支 80.41%、函数 84.41%、行 87.19%(`github-viewport-coverage.log`),四项均通过 80% 阈值。格式、Lint/类型与构建均通过;最大化表单宽度和空分支条件问题已修复,新增四项无条件/显式分支回归。运行视图的自动居中和安全范围采用实测节点尺寸,避免初始高度与运行状态高度不同导致取景位移;补齐七项视口回归,修改前四项表征通过,修改后相关三文件/33项通过(`github-viewport-characterization.log`、`github-viewport-targeted.log`)。Linux 27 张视觉基线已复核;不更新基线的普通模式复跑共 9 项全部通过(`github-buildkit-browser-canonical-verified.log`):包含登录、三视口几何、三视口共 27 个像素检查点、S15 用例/套件及 V1 治理/报告主路径。像素阈值仍为 0.003。S11 业务、重试、超时、取消、并行、权限及报告冒烟另行通过(`github-buildkit-s11-seed.log`)。默认、专注、运行和历史视图通过真实“适应画布”取景,先等待字体、节点和视口稳定;用例选项在弹窗及下拉动画完成后用普通点击选择。远程合并门禁单独确认。 + +### 合并门禁发现的依赖阻断 + +[PR #125](https://github.com/a3384379/FlowTest/pull/125) 首轮完整自动检查在 Python 依赖审计发现 urllib3 2.7.0 的三个已知漏洞。本地前端审计也发现 Axios 1.19.0 的七项高危问题。因此独立修复 `backend/uv.lock` 中的 urllib3 至 2.8.0,以及前端 Axios 最低版本/锁定版本至 1.20.0(包含其 follow-redirects 传递依赖 1.16.1)。未改动扫描规则或风险门槛,也没有升级测试框架和其他工具链。[urllib3 官方说明](https://urllib3.readthedocs.io/en/stable/changelog.html)与 [Axios 官方发布说明](https://github.com/axios/axios/releases/tag/v1.20.0)列明修复及兼容注意事项;定制 HTTPS 代理应核对 urllib3 的代理 TLS 配置变更。 + +依赖修复后,在本工作树独立环境使用冻结安装和仓库声明的 pnpm 11.16.0 验证:Python 160 包审计无已知漏洞,源码安全 Ruff 通过,存储/转移/运行输出/文件授权四文件 15 项定向回归通过(`github-urllib3-audit.json`、`github-security-fixed-ruff.log`、`github-urllib3-targeted.log`)。前端格式、Lint/类型、构建及 111 文件/651 项全量覆盖率均通过,四项覆盖率与上述数值一致(`github-security-fixed-coverage.log`、`github-security-fixed-lint.log`、`github-security-fixed-build.log`)。前端审计高危/严重项为 0,保留两项 Vitest / @vitest/mocker 中危开发依赖条目作单独跟踪(同一 [GHSA-82fw-gwwq-j7x9](https://github.com/advisories/GHSA-82fw-gwwq-j7x9),`github-node-audit-final.json`);既有 `--audit-level high` 门禁保持。 + +源码依赖修复后的镜像扫描进一步发现缓存 Python 基础层中的 OpenSSL 与 PCRE2 高危条目。`backend/Dockerfile` 的共享 Python 运行层、`mock-target/Dockerfile` 的 Python 构建后运行层定向更新 `libssl3t64`、`openssl-provider-legacy`、`libpcre2-8-0`,校验最低 Debian 修复版本分别为 `3.5.7-1~deb13u3`、`3.5.7-1~deb13u3`、`10.46-1~deb13u3`,使缓存或复用基础镜像仍取得修复库。使用原 Debian 签名源,不改 Python/Go 版本、基础镜像默认值、扫描忽略规则或门槛。修复依据为 [Debian OpenSSL 安全公告](https://security-tracker.debian.org/tracker/DSA-6531-1)及 [PCRE2 包状态](https://security-tracker.debian.org/tracker/source-package/pcre2)。 + +运行层修复在本地 Linux arm64 完成:应用运行镜像使用已有阶段二 Python 基础镜像构建通过,mock 镜像使用其正常源码构建通过;两份 Dockerfile 的 `docker build --check` 无警告。实际应用镜像导入 `app.main` 并以独立本地 CA 验证 HTTPS 成功响应、错误主机名拒绝、不可信 CA 拒绝,保持严格证书校验;三个库实际版本均达到上述下限(`github-debian-runtime-build.log`、`github-debian-mock-build.log`、`github-debian-runtime-probe-local-tls-fixed.log`)。CI 路由 91 项定向回归通过(`github-runtime-routing-targeted.log`)。这些构建验证没有替代最新提交的 Linux amd64 全镜像扫描或 Compose 检查。 + +`676c65a` 的远程后端镜像扫描通过后,前端镜像扫描发现 Alpine PCRE2 10.47-r1 的四项高危条目。`frontend/Dockerfile` 在原有 OpenSSL 更新操作中加入 `pcre2>=10.49-r0`,使用基础镜像原有 Alpine 3.24 签名源;不切换软件源或更新 Nginx / Node 工具链。最低版本依据 [PCRE2 10.49 官方安全修复说明](https://github.com/PCRE2Project/pcre2/releases/tag/pcre2-10.49)。本地 Linux arm64 构建取得 PCRE2 10.49-r0;Dockerfile 静态检查无警告,实际镜像的 Nginx 配置检查、首页、流程路由回退和 JS 资源读取通过(`github-frontend-pcre2-build.log`、`github-frontend-pcre2-check.log`、`github-frontend-pcre2-smoke.log`)。完整远程扫描仍按最新提交确认。 + +`eaba4f9` 的远程扫描已通过后端、前端和三种 runner 镜像,环境 daemon 的复用基础镜像仍含 PCRE2 10.48-r0。因此在 `backend/Dockerfile` 选择环境 daemon 基础镜像之后增加 `pcre2>=10.49-r0` 更新;本地复用基础层的扫描又发现 Expat 2.8.4-r0 高危条目,因此同一位置校验 `libexpat>=2.8.5-r0`,依据 [Expat 2.8.5 官方修复说明](https://github.com/libexpat/libexpat/releases/tag/R_2_8_5)。缓存与源码构建路径都取得最低修复版本,保留原工具链、签名源和扫描规则。本地实际 daemon 镜像构建、目标 Dockerfile 静态检查、动态库加载、dockerd / containerd / ctr / Docker 命令及文件系统工具版本检查通过;Grype 0.116.1 的可修复高危/严重项为 0(`github-daemon-pcre2-expat-build.log`、`github-daemon-pcre2-expat-check.log`、`github-daemon-pcre2-expat-smoke.log`、`github-daemon-pcre2-expat-grype.json`)。最新远程全镜像和真实 daemon 部署结果按后续提交核对。 + +上述本地 Linux 浏览器检查在依赖修复前完成;修复后的完整浏览器与部署检查以最新 PR 提交的远程自动验证为准,不沿用旧提交的绿灯。`1259622` 的完整 CI 后端、Windows 和升级回滚检查通过;前端两项测试超时,浏览器矩阵暴露旧导航/布局定位与 1280px 运行截图问题,尚待修复并重新验证。隔离验证栈和临时进程已清理,原工作区仍干净,已有运行实例健康。 + +### 完整 CI 中的浏览器与测试修复 + +`1259622` 的前端全量测试有 649 项通过,两项复合/目录交互测试超过 60 秒。将 GraphQL 配置/绑定与 gRPC mTLS 分成独立场景,并使用一次真实粘贴输入验证完整字段;目录选择测试将查询限定到标签、当前行与工具栏,保留跨 100 条读取、禁止意外写入和切目录清选择的断言。没有提高超时、关闭测试或降低覆盖率。单工作线程下两文件 34 项定向检查通过(`github-ci-timeout-fixed-targeted.log`);随后全量覆盖率 111 文件 / 652 项通过(`github-ci-followup-full-coverage.log`,482.09s),四项覆盖率保持 84.88% / 80.41% / 84.41% / 87.19%。 + +旧侧栏验收已迁移到 68px 常用任务栏及全部模块抽屉,保留三档视口中的未应用草稿/URL/tab、键盘进入/退出、焦点恢复、浏览器历史和逐帧尺寸稳定检查。S16 用抽屉自身关闭按钮退出列表,等待焦点恢复后直接从新版顶栏选择环境,并等待下拉完整动效结束,版本 Diff / 调试使用命名的更多按钮;S21 等待实际弹窗/下拉动画完成再选择;发布门禁和 API 候选选项定位限定到对应行/下拉;新增节点使用宽屏停靠库拖入可命中的画布。小屏顶部改为两行,保留项目、全局搜索、外观及退出入口。截图前增加整图中心与画布中心的几何断言;测量尚未反映新布局时,继续通过真实适应画布操作取景,截图阈值和节点/状态内容比较保持。相关浏览器脚本在本任务独立的 BuildKit PostgreSQL、Redis、MinIO、mock、API/Worker 进程与专用端口上复核,默认 Compose 的 mock 地址保持;本地可通过 `FLOWTEST_E2E_MOCK_TARGET_URL` 选择隔离目标。临时进程不连接原实例数据库。最终本地普通模式已通过导航、发布门禁、编排审计/交互及三视口 27 张截图(32 项,`github-buildkit-browser-ci-followup-final.log`);其后 S21 定向复核通过(`github-buildkit-browser-ci-last-two.log`),S16 在补齐焦点恢复等待后完整主路径通过(`github-buildkit-browser-ci-s16-focus.log`,含运行、节点重放和断点调试)。分次验证不能将此前整次运行写为全通过。原有基线没有再次更新,0.003 像素门槛保持。 + +`676c65a` 的完整远程矩阵已有 61 项通过,剩余 S16 列表关闭焦点竞争及 S30 弹窗动效导致的一次重试已按真实生命周期补齐。前端 650 / 652 项通过,目录复合交互与流程提案人工审核两项超时。因此将完整分页读取(保留 101 条记录)与目录切换清选择拆成独立场景;提案审核改用已有明确按钮标签,禁止发布/运行的断言同时检查隐藏按钮,并保留未接受前零应用、接受后只应用一次的检查。两文件 19 项定向检查通过(`github-second-followup-targeted.log`,29.28s);未提高超时、关闭测试或降低覆盖率。S30 等待弹窗与下拉动效结束并限定选项范围。S16 / S30 同次无重试复核全部通过(含登录准备 3 项,15.3s,`github-second-followup-s16-s30-final.log`);该次复核使用准备好的项目夹具,首次空库缺夹具的失败日志保留。Linux 全量尝试通过 110 文件 / 613 项测试(726.25s),其中新分页/目录测试各约 42.1s、提案审核 12.45s;图命令文件因本地容器遗漏外部 docs 夹具未加载。补齐夹具后该文件 40 项定向检查通过(`github-linux-frontend-coverage-artifacts/results.log`、`github-linux-graph-command-artifacts/results.log`)。这是分次检查,不宣称完整覆盖率通过;最终覆盖率仍由最新 PR 提交的完整 CI 验证。 + +`eaba4f9` 的远程完整 Compose 检查和升级回滚已通过:非 S29 浏览器矩阵 63 项首次通过,其他冒烟 / Runner Fabric 场景亦通过(`github-eaba4f9-compose.log`)。后端覆盖率 90.64%,1692 项通过、11 项跳过,取消测试在强制取消阶段出现一次 SQLite 数据库锁竞争失败;`676c65a` 到 `eaba4f9` 未改动后端源码 / 测试 / 依赖,随后整个 `tests/test_workflows_api.py` 24 项定向复核通过(10.79s,`github-cancel-sqlite-file-targeted.log`)。未修改或跳过取消测试,也未将定向复核视为原完整门禁已通过,保留该失败日志供单独跟踪,并按最新提交重新执行完整门禁。 + +`eaba4f9` 的前端完整 CI 有 651 / 653 项通过,提案审核已通过,目录两个场景仍因 60 秒超时失败。隔离 Linux 逐步计时显示查询 / 选择不到 1 秒,而文本按钮的每次 `toBeVisible` 检查约 20 秒;改为直接检查对应表格行可见,保留完整分页、目录过滤、选择清空和禁止意外写入的断言。按钮操作与页面视觉继续由真实浏览器矩阵验证。单文件 9 项定向回归通过(`github-asset-row-visibility-targeted.log`,5.87s);Linux V8 覆盖采集下 9 项测试断言通过,其中分页 / 清选择为 926ms / 511ms(`github-linux-asset-row-artifacts/results.log`,10.52s)。该单文件覆盖率不足全局 80% 门槛,其退出码不作为完整覆盖率通过证据;诊断计时代码仅在忽略目录的临时归档内,未进入产品或提交。 + +以下为此前各轮验证记录,不能替代主干同步后的最终门禁。 + +## 检查记录 + +工作目录均为上述实施工作树。开发期间的失败及修复日志保留在 `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:`;按 PR 的明确文件范围或提交回退,不使用全局 reset/clean。 diff --git a/docs/design/ui-v5/modules.csv b/docs/design/ui-v5/modules.csv new file mode 100644 index 00000000..30e2dce6 --- /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 / runCase / addCaseToPlan / listLatestCaseRuns / 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..3ae0abff --- /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/e2e/grouped-navigation.spec.ts b/frontend/e2e/grouped-navigation.spec.ts index fc9e0993..d014dc97 100644 --- a/frontend/e2e/grouped-navigation.spec.ts +++ b/frontend/e2e/grouped-navigation.spec.ts @@ -18,13 +18,11 @@ for (const viewport of [ await expect(page.locator('.react-flow__node')).toHaveCount(3) await page.reload() await expect(page.locator('.react-flow__node')).toHaveCount(3) - const navigation = page.getByRole('navigation', { name: '功能导航' }) - const parent = navigation.getByRole('menuitem', { name: '测试设计', exact: true }) - await expect(parent).toHaveAttribute('aria-expanded', 'true') - await expect(navigation.getByRole('link', { name: '流程编排', exact: true })).toHaveAttribute( - 'aria-current', - 'page', - ) + await expect( + page + .getByRole('navigation', { name: '常用任务' }) + .getByRole('link', { name: '流程编排', exact: true }), + ).toHaveAttribute('aria-current', 'page') await expect(page.locator('.page-breadcrumb')).toContainText('测试设计') await page.locator('.react-flow__node[data-id=api]').click() const name = page.getByRole('textbox', { name: '名称', exact: true }) @@ -32,27 +30,34 @@ for (const viewport of [ const editor = await page.getByTestId('workflow-workbench').elementHandle() const beforeURL = page.url() const tabs = await page.locator('.workspace-navigation-tabs').textContent() + const navigation = await openDirectory(page) + const parent = navigation.getByRole('menuitem', { name: '测试设计', exact: true }) + await expect(parent).toHaveAttribute('aria-expanded', 'true') + await expect(navigation.getByRole('link', { name: '流程编排', exact: true })).toHaveAttribute( + 'aria-current', + 'page', + ) await parent.click() await expect(parent).toHaveAttribute('aria-expanded', 'false') - await navigation.getByRole('menuitem', { name: '项目与接口', exact: true }).click() + const projectGroup = navigation.getByRole('menuitem', { name: '项目与接口', exact: true }) + await projectGroup.click() await expect(name).toHaveValue('导航测试未应用草稿') expect(page.url()).toBe(beforeURL) expect(await page.locator('.workspace-navigation-tabs').textContent()).toBe(tabs) - await page.getByRole('button', { name: '收起侧栏', exact: true }).click() - await expect(page.locator('.sidebar')).toHaveCSS('width', '72px') - await expect(name).toHaveValue('导航测试未应用草稿') - await page.getByRole('button', { name: '展开侧栏', exact: true }).click() - await expect(parent).toHaveAttribute('aria-expanded', 'true') + await page.keyboard.press('Escape') + await openDirectory(page) + await expect(projectGroup).toHaveAttribute('aria-expanded', 'true') await expect(name).toHaveValue('导航测试未应用草稿') + await page.keyboard.press('Escape') expect(await editor!.evaluate((element) => element.isConnected)).toBe(true) await expect(page.getByTestId('workflow-canvas-stage')).toBeVisible() - await expect(page.locator('.sidebar')).toHaveCSS('width', '224px') + await expect(page.locator('.sidebar')).toHaveCSS('width', '68px') const sidebar = await page.locator('.sidebar').boundingBox() const header = await page.locator('.topbar').boundingBox() const logout = await page.getByRole('button', { name: /退出$/ }).boundingBox() expect(sidebar!.height).toBe(viewport.height) expect(logout!.x + logout!.width).toBeLessThanOrEqual(viewport.width) - expect(header!.x).toBe(224) + expect(header!.x).toBe(68) await page.screenshot({ path: testInfo.outputPath(`navigation-${viewport.width}.png`), animations: 'disabled', @@ -70,10 +75,13 @@ for (const viewport of [ await page.setViewportSize(viewport) await seedEditor(page) await expect(page.getByTestId('workflow-workbench')).toBeVisible() - const navigation = page.getByRole('navigation', { name: '功能导航' }) + const navigation = await openDirectory(page) await navigation.getByRole('menuitem', { name: '项目与接口', exact: true }).click() const service = navigation.getByRole('link', { name: '服务目录', exact: true }) await expect(service).toBeVisible() + const footerBottom = await page + .locator('.shell-rail-footer') + .evaluate((element) => element.getBoundingClientRect().bottom) const [samples] = await Promise.all([ page.evaluate( () => @@ -82,7 +90,7 @@ for (const viewport of [ const started = performance.now() function sample() { const sidebar = document.querySelector('.sidebar')! - const footer = document.querySelector('.shell-sidebar-footer')! + const footer = document.querySelector('.shell-rail-footer')! frames.push({ services: location.pathname.endsWith('/services'), height: sidebar.getBoundingClientRect().height, @@ -101,14 +109,14 @@ for (const viewport of [ expect(samples.some((sample) => sample.services)).toBe(true) for (const sample of samples) { expect(sample.height).toBeCloseTo(viewport.height, 0) - expect(sample.footerBottom).toBeCloseTo(viewport.height, 0) + expect(sample.footerBottom).toBeCloseTo(footerBottom, 0) } await page.getByRole('tab', { name: '流程编排', exact: true }).click() await expect(page.getByTestId('workflow-workbench')).toBeVisible() }) } -test('N06/N08:collapsed popup supports keyboard selection, Escape, tabs and browser history', async ({ +test('N06/N08:module directory supports keyboard selection, Escape, tabs and browser history', async ({ page, }) => { await page.setViewportSize({ width: 1440, height: 900 }) @@ -116,46 +124,39 @@ test('N06/N08:collapsed popup supports keyboard selection, Escape, tabs and br await navigateMenu(page, '服务目录') await expect(page.getByRole('heading', { name: '服务目录', exact: true })).toBeVisible() await page.goBack() - const parent = page - .getByRole('navigation', { name: '功能导航' }) - .getByRole('menuitem', { name: '测试设计', exact: true }) - await expect(parent).toHaveAttribute('aria-expanded', 'true') - await page.getByRole('button', { name: '收起侧栏', exact: true }).click() - await expect(page.locator('.sidebar')).toHaveCSS('width', '72px') - const popupParent = page - .getByRole('navigation', { name: '功能导航' }) - .getByRole('menuitem', { name: '项目与接口', exact: true }) - await popupParent.focus() + await expect(page.getByTestId('workflow-workbench')).toBeVisible() + const trigger = page.getByRole('button', { name: '全部模块', exact: true }) + await trigger.focus() await page.keyboard.press('Enter') - await expect(popupParent).toHaveAttribute('aria-expanded', 'true') - await expect( - page.locator('.ant-menu-submenu-popup').getByRole('link', { name: '服务目录', exact: true }), - ).toBeVisible() + const navigation = page.getByRole('navigation', { name: '功能导航' }) + const parent = navigation.getByRole('menuitem', { name: '测试设计', exact: true }) + await expect(parent).toHaveAttribute('aria-expanded', 'true') await page.keyboard.press('Escape') - await expect(popupParent).toHaveAttribute('aria-expanded', 'false') - await expect(page.locator('.ant-menu-submenu-popup')).toBeHidden() - await popupParent.focus() - await page.keyboard.press('Enter') - const link = page - .locator('.ant-menu-submenu-popup') - .getByRole('link', { name: '服务目录', exact: true }) - await link.focus() + await expect(trigger).toBeFocused() + await trigger.press('Enter') + const search = page.getByLabel('搜索模块', { exact: true }) + await search.fill('服务目录') + const service = navigation.getByRole('link', { name: '服务目录', exact: true }) + await service.focus() await page.keyboard.press('Enter') - await expect(page.getByRole('heading', { name: '服务目录', exact: true })).toBeVisible() + await expect(page).toHaveURL(/services$/) + await expect(trigger).toBeFocused() const overview = page - .getByRole('navigation', { name: '功能导航' }) - .getByRole('menuitem', { name: '质量总览', exact: true }) + .getByRole('navigation', { name: '常用任务' }) + .getByRole('link', { name: '质量总览', exact: true }) await overview.focus() await page.keyboard.press('Enter') await expect(page).toHaveURL(/dashboard$/) await page.getByRole('tab', { name: /服务目录/ }).click() - await page.getByRole('button', { name: '展开侧栏', exact: true }).click() + await expect(page).toHaveURL(/services$/) + await openDirectory(page) + await search.clear() await expect( - page - .getByRole('navigation', { name: '功能导航' }) - .getByRole('menuitem', { name: '项目与接口', exact: true }), + navigation.getByRole('menuitem', { name: '项目与接口', exact: true }), ).toHaveAttribute('aria-expanded', 'true') + await page.keyboard.press('Escape') await page.getByRole('tab', { name: /流程编排/ }).click() + await openDirectory(page) await expect(parent).toHaveAttribute('aria-expanded', 'true') }) @@ -180,17 +181,16 @@ test('N08/N16:responsive defaults and mobile drawer closure restore focus', as }, testInfo) => { await page.setViewportSize({ width: 1100, height: 800 }) const fixture = await seedEditor(page) - await expect(page.getByRole('button', { name: '展开侧栏', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '全部模块', exact: true })).toBeVisible() await page.setViewportSize({ width: 1440, height: 900 }) - await expect(page.getByRole('button', { name: '收起侧栏', exact: true })).toBeVisible() - await page.getByRole('button', { name: '收起侧栏', exact: true }).click() + await expect(page.locator('.sidebar')).toHaveCSS('width', '68px') await page.setViewportSize({ width: 390, height: 844 }) const trigger = page.getByRole('button', { name: '打开导航', exact: true }) await trigger.click() await page.keyboard.press('Escape') await expect(trigger).toBeFocused() await trigger.click() - await page.locator('.ant-drawer-mask').click({ position: { x: 300, y: 300 } }) + await page.locator('.ant-drawer-mask').click({ position: { x: 350, y: 300 } }) await expect(trigger).toBeFocused() await navigateMenu(page, '服务目录') await expect(page).toHaveURL(new RegExp(`${fixture.root}/services$`)) @@ -208,26 +208,27 @@ test('N08/N16:responsive defaults and mobile drawer closure restore focus', as await expect(page.getByLabel('全局搜索')).toBeInViewport() await expect(page.locator('.global-project-select')).toBeInViewport() await page.setViewportSize({ width: 1920, height: 1080 }) - await expect(page.getByRole('button', { name: '展开侧栏', exact: true })).toBeVisible() + await expect(page.getByRole('button', { name: '全部模块', exact: true })).toBeVisible() }) test('N15:menu scroll keeps brand and footer fixed without scrolling the workspace', async ({ page, }) => { - await page.setViewportSize({ width: 1440, height: 420 }) + await page.setViewportSize({ width: 1440, height: 600 }) await seedEditor(page) - const navigation = page.getByRole('navigation', { name: '功能导航' }) + const navigation = await openDirectory(page) await expect( navigation.locator('.ant-menu-root > .ant-menu-item, .ant-menu-root > .ant-menu-submenu'), ).toHaveCount(7) - const footer = page.locator('.shell-sidebar-footer') + const footer = page.locator('.shell-rail-footer') const before = await footer.boundingBox() const pageScroll = await page.evaluate(() => window.scrollY) + await navigation.getByRole('menuitem', { name: '项目与接口', exact: true }).click() await navigation.evaluate((element) => { element.scrollTop = element.scrollHeight }) await expect(navigation.getByRole('menuitem', { name: '系统管理', exact: true })).toBeInViewport() - await expect(page.locator('.brand')).toBeInViewport() + await expect(page.locator('.shell-brand')).toBeInViewport() await expect(footer).toBeInViewport() const after = await footer.boundingBox() expect(after!.y).toBeCloseTo(before!.y, 0) @@ -236,3 +237,12 @@ test('N15:menu scroll keeps brand and footer fixed without scrolling the works expect(await page.evaluate(() => window.scrollY)).toBe(pageScroll) expect(await navigation.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) }) + +async function openDirectory(page: import('@playwright/test').Page) { + const trigger = page + .getByRole('button', { name: '全部模块', exact: true }) + .or(page.getByRole('button', { name: '打开导航', exact: true })) + await trigger.click() + await expect(page.getByRole('dialog', { name: '全部模块', exact: true })).toBeVisible() + return page.getByRole('navigation', { name: '功能导航' }) +} diff --git a/frontend/e2e/s15-test-assets.spec.ts b/frontend/e2e/s15-test-assets.spec.ts index 5cbb87b0..2a5774fa 100644 --- a/frontend/e2e/s15-test-assets.spec.ts +++ b/frontend/e2e/s15-test-assets.spec.ts @@ -304,11 +304,14 @@ async function createSuitePlan(page: Page, suiteName: string, planName: string) async function chooseOption(page: Page, select: Locator, optionName: string) { await expect(select).toBeEnabled() + const modal = page.locator('.ant-modal:visible').last() + if (await modal.count()) await expect(modal).not.toHaveClass(/ant-zoom-(?:appear|enter|leave)/) await select.click() if (await select.isEditable()) await select.fill(optionName) const dropdown = page.locator('.ant-select-dropdown:visible').last() const option = dropdown.getByText(optionName, { exact: true }) await expect(option).toBeVisible() + await expect(dropdown).not.toHaveClass(/ant-slide-up-(?:appear|enter|leave)/) await option.click() await expect(selectControl(select)).toContainText(optionName) } diff --git a/frontend/e2e/s16-advanced-workflow.spec.ts b/frontend/e2e/s16-advanced-workflow.spec.ts index 6c2776b8..3e366625 100644 --- a/frontend/e2e/s16-advanced-workflow.spec.ts +++ b/frontend/e2e/s16-advanced-workflow.spec.ts @@ -24,7 +24,13 @@ test('S16 子流程、ForEach、调试重放与画布编辑主路径', async ({ const listToggle = page.getByRole('button', { name: '切换工作流列表' }) if ((await listToggle.getAttribute('aria-expanded')) === 'false') await listToggle.click() await page.getByRole('button', { name: parentName, exact: true }).click() - await listToggle.click() + if ((await listToggle.getAttribute('aria-expanded')) === 'true') { + await page + .getByRole('dialog', { name: '工作流列表', exact: true }) + .getByRole('button', { name: /close|关闭/i }) + .click() + await expect(listToggle).toBeFocused() + } await expect(page.getByText('已发布 v2')).toBeVisible() await selectWorkflowEnvironment(page) @@ -34,13 +40,13 @@ test('S16 子流程、ForEach、调试重放与画布编辑主路径', async ({ }) async function selectWorkflowEnvironment(page: Page) { - await page.getByRole('button', { name: '工作流更多操作', exact: true }).click() const environment = page.getByRole('combobox', { name: '工作流环境' }) await expect(environment).toBeEnabled() await environment.click() const dropdown = page.locator('.ant-select-dropdown:visible').last() const firstOption = dropdown.locator('.ant-select-item-option').first() await expect(firstOption).toBeVisible() + await expect(dropdown).not.toHaveClass(/ant-slide-up-(?:appear|enter|leave)/) await firstOption.click() await expect(page.getByRole('button', { name: /运\s*行/ })).toBeEnabled() await page.keyboard.press('Escape') @@ -67,7 +73,7 @@ async function verifyCanvasEditing(page: Page) { } async function verifyVersionDiff(page: Page) { - await page.getByRole('button', { name: /更\s*多/ }).click() + await page.getByRole('button', { name: '工作流更多操作', exact: true }).click() await page.getByRole('button', { name: /版本 Diff/ }).click() const dialog = page.getByRole('dialog', { name: '工作流版本 Diff' }) await expect(dialog).toContainText('批量调用 v1') @@ -87,7 +93,7 @@ async function verifyExecutionDebugAndReplay(page: Page) { await expect(latestRun).toContainText('节点重放结果') await expect(latestRun).toContainText('passed') - await page.getByRole('button', { name: /更\s*多/ }).click() + await page.getByRole('button', { name: '工作流更多操作', exact: true }).click() await page.getByLabel('调试断点').click() await page.getByText('批量调用 v2', { exact: true }).last().click() await page.getByRole('button', { name: /调试至断点/ }).click() diff --git a/frontend/e2e/s21-ai-review.spec.ts b/frontend/e2e/s21-ai-review.spec.ts index 4cedde2d..847a726b 100644 --- a/frontend/e2e/s21-ai-review.spec.ts +++ b/frontend/e2e/s21-ai-review.spec.ts @@ -17,8 +17,12 @@ test('S21 AI 脱敏任务与人工接受主路径', async ({ page }) => { await page.getByRole('button', { name: /新建 AI 任务/ }).click() const createDialog = page.getByRole('dialog', { name: '新建 AI 建议任务' }) + await expect(createDialog).toBeVisible() + await expect(createDialog).not.toHaveClass(/ant-zoom.*-(appear|enter|leave)/) await createDialog.getByLabel('任务类型').click() - await page.getByText('Workflow 草稿', { exact: true }).click() + const options = page.locator('.ant-select-dropdown:visible') + await expect(options).not.toHaveClass(/ant-slide.*-(appear|enter|leave)/) + await options.getByText('Workflow 草稿', { exact: true }).click() const created = page.waitForResponse( (response) => response.url().endsWith('/api/v1/ai/jobs') && response.request().method() === 'POST', diff --git a/frontend/e2e/s23-multi-protocol.spec.ts b/frontend/e2e/s23-multi-protocol.spec.ts index bbb13aec..a21ea244 100644 --- a/frontend/e2e/s23-multi-protocol.spec.ts +++ b/frontend/e2e/s23-multi-protocol.spec.ts @@ -47,9 +47,16 @@ async function importGrpcReflection(page: import('@playwright/test').Page) { const name = `S23 gRPC ${Date.now()}` await page.getByRole('button', { name: /导入协议 Schema/ }).click() const dialog = page.getByRole('dialog', { name: '导入协议 Schema' }) + await expect(dialog).toBeVisible() + await expect(dialog).not.toHaveClass(/ant-zoom.*-(appear|enter|leave)/) await dialog.getByLabel('名称').fill(name) await dialog.getByLabel('导入格式').click() - await page.getByText('Server Reflection(TLS)', { exact: true }).click() + const dropdown = page.locator('.ant-select-dropdown:visible').last() + const reflection = dropdown.getByText('Server Reflection(TLS)', { exact: true }) + await expect(reflection).toBeVisible() + await expect(dropdown).not.toHaveClass(/ant-slide-up-(?:appear|enter|leave)/) + await reflection.click() + await expect(dialog.getByText('Reflection Endpoint', { exact: true })).toBeVisible() await dialog.getByLabel('Schema 内容').fill('grpc-target:50051') await dialog.getByText('明文', { exact: true }).click() const response = page.waitForResponse( diff --git a/frontend/e2e/s30-failure-intelligence.spec.ts b/frontend/e2e/s30-failure-intelligence.spec.ts index a105d1e9..1867a79c 100644 --- a/frontend/e2e/s30-failure-intelligence.spec.ts +++ b/frontend/e2e/s30-failure-intelligence.spec.ts @@ -15,12 +15,14 @@ test('S30 发布风险证据经 AI 变更集逐项审核后只生成草稿', asy await expect(page.getByRole('heading', { name: '质量中心' })).toBeVisible({ timeout: 15_000 }) await page.getByRole('button', { name: '分析发布风险' }).click() const riskDialog = page.getByRole('dialog', { name: '分析发布风险' }) + await expect(riskDialog).toBeVisible() + await expect(riskDialog).not.toHaveClass(/ant-zoom.*-(appear|enter|leave)/) await riskDialog.getByLabel('候选版本').fill('S30 浏览器 RC') await riskDialog.getByLabel('影响分析').click() - await page - .getByText(/S30 浏览器影响分析/) - .last() - .click() + const impactDropdown = page.locator('.ant-select-dropdown:visible').last() + await expect(impactDropdown.getByText(/S30 浏览器影响分析/)).toBeVisible() + await expect(impactDropdown).not.toHaveClass(/ant-slide-up-(?:appear|enter|leave)/) + await impactDropdown.getByText(/S30 浏览器影响分析/).click() const riskCreated = page.waitForResponse( (response) => response.url().endsWith('/release-risks') && response.request().method() === 'POST', @@ -36,12 +38,14 @@ test('S30 发布风险证据经 AI 变更集逐项审核后只生成草稿', asy await expect(page.getByText(/AI 只生成草稿/)).toBeVisible() await page.getByRole('button', { name: '生成 Draft Change Set' }).click() const changeSetDialog = page.getByRole('dialog', { name: '生成 Draft Change Set' }) + await expect(changeSetDialog).toBeVisible() + await expect(changeSetDialog).not.toHaveClass(/ant-zoom.*-(appear|enter|leave)/) await changeSetDialog.getByLabel('变更集名称').fill('S30 浏览器 Draft Change Set') await changeSetDialog.getByLabel('发布风险证据').click() - await page - .getByText(/S30 浏览器 RC/) - .last() - .click() + const riskDropdown = page.locator('.ant-select-dropdown:visible').last() + await expect(riskDropdown.getByText(/S30 浏览器 RC/)).toBeVisible() + await expect(riskDropdown).not.toHaveClass(/ant-slide-up-(?:appear|enter|leave)/) + await riskDropdown.getByText(/S30 浏览器 RC/).click() const changeSetCreated = page.waitForResponse( (response) => response.url().endsWith('/api/v1/ai/change-sets') && response.request().method() === 'POST', diff --git a/frontend/e2e/s31-release-gate.spec.ts b/frontend/e2e/s31-release-gate.spec.ts index 5efb6760..f5a359a6 100644 --- a/frontend/e2e/s31-release-gate.spec.ts +++ b/frontend/e2e/s31-release-gate.spec.ts @@ -1,4 +1,5 @@ import { expect, test, type APIRequestContext } from '@playwright/test' +import { navigateMenu } from './support/navigation' type Identified = { id: string } @@ -28,9 +29,10 @@ test('S31 全局搜索进入发布门禁并生成不可变 PASS 判断', async ( await dialog.locator('.ant-modal-footer .ant-btn-primary').click() expect((await created).status()).toBe(201) - await expect(page.getByText('v3.0.0-rc.browser')).toBeVisible() + const decisionRow = page.getByRole('row').filter({ hasText: 'v3.0.0-rc.browser' }) + await expect(decisionRow).toBeVisible() await expect(page.getByText('PASS').last()).toBeVisible() - await page.getByRole('button', { name: '查看证据' }).click() + await decisionRow.getByRole('button', { name: '查看证据' }).click() const detail = page.getByRole('dialog', { name: '发布判断证据' }) await expect(detail.getByText(/历史判断只读/)).toBeVisible() await expect(detail.getByText('QUALITY_GATE_EVIDENCE_MISSING_OPTIONAL')).toBeVisible() @@ -114,14 +116,11 @@ test('S31 质量指挥中心展示真实历史判断并保持证据深链', asyn 'href', `/projects/${project.id}/impact`, ) - await page - .getByRole('navigation', { name: '功能导航' }) - .getByRole('menuitem', { name: '质量分析', exact: true }) - .click() - await expect(page.getByRole('link', { name: '发布门禁' }).first()).toHaveAttribute( - 'href', - `/projects/${project.id}/release`, - ) + await expect( + page.getByRole('navigation', { name: '常用任务' }).getByRole('link', { name: '发布门禁' }), + ).toHaveAttribute('href', `/projects/${project.id}/release`) + await navigateMenu(page, '发布门禁') + await expect(page).toHaveURL(new RegExp(`/projects/${project.id}/release$`)) }) async function accessTokenFromSession(request: APIRequestContext): Promise { diff --git a/frontend/e2e/s58-failure-repair.spec.ts b/frontend/e2e/s58-failure-repair.spec.ts index 5f419ee4..245304cd 100644 --- a/frontend/e2e/s58-failure-repair.spec.ts +++ b/frontend/e2e/s58-failure-repair.spec.ts @@ -53,6 +53,8 @@ test('S58 失败诊断创建受限 Repair Proposal 并完成 Re-preview', async await expect( page.getByRole('button', { name: `S58 Repair ${suffix}`, exact: true }), ).toBeVisible() + await page.keyboard.press('Escape') + await expect(listToggle).toHaveAttribute('aria-expanded', 'false') await page.getByRole('button', { name: '打开执行历史', exact: true }).click() await page.getByTestId('workflow-runtime-tab-history').click() await expect(page.getByRole('button', { name: '失败诊断' })).toBeVisible() diff --git a/frontend/e2e/support/auth.ts b/frontend/e2e/support/auth.ts index 6ca31ccd..55148f38 100644 --- a/frontend/e2e/support/auth.ts +++ b/frontend/e2e/support/auth.ts @@ -8,7 +8,15 @@ export const authenticationStatePath = '.playwright/.auth/administrator.json' export async function authenticate(page: Page): Promise { const authenticatedShell = page.getByRole('button', { name: '退出' }) const login = page.getByRole('heading', { name: '登录账号' }) - await expect(authenticatedShell.or(login)).toBeVisible() + const reconnect = page.getByRole('button', { name: '重试连接' }) + await expect(authenticatedShell.or(login).or(reconnect)).toBeVisible() + if (await reconnect.isVisible()) { + // Saved setup cookies can already have rotated in a previous test context. + // Start a new session through the normal login UI instead of reusing them. + await page.context().clearCookies() + await page.reload() + await expect(login).toBeVisible() + } if (await authenticatedShell.isVisible()) return await page.getByLabel('账号').fill(administratorEmail) diff --git a/frontend/e2e/support/navigation.ts b/frontend/e2e/support/navigation.ts index 516a53e4..f0f83c75 100644 --- a/frontend/e2e/support/navigation.ts +++ b/frontend/e2e/support/navigation.ts @@ -11,8 +11,12 @@ const groups: Record = { /** Navigate through the same visible parent/leaf controls as a person. */ export async function navigateMenu(page: Page, label: string): Promise { - const trigger = page.getByRole('button', { name: '打开导航', exact: true }) - if (await trigger.isVisible()) await trigger.click() + const trigger = page + .getByRole('button', { name: '打开导航', exact: true }) + .or(page.getByRole('button', { name: '全部模块', exact: true })) + if ((await trigger.isVisible()) && (await trigger.getAttribute('aria-expanded')) !== 'true') { + await trigger.click() + } const navigation = page.getByRole('navigation', { name: '功能导航' }) const group = Object.entries(groups).find(([, leaves]) => leaves.includes(label))?.[0] if (group) { diff --git a/frontend/e2e/support/workflow-editor.ts b/frontend/e2e/support/workflow-editor.ts index 3384f1fc..35c4aa7a 100644 --- a/frontend/e2e/support/workflow-editor.ts +++ b/frontend/e2e/support/workflow-editor.ts @@ -33,6 +33,7 @@ export async function seedEditor( } const project = await create('/projects', { name: `流程编辑验收 ${Date.now()}` }) const root = `/projects/${project.id}` + const mockTargetUrl = process.env.FLOWTEST_E2E_MOCK_TARGET_URL ?? 'http://mock-target:8080' await create( `${root}/security-policy`, { allowed_hosts: ['mock-target'], allowed_private_cidrs: ['172.16.0.0/12'] }, @@ -40,7 +41,7 @@ export async function seedEditor( ) const environment = await create(`${root}/environments`, { name: '验收环境', - base_url: 'http://mock-target:8080', + base_url: mockTargetUrl, classification: 'test', }) const service = await create(`${root}/services`, { @@ -49,7 +50,7 @@ export async function seedEditor( }) await create(`${root}/environments/${environment.id}/service-endpoints`, { service_id: service.id, - base_url: 'http://mock-target:8080', + base_url: mockTargetUrl, }) const api = await create(`${root}/apis`, { name: '验收健康接口', diff --git a/frontend/e2e/v1-acceptance.spec.ts b/frontend/e2e/v1-acceptance.spec.ts index 910f8ad4..f4ee5ef5 100644 --- a/frontend/e2e/v1-acceptance.spec.ts +++ b/frontend/e2e/v1-acceptance.spec.ts @@ -55,13 +55,13 @@ test('V1.0 项目治理与脱敏报告主路径', async ({ page }) => { const businessExecution = page.getByRole('row', { name: /V1 登录下单流程/ }) await expect(businessExecution).toContainText('8/8 通过') await businessExecution.getByRole('button', { name: '详情' }).click() - const report = page.getByRole('dialog', { name: '执行报告详情' }) + const report = page.getByRole('region', { name: '执行报告详情' }) await expect(report).toContainText('V1 登录下单流程') await expect(report).toContainText('登录') await expect(report).toContainText('查询用户') await expect(report).toContainText('创建订单') await expect(page.locator('body')).not.toContainText('mock-token') - await page.keyboard.press('Escape') + await report.getByRole('button', { name: '返回执行列表', exact: true }).click() await expect(report).toBeHidden() for (const [menu, heading] of [ diff --git a/frontend/e2e/workflow-editor-audit.spec.ts b/frontend/e2e/workflow-editor-audit.spec.ts index 9130b465..7e372083 100644 --- a/frontend/e2e/workflow-editor-audit.spec.ts +++ b/frontend/e2e/workflow-editor-audit.spec.ts @@ -166,7 +166,10 @@ test('F02:A v12 未应用请求的取消、恢复与确认切换到 B v2', asy }) async function chooseApiB(page: Page) { await page.locator('.workflow-inspector .config-target .ant-select').first().click() - await page.getByTitle('候选接口 B', { exact: true }).click() + await page + .locator('.ant-select-dropdown:visible') + .getByTitle('候选接口 B', { exact: true }) + .click() } test('F03:80 字符映射编辑、第二行编辑、删除各自完整撤销', async ({ page }) => { await seedEditor(page) diff --git a/frontend/e2e/workflow-editor-interactions.spec.ts b/frontend/e2e/workflow-editor-interactions.spec.ts index c1201c55..bafd6ada 100644 --- a/frontend/e2e/workflow-editor-interactions.spec.ts +++ b/frontend/e2e/workflow-editor-interactions.spec.ts @@ -74,14 +74,12 @@ test('HIST/KEY/PAL:拖动单事务、输入焦点保护和节点库新增', as await page.getByRole('button', { name: '应用节点配置', exact: true }).click() await expect(page.getByText('节点内容已从外部更新', { exact: false })).toHaveCount(0) await closeInspector(page) - await page.getByRole('button', { name: 'plus 添加节点', exact: true }).click() - await page.getByRole('button', { name: '基础', exact: true }).click() - await page - .locator('.workflow-library-card') + const library = page.getByRole('complementary', { name: '节点库', exact: true }) + await library + .locator('.workflow-library-row') .filter({ hasText: '等待' }) - .dragTo(page.getByLabel('工作流画布', { exact: true }), { targetPosition: { x: 180, y: 140 } }) + .dragTo(page.locator('.react-flow__pane'), { targetPosition: { x: 180, y: 140 } }) await expect(page.locator('.react-flow__node')).toHaveCount(4) - await page.getByRole('button', { name: '返回画布', exact: true }).click() await page.getByRole('button', { name: 'undo 撤销', exact: true }).click() await expect(page.locator('.react-flow__node')).toHaveCount(3) }) diff --git a/frontend/e2e/workflow-editor-layout.spec.ts b/frontend/e2e/workflow-editor-layout.spec.ts index 54606a50..90ab23a7 100644 --- a/frontend/e2e/workflow-editor-layout.spec.ts +++ b/frontend/e2e/workflow-editor-layout.spec.ts @@ -2,9 +2,9 @@ import { expect, test, type Locator, type Page } from '@playwright/test' import { seedEditor } from './support/workflow-editor' const viewports = [ - { width: 1280, height: 800, minimumCanvasHeight: 460, listVisible: false }, - { width: 1440, height: 900, minimumCanvasHeight: 600, listVisible: true }, - { width: 1920, height: 1080, minimumCanvasHeight: 720, listVisible: true }, + { width: 1280, height: 800, minimumCanvasHeight: 460 }, + { width: 1440, height: 900, minimumCanvasHeight: 600 }, + { width: 1920, height: 1080, minimumCanvasHeight: 720 }, ] for (const viewport of viewports) { @@ -28,20 +28,21 @@ for (const viewport of viewports) { page.getByRole('button', { name: '切换工作流列表', exact: true }), ) expect(toggle.x + toggle.width + 6).toBeLessThanOrEqual(title.x) - expect(title.x + title.width).toBeLessThanOrEqual(modes.x) + expect(title.y + title.height).toBeLessThanOrEqual(modes.y + 1) + expect(title.x + title.width).toBeLessThanOrEqual(actions.x) expect(modes.x + modes.width).toBeLessThanOrEqual(actions.x) expect((await requiredBox(canvas)).height).toBeGreaterThanOrEqual(viewport.minimumCanvasHeight) expect((await requiredBox(toolbar)).height).toBeLessThanOrEqual(56) await expectPageWithoutOverflow(page, viewport) - if (viewport.listVisible) { - await expect(list).toBeVisible() - const listWidth = (await requiredBox(list)).width - expect(listWidth).toBeGreaterThanOrEqual(240) - expect(listWidth).toBeLessThanOrEqual(280) - } else { - await expect(list).toBeHidden() - } + await expect(list).toBeHidden() + await page.getByRole('button', { name: '切换工作流列表', exact: true }).click() + await expect(list).toBeVisible() + const listWidth = (await requiredBox(list)).width + expect(listWidth).toBeGreaterThanOrEqual(280) + expect(listWidth).toBeLessThanOrEqual(400) + await page.keyboard.press('Escape') + await expect(list).toBeHidden() await page.screenshot({ animations: 'disabled', path: testInfo.outputPath('default-edit.png') }) @@ -63,6 +64,18 @@ for (const viewport of viewports) { expect(canvasShare).toBeGreaterThanOrEqual(0.52) expect((await requiredBox(inspector)).width).toBeGreaterThanOrEqual(320) + await page.getByRole('button', { name: '最大化配置', exact: true }).click() + const fullscreen = page.getByRole('dialog', { name: '节点与连线配置', exact: true }) + await expect(fullscreen).toBeVisible() + expect((await requiredBox(fullscreen.locator('.config-target'))).width).toBeGreaterThanOrEqual( + 250, + ) + expect((await requiredBox(fullscreen.locator('.config-request'))).width).toBeGreaterThanOrEqual( + (await requiredBox(fullscreen.locator('.workflow-node-fields'))).width * 0.6, + ) + await expect(fullscreen.getByRole('tab', { name: 'Params', exact: true })).toBeVisible() + await page.getByRole('button', { name: '还原配置', exact: true }).click() + await page.getByRole('button', { name: '专注模式', exact: true }).click() const focusToolbar = page.getByTestId('workflow-focus-toolbar') await expect(focusToolbar).toBeVisible() @@ -83,7 +96,7 @@ for (const viewport of viewports) { } await expect( focusToolbar.getByRole('button', { name: '运行已发布版本', exact: true }), - ).toHaveCSS('color', 'rgb(71, 84, 103)') + ).toBeDisabled() await page.screenshot({ animations: 'disabled', path: testInfo.outputPath('focus.png') }) await focusToolbar.getByRole('button', { name: 'plus 添加节点', exact: true }).click() diff --git a/frontend/e2e/workflow-editor-visual.spec.ts b/frontend/e2e/workflow-editor-visual.spec.ts index 574a348a..26d17c1c 100644 --- a/frontend/e2e/workflow-editor-visual.spec.ts +++ b/frontend/e2e/workflow-editor-visual.spec.ts @@ -22,6 +22,9 @@ for (const viewport of viewports) { test.setTimeout(90_000) await page.setViewportSize(viewport) await seedEditor(page) + await page.evaluate(() => document.fonts.ready) + await settleViewport(page) + await fitCanvas(page) const directory = resolve(matrixRoot, `${viewport.width}x${viewport.height}`) mkdirSync(directory, { recursive: true }) @@ -54,11 +57,8 @@ for (const viewport of viewports) { await page.getByRole('button', { name: '专注模式', exact: true }).click() await expect(page.getByTestId('workflow-focus-toolbar')).toBeVisible() - await page - .getByTestId('workflow-focus-toolbar') - .getByRole('button', { name: /适应画布/ }) - .click() await settleViewport(page) + await fitCanvas(page) await captureState('07-focus-mode') await page.getByRole('button', { name: '退出专注模式', exact: true }).click() @@ -69,6 +69,9 @@ for (const viewport of viewports) { expect(runtimeHeight).toBeGreaterThanOrEqual(200) expect(runtimeHeight).toBeLessThanOrEqual(280) await expect(page.locator('.ant-message-notice')).toHaveCount(0, { timeout: 6_000 }) + await expect(page.locator('.flow-node.is-passed')).toHaveCount(3) + await settleViewport(page) + await fitCanvas(page) await captureState('08-run-mode') await page.getByTestId('workflow-runtime-tab-history').click() @@ -76,6 +79,8 @@ for (const viewport of viewports) { await expect(page.getByText('历史快照 · 不可修改')).toBeVisible() await expect(page.locator('.workflow-workbench-card .ant-card-loading-content')).toHaveCount(0) await expect(page.locator('.workflow-workbench-card .react-flow__node').first()).toBeVisible() + await settleViewport(page) + await fitCanvas(page) await captureState('09-history-mode') }) } @@ -88,6 +93,7 @@ async function capture( ): Promise { await expectNoOverflow(page) await page.evaluate(() => document.fonts.ready) + await settleViewport(page) const screenshot = resolve(directory, `${name}.png`) await page.screenshot({ animations: 'disabled', @@ -151,6 +157,7 @@ function visualFixtureMasks(page: Page, name: string): Locator[] { page.locator('.workflow-runtime-panel-heading .ant-typography-secondary'), page.locator('.workflow-meta .ant-tag').filter({ hasText: /^执行 / }), page.locator('.flow-node-status small'), + page.locator('.workflow-volatile-value'), ] if (name === '09-history-mode') masks.push( @@ -166,12 +173,51 @@ async function settleViewport(page: Page): Promise { let stableFrames = 0 while (stableFrames < 10) { await new Promise((resolve) => requestAnimationFrame(() => resolve())) - const current = document.querySelector('.react-flow__viewport')?.getAttribute('style') ?? '' + const current = JSON.stringify({ + viewport: document.querySelector('.react-flow__viewport')?.getAttribute('style'), + nodes: Array.from(document.querySelectorAll('.react-flow__node'), (element) => { + const { x, y, width, height } = element.getBoundingClientRect() + return { x, y, width, height } + }), + }) stableFrames = current === previous ? stableFrames + 1 : 0 previous = current } }) } +async function fitCanvas(page: Page): Promise { + const fit = page.getByRole('button', { name: /适应画布/ }) + await fit.click() + await settleViewport(page) + await expect + .poll(async () => { + const offset = await graphCenterOffset(page) + if (offset > 1) { + await fit.click() + await settleViewport(page) + } + return graphCenterOffset(page) + }) + .toBeLessThanOrEqual(1) +} +async function graphCenterOffset(page: Page): Promise { + return page.evaluate(() => { + const canvas = document.querySelector('.react-flow')!.getBoundingClientRect() + const nodes = Array.from(document.querySelectorAll('.react-flow__node'), (element) => + element.getBoundingClientRect(), + ) + const centerX = + (Math.min(...nodes.map((node) => node.left)) + Math.max(...nodes.map((node) => node.right))) / + 2 + const centerY = + (Math.min(...nodes.map((node) => node.top)) + Math.max(...nodes.map((node) => node.bottom))) / + 2 + return Math.max( + Math.abs(centerX - canvas.x - canvas.width / 2), + Math.abs(centerY - canvas.y - canvas.height / 2), + ) + }) +} async function measureWorkbench(page: Page) { return page.evaluate(() => { const ids = [ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png index c5221201..f9c2ae85 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-01-default-edit-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-02-add-node-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-02-add-node-chromium-linux.png index 2e470754..c58824ea 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-02-add-node-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-02-add-node-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png index 5ebf0783..4ba04b48 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-03-node-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png index 0e921747..77d686c0 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-04-edge-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png index 16bc033f..a11deea8 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-05-inspector-resized-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-06-fullscreen-config-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-06-fullscreen-config-chromium-linux.png index 10bb6054..b179ace8 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-06-fullscreen-config-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-06-fullscreen-config-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-07-focus-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-07-focus-mode-chromium-linux.png index 8e312d84..f8a9eafd 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-07-focus-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-07-focus-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png index 7984ce20..02b20a44 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-08-run-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png index ec08a67c..a0845086 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1280x800-09-history-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png index c38139a4..223cd44a 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-01-default-edit-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-02-add-node-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-02-add-node-chromium-linux.png index cccd7585..a62a941f 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-02-add-node-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-02-add-node-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png index cef8b514..b7eb87de 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-03-node-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png index 244ecdb3..887692a2 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-04-edge-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png index c580e5fa..8bb8ebe7 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-05-inspector-resized-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-06-fullscreen-config-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-06-fullscreen-config-chromium-linux.png index 5e99e7dc..a4b1926d 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-06-fullscreen-config-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-06-fullscreen-config-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-07-focus-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-07-focus-mode-chromium-linux.png index 1f03ae04..99f17aca 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-07-focus-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-07-focus-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png index 1ae93806..bb040233 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-08-run-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png index 7305bc44..6f8f74d5 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1440x900-09-history-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png index 07a7e558..ced7bc7f 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-01-default-edit-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-02-add-node-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-02-add-node-chromium-linux.png index ff9c8e1d..c89fd4d2 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-02-add-node-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-02-add-node-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png index ce7e4cfc..b8d211cb 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-03-node-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png index 1a6d2eec..6e47d753 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-04-edge-selected-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png index 18cfa270..a0c5919e 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-05-inspector-resized-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-06-fullscreen-config-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-06-fullscreen-config-chromium-linux.png index e26f330b..48b764ba 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-06-fullscreen-config-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-06-fullscreen-config-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-07-focus-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-07-focus-mode-chromium-linux.png index d079bc5a..d1cf62e1 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-07-focus-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-07-focus-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png index 618c2389..d64fd3b8 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-08-run-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png index 86f45263..64a21c44 100644 Binary files a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png and b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/1920x1080-09-history-mode-chromium-linux.png differ diff --git a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/README.md b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/README.md index 48770a47..9f611364 100644 --- a/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/README.md +++ b/frontend/e2e/workflow-editor-visual.spec.ts-snapshots/README.md @@ -1,6 +1,6 @@ # Workflow DOM 截图基线 -本目录保存三档尺寸 × 九种状态的 27 张真实 DOM 基线。概念设计图用于人工核对结构、层级与状态表达;这些 PNG 用于守住已经审阅的浏览器呈现,不代表 V2 全部设计验收通过。 +本目录保存三档尺寸 × 九种状态的 27 张真实 DOM 基线。2026-10-02 更新为 v5 冰蓝白工作区,包含节点库、两排 Header、检查器分区、最大化配置、运行轨迹和冻结历史。概念设计图用于人工核对结构、层级与状态表达;这些 PNG 用于守住已经审阅的浏览器呈现,不代表 v5 全部设计验收通过。 ## 更新与回归 @@ -11,8 +11,12 @@ 截图测试同时输出 geometry JSON,记录提交 SHA、前端工作区差异摘要、前端源码摘要、viewport、scenario 和浏览器版本。工作区未提交时,SHA 必须结合差异摘要阅读。测试只采集自身创建的合成验收项目。 -原始截图不遮罩。像素比较仅遮罩合成项目时间戳、执行 ID、执行时间及节点响应耗时;节点名称、成功/失败状态、按钮和布局保持可比较。专注模式检查点通过真实“适应画布”操作取景,产品不会因普通编辑自动取景。 +原始截图不遮罩。像素比较仅遮罩合成项目时间戳、执行 ID、执行时间及节点响应耗时;节点名称、成功/失败状态、按钮和布局保持可比较。字体、节点尺寸和视口稳定后才采集截图。默认、专注、运行与历史检查点通过真实“适应画布”操作取景,产品不会因普通编辑自动取景。 -像素差异比例上限为 0.003;另有独立几何断言守住画布高度、列表宽度、画布占比、工具栏高度、Header 不重叠与页面无横向溢出。macOS 可采集原始截图,但相对 Linux 基线的像素检查标记为 NOT RUN。 +像素差异比例上限为 0.003;另有独立几何断言守住画布高度、列表抽屉宽度、画布占比、工具栏高度、两排 Header 不重叠、最大化请求表单宽度与页面无横向溢出。macOS 可采集原始截图,但相对 Linux 基线的像素检查标记为 NOT RUN。 + +本次在官方 Playwright Linux arm64 镜像中,使用构建后的真实前端、当前主干的 API/Worker,以及本任务独立 Compose 项目的 PostgreSQL、Redis、MinIO 和 mock-target 生成基线。API/Worker/浏览器使用临时 BuildKit 进程启动,不中断用户已有实例;这项本地验证不代替 GitHub 的完整 Compose 验收。全部 27 张截图已复核;普通模式的像素检查结果见 v5 实施记录。 本轮修复及验收边界见 [审计整改记录](../../../docs/workflow-editor-codeplan/audit-20260913-resolution.md)。 + +v5 的当前交付范围与剩余验收见 [实施记录](../../../docs/design/ui-v5/implementation.md)。 diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 39926dfa..7a9a5bc7 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -37,7 +37,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: '项目管理', @@ -138,7 +139,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: '质量总览' })) @@ -286,11 +286,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 e0c9e864..14559dd6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -32,6 +32,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')) @@ -146,10 +147,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..ceeb4dfa --- /dev/null +++ b/frontend/src/features/test-assets/AssetRunEvidence.tsx @@ -0,0 +1,177 @@ +import { Alert, Button, Empty, Space, Table, Tag, Typography } from 'antd' +import { Link } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +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' +import { listLatestTestCaseRuns } from './test-asset-service' + +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 ( +
+ {kind === 'case' && } + + {!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 LatestCaseRunEvidence({ projectId, id }: { projectId: string; id: string }) { + const latest = useQuery({ + queryKey: ['test-case-runs', projectId, id], + queryFn: () => listLatestTestCaseRuns(projectId, [id]), + refetchInterval: (query) => + query.state.data?.some((run) => ['queued', 'running'].includes(run.status)) ? 2000 : false, + }) + const run = latest.data?.[0] + if (latest.error) { + return ( + + ) + } + if (!run) + return ( + + {latest.isPending ? '读取最近运行中' : '暂无用例运行记录'} + + ) + const location = { executionId: run.execution_id, workflowId: run.workflow_id } + return ( +
+ 最近用例运行 + + + + 用例 v{run.case_version} · 流程 v{run.workflow_version} + + {run.source === 'direct' ? '直接运行' : '测试计划'} + 报告 + 编排回看 + +
+ ) +} + +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..6cdb32dc --- /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, 100)) +} + +export function listSuiteCatalog(projectId: string, search: string, tag: string) { + return collectPages((page) => listTestSuites(projectId, search, tag, page, 100)) +} + +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 7c723603..3cbc225e 100644 --- a/frontend/src/features/test-assets/test-asset-service.ts +++ b/frontend/src/features/test-assets/test-asset-service.ts @@ -185,9 +185,10 @@ export async function listTestSuites( search: string, tag: string, page = 1, + pageSize = 20, ): Promise> { const response = await apiClient.get>(`/projects/${projectId}/test-suites`, { - params: { page, page_size: 20, search: search || undefined, tag: tag || undefined }, + params: { page, page_size: pageSize, 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 a34c6ba6..0180b03b 100644 --- a/frontend/src/features/test-assets/test-asset-view-model.ts +++ b/frontend/src/features/test-assets/test-asset-view-model.ts @@ -107,17 +107,25 @@ export function suiteInput( tags: values.tags ?? [], items: values.caseIds.map((caseId) => ({ test_case_id: caseId, - test_case_version: - values.memberVersions && Object.hasOwn(values.memberVersions, caseId) - ? values.memberVersions[caseId] - : (previous?.draft_definition.items.find((item) => item.test_case_id === caseId) - ?.test_case_version ?? - cases.find((item) => item.id === caseId)?.current_version ?? - null), + test_case_version: selectedMemberVersion(caseId, values, previous, cases), })), } } +function selectedMemberVersion( + caseId: string, + values: SuiteFormValues, + previous: TestSuite | undefined, + cases: TestCase[], +): number | null { + if (values.memberVersions && Object.hasOwn(values.memberVersions, caseId)) { + return values.memberVersions[caseId] + } + const saved = previous?.draft_definition.items.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 a05b7140..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,10 +16,7 @@ import { createTestSuite, diffTestCaseVersions, diffTestSuiteVersions, - listTestCases, - listLatestTestCaseRuns, listTestCaseVersions, - listTestSuites, listTestSuiteVersions, moveTestCases, moveTestSuites, @@ -31,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 = { @@ -53,10 +54,12 @@ describe('useTestAssets', () => { beforeEach(() => { vi.resetAllMocks() vi.mocked(useProjectContext).mockReturnValue({ projectId: 'project-1' } as never) - vi.mocked(listTestCases).mockResolvedValue(page([testCase])) - vi.mocked(listLatestTestCaseRuns).mockResolvedValue([]) - 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) @@ -81,9 +84,10 @@ describe('useTestAssets', () => { result.current.setSearch('登录') result.current.setTag('smoke') }) - await waitFor(() => expect(listTestCases).toHaveBeenCalledWith('project-1', '登录', 'smoke', 1)) + 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 647b418e..60e4266d 100644 --- a/frontend/src/features/test-assets/use-test-assets.ts +++ b/frontend/src/features/test-assets/use-test-assets.ts @@ -1,10 +1,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useCallback, useState } from 'react' +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 type { TestCase, TestCaseRun, TestSuite, VersionDiff } from '../../lib/api' +import { getProjectPermission } from '../projects/project-service' +import type { TestCase, TestSuite, VersionDiff } from '../../lib/api' import { cloneTestCase, cloneTestSuite, @@ -12,10 +13,7 @@ import { createTestSuite, diffTestCaseVersions, diffTestSuiteVersions, - listTestCases, - listLatestTestCaseRuns, listTestCaseVersions, - listTestSuites, listTestSuiteVersions, moveTestCases, moveTestSuites, @@ -28,56 +26,28 @@ import { updateTestCase, updateTestSuite, } from './test-asset-service' +import { listAssetWorkflows, listCaseCatalog, listSuiteCatalog } from './asset-workspace-service' export function useTestAssets() { const queryClient = useQueryClient() const { projectId } = useProjectContext() const [search, setSearch] = useState('') const [tag, setTag] = useState('') - const [casePage, setCasePage] = useState(1) - const [suitePage, setSuitePage] = useState(1) const [diff, setDiff] = useState(null) - const updateSearch = useCallback((value: string) => { - setSearch(value) - setCasePage(1) - setSuitePage(1) - }, []) - const updateTag = useCallback((value: string) => { - setTag(value) - setCasePage(1) - setSuitePage(1) - }, []) const enabled = Boolean(projectId) const cases = useQuery({ - queryKey: ['test-cases', projectId, search, tag, casePage], - queryFn: () => listTestCases(projectId!, search, tag, casePage), + queryKey: ['test-cases', projectId, search, tag], + queryFn: () => listCaseCatalog(projectId!, search, tag), enabled, }) - const suiteCaseOptions = useQuery({ - queryKey: ['test-cases', projectId, 'suite-options'], - queryFn: () => listTestCases(projectId!, '', '', 1, 100), - enabled, - }) - const caseIds = cases.data?.items.map((item) => item.id) ?? [] - const latestRuns = useQuery({ - queryKey: ['test-case-runs', projectId, caseIds.join('|')], - queryFn: () => listLatestTestCaseRuns(projectId!, caseIds), - enabled: enabled && cases.isSuccess, - refetchInterval: (query) => - (query.state.data as TestCaseRun[] | undefined)?.some( - (run) => run.status === 'queued' || run.status === 'running', - ) - ? 2000 - : false, - }) const suites = useQuery({ - queryKey: ['test-suites', projectId, search, tag, suitePage], - queryFn: () => listTestSuites(projectId!, search, tag, suitePage), + queryKey: ['test-suites', 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({ @@ -90,11 +60,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] }), ]) } @@ -160,19 +143,17 @@ export function useTestAssets() { projectId, search, tag, - setSearch: updateSearch, - setTag: updateTag, - casePage, - suitePage, - setCasePage, - setSuitePage, + setSearch, + setTag, cases, - suiteCaseOptions, - latestRuns, suites, 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/FlowProposalReviewDialog.test.tsx b/frontend/src/features/workflows/FlowProposalReviewDialog.test.tsx index a7bc00ae..b581ee0d 100644 --- a/frontend/src/features/workflows/FlowProposalReviewDialog.test.tsx +++ b/frontend/src/features/workflows/FlowProposalReviewDialog.test.tsx @@ -158,11 +158,17 @@ describe('FlowProposalReviewDialog', () => { expect(within(dialog).getByText('断言差异')).toBeInTheDocument() expect(within(dialog).getByText('证据 / 置信度')).toBeInTheDocument() expect(within(dialog).getByText('未决项 0')).toBeInTheDocument() - expect(within(dialog).queryByRole('button', { name: '发布版本' })).not.toBeInTheDocument() - expect(within(dialog).queryByRole('button', { name: '运行' })).not.toBeInTheDocument() + expect( + within(dialog).queryByRole('button', { name: '发布版本', hidden: true }), + ).not.toBeInTheDocument() + expect( + within(dialog).queryByRole('button', { name: '运行', hidden: true }), + ).not.toBeInTheDocument() - expect(within(dialog).getByRole('button', { name: '应用到工作流草稿' })).toBeDisabled() - await browser.click(within(dialog).getByRole('button', { name: '接受' })) + expect(within(dialog).getByLabelText('应用到工作流草稿')).toBeDisabled() + expect(applyCalls).toBe(0) + expect(appliedWorkflowId).toBe('') + await browser.click(within(dialog).getByLabelText('接受')) await waitFor(() => expect(within(dialog).getByLabelText('应用到工作流草稿')).not.toBeDisabled(), ) 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..4cb0505b 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' }, @@ -482,10 +522,10 @@ describe('WorkflowDesigner', () => { expect(copied.nodes.at(-1)?.bindings).not.toBe(grpcNode?.bindings) }) - it('edits protocol capability configuration, mTLS, and upstream bindings', async () => { + it('edits GraphQL capability configuration and upstream bindings', async () => { const browser = userEvent.setup() const graphqlDefinition = protocolGraph('graphql') - const graphqlView = render( + render( { fireEvent.click(screen.getByTestId('rf__node-graphql-4')) expect(screen.getByDisplayValue('https://api.example.com/graphql')).toBeVisible() await browser.clear(screen.getByLabelText('GraphQL Endpoint')) - await browser.type(screen.getByLabelText('GraphQL Endpoint'), 'https://graphql.example.com') + await browser.paste('https://graphql.example.com') fireEvent.blur(screen.getByLabelText('Variables(JSON)'), { target: { value: '{"id":"initial"}' }, }) await browser.click(screen.getByRole('button', { name: 'plus 添加' })) expect(screen.getByLabelText('Capability 绑定源')).toHaveValue('node_outputs.api.body') - await browser.type(screen.getByLabelText('Capability 绑定源'), '.id') + await browser.click(screen.getByLabelText('Capability 绑定源')) + await browser.paste('.id') await browser.clear(screen.getByLabelText('Capability 绑定目标')) - await browser.type(screen.getByLabelText('Capability 绑定目标'), 'variables.id') - graphqlView.unmount() + await browser.paste('variables.id') + expect(screen.getByLabelText('GraphQL Endpoint')).toHaveValue('https://graphql.example.com') + expect(screen.getByLabelText('Capability 绑定源')).toHaveValue('node_outputs.api.body.id') + expect(screen.getByLabelText('Capability 绑定目标')).toHaveValue('variables.id') + }) + it('configures gRPC mTLS and removes upstream bindings', async () => { + const browser = userEvent.setup() render( { 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..cf555eb5 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 +
+
+ void clearSelection()} + canvas={ +
{ + if (canvasEditable) { + event.preventDefault() + event.dataTransfer.dropEffect = 'copy' + } }} - 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() - }} - onNodeContextMenu={(event, node) => { - event.preventDefault() - void openObjectMenu('node', node.id, event) - }} - 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, - ) + onKeyDown={hotkeys} + onBlur={(event) => { + if (!event.currentTarget.contains(event.relatedTarget)) editor.cancelDrag() }} - 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 @@ -2276,19 +2571,27 @@ function useCanvasAutoFrame( }, [canvasRef, flowRef, scope]) } -function keepSelectionVisible( - instance: ReactFlowInstance, +type SelectionViewport = Pick< + ReactFlowInstance, + 'getViewport' | 'setCenter' +> & { + getNode: (id: string) => Pick | undefined +} + +// eslint-disable-next-line react-refresh/only-export-components +export function keepSelectionVisible( + instance: SelectionViewport, definition: WorkflowDefinition, selection: WorkflowSelection, size: { width: number; height: number }, toolbar: { width: number; height: number }, ): void { - const center = selectionCenter(definition, selection) + const center = selectionCenter(instance, definition, selection) if (!center) return const viewport = instance.getViewport() const x = center.x * viewport.zoom + viewport.x const y = center.y * viewport.zoom + viewport.y - const extent = selectionViewportExtent(selection, viewport.zoom, toolbar) + const extent = selectionViewportExtent(instance, selection, viewport.zoom, toolbar) const insetX = Math.min(size.width / 2, extent.width + 24) const insetY = Math.min(size.height / 2, extent.height + 24) if (x >= insetX && x <= size.width - insetX && y >= insetY && y <= size.height - insetY) return @@ -2301,29 +2604,34 @@ function selectionToolbarSize(canvas: HTMLDivElement): { width: number; height: } function selectionViewportExtent( + instance: SelectionViewport, selection: WorkflowSelection, zoom: number, toolbar: { width: number; height: number }, ): { width: number; height: number } { - if (selection?.kind === 'node') + if (selection?.kind === 'node') { + const size = canvasNodeSize(instance, selection.id) return { - width: Math.max((NODE_INITIAL_WIDTH * zoom) / 2, toolbar.width / 2), - height: (NODE_INITIAL_HEIGHT * zoom) / 2 + toolbar.height + 12, + width: Math.max((size.width * zoom) / 2, toolbar.width / 2), + height: (size.height * zoom) / 2 + toolbar.height + 12, } + } return { width: toolbar.width / 2, height: toolbar.height / 2 + 44 } } function selectionCenter( + instance: SelectionViewport, definition: WorkflowDefinition, selection: WorkflowSelection, ): { x: number; y: number } | null { if (!selection) return null if (selection.kind === 'node') { const node = definition.nodes.find((candidate) => candidate.id === selection.id) + const size = canvasNodeSize(instance, selection.id) return node ? { - x: node.position.x + NODE_INITIAL_WIDTH / 2, - y: node.position.y + NODE_INITIAL_HEIGHT / 2, + x: node.position.x + size.width / 2, + y: node.position.y + size.height / 2, } : null } @@ -2331,9 +2639,22 @@ function selectionCenter( const source = definition.nodes.find((candidate) => candidate.id === edge?.source) const target = definition.nodes.find((candidate) => candidate.id === edge?.target) if (!source || !target) return null + const sourceSize = canvasNodeSize(instance, source.id) + const targetSize = canvasNodeSize(instance, target.id) + return { + x: (source.position.x + sourceSize.width / 2 + target.position.x + targetSize.width / 2) / 2, + y: (source.position.y + sourceSize.height / 2 + target.position.y + targetSize.height / 2) / 2, + } +} + +function canvasNodeSize( + instance: SelectionViewport, + id: string, +): { width: number; height: number } { + const node = instance.getNode(id) ?? {} return { - x: (source.position.x + target.position.x + NODE_INITIAL_WIDTH) / 2, - y: (source.position.y + target.position.y + NODE_INITIAL_HEIGHT) / 2, + width: node.measured?.width ?? node.width ?? NODE_INITIAL_WIDTH, + height: node.measured?.height ?? node.height ?? NODE_INITIAL_HEIGHT, } } function editorUserId(store: { user: { id: string } | null }): string { 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..2d409b52 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,71 @@ 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 (
{ + it.each([undefined, null])('does not infer a branch when condition is %s', (condition) => { + const locate = vi.fn() + // Exercise the HTTP shape with an omitted property as well as explicit null. + const serializedDefinition: WorkflowDefinition = JSON.parse( + JSON.stringify({ ...definition, edges: [{ ...definition.edges[0]!, condition }] }), + ) + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /^结束$/ })) + expect(locate).toHaveBeenCalledWith('target') + expect(screen.queryByText(/条件.*分支/)).not.toBeInTheDocument() + }) + + it.each([ + ['true', '条件成立分支'], + ['false', '条件不成立分支'], + ] as const)('labels the explicit %s branch from the definition', (condition, label) => { + render( + , + ) + expect(screen.getByRole('button', { name: `结束 · ${label}` })).toBeVisible() + }) +}) diff --git a/frontend/src/flow/WorkflowNodeRelations.tsx b/frontend/src/flow/WorkflowNodeRelations.tsx new file mode 100644 index 00000000..00f24276 --- /dev/null +++ b/frontend/src/flow/WorkflowNodeRelations.tsx @@ -0,0 +1,75 @@ +import { NodeIndexOutlined } from '@ant-design/icons' +import { Button, Empty, Space, Typography } from 'antd' +import type { WorkflowDefinition, WorkflowNode } from '../lib/api' +import { dataReferences } from './editor/node-presentation' +import { effectiveConfig } from './editor/graph-analysis' + +export default function WorkflowNodeRelations({ + node, + definition, + onLocateNode, +}: { + node: WorkflowNode + definition: WorkflowDefinition + onLocateNode?: (nodeId: string) => 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..bc9bf158 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,12 @@ 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..0a80bbf2 --- /dev/null +++ b/frontend/src/flow/WorkflowRunTrajectory.tsx @@ -0,0 +1,141 @@ +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/WorkflowViewport.test.ts b/frontend/src/flow/WorkflowViewport.test.ts new file mode 100644 index 00000000..66a038b1 --- /dev/null +++ b/frontend/src/flow/WorkflowViewport.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from 'vitest' + +import { keepSelectionVisible } from './WorkflowDesigner' +import { workflowDefinition } from '../test/fixtures' + +type SelectionViewport = Parameters[0] + +function viewport() { + return { + getNode: vi.fn(() => undefined), + getViewport: vi.fn(() => ({ x: 0, y: 0, zoom: 1 })), + setCenter: vi.fn(async () => true), + } +} + +describe('workflow selection viewport', () => { + it('centers an offscreen node with its initial dimensions before measurement', () => { + const instance = viewport() + const node = workflowDefinition.nodes[1] + keepSelectionVisible( + instance, + workflowDefinition, + { kind: 'node', id: node.id }, + { width: 50, height: 50 }, + { width: 0, height: 0 }, + ) + expect(instance.setCenter).toHaveBeenCalledWith(node.position.x + 116, node.position.y + 55, { + duration: 0, + zoom: 1, + }) + }) + + it('leaves the viewport unchanged when the node and toolbar already fit', () => { + const instance = viewport() + instance.getViewport.mockReturnValue({ x: 200, y: 200, zoom: 1 }) + keepSelectionVisible( + instance, + workflowDefinition, + { kind: 'node', id: workflowDefinition.nodes[1].id }, + { width: 1000, height: 800 }, + { width: 240, height: 36 }, + ) + expect(instance.setCenter).not.toHaveBeenCalled() + }) + + it('ignores a missing selection or an edge with missing endpoints', () => { + const instance = viewport() + for (const selection of [null, { kind: 'edge' as const, id: 'missing' }]) { + keepSelectionVisible( + instance, + workflowDefinition, + selection, + { width: 50, height: 50 }, + { width: 0, height: 0 }, + ) + } + expect(instance.setCenter).not.toHaveBeenCalled() + }) + + it('centers an offscreen edge between its endpoint centers', () => { + const instance = viewport() + const edge = workflowDefinition.edges[0] + const source = workflowDefinition.nodes.find((node) => node.id === edge.source)! + const target = workflowDefinition.nodes.find((node) => node.id === edge.target)! + keepSelectionVisible( + instance, + workflowDefinition, + { kind: 'edge', id: edge.id }, + { width: 50, height: 50 }, + { width: 0, height: 0 }, + ) + expect(instance.setCenter).toHaveBeenCalledWith( + (source.position.x + target.position.x) / 2 + 116, + (source.position.y + target.position.y) / 2 + 55, + { duration: 0, zoom: 1 }, + ) + }) + + it('keeps a measured runtime node centered after fitting a short canvas', () => { + const instance = viewport() + instance.getNode.mockReturnValue({ measured: { width: 232, height: 125 } }) + instance.getViewport.mockReturnValue({ x: 194, y: 62.5, zoom: 1 }) + keepSelectionVisible( + instance, + workflowDefinition, + { kind: 'node', id: 'api' }, + { width: 820, height: 250 }, + { width: 400, height: 44 }, + ) + expect(instance.setCenter).not.toHaveBeenCalled() + }) + + it('includes the measured node height when checking toolbar clearance', () => { + const instance = viewport() + instance.getNode.mockReturnValue({ measured: { width: 300, height: 200 } }) + instance.getViewport.mockReturnValue({ x: 0, y: 420, zoom: 1 }) + keepSelectionVisible( + instance, + workflowDefinition, + { kind: 'node', id: 'api' }, + { width: 800, height: 600 }, + { width: 200, height: 40 }, + ) + expect(instance.setCenter).toHaveBeenCalledWith(250, 100, { duration: 0, zoom: 1 }) + }) + + it('centers edges using each endpoint size without changing stored positions', () => { + const instance = viewport() + instance.getNode.mockImplementation((id) => + id === 'start' ? { measured: { width: 200, height: 80 } } : { width: 300, height: 200 }, + ) + const before = structuredClone(workflowDefinition) + keepSelectionVisible( + instance, + workflowDefinition, + { kind: 'edge', id: 'start-api' }, + { width: 50, height: 50 }, + { width: 0, height: 0 }, + ) + expect(instance.setCenter).toHaveBeenCalledWith(175, 70, { duration: 0, zoom: 1 }) + expect(workflowDefinition).toEqual(before) + }) +}) 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..a9fef9b8 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 { @@ -748,31 +780,33 @@ .workflow-split-body.inspector-maximized .workflow-node-configuration.is-fullscreen > .workflow-config-section, -.workflow-split-body.inspector-maximized - .workflow-node-configuration-main - > .workflow-config-section, +.workflow-split-body.inspector-maximized .workflow-node-fields > .workflow-config-section, .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%); } -.workflow-split-body.inspector-maximized .workflow-node-configuration-main { +.workflow-split-body.inspector-maximized .workflow-node-fields:has(> .workflow-config-section) { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 16px; } -.workflow-split-body.inspector-maximized .workflow-node-configuration-main > .config-target, -.workflow-split-body.inspector-maximized .workflow-node-configuration-main > .config-policy { +.workflow-split-body.inspector-maximized .workflow-node-fields > * { + grid-column: 1 / -1; +} + +.workflow-split-body.inspector-maximized .workflow-node-fields > .config-target, +.workflow-split-body.inspector-maximized .workflow-node-fields > .config-policy { grid-column: span 4; } -.workflow-split-body.inspector-maximized .workflow-node-configuration-main > .config-request, -.workflow-split-body.inspector-maximized .workflow-node-configuration-main > .config-mapping { +.workflow-split-body.inspector-maximized .workflow-node-fields > .config-request, +.workflow-split-body.inspector-maximized .workflow-node-fields > .config-mapping { grid-column: span 8; } @@ -785,16 +819,14 @@ } @media (max-width: 1100px) { - .workflow-split-body.inspector-maximized - .workflow-node-configuration-main - > .workflow-config-section { + .workflow-split-body.inspector-maximized .workflow-node-fields > .workflow-config-section { grid-column: 1 / -1; } } .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 0ce2a0d0..567654cd 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 { useAuthStore } from './features/auth/auth-store' @@ -21,29 +22,20 @@ const queryClient = new QueryClient({ }, }) +applyIceAppearance(document.documentElement) + useAuthStore.subscribe((state, previous) => { if (state.epoch !== previous.epoch) queryClient.clear() }) createRoot(document.getElementById('root')!).render( - + - + , ) diff --git a/frontend/src/pages/ApiConsolePage.test.tsx b/frontend/src/pages/ApiConsolePage.test.tsx index b8fb4d4b..1fa73038 100644 --- a/frontend/src/pages/ApiConsolePage.test.tsx +++ b/frontend/src/pages/ApiConsolePage.test.tsx @@ -1,15 +1,17 @@ +import { createIceTheme } from '../theme/ice-theme' import { useAuthStore } from '../features/auth/auth-store' import { authenticateTestUser } from '../test/auth' 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' @@ -20,48 +22,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 @@ -133,12 +174,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 adc85922..0cef7900 100644 --- a/frontend/src/pages/TestAssetsPage.test.tsx +++ b/frontend/src/pages/TestAssetsPage.test.tsx @@ -294,6 +294,64 @@ 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], { ...testSuite, draft_definition: { items: previous } }).items, + ).toEqual(previous) + expect( + suiteInput({ ...values, memberVersions: { [testCase.id]: 2 } }, [testCase], { + ...testSuite, + draft_definition: { items: previous }, + }).items, + ).toEqual([{ test_case_id: testCase.id, test_case_version: 2 }]) + expect( + caseInput( + { + name: '用例', + description: '', + tags: [], + 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 6cfa2858..e29583f6 100644 --- a/frontend/src/pages/TestAssetsPage.tsx +++ b/frontend/src/pages/TestAssetsPage.tsx @@ -5,15 +5,19 @@ import { FolderOpenOutlined, MinusCircleOutlined, MoreOutlined, + PlayCircleOutlined, PlusOutlined, RocketOutlined, } from '@ant-design/icons' import { + Alert, + App, Button, Card, Dropdown, Form, Input, + InputNumber, Modal, Pagination, Radio, @@ -26,11 +30,26 @@ import { } from 'antd' import { useEffect, useState } from 'react' import { useNavigate, useSearchParams } from 'react-router-dom' +import { useQuery, useQueryClient } from '@tanstack/react-query' +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 { addCaseToPlan, getTestCase, - getTestSuite, + listLatestTestCaseRuns, listTestCaseVersions, type RunCaseInput, } from '../features/test-assets/test-asset-service' @@ -65,85 +84,173 @@ import { } from '../lib/api' export default function TestAssetsPage() { + const { projectId } = useProjectContext() + return +} + +function TestAssetsWorkspace() { const navigate = useNavigate() - const [searchParams] = useSearchParams() + 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 [runCase, setRunCase] = useState(null) const [planCase, setPlanCase] = useState(null) + const [runVersion, setRunVersion] = useState() + const [planVersion, setPlanVersion] = useState() const [recentRun, setRecentRun] = useState(null) - const [focusError, setFocusError] = useState(null) 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 = pageItems(state.suiteCaseOptions.data).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') - const projectId = state.projectId - const setSearch = state.setSearch - const setCasePage = state.setCasePage - const setSuitePage = state.setSuitePage - useEffect(() => { - if (!focusedId || !projectId || focusedType === 'contracts') return - let active = true - const request = - focusedType === 'suite' - ? getTestSuite(projectId, focusedId) - : getTestCase(projectId, focusedId) - void request - .then((item) => { - if (!active) return - setFocusError(null) - setSearch(item.name) - setCasePage(1) - setSuitePage(1) - }) - .catch((reason: unknown) => { - if (active) setFocusError(`无法定位深链目标:${apiErrorMessage(reason)}`) - }) - return () => { - active = false - } - }, [focusedId, focusedType, projectId, setCasePage, setSearch, setSuitePage]) useEffect(() => { if (focusedType === 'contracts' && state.projectId) { navigate(`/projects/${state.projectId}/contracts?tab=automation`, { replace: true }) } }, [focusedType, navigate, state.projectId]) + 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' : 'case') + next.delete('focus') + next.delete('version') + setSearchParams(next) + } + function openTarget(target: PublishedAssetTarget, execute: boolean) { + if (target.kind === 'suite') return setPlan({ targets: [target], execute }) + void action(async () => { + const item = await getTestCase(state.projectId!, target.id) + if (execute) { + setRunVersion(target.version) + setRunCase(item) + } else { + setPlanVersion(target.version) + setPlanCase(item) + } + }) + } + const workspace: AssetTableWorkspace = { + projectId: state.projectId ?? '', + page: casePage, + onPage: (page) => { + setCasePage(page) + setSelectedCases([]) + }, + onFocus: focus, + onPlan: openTarget, + workflows: pageItems(state.workflows.data), + history, + canEdit: state.canEdit, + canExecute: state.canExecute, + } return ( - <> - - {focusError && {focusError}} - { - if (state.projectId) - navigate( - `/projects/${state.projectId}/workflows?focus=${run.workflow_id}&execution=${run.execution_id}`, - ) - }} - focusedId={focusedId} - focusedType={focusedType} - /> +
+ resetSelection()} /> +
+ +
+ + + { + setSuitePage(page) + setSelectedSuites([]) + }, + }} + onChangeTab={changeTab} + onAction={action} + onPlan={(targets) => setPlan({ targets, execute: false })} + onRunCase={(item) => { + setRunVersion(undefined) + setRunCase(item) + }} + onPlanCase={(item) => { + setPlanVersion(undefined) + setPlanCase(item) + }} + onViewRun={(run) => + navigate(`/projects/${state.projectId}/reports?execution=${run.execution_id}`) + } + /> +
+
{state.diff && state.setDiff(null)} />} + + setPlan(null)} /> + setPlan({ targets: [target], execute: false })} + /> +
+ ) +} + +function WorkspaceCaseDialogs({ + state, + runCase, + runVersion, + planCase, + planVersion, + recentRun, + setRunCase, + setPlanCase, + setRecentRun, + onNewPlan, +}: { + state: AssetState + runCase: TestCase | null + runVersion?: number + planCase: TestCase | null + planVersion?: number + recentRun: TestCaseRun | null + setRunCase: (value: TestCase | null) => void + setPlanCase: (value: TestCase | null) => void + setRecentRun: (value: TestCaseRun | null) => void + onNewPlan: (target: PublishedAssetTarget) => void +}) { + const navigate = useNavigate() + return ( + <> {runCase && state.projectId && ( workflow.id === runCase.draft_definition.workflow_id, @@ -177,8 +338,13 @@ export default function TestAssetsPage() { setPlanCase(null)} + onCreate={(version) => { + onNewPlan({ kind: 'case', id: planCase.id, name: planCase.name, version }) + setPlanCase(null) + }} /> )} {recentRun && ( @@ -203,10 +369,151 @@ export default function TestAssetsPage() { ) } +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 (
@@ -222,15 +529,20 @@ function AssetHeading({ state }: { state: AssetState }) { aria-label="搜索测试用例" allowClear placeholder="搜索名称或描述" - defaultValue={state.search} - onSearch={state.setSearch} + onSearch={(value) => { + onFilter() + state.setSearch(value) + }} /> state.setTag(event.target.value)} + onChange={(event) => { + onFilter() + state.setTag(event.target.value) + }} />
@@ -255,18 +567,52 @@ function AssetTabs(props: { onViewRun: (run: TestCaseRun) => void focusedId?: string focusedType: string | null + workspace: AssetTableWorkspace + suiteWorkspace: AssetTableWorkspace + onChangeTab: (key: string) => void + onAction: (operation: () => Promise) => Promise + onPlan: (targets: PublishedAssetTarget[]) => void }) { return ( + ) } +function AssetLoadErrors({ state }: { state: AssetState }) { + const queries = [ + state.cases, + state.suites, + state.workflows, + state.caseOptions, + state.environments, + state.folders, + state.permissions, + ] + const failed = queries.find((query) => 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 @@ -280,36 +626,38 @@ 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)} > - 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} onRun={props.onRunCase} onPlan={props.onPlanCase} onViewRun={props.onViewRun} - onPublish={(item) => void state.publishCase(item.id)} - onClone={(item) => void state.cloneCase(item)} - onDiff={(item) => void state.loadCaseDiff(item)} /> ), @@ -336,32 +684,53 @@ 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)} > 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, @@ -424,6 +793,9 @@ export function AssetPane({ onFolderChange, onCreate, onMove, + moveDisabled = false, + onPlan, + planDisabled = false, children, }: { title: string @@ -434,6 +806,9 @@ export function AssetPane({ onFolderChange: (value: string | null) => void onCreate: () => void onMove: () => void + moveDisabled?: boolean + onPlan?: () => void + planDisabled?: boolean children: React.ReactNode }) { return ( @@ -450,15 +825,50 @@ export function AssetPane({ options={folders.map((folder) => ({ value: folder.id, label: folder.name }))} onChange={(value?: string) => onFolderChange(value ?? null)} /> - + {onPlan && ( + + )} + 仅选择当前页 {children} ) } +function WorkspaceCaseTable(props: Parameters[0]) { + const workspace = props.workspace! + const ids = props.items + .slice((workspace.page - 1) * 20, workspace.page * 20) + .map((item) => item.id) + const latest = useQuery({ + queryKey: ['test-case-runs', workspace.projectId, ids.join('|')], + queryFn: () => listLatestTestCaseRuns(workspace.projectId, ids), + refetchInterval: (query) => + query.state.data?.some((run) => ['queued', 'running'].includes(run.status)) ? 2000 : false, + }) + return ( + + ) +} + export function CaseTable({ items, workflows = [], @@ -466,9 +876,6 @@ export function CaseTable({ latestRuns, latestLoading = false, latestError = false, - total, - page, - onPageChange, loading, selected, onSelect, @@ -480,6 +887,7 @@ export function CaseTable({ onClone, onDiff, focusedId, + workspace, }: { items: TestCase[] workflows?: Workflow[] @@ -487,9 +895,6 @@ export function CaseTable({ latestRuns?: TestCaseRun[] latestLoading?: boolean latestError?: boolean - total?: number - page?: number - onPageChange?: (page: number) => void loading: boolean selected: string[] onSelect: (ids: string[]) => void @@ -501,20 +906,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', @@ -524,12 +945,16 @@ export function CaseTable({ title: '类型', render: (_, item) => (item.is_template ? 模板 : '用例'), }, - { - title: '关联流程', - render: (_, item) => - workflows.find((workflow) => workflow.id === item.draft_definition.workflow_id)?.name ?? - '流程不可用', - }, + ...(!workspace + ? [ + { + title: '关联流程', + render: (_: unknown, item: TestCase) => + workflows.find((workflow) => workflow.id === item.draft_definition.workflow_id) + ?.name ?? '流程不可用', + }, + ] + : []), { title: '运行环境', render: (_, item) => @@ -573,6 +998,8 @@ export function CaseTable({ onPublish={onPublish} onClone={onClone} onDiff={onDiff} + canEdit={workspace?.canEdit ?? true} + canExecute={workspace?.canExecute ?? true} /> ), }, @@ -589,6 +1016,8 @@ function CaseRowActions({ onPublish, onClone, onDiff, + canEdit, + canExecute, }: { item: TestCase onRun?: (item: TestCase) => void @@ -597,22 +1026,32 @@ function CaseRowActions({ onPublish: (item: TestCase) => void onClone: (item: TestCase) => void onDiff: (item: TestCase) => void + canEdit: boolean + canExecute: boolean }) { return ( - - void loading: boolean selected: string[] onSelect: (ids: string[]) => void @@ -671,22 +1105,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, }, { @@ -707,7 +1157,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} /> ), }, @@ -723,38 +1175,107 @@ 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) => ( + + ), + }, + ] +} + +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']} + > + + + + )} ) } @@ -811,6 +1332,17 @@ export function CaseDialog({ if (!form.isFieldsTouched()) return onClose() Modal.confirm({ title: '放弃未保存的修改?', onOk: onClose }) } + 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, current?.is_template ?? false)) + } catch (failure) { + setError(apiErrorMessage(failure)) + } + } return ( - void form - .validateFields() - .then((values) => onSave(caseInput(values, definition, current?.is_template ?? false))) - } + onOk={() => void save()} >
@@ -874,6 +1402,7 @@ export function CaseDialog({ + {error && }
) } @@ -934,6 +1463,7 @@ function KeyValueEditor({ export function RunCaseDialog({ item, + initialVersion, projectId, workflow, environment, @@ -942,6 +1472,7 @@ export function RunCaseDialog({ onRun, }: { item: TestCase + initialVersion?: number projectId: string workflow?: Workflow environment?: Environment @@ -951,9 +1482,9 @@ export function RunCaseDialog({ }) { const { versions, loading, loadError } = useCaseVersions(projectId, item.id) const [source, setSource] = useState( - item.current_version ? undefined : 'draft', + initialVersion ? 'published' : item.current_version ? undefined : 'draft', ) - const [version, setVersion] = useState(item.current_version ?? undefined) + const [version, setVersion] = useState(initialVersion ?? item.current_version ?? undefined) const [submitError, setSubmitError] = useState(null) const error = loadError ?? submitError const canRun = canRunCase(source, version, versions, item.draft_fingerprint, loading, error) @@ -1141,13 +1672,21 @@ function hasDraftChanges(item: TestCase, current: TestCaseVersion, workflow?: Wo export function AddCaseToPlanDialog({ item, + initialVersion, projectId, onClose, + onCreate, }: { item: TestCase + initialVersion?: number projectId: string onClose: () => void + onCreate?: (version: number) => void }) { + const client = useQueryClient() + const { versions, loading, loadError } = useCaseVersions(projectId, item.id) + const [version, setVersion] = useState(initialVersion ?? item.current_version ?? undefined) + const selectedVersion = versions.find((candidate) => candidate.version === version) const [page, setPage] = useState(1) const [plans, setPlans] = useState([]) const [total, setTotal] = useState(0) @@ -1172,10 +1711,12 @@ export function AddCaseToPlanDialog({ } }, [projectId, page]) const submit = async () => { - if (!item.current_version || !planId) return + if (!selectedVersion || !planId) return setSaving(true) try { - await addCaseToPlan(projectId, planId, item.id, item.current_version) + await addCaseToPlan(projectId, planId, item.id, selectedVersion.version) + await client.invalidateQueries({ queryKey: ['test-plans', projectId] }) + await client.invalidateQueries({ queryKey: ['asset-plans', projectId] }) onClose() } catch (reason) { setError(apiErrorMessage(reason)) @@ -1189,13 +1730,32 @@ export function AddCaseToPlanDialog({ open onCancel={onClose} onOk={() => void submit()} - okButtonProps={{ disabled: !planId || !item.current_version }} + okButtonProps={{ disabled: !canJoinPlan(planId, selectedVersion, loading, loadError) }} confirmLoading={saving} > - {item.name} · 固定用例 v{item.current_version}。加入计划不会立即执行。 + {item.name} · 固定用例 v{version ?? '—'}。加入计划不会立即执行。 + - {error && {error}} + {(loadError || error) && ( + {loadError ?? error} + )} ) } +function canJoinPlan( + planId: string | undefined, + version: TestCaseVersion | undefined, + loading: boolean, + error: string | null, +): boolean { + return Boolean(planId && version && !loading && !error) +} + export function SuiteDialog({ current, cases, @@ -1234,7 +1805,17 @@ export function SuiteDialog({ onSave: (input: TestSuiteDraftInput) => Promise }) { const [form] = Form.useForm() - const selectedCaseIds = Form.useWatch('caseIds', form) as string[] | undefined + const [error, setError] = useState(null) + async function save() { + const values = await form.validateFields().catch(() => null) + if (!values) return + setError(null) + try { + await onSave(suiteInput(values, cases, current ?? undefined)) + } catch (failure) { + setError(apiErrorMessage(failure)) + } + } return ( - void form - .validateFields() - .then((values) => onSave(suiteInput(values, cases, current ?? undefined))) - } + onOk={() => void save()} >
+ ({ - value: index + 1, - label: `固定 v${index + 1}`, - })), - ]} - /> - - ) - })} 套件成员独立运行;展示顺序不代表共享变量或响应。 @@ -1288,10 +1846,49 @@ export function SuiteDialog({ + + {state.breakpointNodeId && (