某个客户的生产租户里,业务顾问在标准采购订单审核页面上提了一个需求:右上角要加一个"自定义检查"按钮,点击后先在浏览器控制台把当前单据上下文打出来,确认扩展链路是通的,之后再慢慢接真实逻辑。这个需求乍看很小,但放在 SAP S/4HANA Cloud Public Edition 的标准 Fiori Object Page 上,意味着你不能改标准程序、不能把后端 OData 服务拆开、也不能像过去 NetWeaver 时代那样直接往视图里硬塞一段代码。你能依赖的,只有 SAP 暴露出来的那一层 UI 扩展能力。
这篇文章把我在实际项目里走通的路径完整写下来:从创建 Adaptation Project,到在 Object Page 上挂自定义 Action,再到用 Controller Extension 把 console.log 真正打出来,最后是部署验证和常见坑。适合正在 BTP / SAP Business Application Studio 上做 Fiori 扩展的顾问、开发,也适合那些刚接手 S/4HANA Cloud 公共云扩展需求、想搞清楚"这套机制到底怎么落地"的人。
1. 先把问题讲透:标准 Object Page 上加按钮,为什么需要绕这么多层
1.1 一个 Action 的落点不在代码里,而在扩展机制里
Object Page 是 Fiori elements 里最常见的详情页模板,采购订单、销售订单、业务伙伴这类标准页面的主体结构都是它。页面右上角那一排按钮,比如"更多""创建采购订单""原始日志",在 Fiori elements 里统称为 Action。所谓自定义 Action,就是在标准命令区里插入一个你自己命名的新按钮。
插入按钮不难,难的是按钮按下之后,你想执行的代码放在哪里。标准 Object Page 的控制器是 SAP 封装好的,SAP S/4HANA Cloud Public Edition 明确禁止修改标准对象,所以你不能像在旧的 NetWeaver 客户端里那样直接写死一个方法。你必须通过官方扩展点,把自定义逻辑注入进去。
console.log 在这里有特殊价值:它是验证注入是否成功的最短路径。Fiori 页面是一个由 OData 数据源、注解、控制器、视图片段组成的运行时环境,任何一个环节没接上,页面都可能是正常显示的,但你的自定义代码就是没被触发。用一行 console.log,配合浏览器开发者工具,可以快速判断代码有没有被加载、事件有没有被绑定、上下文对象长什么样。把它理解为在墙上刷漆前先打一层腻子,肉眼看不出大问题,但手摸一下就知道附着力。
1.2 公共云的限制边界:能扩展哪里,不能碰哪里
在公共云版本里,SAP 给出了清晰的扩展边界。底层的数据库表、后台大量 ABAP 对象、标准 OData 服务实现,都属于不开放区域。可扩展的区域集中在三块:业务配置、自定义业务对象/字段/逻辑、以及 UI 层。
UI 层又能分成两类。一类是 Key User 在页面上直接做的 Adapt UI 个性化,比如调整字段顺序、隐藏按钮、新增一个带固定跳转的瓷砖,这类操作不需要代码。另一类是开发者通过 Adaptation Project 做的代码级扩展,这正是我们需要的路。
可以用一张表快速看清楚:
| 扩展方式 | 能否在标准 Object Page 上加 Action | 能否执行自定义 JavaScript | 适合场景 |
|---|---|---|---|
| Key User 的 Adapt UI | 部分可以 | 不可以 | 简单布局调整、隐藏显示字段、创建磁贴 |
| Adaptation Project + Controller Extension | 可以 | 可以 | 自定义按钮、埋点日志、调用扩展服务 |
| 自定义 Fiori 应用 | 不可以(独立应用) | 可以 | 完全新的业务页面,不走标准页面 |
| ABAP Cloud / RAP 扩展 | 间接 | 不可以 | 自定义字段、自定义行为、后端逻辑服务 |
所以标题里的需求,标准的答案就是第二个:Adaptation Project + Controller Extension。
1.3 为什么不是干脆做一个自定义 Fiori App
经常有人问:既然都要写 JS,为什么不在 SAP Business Application Studio 里直接创建一个全新的 Fiori 应用,页面由自己控制,想打多少 log 都行?
因为目标页面是"标准采购订单详情页"。自定义 Fiori App 只能通过启动磁贴进入自己的界面,它没法长在标准应用里面。你做一个一模一样的自定义页面,意味着要维护一整条 OData 服务、复制关键 UI 逻辑,到头来页面和标准功能的审批流、智能字段、校验逻辑全部脱节。公共云里 SAP 升级标准应用后,你的自定义页面还得自己跟进。而 Adaptation Project 是在标准页面外面包一层增量补丁,SAP 升级主体时,你的按钮和逻辑仍然挂在补丁层上,兼容性由官方扩展点机制兜底。
这也是为什么我倾向于把这条路称为"可行路径",因为它不是绕过系统,而是顺着系统的扩展点做增量。
2. 开工前的三个前置条件:账号权限、开发环境、可验证的测试页面
2.1 账号权限:扩展开发不是普通业务用户想开就能开
很多人第一步就卡在权限上,症状是 BAS 里已经创建了项目,但拉取标准应用列表时一片空白,或者 Fiori Launchpad 里右键没有 "Adapt UI" 菜单。
在 SAP S/4HANA Cloud Public Edition 里,要做代码级 UI 扩展,通常需要两类账号配合:
- BTP 子账户账号:属于 Cloud Foundry 空间,拥有开发者空间权限,能创建 Dev Space、能绑定服务实例。
- S/4HANA Cloud 租户账号:需要分配到包含 SAP_UI_FLEX 相关业务角色的用户组,同时要有文档服务、业务编目等的访问权限。
如果你用的是 SAP 官方推荐的带扩展性的开发租户,一般会预置一个开发用户。但客户环境里经常是从生产租户复制过来的,用户角色没同步,那就得先在 Fiori Launchpad 里检查当前用户是否有 "Adapt UI" 入口。没有这个入口,后面全白搭。
2.2 开发环境:基于 SAP Business Application Studio 的 Adaptation Project
SAP S/4HANA Cloud 公共云的前端扩展开发,官方推荐的 IDE 是 SAP Business Application Studio,简称 BAS。打开 BAS 后,先创建一个 Dev Space,选择类型时用 "SAP Fiori" 或者 "Full Stack Cloud Application" 都行,我习惯选前者,启动更快,预装的 Fiori 工具链也更全。
创建项目时,模板列表里找到 "UI Adaptation",有些版本叫 "Adaptation Project" 或 "SAP S/4HANA Cloud Extensibility"。填完项目名称后,会让你指定要扩展的目标应用。这里有两种常见操作:
- 直接输入标准应用的 OData 服务或语义对象,让系统帮你解析出对应的 List Report / Object Page。
- 从系统已激活的应用列表中选择一个模板应用。
选好之后,BAS 会把标准应用的 Fiori elements 配置结构和本地扩展目录生成出来。这时注意看项目根目录里应该有一个webapp目录,下面有annotations、manifest.json、ext之类的子目录。没有这些文件夹,说明项目创建类型选错了,后面没法做代码扩展。
2.3 测试页面:别选一个没数据的空页面
选哪个标准应用做验证,直接影响你调试的心情。建议选一个在系统里能稳定打开、且有主数据的详情页,比如采购订单Purchase Order或销售订单Sales Order。原因很实在:Object Page 的 Action 按钮通常在页头区域渲染,页面如果连基本绑定上下文都没有,Action 区域可能都不出现,你加了按钮也无从点起。
如果你的测试租户里没有现成的业务单据,先用 Fiori Launchpad 建一张最简单的采购订单,或者用标准 Demo 数据流程生成一份。console.log 本身不依赖业务数据,但 Object Page 需要有个上下文对象才能触发正常渲染。
3. 在 Adaptation Project 中挂上自定义 Action:找对扩展点的两种姿势
3.1 标准姿势:从结构树里找到 Object Page,用 Add Action 生成增量
项目生成成功后,在 BAS 左侧会看到与标准应用结构对应的树。不同版本展示形式略有差异,但逻辑是一样的:先看到 Application Structure,展开之后能看到 List Report 节点和 Object Page 节点。
在 Object Page 节点上右键,通常会出现 "Add Action" 或 "Add Custom Action" 选项。点进去之后,需要填几个字段:
- Action ID:给按钮一个稳定的内部 ID,建议用命名空间前缀,避免和标准按钮撞车。
- Label:按钮显示文字,比如"自定义检查"。多语言环境里,这个 Label 可能会生成到 i18n 文件里。
- Icon:可选,比如
sap-icon://inspect-down。 - Position:按钮放在 Header 区、Footer 区还是"更多"菜单里面。
确认后,向导会生成一段增量配置,核心就是往该 Object Page 的标准注解集合里注入一个UI.DataFieldForAction或类似的自定义 Action 定义。这正是 Fiori elements 页面运行时能够识别并渲染的机制,标准页面本身没有被改动,只是在运行期多拿了一层注解。
这里我想特别强调:很多人在这个界面里找不到"事件处理代码"的入口,以为加完 Button 就结束了。实际上这是 UI Adaptation 工具的常见设计——Action 的视觉定义和它的行为定义是分开的。行为要交给 Controller Extension 去接,这就是下一步的正题。
3.2 曲线策略:如果工具不提供事件绑定,用运行时注入兜底
如果你的标准应用基于较老的 Fiori elements 版本,或者 SAP 并没有给这个页面暴露干净的 Action 扩展点,工具里可能只有按钮定义,没有事件绑定配置。这种时候,还有一条兜底路线:在控制器扩展的onAfterRendering生命周期里,动态拿到 Object Page 的 Header 按钮容器,手动画一个sap.m.Button塞进去,由你控制它的 press 事件。
看一段示意代码:
sap.ui.define([ "sap/ui/core/mvc/ControllerExtension", "sap/m/Button" ], function (ControllerExtension, Button) { "use strict"; return ControllerExtension.extend("my.extension.controller.ObjectPageExt", { onAfterRendering: function () { var oPage = this.base.getView().byId("page"); var oActionBar = oPage.getAggregation("headerContent") || oPage.getAggregation("headerTitle"); if (!this._oCustomActionBtn && oActionBar) { this._oCustomActionBtn = new Button({ text: "自定义检查", press: this.onMyActionPress.bind(this) }); oActionBar.addAction(this._oCustomActionBtn); } }, onMyActionPress: function (oEvent) { console.log("custom action pressed", oEvent); } }); });这段代码的好处是不依赖注解的下发链路,坏处是它更像打补丁,尺寸不大但有点野。如果你有精力,还是优先走官方 Add Action 的路线;曲线策略适合做验证、做 POC,或者遇到极端页面没有扩展点时临时顶上。
4. Controller Extension 与 console.log:代码怎么写、事件怎么绑才不踩坑
4.1 控制器扩展的基础结构:ControllerExtension.extend
Adaptation Project 里能执行自定义 JS 的关键,是 Fiori elements 支持通过ControllerExtension扩展标准控制器。你会发现它不是要你重写整个控制器,而是让你"插一根管子"到标准控制器的生命周期里。这样 SAP 升级时不会覆盖你的代码,扩展点依然有效。
我通常会在webapp/ext/controller/目录下新建一个文件,文件名带清晰的业务含义,比如ObjectController.ext.js。核心结构长这样:
sap.ui.define([ "sap/ui/core/mvc/ControllerExtension", "sap/ui/model/json/JSONModel" ], function (ControllerExtension, JSONModel) { "use strict"; return ControllerExtension.extend("my.extension.controller.ObjectController", { // Fiori elements 标准控制器的生命周期 onBeforeRendering: function () { console.log("ObjectPage onBeforeRendering"); }, onAfterRendering: function () { console.log("ObjectPage onAfterRendering"); }, // 自定义 Action 的事件处理 onMyActionPress: function (oEvent) { var oView = this.base.getView(); var oBindingContext = oView.getBindingContext(); var oCurrentObject = oBindingContext ? oBindingContext.getObject() : null; console.log("Custom action pressed", oEvent); console.log("Current object:", oCurrentObject); } }); });注意重点是this.base。在ControllerExtension里,this并不是标准页面的控制器实例,你想要访问标准控制器的视图、模型、路由,都要通过this.base把标准控制器对象拿过来。很多第一次写扩展的人会习惯性地this.getView(),结果报错,原因就在这里。
4.2 把 Handler 和 Action 绑起来
Action 加好之后,需要在项目配置里把按钮的 press 事件指向上面这个方法。具体入口取决于你的 BAS 版本,常见的是在 "Action" 的配置面板里找到 "Handler" 或 "Event" 字段,填my.extension.controller.ObjectController.onMyActionPress。
如果你手改manifest.json,会看到类似这样的结构:
"sap.ui5": { "extends": { "controllers": { "sap.suite.ui.generic.template.ObjectPage.controllers.ObjectPageController": "my.extension.controller.ObjectController" } } }这行的意思是:当标准 Object Page 控制器运行时,额外加载扩展控制器ObjectController,并把生命周期事件和自定义方法注入进去。配置完成后,点击自定义按钮时,标准控制器找不到这个处理函数,就会到扩展控制器里找,最终执行我们的onMyActionPress。
这里有个非常容易犯的错:Handler 字符串写成了my.extension.controller.ObjectController.onMyActionPress,但文件实际暴露的 name 空间不一致。Fiori elements 做事件解析时对命名空间是强校验的,大小写差一个字母都不会匹配。如果按钮点了没反应,第一件事就是检查这个字符串和sap.ui.define里的第一个参数、以及extend方法的类名,三者必须完全一致。
4.3 在 console.log 里能看到什么:上下文对象的读取
执行console.log("Current object:", oCurrentObject)时,预期能在浏览器的 Console 面板看到一整段采购订单的字段集合。getBindingContext()在 Object Page 上取到的通常就是当前单据行项的上下文,可以直接拿到销售订单号、采购订单号、状态等关键字段。
但是要注意:某些 Object Page 的页面级上下文,和页头详情区并非同一个绑定上下文。如果oView.getBindingContext()返回 null,不要慌,可以从事件源控件身上找:
onMyActionPress: function (oEvent) { var oSource = oEvent.getSource(); var oCtx = oSource.getBindingContext() || this.base.getView().getBindingContext(); console.log(oCtx && oCtx.getObject()); }在 Fiori elements 里,按钮往往由标准模板创建,它所在的命名视图带有一个标准绑定上下文。直接getSource().getBindingContext()有时候拿不到,因为按钮可能不在业务数据视图内。最稳妥的写法是两者都试,先取控件上的上下文,再退回视图级上下文。
还有一个实践心得:console.log不要只打字符串,最好把整个对象打出来。浏览器控制台允许你展开这个对象,查看所有字段结构,这对理解标准对象页在运行时到底绑定了什么,非常有帮助。我经常是先在按钮事件里打一次完整对象,再把字段名一个个摘进去。
5. 部署、激活、排障:从 F12 无输出到 Console 打出一行行日志
5.1 把 Adaptation Project 部署到云租户
本地代码写完只是第一步,公共云环境里,用户访问的 Fiori Launchpad 不会实时感知 BAS 里的改动。你需要在 BAS 里执行部署,把扩展内容推送到对应的 S/4HANA Cloud 租户。
不同项目的部署入口名称略有不同,常见的是 Deploy Application / Deploy to SAP S/4HANA Cloud。部署时会让你选择目标传输请求或软件包。商业上生产租户一般会有正式的传输管理流程,测试租户如果配置了 PMS 或自定义传输请求,也可以直接走。
我建议分两步走:先在开发/测试租户部署,验证代码没问题,再通过正式的传输链路上生产。公共云的好处是标准应用的版本升级由 SAP 负责,你的扩展作为增量包存在,只要扩展点没有在新版本里被废弃,部署后按钮就会继续常驻。
部署完成后,重新进入 Fiori Launchpad,打开扩展过的标准应用。如果页面看不到自定义按钮,先做三件事:
- 确认用户角色里包含该 UI Adaptation 对应的编目。
- 确认部署时选的传输请求真的发放到了当前租户。
- 强制刷新浏览器缓存,清除
/ui2/upd相关缓存片段后重进。
5.2 打开浏览器开发者工具:验证链路的标准动作
点击自定义按钮后,如果一切正常,浏览器 Console 面板会出现Custom action pressed。如果没有任何输出,按下面的顺序排查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 按钮没出现 | 扩展未部署;角色未分配;缓存 | 重部署、检查业务角色、强制刷新 |
| 按钮出现但点击无反应 | Handler 名称空间写错;控制器扩展文件未被加载 | 检查 manifest 的 controllers 映射;在函数里加断点 |
Console 报this is not allowed with this security level configuration | 当前用户安全上下文不支持该操作或事件 | 用有扩展权限的管理员账号再测;检查 IUUC 配置 |
Console 报Cannot read properties of undefined | this.base在某个生命周期里没有初始化完成 | 把代码放到 onAfterRendering 或事件函数中,避免在 onInit 里挂行为 |
| 按钮出来了,但 log 打了旧值 | 浏览器缓存了旧版 JS | Ctrl+F5 强制刷新,或修改 JS 文件名带上版本号 |
关于this action is not allowed with this security level configuration这类报错,很多人以为是代码问题,实际上往往是权限配置导致的运行时拦截。举个例子:你在 BAS 的预览模式下打开页面,本地开发环境的安全策略和云租户不一致,某些非标准操作会被安全级别挡住。换成租户内用户账号访问后,这个错误通常就消失了。
5.3 光看 log 还不够:加断点和查看 Source 面板
console.log 只是最基本的验证方式,如果代码复杂度上来了,需要看更细的执行顺序,我建议直接在onMyActionPress函数里加一个debugger;语句:
onMyActionPress: function (oEvent) { debugger; console.log("Custom action pressed", oEvent); }然后在浏览器开发者工具里重新点击按钮,脚本会在debugger处停下来,你可以沿着调用栈检查oEvent的来源、this.base的可用性、绑定上下文的取值。这一步最直观,能省去很多瞎猜的时间。
不过要记住:debugger;只适合开发阶段,部署到生产之前一定要删掉,否则用户按 F12 能看到你的调试停点,既不专业,也容易在断点上拖慢页面交互。
6. 从 console.log 到真业务逻辑:公共云里后续还能怎么演化
6.1 把按钮动作接到自定义后端服务上
console.log 验证通过,意味着整条 UI 扩展链路已经打通,接下来就不要再打印日志了,而是把 Action 指向真正的业务逻辑。
公共云里,如果你需要按钮去读或写数据,最规范的路径不是直接调标准 OData 的修改服务,而是新建一个自定义 CDS 视图或自定义业务对象,把逻辑暴露成一个 OData 服务,然后在前端通过oModel.create或oModel.callFunction调用。代码大概长这样:
onMyActionPress: function (oEvent) { var oModel = this.base.getView().getModel(); var oPayload = { purchaseOrder: this.base.getView().getBindingContext().getProperty("PurchaseOrder"), // 其他业务字段 }; oModel.callFunction("/MY_CUSTOM_SRV/executeCheck", { method: "POST", urlParameters: oPayload, success: function (oResult) { sap.m.MessageToast.show("检查完成"); console.log(oResult); }, error: function (oError) { console.error(oError); } }); }这里把console.log用作结果观察,其实是合理的,因为后端服务的返回结构只有打出来看最直观。等确认无误,再把console.log替换成页面提示、数据刷新或导航跳转。
6.2 用 MessageToast 给用户可见反馈
如果这个按钮是给业务人员用的,他们不会打开浏览器控制台去看到底打了什么 log。所以最终形态里,Action 触发后应该有一个可见反馈,最常见的是sap.m.MessageToast.show或者标准消息框:
sap.ui.require(["sap/m/MessageToast"], function (MessageToast) { MessageToast.show("自定义检查已完成,共处理 12 条记录"); });对于错误场景,用sap.m.MessageBox.error更合适。这里要额外注意:在 Controller Extension 里引入这些库,不要写在sap.ui.define的依赖数组里就完了,还要保证代码在 UI5 的模块加载器里正确解析。如果只是想快速验证,用sap.ui.require在函数内懒加载也行。
6.3 长期维护的几个习惯
最后聊几个我自己反复踩过的坑。
第一,console.log 不是免费的标签。公共云生产环境里,如果每个用户点击一次按钮都会在浏览器控制台刷出几条日志,虽然不影响系统性能,但会影响页面调试的干净度。我的习惯是加一个环境判断:
var bDebugMode = window.location.href.indexOf("debug=true") !== -1; if (bDebugMode) { console.log("Custom action pressed"); }这样生产环境默认安静,需要排障时在 URL 后面拼debug=true,日志才输出。
第二,Controller Extension 方法不要命名得太通用。比如onPress、onClick这种很危险的词,一旦某天标准控制器里也有同名方法,有可能触发你意料之外的覆盖。建议都带上你的业务前缀,比如onZCustomCheckPress,这样既好搜,也好排错。
第三,公共云升级问题。SAP 会在新版本里调整标准页面的控制器类名、注解结构、模板 API。适配项目的好处是升级时 SAP 尽量保证兼容,但你还是应该在每次季度发布后,花十分钟到测试租户上点一下自定义按钮,确认 console.log 还能正常输出。扩展点失效往往不是报明显错误,而是按钮静默消失或者 Handler 不再被调用。
我自己的做法是:把这条验证路径写成一个简单的冒烟测试文档,每个季度发布后照着点一轮,两分钟就能覆盖所有自定义 Action、控制器扩展和自定义服务链路。这个习惯看似朴素,但真的能避免很多生产事故。
从第一行 console.log 出现,到按钮真正跑通自定义业务逻辑,Adaptation Project 这条路说长不长,说短不短。核心不是代码量,而是搞清楚 Fiori elements 的扩展机制是怎么把按钮、控制器、注解串起来的。只要这个链路摸清了,往后在任意标准 Object Page 上加工,都只是重复这套框架而已。