
页面上点「新增」,弹出一个表单,填完点确定,表格里多一行;点「编辑」,同一个弹窗再次出现,字段已经填好了——这是几乎所有业务系统每天都在跑的交互。很多框架把这件事拆成路由、状态管理、弹窗组件、表单库、请求层五块,新手要凑齐五份知识才能跑通。在 Geejing WebBuilder 里,它收敛成一个组件加一个回调:Wb.Window 负责把表单装起来、把值收齐、把按钮和校验管好,业务代码只需要回答「拿到这些值以后做什么」。
这篇文章只讲这一个组件,从最小可用配置开始,一路走到新增、编辑、关闭、复用和排错。所有配置名、方法名、事件名都取自平台自带的组件定义与官方示例,不依赖任何第三方库。对一套企业级快速开发平台来说,把高频交互收敛到一条可预期的链路上,比提供一百个零散配置项更有价值。
很多人第一次用弹窗,会把它理解成「盖在页面上的一层 div」。这个理解会直接导致后面踩坑:不知道字段值从哪里来,不知道确定按钮为什么能自动校验,也不知道为什么关掉以后数据还在。
在平台的组件清单里,弹窗其实是一族:Wb.Window 是通用窗体,Wb.Dialog 偏向带确定/取消按钮的对话框,Wb.Drawer 是侧滑抽屉。三者属性高度同构,本文选 Wb.Window 作为主线,因为它既出现在官方的窗体示例中,也是官方 CRUD 示例里承载新增/编辑表单的那一个。组件定义里 Wb.Window 有 129 个属性、31 个事件,看上去吓人,但真正决定一次业务闭环的只有三组:
分组 | 关键项 | 作用 |
|---|---|---|
布局组 |
| 决定表单字段怎么排、放几个 |
对话框组 |
| 决定有没有确定/取消、遮罩、关闭行为 |
提交组 |
| 决定点确定时值怎么收、怎么交给业务 |
理解了这个分组,剩下的属性都是「按需微调」。Geejing WebBuilder 的组件设计有个明显特点:容器自己知道里面有哪些控件。所以你不需要给每个输入框写 v-model,也不需要手动拼 FormData——窗口会把子控件的值一次性收成一个普通对象交给你。
平台模块用 XWL 描述界面,本质是一段 YAML。一个最小窗体长这样:
- cls: Wb.Window
properties:
cid: editWin
title: 人员信息
width: 40em
layout: grid2
items:
- cls: Wb.Text
properties:
cid: code
text: 编码
- cls: Wb.Text
properties:
cid: full_name
text: 姓名几个名词先解释清楚:
cls 是组件类名,Wb.Window 就是窗体;cid 是组件在当前模块里的唯一标识,之后所有 app.editWin 都靠它取到这个对象;items 是子组件数组,输入框、下拉框、日期框都挂在这里;layout: grid2 表示两列网格布局,autoGrid: 'true' 可以让字段自动依次入格;'true',这是模块解析器的约定,照写即可。窗口默认不会自己弹出来。官方示例的写法非常直白,在按钮的 click 事件里调一次 show():
app.modalWin.show();如果希望模块一加载就可见,可以设置 visible: 'true';如果用的是 showDo() 这类带回调的快捷方法,又想自己控制显示时机,manualShow 可以阻止自动 show()。这三者的区别,是新手最容易混淆的第一处:窗口是常驻对象,show() 只是把它显示出来,hide() 只是藏起来,对象本身还在内存里。
官方 CRUD 示例里的编辑窗体,是把「布局 + 校验 + 数据绑定」一次配齐的范本。下面这段是它的结构节选:
- cls: Wb.Window
properties:
cid: editWin
autoGrid: 'true'
layout: grid2
resetDialog: 'true'
items:
- cls: Wb.Text
properties:
cid: code
maxLength: '5'
required: 'true'
text: Code
- cls: Wb.Select
properties:
bindField: user_id
cid: user_name
forceSelect: 'true'
required: 'true'
text: Username
textField: user_name
valueField: sid
url: '@xurl+ ''/actions&xaction=selectUsers'''
- cls: Wb.Number
properties:
cid: salary
decimalCount: '2'
text: Salary
- cls: Wb.TextArea
properties:
cid: cv
gridColumn: span 2
height: 8em值得逐条拆开看:
required: 'true' 把校验规则写在控件上,而不是写在提交按钮的 if 里;maxLength、decimalCount、valueType: email 同理。窗口点确定时会统一调用 Wb.verify() 检查窗内所有控件,不通过就停在原地,业务回调根本不会执行。这就是「弹窗自己会校验」的真相。
gridColumn: span 2、gridRow: span 3 用来让某个字段跨列或跨行,配合 autoGrid 可以省掉手写一个表格布局。url + valueField + textField 让下拉框直接从服务端取数,bindField 决定提交时用哪个字段名。这些都不是弹窗的能力,而是子控件自带的能力,窗口只负责把它们组织到一起。
作为一套企业级快速开发平台,这种「容器只做组织、控件自带校验与取数」的分工,让同一个窗体在新增、编辑、查看三种场景下都能复用,不需要复制三份界面定义。
okHandler 拿到的是整窗的值先回答最关键的问题:确定按钮被点击时,框架到底做了什么?组件源码里的 onOk 只有几行,但顺序非常重要(下面为节选,注释是解读):
onOk(e) {
let me = this;
e.stopEvent();
if (Wb.verify(me)) { // ① 窗内控件全部校验通过
if (me.fireEvent('beforeok') !== false) { // ② beforeok 可一票否决
me.okHandler?.call(me, me, Wb.getValue(me)); // ③ 收齐值,交给业务回调
me.fireEvent('ok'); // ④ 触发 ok 事件
}
}
}也就是说,okHandler(win, values) 里的 values 不是某个输入框的值,而是 Wb.getValue(win) 收集的整窗对象,键就是各控件的 cid。字段没有 cid、或者压根没放进 items,它就不会出现在这个对象里——这是「提交后少一个字段」最常见的原因。
官方新增按钮的完整写法如下,可以直接照着改 URL:
let win = app.editWin;
win.icon = 'add';
win.title = Str.add;
win.focusControl = null;
win.okHandler = (win, values) => {
Wb.ajax({
url: xurl + '/actions&xaction=add',
params: values,
json: true,
success(resp) {
app.grid1.appendRecord(Wb.apply(values, resp));
win.hide();
},
failure(resp) {
Wb.checkExists(resp, 'wb_staff_code', app.editWin.down('code'));
}
});
};
win.show();
win.resetScroll();三个细节决定了这段代码是否好用:
win.hide() 必须写在 success 里。 请求失败时窗口保持打开,用户改一下就能重提;如果写在外面,请求还没回来弹窗就消失了,错误也无从提示。Wb.apply(values, resp) 把服务端回填合并进来。 主键、创建时间这类由数据库生成的值,合并后一起 appendRecord 追加到表格,不必重新查整张表。Wb.checkExists 处理唯一键冲突。 它把服务端的重复提示定位到 down('code') 这个控件上,用户能立刻看见是哪一格填重了。failure 里没有关窗动作,这是刻意的。Geejing WebBuilder 把「什么时候关」的决定权留给业务,而不是替你乐观地关掉。
新增和编辑共用 editWin,靠的是打开前的四步准备。官方编辑按钮的完整实现(略去附件预览)如下:
let win = app.editWin, rec = app.grid1.selection, data;
if (!rec) {
Wb.tipSelect();
return;
}
data = rec.data;
win.icon = 'edit';
win.title = Str.edit + ' - ' + data.code;
win.focusControl = true;
Wb.setValue(win, data);
win.okHandler = (win, values) => {
Wb.ajax({
url: xurl + '/actions&xaction=edit',
params: rec.getDiffer(values),
json: true,
success(resp) {
rec.update(Wb.apply(values, resp));
win.hide();
},
failure(resp) {
Wb.checkExists(resp, 'wb_staff_code', app.editWin.down('code'));
}
});
};
win.show();对照新增,差异只有四处,每一处都有讲究:
if (!rec) { Wb.tipSelect(); return; } 先挡住「没选行就点编辑」;Wb.setValue(win, data) 把选中行的数据一次性灌进窗内所有控件,字段名与 cid 对应;rec.getDiffer(values) 只提交被改动过的字段,避免把整行原样发回服务端,也减少并发覆盖;focusControl 新增时设 null、编辑时设 true:前者让焦点不落在某个具体输入框,后者让焦点回到用户上次操作过的控件,连续编辑多行时手感差别很明显。还有一条隐藏规则:okHandler 会在窗口隐藏时被清空。组件文档写得很直白——「It will be cleared after the window is hidden each time」,源码里对应 me.okHandler = undefined;。所以每次 show() 之前都要重新赋值,不能指望配一次管到底。这不是 bug,而是防止上一次提交的闭包被误触发。
resetDialog: 'true' 则负责另一个常见问题:编辑完关掉,再点新增,窗里还留着上一个人的姓名。它在 hide 事件上挂了 Wb.reset(this),自动把控件恢复到初始值;如果希望清空而不是恢复默认值,用 clearDialog: 'true'(对应 Wb.clear(this));只想要按钮和遮罩、不关心重置,用 dialog: 'true'。源码里三者的初始化逻辑是同一段:
if (me.dialog || resetDialog || clearDialog) {
configs.modal ??= true;
configs.buttons ??= [{ text: Str.ok, cid: 'ok', type: 'primary', handler: me.onOk, scope: me },
{ text: Str.cancel, cid: 'cancel', handler: me.onCancel, scope: me }];
me.closeOnEsc ??= configs.closable;
me.okOnEnter ??= true;
if (clearDialog) me.on('hide', function () { Wb.clear(this); });
else if (resetDialog) me.on('hide', function () { Wb.reset(this); });
}
if (me.okOnEnter) me.onEnter ??= me.onOk;
if (me.closeOnEsc) me.onEsc ??= me.onCancel;换句话说,只要打开这三个开关之一,遮罩、确定/取消按钮、Esc 关闭、回车确定四件事就一次性配好了,不用手写 buttons。企业级快速开发平台常见的「约定优于配置」,在这里落成了几行确定性的代码。
close() 执行的是 this[this.closeAction]()。通用组件默认 closeAction: 'destroy',而 Wb.Window 特意把默认值改成了 'hide'——因为业务弹窗要反复复用,每次销毁重建既浪费又容易丢状态。
于是有两条路线:
hide,配 resetDialog,一个 editWin 用到底;closeAction: 'destroy',关闭即释放;官方示例里那个「Destroy on Close」窗体就是这么配的,并在 destroy 事件里提示已释放。如果一个按钮要开出多个互不干扰的窗口,官方示例给了完整的动态创建写法:
let win = app.dynamicNewWin;
delete win.cid; // 避免与实例 cid 冲突
win.owner = parentContainer; // 父容器销毁时自动销毁该窗口
win.x = Wb.random(70) + 'vw';
win.y = Wb.random(70) + 'vh';
win = Wb.create(win);
win.show();这里 delete win.cid 是关键:模板上的 cid 用于设计期定位,实例化多个副本时必须删掉,否则后开的会覆盖先开的引用;owner 把窗口挂到父容器生命周期上,避免页面切走后残留一堆孤儿窗口;instanced: 'false' 则表明这份定义是模板而非单例。
其他常用开关:modal: 'true' 加遮罩并限制焦点外溢;closeOnEsc、closeOnBack、okOnEnter 管键盘与返回键;beforeclose 可以在关闭前做「有未保存内容」的拦截;maximizable、resizable、autoCenter 属于外观微调。Geejing WebBuilder 把这些都放在同一个属性面板里,改一个值即可生效,不需要额外的状态变量。这类细节通常不会写进需求文档,却直接决定系统长期运行后页面是否越用越卡;一套企业级快速开发平台的功力,往往就藏在这些默认约定里。
显式写法适合讲清原理,日常开发还可以更省。官方示例在注释里标了「Preferred way」,指的是表格组件提供的 insertRecord 与 editRecord:
// 新增:传请求配置 + 窗体,其余交给框架
app.grid1.insertRecord({
url: xurl + '/actions&xaction=add',
failure(resp) {
Wb.checkExists(resp, 'wb_staff_code', app.editWin.down('code'));
}
}, app.editWin);
// 编辑:同样只需请求配置 + 窗体
app.grid1.editRecord({
url: xurl + '/actions&xaction=edit',
failure(resp) {
Wb.checkExists(resp, 'wb_staff_code', app.editWin.down('code'));
}
}, app.editWin);它们并不是黑盒。看实现就知道,insertRecord 内部调 win.showAdd(...),editRecord 内部调 win.showEdit(data, ...),而这两个方法最终都走 showDo(title, callback):
showAdd(subTitle, callback) {
let me = this, scrollEl = me.scrollEl;
if (Wb.isFunction(subTitle)) { callback = subTitle; subTitle = null; }
me.icon = 'add';
me.focusControl = null;
me.showDo(subTitle ? (Str.add + ' - ' + subTitle) : Str.add, callback);
scrollEl.scrollTop = scrollEl.scrollLeft = 0;
}
showEdit(data, subTitle, callback) {
let me = this;
if (Wb.isFunction(subTitle)) { callback = subTitle; subTitle = null; }
me.icon = 'edit';
Wb.setValue(me, data);
me.focusControl = true;
me.showDo(subTitle ? (Str.edit + ' - ' + subTitle) : Str.edit, callback);
}
showDo(title, callback) {
let me = this;
if (Wb.isFunction(title)) { callback = title; title = null; }
if (title != null) me.title = title;
if (callback) me.okHandler = callback;
if (!me.manualShow) me.show();
}上一节里手工做的「改图标、改标题、设焦点、灌数据、挂回调、显示、回滚滚动条」,这里全被包进去了;insertRecord 还会在请求成功后自动 addRecord、win.hide(),editRecord 自动 getDiffer、rec.update、win.hide()。明白显式写法之后再切到快捷方法,出问题时能立刻定位到是哪一层,不会卡在「框架替我做了什么」上。对一套企业级快速开发平台来说,同时保留展开写法与收敛写法,比只给一种抽象更实用。
删除确认不需要自己搭窗体,直接调用封装好的静态方法:
Wb.confirm(Wb.getActionHint(recs, 'code'), f => {
Wb.ajax({
url: xurl + '/actions&xaction=del',
params: { del: grid.originData },
success() { grid.delRecords(); }
});
});需要自定义按钮时,用 Wb.Window.showMessage:
Wb.Window.showMessage({
title: 'Customize', text: 'Some message', icon: 'share',
name: 'sys.dialog', // 同名窗口复用同一个实例
buttons: [
{ text: 'Primary', type: 'primary', cid: 'primary' }, // 第一个按钮 = 回车默认
{ text: Str.cancel, cid: 'cancel' } // 最后一个按钮 = Esc 默认
],
callback(btn) {
this.close();
Wb.tip(btn + ' clicked');
}
});Wb.info、Wb.error、Wb.tip、Wb.choose 属于同一族,返回值和 callback 的用法一致。它们与表单弹窗共享 buttons、onEnter、onEsc 这套机制,所以学会一次配置,两种场景都能覆盖。Geejing WebBuilder 在官方文档里把「第一个按钮对应回车、最后一个对应 Esc」写进了注释,这类约定能省掉大量键盘事件处理代码。
现象 | 原因 | 处理 |
|---|---|---|
点确定完全没反应,回调不进 |
| 先看必填项与格式校验,再检查 |
第一次能提交,第二次点确定没反应 |
| 每次 |
请求还没回来弹窗就关了 |
| 移到 |
提交对象少字段 | 控件没设 | 补齐 |
新增时残留上一次编辑的值 | 没开 | 需要恢复默认值用 |
编辑把整行都发回服务端 | 直接提交了 | 改用 |
连续点新增后窗口互相覆盖 | 模板 | 动态多开参考 |
关掉再开,滚动条停在中间 | 打开编辑窗时未回滚滚动位置 |
|
页面切换后窗口对象还在 |
| 一次性窗口设 |
最后给一份动手前的自查清单,照着过一遍基本不会返工:
cid,所有需要提交的控件也都有 cid;layout 与 autoGrid,跨列字段用 gridColumn;okHandler,并在请求成功后才 hide();Wb.setValue 回显、getDiffer 提交;resetDialog,一次性窗口配 closeAction: 'destroy';Wb.checkExists 定位到具体控件;insertRecord / editRecord。把弹窗这件事从头到尾走一遍会发现,真正需要业务开发者动脑的只有「提交到哪个地址」和「成功后怎么更新表格」两件事,其余的布局、校验、取值、按钮、键盘、遮罩、焦点、重置都由组件承担。这正是企业级快速开发平台应该提供的东西:不是替你写死业务,而是把每次都要重写一遍的那部分彻底拿走。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。