第二版
- 文章作者: flytreeleft - flytreeleft@crazydan.org
- 文章链接: https://nop.crazydan.io/practice/demo/v2
- 版权声明: 本文章采用许可协议《署名 4.0 国际 (CC BY 4.0)》,转载或商用请注明文章来源及作者信息。
这一版将实现对业务模型的条件查询、父子层级查询和显示、自定义前端组件等定制化调整。
下载 nop-demo.orm.v2.xlsx 并覆盖工程目录下的
model/nop-demo.orm.xlsx
文件,再通过 nop-cli gen
生成模型和前端页面:
# 注意,请根据当前运行环境修改 JDK 17+ 的安装路径
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk
${JAVA_HOME}/bin/java \
-Dfile.encoding=UTF8 \
-jar ./nop-cli.jar \
gen -t=/nop/templates/orm \
./model/nop-demo.orm.xlsx
父子树查询和显示
若要支持父子树查询和显示,则需要在 Excel 数据模型中,为父节点字段添加 parent
【标签】,
并在【关联列表】中指定父节点对应的【关联属性名】:
对此,Nop 将会在该模型的 XMeta 文件中增加树形结构相关的配置:
<meta ...>
...
<tree parentProp="parentId" childrenProp="children"/>
<props>
<prop name="parentId" tagSet="parent"
ui:control="tree-parent">
...
</prop>
<prop name="children" tagSet="pub" lazy="true">
...
</prop>
</props>
</meta>
也就是,在 XMeta 中会定义 tree
并指定父/子属性名,
同时,在父属性上设置 ui:control="tree-parent"
以声明父属性以 AMIS 控件 tree-parent
显示(如下图所示):
添加并定制过滤查询
该定制涉及以下内容:
- 表单标题需更改为
过滤查询
- 查询需支持模糊匹配:简单的就是包含查询
- 过滤条件变化后,自动即时查询,以减少点击次数
Nop 生成的视图页面已默认内置对业务模型的查询支持,只需要配置 query
表单的布局即可:
<?xml version="1.0" encoding="UTF-8" ?>
<view xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xui/xview.xdef"
x:extends="super">
...
<forms>
<form id="query" submitOnChange="true">
<layout>
name[地区名称]
</layout>
</form>
</forms>
</view>
submitOnChange="true"
为 AMIS 的 form 组件配置, 用于设置在表单值发生变化时自动提交表单,以实时更新数据列表。
但其默认的过滤表单的标题为 筛选
,需要将其改为 过滤查询
,而且属性的过滤算符默认为
eq
(等值匹配),需要修改为包含(contains
),以支持对名称的模糊查询。
对视图页面某元素的调整可直接修改对应的 *.page.yaml
,在其中通过 Delta
机制实现定制调整:
x:gen-extends: |
<!-- view 必须为 Delta 层的绝对路径,若为相对路径,则其引用的将是初始层的文件 -->
<web:GenPage view="/nop/demo/pages/Region/Region.view.xml" page="main" xpl:lib="/nop/web/xlib/web.xlib" />
body:
- name: crud-grid
filter:
id: crud-filter
title: 过滤查询
x:virtual: true
x:virtual: true
也可通过 AMIS 的设计器调整,其调整后的差量内容也会放在
main.page.yaml
中。
在 Nop 中只有显式声明的过滤算符才允许被使用,而可用的过滤算符定义在
XMeta 中 <prop/>
标签的 allowFilterOp
属性上。
详情请参考文档《为列表页面增加多个查询条件》。
这里需要为 Region
的 name
属性增加 contains
查询(等同于 SQL 中的 LIKE
):
<?xml version="1.0" encoding="UTF-8" ?>
<meta xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xmeta.xdef"
x:extends="super">
<props>
<prop name="name" allowFilterOp="eq,contains"/>
</props>
</meta>
最后,在 XView 中为过滤表单元素配置过滤算符(filterOp="contains"
)即可:
<?xml version="1.0" encoding="UTF-8" ?>
<view xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xui/xview.xdef"
x:extends="super">
...
<forms>
<form id="query" submitOnChange="true">
<layout>
name[地区名称]
</layout>
<cells>
<cell id="name" filterOp="contains"/>
</cells>
</form>
...
</forms>
</view>
定制父子树形显示控件
Nop 平台可用的控件定义在
nop-web/src/main/resources/_vfs/nop/web/xlib/control.xlib
中,其以{editMode}-{control}
形式作为 XML 标签名, 并按内置规则匹配业务模型属性的显示控件。
此次定制需满足以下要求:
- 按数据的创建时间(
createdAt
)升序排序父子节点,以保持节点间的相对顺序不变 - 查询(
query
)、编辑(edit
)模式均使用相同的树形控件
首先,对 Nop 内置的控件进行扩展,让查询模式继承自编辑模式的控件:
<?xml version="1.0" encoding="UTF-8" ?>
<lib xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xlib.xdef"
x:extends="/nop/web/xlib/control.xlib">
<tags>
<query-tree-parent x:prototype="edit-tree-parent"/>
</tags>
</lib>
注意,模型属性所采用的组件是在 XMeta 中的
<prop/>
节点上声明的:<prop name="parentId" ui:control="tree-parent">
,ui:control
即为控件类型。
再在 XView 中引入新的控件库,并在过滤表单的布局中放置 parentId
即可:
<?xml version="1.0" encoding="UTF-8" ?>
<view xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xui/xview.xdef"
x:extends="super">
<controlLib>/nop/demo/pages/control.xlib</controlLib>
...
<forms>
<form id="query" submitOnChange="true">
<layout>
parentId[上级地区] name[地区名称]
</layout>
</form>
...
</forms>
</view>
不过,还需要将默认的结果排序规则调整为按 createdAt
升序排序。
这里直接修改 edit-tree-parent
控件的定义,以在 AMIS 组件的请求参数中附加
orderBy
参数:
<?xml version="1.0" encoding="UTF-8" ?>
<lib xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xlib.xdef"
x:extends="/nop/web/xlib/control.xlib">
<tags>
<query-tree-parent x:prototype="edit-tree-parent"/>
<edit-tree-parent>
<source x:override="merge">
<tree-select>
<source>
<data>
<orderBy>${[{name: 'createdAt', desc: false}]}</orderBy>
</data>
</source>
</tree-select>
</source>
</edit-tree-parent>
</tags>
</lib>
edit-tree-parent
实际使用的是 AMIS 的 tree-select
组件,并在该组件的数据源 source
中新增了 orderBy
参数,该参数为
json 数组数据,其元素结构为 {name: '<propName>', desc: true}
。
需要注意的是,扩展的 edit-tree-parent
将与 /nop/web/xlib/control.xlib
中的同名控件合并,但 <source/>
节点必须显式指定合并策略为
merge
(x:override="merge"
),否则,由于在控件库的 XSchema
中该节点没有声明包含子节点,故而,此类节点的默认合并策略为
merge-replace
,该策略会使用新的节点结构覆盖旧的节点,造成旧节点上的结构丢失,
具体可见 io.nop.xlang.delta.DeltaMerger#merge
的处理逻辑。
沿父级显示全部父节点名称
该定制涉及以下方面:
- 在 GraphQL Selection 中需设置获取足够的嵌套层级的
parent{name}
- GraphQL 不支持递归嵌套,只能显式嵌套多级层级结构
parent
的显示组件上需沿parent
路径获取全部父节点名称后,再拼接在一起显示, 但仅显示内容按此变化,映射的parent.oid
依然为其直接父节点
也就是,需对 GraphQL 请求参数进行修改,并重新组装返回结果。
Nop 平台所使用的 AMIS 框架支持对
API 请求
进行前处理(requestAdaptor
)和后处理(adaptor
),
因此,可以在这两处位置实现相应的定制化处理。
由于地区和部门的前端页面都需进行相同的改造,所以,
针对 <api/>
的定制化修改需提取到一个 Xpl 库中,以便于复用该类修改:
<?xml version="1.0" encoding="UTF-8" ?>
<lib xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xlib.xdef">
<tags>
<!-- AMIS 采用的是 json 格式数据,故需设置 Xpl 函数的输出为 xjson -->
<GridListApi outputMode="xjson">
<!-- 声明必须传递的参数 bizObjName,以指定业务模型名称 -->
<attr name="bizObjName" mandatory="true"/>
<source>
<!--
any 标签会与调用 x:gen-extends 标签的父节点合并,
所以,这里的标签名没有意义,只是用于确保其子节点能够合并为目标节点的子节点
-->
<any>
<api url="@query:${bizObjName}__findPage"
gql:selection="{@pageSelection}">
<!-- 调用当前库(thisLib)的函数 ApiRequestAdaptor -->
<thisLib:ApiRequestAdaptor/>
<adaptor>return mutateTree_findPage(payload)</adaptor>
</api>
</any>
</source>
</GridListApi>
<!-- 对查看页面的定制支持 -->
<GetInitApi outputMode="xjson">
<attr name="bizObjName" mandatory="true"/>
<source>
<any>
<initApi url="@query:${bizObjName}__get?id=$id"
gql:selection="{@formSelection}">
<thisLib:ApiRequestAdaptor/>
<adaptor>return mutateTree_get(payload)</adaptor>
</initApi>
</any>
</source>
</GetInitApi>
<ApiRequestAdaptor outputMode="xjson">
<source>
<!-- 以字符串替换方式,将请求中的 gql:selection 进行修改 -->
<requestAdaptor><![CDATA[
const selection = api['gql:selection'];
api['gql:selection'] =
selection.replaceAll(
'parent{name}',
'parent{name,parent{name}}'
);
return api;
]]></requestAdaptor>
</source>
</ApiRequestAdaptor>
</tags>
</lib>
GetInitApi
也可以直接复用GridListApi
:<GetInitApi x:prototype="GridListApi"/>
。
在 requestAdaptor
和 adaptor
标签内的内容都是 js 代码,其为
AMIS API 的适配器函数体,并且需要返回 payload
结构。
在适配器函数体中可访问的变量可查看 AMIS API 文档。
在后处理 adaptor
函数中调用了全局的转换函数 mutateTree_findPage
和 mutateTree_get
,二者定义在外部 js 库中:
window.mutateTree_findPage = (payload) => {
// {total, page, items} = payload.data
payload.data.items.forEach(mutateTree);
return payload;
};
window.mutateTree_get = (payload) => {
mutateTree(payload.data);
return payload;
};
function mutateTree(item) {
let p = item.parent;
const names = [];
while (p) {
names.push(p.name);
p = p.parent;
}
if (item.parent) {
item.parent.name = names.reverse().join(' / ');
}
}
再通过 xui:import
在 XPage 中引入该库:
...
xui:import: /nop/demo/pages/common.lib.js
...
对外部库的引入需要注意以下几点:
- 需在
nop-demo-app/pom.xml
中引入依赖io.github.entropy-cloud:nop-js
和org.graalvm.js:js
才能支持对外部 js 库的编译和打包 - js 库文件必须以
.lib.xjs
作为文件后缀,否则,Nop 将不会识别和处理 xui:import
指向的 Delta 层的绝对路径,且文件后缀需从.xjs
修改为.js
,否则会找不到库文件。注:.xjs
将最终被编译为.js
- 在
adaptor
中无法直接调用库文件中的导出函数,只能将函数注册为全局函数
接着,在页面视图 XView 中调用前面的 Xpl 函数,以实现对 <api/>
的定制:
<?xml version="1.0" encoding="UTF-8" ?>
<view xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xui/xview.xdef"
x:extends="super">
<pages>
<!-- 该数据的管理列表页面 -->
<crud name="main" grid="list">
<table>
<!-- 编译时扩展 -->
<x:gen-extends>
<!-- 标签前缀与库文件名相同 -->
<nested:GridListApi
bizObjName="Region"
xpl:lib="/nop/demo/pages/nested.xlib"/>
</x:gen-extends>
</table>
</crud>
<!-- 该数据的弹出选择框 -->
<picker name="picker" grid="pick-list"
x:prototype="main"
x:prototype-override="bounded-merge">
<table noOperations="true"/>
</picker>
<!-- 单条数据的查看页面 -->
<simple name="view" form="view">
<x:gen-extends>
<nested:GetInitApi
bizObjName="Region"
xpl:lib="/nop/demo/pages/nested.xlib"/>
</x:gen-extends>
</simple>
</pages>
</view>
在页面视图中,main
页面的 grid
默认为 tree-list
,其为树形表格,
需修改为普通表格 list
。由于数据的弹出选择框 picker
与其管理列表是相同的,
只是去掉了对数据的处理按钮,故而,其可以仅复用(bounded-merge
)页面
main
的 table
结构,并去掉列表中的【操作】列(noOperations="true"
)即可。
过滤父节点的全部子孙节点
该定制需满足以下要求:
- 涉及改动的地方要尽可能少,并保持一定的通用性
- 仅在前端包含按父节点过滤的条件时,才对其补充多级父节点过滤条件, 同时保证其他过滤条件不变
注意,在 Nop 中若是需要新增查询条件,则需要在 XMeta 中定义
prop
,并设置 queryable="true"
,这样才能根据该属性进行查询过滤:
<?xml version="1.0" encoding="UTF-8" ?>
<meta xmlns:x="/nop/schema/xdsl.xdef"
x:schema="/nop/schema/xmeta.xdef"
x:extends="super">
<props>
<prop name="parent.parentId" internal="true" queryable="true">
<schema type="String"/>
</prop>
</props>
</meta>
但是,级联属性默认是以 inner join
方式连接关联表的,并且不能指定和修改该默认行为,
只能在对应的 BizModel
中新增 GraphQL 查询接口或扩展现有的接口,
并通过构造 EQL
的方式显示指定关联对象的连接方式为 left join
,从而实现对父节点的子孙节点的查询。
以对 Region
的定制为例,先重载其 doFindPage
接口,在其查询条件包含 parentId
时做子孙节点的过滤查询:
@BizModel("Region")
public class RegionBizModel extends CrudBizModel<Region> {
@Override
public PageBean<Region> doFindPage(
QueryBean query, BiConsumer<QueryBean, IServiceContext> prepareQuery,
FieldSelectionBean selection, IServiceContext context
) {
TreeBean filter = query.getFilter();
if (filter != null) {
for (TreeBean child : filter.getChildren()) {
if (child.getTagName().equals("eq") //
&& child.getAttr("name").equals("parentId") //
&& child.getAttr("value") != null //
) {
return doFindChildrenPage(
query, child, prepareQuery, selection, context
);
}
}
}
return super.doFindPage(query, prepareQuery, selection, context);
}
}
然后,根据查询条件 QueryBen
构造 SQL
(其采用 EQL 语法):
private SQL newJoinedSQL(QueryBean query, boolean counting) {
SQL.SqlBuilder sql = SQL.begin();
sql.name(query.getName());
sql.disableLogicalDelete(query.isDisableLogicalDelete());
String entityAlias = "o";
sql.append("select ").append(counting ? "count(1)" : entityAlias)
.append(" from ").append(getEntityName()).as(entityAlias);
// 显式限定仅查询 4 个层级
String[] parentAliases = new String[]{
entityAlias, "p1", "p2", "p3"
};
// left join Region p1 on p1.oid = o.parentId
// left join Region p2 on p2.oid = p1.parentId
for (int i = 1; i < parentAliases.length; i++) {
String prev = parentAliases[i - 1];
String curr = parentAliases[i];
sql.br()
.append("left join ")
.append(getEntityName()).as(curr)
.append(" on ").append(curr).append(".oid = ")
.append(prev).append(".parentId");
}
DaoQueryHelper.appendWhere(
sql, entityAlias, query.getFilter()
);
if (!counting) {
DaoQueryHelper.appendOrderBy(
sql, entityAlias, query.getOrderBy()
);
}
return sql.end();
}
最后,通过 ORM 查询分页数据:
private PageBean<Region> doFindChildrenPage(
QueryBean query, TreeBean parentFilter,
BiConsumer<QueryBean, IServiceContext> prepareQuery,
FieldSelectionBean selection, IServiceContext context
) {
prepareFindPageQuery(query, "doFindChildrenPage", context);
if (prepareQuery != null) {
prepareQuery.accept(query, context);
}
PageBean<Region> pageBean = new PageBean<>();
pageBean.setLimit(query.getLimit());
pageBean.setOffset(query.getOffset());
pageBean.setTotal(-1L);
// 添加各层级的父节点过滤条件
Object value = parentFilter.getAttr("value");
List<TreeBean> filters =
Arrays.stream(new String[]{ "p1", "p2", "p3" })
.map((alias) ->
eq("parentId", value)
// 指定过滤属性所属的对象别名: p1.parentId = ?
.attr("owner", alias)
)
.collect(Collectors.toList());
filters.add(parentFilter);
// 过滤掉已逻辑删除的数据
TreeBean filter = and(or(filters), eq("deleted", 0));
query.setDisableLogicalDelete(true);
query.getFilter().replaceChild(parentFilter, filter);
if (selection != null
&& selection.hasField(GraphQLConstants.FIELD_TOTAL)
) {
SQL sql = newJoinedSQL(query, true);
long total = orm().runInSession(session ->
orm().findLong(sql, 0L)
);
pageBean.setTotal(total);
}
if (selection == null
|| selection.hasField(GraphQLConstants.FIELD_ITEMS)
) {
SQL sql = newJoinedSQL(query, false);
List<Region> ret = orm().runInSession(session ->
orm().findPage(
sql,
query.getOffset(),
query.getLimit()
)
);
pageBean.setItems(ret);
}
return pageBean;
}
对于以上代码需注意以下几点:
attr("owner", alias)
是为该过滤属性设置所属对象的别名, 最终,在where
中拼装的条件便是p1.parentId = ?
形式。 若对过滤条件不设置owner
,则默认采用主体对象的别名o
拼接属性,如o.parentId
- 由于默认的逻辑删除过滤会以
and
方式对各级关联对象都附加deleted = 0
的过滤条件,使得最终结果仅为最后层级的数据, 故而,需要通过query.setDisableLogicalDelete(true)
禁用该行为,并仅对查询主体对象进行逻辑删除过滤即可:eq("deleted", 0)
由于是对已有分页查询接口的直接定制,故而,不需要修改和调整前端。
注意事项
xpl:lib
必须为 Delta 层的绝对路径,不能是相对路径- 使用
xpl:lib
的标签前缀需与库文件名相同 - 若在 Delta 层中修改
*.page.yaml
,其view
属性值必须为 Delta 层的绝对路径, 若使用相对路径,则其引用的将是 vfs 初始层的文件 - EQL 中没有布尔值,需对
Boolean
的过滤属性赋值为0
或1