准备好数据模型描述(参考tool-wui-page-example.txt) 用在线工具生成对象管理源文件框架: http://localhost/dev/jdcloud/tool/ 它可以生成管理端文件、升级数据库、服务端代码、更新菜单项。
开发中若要增加个别字段,不想全部重新生成所有文件,可直接在DESIGN.md的表定义中添加,然后在git-bash命令行中刷新数据库:
cd tool
sh upgrade.sh initdb
然后在列表页和详情对话框中分别添加该字段。各种类型的字段详见下面章节说明。
注意全部弄好后,在上线前应更新meta用于在线部署:
cd tool
make meta
(把更新内容提交到git库)
然后在线上运行 http://{myserver}/{mysvc}/tool/upgrade/
来刷新数据库。
[底层方法]
也可以直接用命令行工具:server/web/page/create-wui-page.php 先准备好meta.txt文件放在该目录下,然后运行命令:
php create-wui-page.php -
写meta.txt示例:
@User: id, name(s), storeId, status(4), weixinData(1000), dscr(t), picId, 积分&, adminFlag
用户: 编号, 昵称, 企业/linkTo:Store/textField:storeName, 状态/enum:UserStatusList, 微信数据, 描述, 头像, 积分, 企业管理员/enum:YesNoMap
&
整数, @
货币(高精度小数)注意:符号最好用半角英文符号,尽管为了方便也兼容中文逗号和冒号。
对话框中:(dlgXXX.html)
<!-- 编号(id)为系统字段,不允许修改, 但可以查询 -->
<tr>
<td>编号</td>
<td><input name="id" disabled></td>
</tr>
<!-- 姓名(name)为必填字段,习惯上加*标识 -->
<tr>
<td>姓名*</td>
<td><input name="name" class="easyui-validatebox" data-options="required:true"></td>
</tr>
<!-- 手机号(phone)选填, 须验证11位格式 -->
<tr>
<td>手机号</td>
<td><input name="phone" class="easyui-validatebox" data-options="validType:'cellphone'"></td>
</tr>
<!-- 备注(cmt)是长文本字段,选填 -->
<tr>
<td>备注</td>
<td><textarea name="cmt" rows=3></textarea></td>
</tr>
<!-- 用wui-picker-edit标识默认不建议修改的字段,可手工点击修改图标 -->
<!-- 字段说明推荐用hint标识,有时也用placeholder(常用于简单格式说明,缺点是填上字就看不见了)或title(鼠标移上才有,更隐晦) -->
<tr>
<td>优先级</td>
<td>
<input name="pri" placeholder="1-9数字, 数字越大越优先, 一般填写5" title="1-9数字, 数字越大越优先, 一般填写5" class="wui-picker-edit">
<p class="pri">1-9数字, 数字越大越优先, 一般填写5</p>
</td>
</tr>
关于字段验证类型(validType),参考管理端文档.easyui-validatebox
。 若要定制字段显示内容,参考管理端文档formatter
;若要定制字段显示样式,参考管理端文档datagrid.styler
.
列表中:(pageXXX.html)
<th data-options="field:'id', sortable:true, sorter:intSort">编号</th>
<th data-options="field:'name', sortable:true">姓名</th>
<th data-options="field:'phone', sortable:true">手机号</th>
<th data-options="field:'cmt', sortable:true">备注</th>
如type,status这种字段,由若干固定值构成。展示的需求为:
在pageXXX.html中定义:
<th data-options="field:'status', jdEnumMap: OrderStatusMap, formatter:Formatter.enum(OrderStatusMap), styler:Formatter.enumStyler({PA:'Warning', RE:'Disabled', CR:'#00ff00'}), sortable:true">状态</th>
在全局文件app.js或应用主文件如store.js中定义全局常量:
formatter用于设置显示文字;jdEnumMap用于在导出excel时也能正确转换。
styler用于设置显示样式,常用Formatter.enumStyler或Formatter.enumFnStyler来定义不同值的颜色,请参考管理端手册。
特别地,对于YesNo类型的字段(字段名一般为xxxFlag,也称为flag字段),使用系统自带的YesNoMap或YesNoMap2。见下面flag字段章节。
上面Formatter.enum及Formatter.enumStyler是框架预定义的常用项,也可自定义formatter或styler,一般在pageXXX.js中定义(如果别的地方需要共用,则放到主逻辑文件如store.js中),如:
var OrderColumns = {
status: function (value, row) {
if (! value)
return;
return OrderStatusMap[value] || value;
},
statusStyler: function (value, row) {
var colors = {
CR: "#000",
RE: "#0f0",
CA: "#ccc"
};
var color = colors[value];
if (color)
return "background-color: " + color;
}
};
在pageXXX.html引用:
<th data-options="field:'status', jdEnumMap: OrderStatusMap, formatter:OrderColumns.status, styler:OrderColumns.statusStyler, sortable:true">状态</th>
在dlgXXX.html中为status字段定义下拉列表:
<select name="status" class="my-combobox" data-options="jdEnumMap:OrderStatusMap"></select>
参考:my-combobox组件。
新的设计中,建议直接使用中文来定义枚举,不必做转换,即status的值直接是“待服务”, “已服务”这些,不再用“PA”, “RE”这些缩写。 这样上面可简化成:
<th data-options="field:'status', sortable:true, styler:Formatter.enumStyler({'待服务':'Warning'})">状态</th>
在全局文件app.js或应用主文件如store.js中定义全局常量:
var OrderStatusList = "未付款;待服务;已服务;已评价;已取消;正在服务";
不再需要设置formatter和jdEnumMap,因为显示和导出文件时无须做文字转换。
styler由于比较简单,可使用Formatter.enumStyler直接为各种状态定义颜色,预设的有Warning, Error, Info, Disabled四种,也可以直接指定颜色,注意null也可指定颜色。
{'待服务':'Warning', '正在服务':'Error', '已服务':'Info', '未付款':'Disabled', '已取消':'#cccccc', null: 'Error'}
在dlgXXX.html中定义:
<select name="status" class="my-combobox" data-options="jdEnumList:OrderStatusList"></select>
注意:jdEnumList与jdEnumMap选项格式不同。
示例:是否“企业管理员”字段 - adminFlag
列表页:pageUser.html
<th data-options="field:'adminFlag', sortable:true, jdEnumMap:YesNoMap, formatter: Formatter.enum(YesNoMap), styler:Formatter.enumStyler({})">企业管理员</th>
详情对话框:dlgUser.html
<select name="adminFlag" class="my-combobox" data-options="jdEnumMap:YesNoMap"></select>
YesNoMap是框架定义的:0-否,1-是;类似的还有YesNoMap2: 0-否,1-是,2-处理中。
也可以自行定义。示例:disableFlag - 禁用和启动状态
在app.js中定义常量:
window.DisableMap = {
0: "启用",
1: "禁用"
};
在列表页:pageXXX.html
<th data-options="field:'disableFlag', sortable:true, formatter: Formatter.enum(DisableMap), styler:Formatter.enumStyler({1:'Disabled'})">启用状态</th>
详情对话框:dlgXXX.html
<select name="disableFlag" class="my-combobox" data-options="jdEnumMap:DisableMap"></select>
示例:用户关联所在企业(User.storeId=Store.id)
实现:
先在设计文档中,为用户查询接口添加返回关联字段storeName: 文件DESIGN.md
User.query() -> tbl(..., storeName)
在后端添加关联字段:api_objects.php
class AC0_User extends AccessControl
{
protected $vcolDefs = [
[
"res" => ["s.name storeName"],
"join" => "LEFT JOIN Store s ON s.id=t0.storeId",
"default" => true
]
];
}
class AC2_User extends AC0_User { }
注意:
"default"=>true
。列表页 pageUser.html
<th data-options="field:'storeId', sortable:true, sorter:intSort, formatter: Formatter.linkTo('storeId', '#dlgStore', 'storeName')">企业</th>
明细页 dlgUser.html 从企业列表中选择:
<tr>
<td>企业</td>
<td>
<select name="storeId" class="my-combobox" data-options="ListOptions.Store()"></select>
</td>
</tr>
在主文件store.js中添加ListOptions.Store定义:
var ListOptions = {
...
Store: function () {
var opts = {
valueField: "id",
textField: "name",
url: WUI.makeUrl('Store.query', {
res: 'id,name',
pagesz: -1
}),
formatter: function (row) { return row.id + "-" + row.name; }
};
return opts;
}
}
需求:
参考wui-upload组件。
在pageXXX.html中为表格设置列:(习惯上图片字段名为picId; 多图字段名为pics)
<th data-options="field:'picId', sortable:true, formatter: Formatter.pics">主图</th>
如果是附件文件:(习惯上文件字段名为atts)
<th data-options="field:'atts', sortable:true, formatter: Formatter.atts">文件</th>
在dlgXXX.html中设置字段:
单图:
<tr>
<td>主图</td>
<td class="wui-upload" data-options="multiple:false">
<input name="picId">
</td>
</tr>
多图:
单文件:(选项fname:1表示字段中存储文件名,这样就只能用atts不能用attId)
<tr>
<td>证书文件</td>
<td class="wui-upload" data-options="multiple:false, pic:false, fname:1">
<input name="atts">
</td>
</tr>
多文件:
<tr>
<td>附件</td>
<td class="wui-upload" data-options="pic:false, fname:1">
<input name="atts">
</td>
</tr>
系统用户(Employee)可以赋予一到多个角色。 表设计:
@Employee: id, name, ..., roles
- roles: List(role). 角色列表,示例: "mgr"(高级管理员), "emp"(管理员), "审核专员", "审核专员,日志分析员"
其中mgr, emp是系统固有角色,其它角色可自定义包含的权限。
@Role: id, name, perms
- perms: List(perm). 权限列表。
在系统用户设置对话框中这样展示角色:dlgEmployee.html
<tr>
<td>角色</td>
<td class="wui-checkList" data-options="ListOptions.Role()">
<input type="hidden" name="roles">
<div><label><input type="checkbox" value="mgr">最高管理员</label></div>
<div><label><input type="checkbox" value="emp" checked>管理员</label></div>
</td>
</tr>
wui-checkList将自动序列化和反序列化角色列表。固定的选项直接列出,动态的选项根据数据库查询列表,在data-options中指定url相关选项,这与my-combobox组件的使用方式相同。
在全局逻辑中设置ListOptions.Role: store.js
var ListOptions = {
...
Role: function () {
var opts = {
valueField: "name",
textField: "name",
url: WUI.makeUrl('Role.query', {
res: 'name',
pagesz: -1
})
};
return opts;
}
}
示例:添加用户时,自动填写:
在后端完成自动补全:api_objects.php
class AC2_User extends AccessControl
{
protected function onValidate()
{
if ($this->ac == "add") {
$_POST["createTm"] = date(FMT_DT);
if (!issetval("status"))
$_POST["status"] = "待审核";
}
}
}
明细页中,在添加时,要求“创建时间”字段不可填,而状态字段自动变成“待审核”:在dlgUser.js中动态修改
function onBeforeShow(ev, formMode, opt)
{
var objParam = opt.objParam;
var forAdd = formMode == FormMode.forAdd;
setTimeout(onShow);
function onShow() {
// 添加时灰掉createTm字段
frm.createTm.disabled = forAdd;
// 添加时自动填写字段
if (forAdd) {
$(frm.status).val("待审核");
$(frm.storeId).val(1);
}
}
}
设置值时,尽量用jQuery操作。虽然一般也可以用
frm.status.value = "待审核";
frm.storeId.value = 1;
但是会有bug,比如当第一次打开对话框做添加操作时,企业列表尚未加载成功,设置frm.storeId.value=1
无效。 而select控件的jQuery.val函数做了扩展,用$(frm.storeId).val(1)
就可以成功操作。
需求:pwd字段,要求在对话框显示成****
。
在dlgUser.html中,
function onBeforeShow(ev, formMode, opt)
{
if (formMode == FormMode.forSet)
opt.data.pwd = "****";
}
注意:和前面章节在添加时给初值不同,当时是在onShow中设置UI组件;而这里是在onBeforeShow中修改初始数据opt.data。 因为,如果设置UI组件,则提交时判断UI与初值不同,就会提交修改;而修改了初值,在提交时,如果在UI上未修改,就不会做提交。
示例:条目Item分为多个类别type. 查询条目接口:
Item.query(type?)
- 当type="广告位"时,按cond="广告位优先级>0"查询,默认按此优先级倒序排列;
- 当type="新鲜事"时,过滤type="活动"/"集市"的条目。
- 否则按type指定值过滤。
实现:api_objects.php, 在onQuery中用addCond增加条件:
protected function onQuery()
{
$type = param("type");
if ($type) {
if ($type == "广告位") {
$this->addCond("广告位优先级>0");
// 设置排序条件,可以设置`$this->defaultSort`(可被orderby接口参数覆盖),也可写死即设置`$this->sqlConf["orderby"]`
//$this->sqlConf["orderby"] = "广告位优先级 DESC";
$this->defaultSort = "广告位优先级 DESC";
}
else if ($type == "新鲜事") {
$this->addCond("type IN ('活动', '集市')");
}
else {
$this->addCond("type=" . Q($type));
}
}
}
添加过滤逻辑示例:
Item.query(q?)
- AUTH_USER
- 默认用户只能看已发布的所有条目(即按status='发布中'过滤)
- 用户可以看到自己的除了“已删除”状态外的所有条目,指定参数为`q=my`
在api_objects.php的AC1_User中实现:
protected function onQuery()
{
...
$q = param("q");
if ($q == "my") {
$uid = $_SESSION["uid"];
$this->addCond("userId=$uid");
$this->addCond("status<>'已删除'");
}
else {
$this->addCond("status='发布中'");
}
}
特别须注意的是:onQuery函数也会被set/del/setIf/delIf等接口回调,用于限制可修改数据的范围。 所以在onQuery中,如果不是为了限制范围(addCond)的逻辑,比如只是为了query接口的逻辑,可加$this->ac == "query"
限定,避免影响set/del等接口,如:
应改为:
[需求]
[数据模型]
@User: id, storeId, adminFlag
- adminFlag: 是否是企业管理员
@Store: id, name, 积分, 地址, 联系人
[接口设计]
Store.set()(地址, 联系人, ...)
- (AUTH_USER & PERM_ADMIN_USER) | AUTH_EMP
- 只可更新自己所在企业,不必传id
[后端实现]
先定义一个权限PERM_ADMIN_USER,表示企业管理员. api.php
// 权限类型
...
const PERM_ADMIN_USER = 0x200;
$PERMS = [
...
PERM_ADMIN_USER => "admin-user",
];
在用户登录时保存adminFlag字段到session:php/class/LoginImp.php LoginImp.onLogin是login插件的接口实现,参考login插件。(plugin/login)
class LoginImp extends LoginImpBase
{
// 登录成功时回调
function onLogin($type, $id, &$ret)
{
if ($type == "user") {
$_SESSION["adminFlag"] = $ret["adminFlag"];
}
}
}
在onGetPerms中设置权限:api.php
function onGetPerms()
{
$perms = 0;
if (isset($_SESSION["uid"])) {
$perms |= AUTH_USER;
if ($_SESSION["adminFlag"]) {
$perms |= PERM_ADMIN_USER;
}
}
...
}
注意:用户权限一旦被修改,必须重新登录才能生效。
session变量属于重要的后端内部接口,在主设计文档DESIGN.md中添加说明:
## 后端内部接口
会话变量:
- 用户登录
- uid: 用户编号
- adminFlag: 管理员标志
- 员工登录
- empId: 员工编号
实现Store.set接口: api_objects.php 检查PERM_ADMIN_USER权限,并自动补上id参数,
class AC1_Store extends AC_Store
{
// 默认没有set操作
protected $allowedAc = ["get", "query"];
protected $readonlyFields = ["企业积分", "name"];
protected function onInit() {
// 当有权限时才加set操作
if (hasPerm(PERM_ADMIN_USER)) {
$this->allowedAc[] = "set";
}
}
protected function onValidateId() {
// set时强制设置成自己企业。
if (!param("id") || $this->ac == "set") {
$uid = $_SESSION["uid"];
$this->id = queryOne("SELECT storeId FROM User WHERE id=" . $uid);
}
}
}
用$allowedAc
来限制操作; 用$readonlyFields
来限制更新操作的字段。add/set操作都不可设置这些字段。 如果add接口可以设置该字段,但set操作不可以改,应使用$readonlyFields2
。假如此例中“name”想要在添加时可指定,但企业积分add/set时都只读:
protected $readonlyFields = ["企业积分"];
protected $readonlyFields2 = ["name"];
AC1类保证了用户已登录(可以安全地取session变量uid), 用hasPerm()
来判断权限,如“企业管理员”时添加“set”接口。也可用checkAuth
检查权限,不符时将直接报错返回。
[限制set操作]
上面为了限制set操作只对本企业,直接在onValidateId中设置id。这意味着即使是给定了错误的id,也不会报错,而是仍修改本企业。 标准的做法是在onQuery中为set操作限定范围,如下:
class AC1_Store extends AC_Store
{
...
protected function onValidateId() {
if (! param("id")) { // 不强制设置set操作的id
$uid = $_SESSION["uid"];
$this->id = queryOne("SELECT storeId FROM User WHERE id=" . $uid);
}
}
protected function onQuery() {
// 对set的范围进行限定
if ($this->ac == "set") {
$uid = $_SESSION["uid"];
$storeId = queryOne("SELECT storeId FROM User WHERE id=" . $uid);
$this->addCond("id=$storeId");
}
}
}
这时,如果给定的id不正确,就会直接报错。
示例:查询用户时,隐藏微信数据等字段。
@User: id, name, weixinKey, weixinData(2000)
后端实现:api_objects.php 设置$hiddenFields
.
在onQuery中限制操作范围。
本节与之前后端查询时加限制条件
类似。应特别注意onQuery不止用于query接口,还被get/set/del等接口用于限定数据操作范围,即执行其中的addCond操作。 必要时应小心判断this->ac == 'query'
,避免影响其它接口。
需求:
数据模型:
@Store: id, name
@User: id, name, storeId
交互接口:
User.query()
User.get(id?)
User.set(id?)
限定查看自己企业的用户,限定只能操作自己的数据。
但企业管理员可修改企业中的用户。
如果未指定id参数,以当前用户id补齐。
后端实现:
class AC1_User extends AC0_User
{
protected $allowedAc = ["get", "set", "query"];
protected function onValidateId()
{
// 自动补上id参数
if (!param("id")) {
$uid = $_SESSION["uid"];
$this->id = $uid;
}
}
protected function onQuery() {
$uid = $_SESSION["uid"];
// 读操作(get/query)或是管理员,限制storeId
if ($this->ac == "get" || $this->ac == "query" || hasPerm(PERM_ADMIN_USER)) {
$storeId = queryOne("SELECT storeId FROM User WHERE id=" . $uid);
$this->addCond("storeId=$storeId");
}
// 写操作,限制uid
else {
$this->addCond("id=$uid");
}
}
}
使用addRes动态添加字段。
需求:预订会议室时,根据日期查看会议室列表。
数据模型:
@Room: id, name
@RoomOrder: id, roomId, 时段
接口:
Room.query(dt?) -> tbl(id, ..., 已用时段?)
- dt: 如果指定日期dt,则返回每个会议室当天已被占用的时段
后端实现:
class AC1_Room extends AccessControl
{
protected $allowedAc = ["get", "query"];
protected function onQuery() {
$dt = param("dt/dt");
if ($dt) {
$dtStr = date(FMT_DT, $dt);
$this->addRes("(SELECT group_concat(时段) FROM RoomOrder WHERE roomId=t0.id AND dt='$dtStr' AND status<>'已取消') 已用时段");
}
}
}
示例:通过手机号发优惠券时,支持批量发量,用逗号分隔的多个手机号。
接口:
手机号userPhone只有一个时:
Coupon.add()(userPhone, ...) -> id
如果userPhone包含多个手机号:(用逗号隔开,支持中文逗号,支持有空格)
Coupon.add()(userPhone, ...) -> {cnt, idList}
实现: 重载add接口,如果是批量添加则通过callSvc再调用add接口:
class AC2_Coupon extends AccessControl
{
function api_add() {
if (@$_POST["userPhone"]) {
$arr = preg_split('/[,,]/u', $_POST["userPhone"]);
if (count($arr) > 1) {
$idList = [];
foreach ($arr as $e) {
$postParam = array_merge($_POST, ["userPhone"=>trim($e)]);
$idList[] = $this->callSvc(null, "add", null, $postParam);
}
setRet(0, [
"cnt"=>count($idList),
"idList"=>$idList
]);
throw new DirectReturn();
}
}
return parent::api_add();
}
}
类似例子可参考: http://oliveche.com/jdcloud-site/demo2/DEV.html#共用订单页面
示例:定义了Item表,通过type区分 活动、发包、集市等多种功能。
@Item: id, type(4), status(4), name, label, content(t), picId, pics, userId, storeId, price, qty, leftQty, startTm, endTm, 时间, 地点, 联系人, 联系方式, 积分&, 广告位优先级&, 公告优先级&
假如已经生成了pageItem页面,可以通过pageFilter机制给这个页面添加过滤条件。比如只显示“活动”类型:
WUI.showPage('pageItem', {title: '活动管理', pageFilter: {cond: {type:'活动'}}})
在store.html中修改主应用的菜单,在菜单中将原先的:
<a href="#pageItem">活动管理</a>
改成多项,分别指定type参数:
<a href="javascript:WUI.showPage('pageItem', {title: '活动管理', pageFilter: {cond: {type:'活动'}}})">活动管理</a>
<a href="javascript:WUI.showPage('pageItem', {title: '企业合作', pageFilter: {cond: {type:'发包'}}})">企业合作</a>
...
可优化一下,避免标题重复多遍,菜单项改写成这样:
<a itemType="活动">活动管理</a>
<a itemType="发包">企业合作</a>
...
在store.js中处理菜单项的点击事件:
function main()
{
...
// 打开item的各个衍生页。title自动从当前a对象内容获取
$("#menu a[itemType]").click(function () {
var type = $(this).attr("itemType");
var title = $(this).text();
WUI.showPage('pageItem', {
title: title,
pageFilter: {cond: {type: type}}
});
});
}
在列表页初始化函数initPageXXX中处理showPage传入的opt参数:
function initPageItem(opt)
{
...
var jdlg = $("#dlgItem");
// 1. 传参给对话框,这里title参数制定对话框与列表页显示一样的标题。
jdlg.objParam = {
title: opt.title
};
jtbl.datagrid({
url: WUI.makeUrl("Item.query"),
...
});
// 2. 不同类型显示不同的列, 封装了WUI.toggleFields函数, 列表框和明细框中均可用
var type = WUI.getPageFilter(jpage, "type");
toggleItemFields(jtbl, type);
// 原理是一列列控制:
// WUI.toggleCol(jtbl, 'type', !type);
// WUI.toggleCol(jtbl, 'status', !type || type!="公告");
...
}
页面通过jdlg.objParam传参数给明细对话框。 特别地,objParam.title 参数将修改对话框的标题。参考 showObjDlg 的opt参数。
常常需要不同类型显示不同的字段。 在store.js中封装一个对于Item对象的字段控制函数toggleItemFields, 它内部使用WUI.toggleFields工具函数(v5.4),可同时适用于控制列表页中列的显示,以及明细页上的字段的显示,示例:
function toggleItemFields(jo, type)
{
WUI.toggleFields(jo, {
// 控制动态字段的显示,对应datagrid列表中各列的field选项值,或明细页中输入项的name值。
type: !type,
status: !type || type!="公告",
tm: !type || type=="活动" || type=="卡券" || type=="停车券"
});
}
在 dlgItem.js中,根据type动态显示要哪些输入框,使用与page页面中相同的toggleItemFields函数:
function initDlgItem()
{
...
WUI.setDlgLogic(jdlg, "type", {
disabledForAdd: true,
watch: "type",
setOption: function (row) {
// 控制字段显示, 与列表上共用toggleItemFields函数
toggleItemFields(jfrm, row.type);
// 原理是控制每个字段显示, 类似于:
// $(frm.type).closest("tr").toggle(!type);
// $(frm.status).closest("tr").toggle(!type || type!="公告");
return {
jdEnumMap: row.type=="其它"? ItemStatusMap_其它: ItemStatusMap
};
}
});
}
setDlgLogic函数setOption参数实现的底层可参考mycombobox和wui-combogrid的setOption事件:动态修改下拉框的选项,实现动态更新查询等。 注意旧代码使用WUI.showByType方案,现在已不推荐使用。
在store.js中定义常量:
订单日志OrderLog与增项日志表IncrLog,希望查询订单日志时,一并返回关联的增项日志。
@Order: id, ...
@OrderLog: id, orderId, action, dscr, empId, createTm, field, fieldName, originValue, newValue
@IncrementProject: id, orderId, ...
@IncrLog: id, incrId, action, empId, dscr(l), createTm
接口为:
OrderLog.query(cond="orderId={orderId}")
IncrLog.query()
现在希望通过OrderLog.query也同时返回该订单对应增项的操作日志。
后端原代码为:api_objects.php
class AC2_OrderLog extends AccessControl
{
protected $allowedAc = ["query"];
protected $vcolDefs = [
[
"res" => ["emp.name empName"],
"join" => "LEFT JOIN Employee emp ON emp.id=t0.empId",
"default" => true
]
];
}
通过AccessControl::$table字段可设定为一个UNION子查询:
class AC2_OrderLog extends AccessControl
{
protected $table = "(SELECT orderId, action, createTm, empId, field, fieldName, originValue, newValue from OrderLog t0
union
select i.orderId, '增项操作', t1.createTm, t1.empId, 'incrStatus', '增项状态', null, t1.dscr from IncrLog t1
inner join IncrementProject i on incrId=i.id)";
...
}
在显示时段长度时,数据模型中使用秒来计算:
@ReviewLog: id, empId, asrReqId, tm, t&
- t: 审核时长(秒)
在前端展示如下:pageReviewLog.html
<th data-options="field:'t', sortable:true, sorter:intSort">审核时长(秒)</th>
客户希望不要直接展示秒数,而是以“时:分:秒”的习惯方式来显示。
有两种解决方案:一是在后端做格式转换,二是在前端做(但如果考虑到导出操作,后端也需要做转换)。
一般建议在后端做转换,这样查询和导出文件均可以兼顾:
// 支持毫秒和秒转成 时:分:秒 格式
function timeStr($t, $isSec=false)
{
$s = $isSec? $t: (int)($t / 1000);
$h = (int)($s / 3600);
$s -= $h * 3600;
$m = (int)($s / 60);
$s -= $m * 60;
if ($h == 0)
return sprintf("%02d:%02d", $m, $s);
return sprintf("%02d:%02d:%02d", $h, $m, $s);
}
class AC2_ReviewLog extends AccessControl
{
...
protected function onQuery() {
$this->enumFields["t"] = function ($v, $row) {
if ($v) {
// 转格式
return timeStr($v, true);
}
};
}
}
要注意,由于该列是整型,前端生成列时会以intSort来排序,这时应调整排序:
<th data-options="field:'t', sortable:true">审核时长</th>
后端修改对排序不影响(当按t排序时在后端仍然是整数排序,不会按“时:分:秒”字符串来排)。 其实jd-web前端,在点击列头排序时,一般都是交给后端来排序,sorter字段不起作用;但对小于5条数据的列表排序有优化,这时不会发送后端(在本例中,将按显示的字符串排序,会有些小问题)。
在某些情况下(比如,处理过于复杂,想节约后端的处理资源;或是为了兼容以前代码等),由前端为字段添加formatter来设置字段格式: pageReviewLog.html
<th data-options="field:'t', sortable:true, sorter:intSort, formatter:Formatter.t">审核时长(秒)</th>
在store.js中定义格式化函数t:
var Formatter = {
...
t: function (val, row) {
var h = Math.floor(val / 3600);
val -= h*3600;
var n = Math.floor(val / 60);
val -= n*60;
if (h == 0)
return pad_2(n)+":"+pad_2(val);
return h+":"+pad_2(n)+":"+pad_2(val);
function pad_2(number)
{
return number < 10? ("0" + number) : ("" + number);
}
}
}
这时,页面显示好看了,但有个问题:在导出excel文件时仍显示秒数。 这就需要在后端调整,用到enumFields为输出字段设置处理函数:api_objects.php
class AC2_ReviewLog extends AccessControl
{
...
protected function onQuery() {
if ($this->isFileExport()) {
// 这里与上一节的处理一样
$this->enumFields["t"] = function ($v, $row) {
if ($v) {
// 转格式
return timeStr($v, true);
}
};
}
}
}
其中用isFileExport来判断是否是文件导出操作。
示例图样:
考虑订单表Ordr与订单明细表Ordr1,v5.4起框架支持添加对象时带子表。表如下:
@Ordr: id, status, ...
@Ordr1: id, orderId, itemId, qty, ...
定义添加接口:
Ordr.add(status, ... @ordr1);
先实现后端支持子表:
class AC2_Ordr extends AccessControl
{
protected $subobj = [
"ordr1" => ["obj"=>"Ordr1", "cond"=>"orderId=%d", "AC"=>"AC2_Ordr1"]
];
}
// 用于独立的子表查询
class AC2_Ordr1 extends AccessControl
{
}
v5.5支持wui-subobj子表组件,管理端定义主表对话框时,使用wui-subobj组件加上子表表格: (手册中搜索wui-subobj)
<form my-obj="Ordr" title="订单" style="width:500px;height:400px;" wui-script="dlgOrdr.js" my-initfn="initDlgOrdr">
...
<div class="easyui-tabs">
<div class="wui-subobj" data-options="obj:'Ordr1', relatedKey:'orderId', valueField:'orders', dlg:'dlgOrdr1'" title="订单明细">
<table>
<thead><tr>
<th data-options="field:'id', sortable:true, sorter:intSort">编号</th>
<th data-options="field:'itemId', sortable:true, sorter:intSort">产品</th>
<th data-options="field:'qty', sortable:true, sorter:numberSort">数量</th>
</tr></thead>
</table>
</div>
</div>
</form>
Ordr1.query
接口。Ordr.add()(..., orders)
接口。然后,在子表对话框中须将主表关联字段(此处即orderId字段,即wui-subobj的relatedKey选项值)设置为wui-fixedField类,这样该字段将会被自动设置值:dlgOrdr1.html
<tr>
<td>编号</td>
<td>
<input name="id" disabled>
<!-- 主表关联字段,设置隐藏,设置wui-fixedField类即可 -->
<input name="orderId" class="wui-fixedField" style="display:none">
</td>
</tr>
若是简单的只读子表(不可添加、更新等),例如用于显示日志,则不必关联对话框:
<div class="wui-subobj" data-options="obj:'Ordr1', relatedKey:'orderId', res:'id,itemId,qty'" title="订单明细">
<table>
...
</table>
</div>
这时可以指定res字段,即query接口的res参数,可减少返回字段。
wui-subobj封装了常见操作,v5.5以前的实现见下节,可以了解其原理。 若对子表进行复杂的控制,例如随着主表一起添加,但添加后只读,参考后面例子。
(v5.5起可以用wui-subobj组件)
管理端定义主表对话框时,加上子表表格,使用notForFind类让它在查询模式下不显示:dlgOrdr.html
<form my-obj="Ordr" title="订单" style="width:500px;height:400px;" wui-script="dlgOrdr.js" my-initfn="initDlgOrdr">
...
<div class="notForFind">
<p><b>订单明细</b></p>
<table id="tblOrdr1">
<thead><tr>
<th data-options="field:'id', sortable:true, sorter:intSort">编号</th>
<th data-options="field:'itemId', sortable:true, sorter:intSort">产品</th>
<th data-options="field:'qty', sortable:true, sorter:numberSort">数量</th>
</tr></thead>
</table>
</div>
</form>
管理端实现主表对话框时,在onShow时显示子表,注意添加模式时,应对子表对话框设置offline模式,即缓存子表列表, 在onValidate时设置主表字段newData.ordr1。在设置时,子表的CRUD是立即和独立完成的,不依赖于主表(即直接使用后端的AC2_Ordr1类)。 文件dlgOrdr.js:
function initDlgOrdr()
{
...
// 子表列表与子表对话框
var jtbl = jdlg.find("#tblOrdr1");
var jdlg1 = $("#dlgOrdr1");
jdlg.on("beforeshow", onBeforeShow)
.on("validate", onValidate);
function onBeforeShow(ev, formMode, opt)
{
...
var forAdd = formMode == FormMode.forAdd;
var forSet = formMode == FormMode.forSet;
setTimeout(onShow);
function onShow() {
if (forAdd || forSet) {
var orderId = opt.data.id;
jdlg1.objParam = {
orderId: orderId, // 与子表对话框中wui-fix
offline: forAdd // 添加时主子表一起提交;更新时子表单独提交
};
jtbl.jdata().toolbar = forAdd && "ads"; // add/del/set
var dgOpt = {
toolbar: WUI.dg_toolbar(jtbl, jdlg1),
onDblClickRow: WUI.dg_dblclick(jtbl, jdlg1),
data: forAdd && [],
url: forSet && WUI.makeUrl("Ordr1.query", {cond: "orderId=" + orderId})
};
jtbl.datagrid(dgOpt);
}
}
}
function onValidate(ev, mode, oriData, newData)
{
if (mode == FormMode.forAdd) {
// 添加时设置子表字段
newData.ordr1 = jtbl.datagrid("getData").rows;
}
}
}
子表对话框须将主表关联字段(此处即orderId字段)设置为wui-fixedField类,它表示在添加时将使用jdlg.objParam中相应字段:dlgOrdr1.html
<tr>
<td>编号</td>
<td>
<input name="id" disabled>
<!-- 主表关联字段,设置隐藏,设置wui-fixedField类即可 -->
<input name="orderId" class="wui-fixedField" style="display:none">
</td>
</tr>
(v5.5起可以用wui-subobj组件,指定valueField,指定readonly:true即可实现多数功能。但细节操作比如onCrud不支持)
考虑一个典型的主-子表结构:库存记录表InvRecord保存总金额,及其子表“库存明细”InvRecord1,定义如下:
库存记录(也可用于销售),记录出入库的时间、总金额等。
@InvRecord: id, tm, type(s), whId, amount, cusId, discountId, empId, cmt(l), discRate@, tax@
- type: Enum(出库,入库,报损,赠送,打包,拆包,销售, 调拨入库,调拨出库)
库存明细,记录每项物料(Item)的数量、金额等。
@InvRecord1: id, invId, itemId, qty, curQty, price, itemName, taxRate@, total, noTaxTotal, dir
- invId: 关联InvRecord.id
添加、编辑、查看库存记录时,使用如下接口:
InvRecord.add()(type,whId,...,inv1)
InvRecord.set()(dscr...)
InvRecord.query/get() -> { ..., empName?, whName?, @inv1={itemId,qty,curQty, itemName, price} }
- inv1 (add操作参数): List(itemId,qty,price?)。添加时,通过inv1指定子表,示例:"101:3:180.00,102:2:200.00"
- inv1 (query/get返回参数): 一个数组。
- set接口不可更新子表。即当库存记录添加后,明细表不可编辑(应显示为只读)。
设计WEB管理端时,对主表仍是经典的列表页-详情对话框模式(pageInvRecord.html/js, dlgInvRecord.html/js)。
在详情对话框中展示明细子表:dlgInvRecord.html
<form my-obj="InvRecord" wui-script="dlgInvRecord.js" my-initfn="initDlgInvRecord" title="出入库">
<table>
<tr>
<td>编号</td>
<td><input name="id" disabled></td>
</tr>
...
</table>
<div id="divInvRecord1" class="notForFind">
<p><b>商品明细</b></p>
<table id="tblInvRecord1" style="width:auto;height:auto">
<thead><tr>
<th data-options="field:'itemName', formatter:Formatter.itemId">商品</th>
<th data-options="field:'qty', formatter:WUI.formatter.number,styler: InvRecordColumns.typeStyler">数量</th>
<th data-options="field:'curQty', formatter:WUI.formatter.number">剩余数量</th>
<th data-options="field:'price', formatter:WUI.formatter.number">单价(含税)</th>
<th data-options="field:'total', formatter:WUI.formatter.number">总价</th>
<th data-options="field:'noTaxTotal', formatter:WUI.formatter.number">不含税总价</th>
</tr></thead>
</table>
</div>
</form>
对话框在更新模式(forSet)下,显示子表数据,不可编辑;在查询模式(forFind)下,不显示子表; 在添加模式下(forAdd),子表为空,可进行CRUD操作,且在操作时,自动重算主表金额: dlgInvRecord.js
function initDlgInvRecord()
{
...
// 用objParam给子表对话框 dlgInvRecord1 传参。
var jdlg1 = $("#dlgInvRecord1");
jdlg1.objParam = {
offline: true, // 指定该项,则子表操作时将不会立即提交到数据库,可在validate事件中对整个表数据进行处理。
onCrud:function () { // 在操作子表时回调,可用于在明细项改变时,重新计算主表amount值
onUpdateAmount();
}
};
var jtbl = jdlg.find("#tblInvRecord1");
// 显示子表及工具栏(工具栏将在onShow里面再判断是否显示)
// 注意:要在onShow中loadData,即使是空数据。否则可能表格行对不齐。
jtbl.jdata().toolbar = "ads"; // add/del/set
jtbl.datagrid({
toolbar: WUI.dg_toolbar(jtbl, jdlg1),
onDblClickRow: WUI.dg_dblclick(jtbl, jdlg1),
data: []
});
// 加载时,根据query/get操作的inv1数组,显示子表数据(当添加时,显示空表,所以用[])
function onBeforeShow(ev, mode, opt)
{
var forAdd = mode == FormMode.forAdd;
// 非添加模式下,隐藏子表工具栏,不允许操作(但可以双击一行查看明细,后面将设置这时子对话框只读)
jtbl.closest(".datagrid").find(".datagrid-toolbar").toggle(forAdd);
var inv1Arr = opt.data && opt.data.inv1 || [];
setTimeout(onShow);
function onShow() {
// 显示子表
jtbl.datagrid("loadData", inv1Arr);
}
}
// 提交时(添加操作),生成InvRecord.add接口需要的inv1参数
function onValidate(ev, mode, oriData, newData)
{
if (mode == FormMode.forAdd) {
var inv1Arr = jtbl.datagrid("getData").rows;
if (inv1Arr.length == 0) {
WUI.app_alert("请添加商品明细!", "w");
return false;
}
newData.inv1 = WUI.objarr2list(inv1Arr, ["itemId","qty","price"]);
}
}
function onUpdateAmount() {
// 重新计算主表amount值
var inv1Arr = jtbl.datagrid("getData").rows,
discRate = frm.discRate.value || 100,
amount = 0;
$.each(inv1Arr,function(k,v) {
amount += ( v.price * v.qty * discRate / 100 ).toFixed(2) - 0;
})
frm.amount.value = amount;
}
}
子表对话框,在添加记录时可用,在查看记录时显示为只读: dlgInvRecord1.js (dlgInvRecord1.html没有特殊设置,略)
function initDlgInvRecord1()
{
...
// setDlgReadonly只对查看记录时有效
WUI.setDlgReadonly(jdlg, function (data) {
return true;
});
}
考虑商品(Item)与订单(Order)对象关系,或引申为活动项(Item)与活动报名(Order)对象关系:
@Item: id, name
@Ordr: id, name, userId, itemId
@User: id, name, picId
需求:
管理端图样:
获取商品时,设计接口如下:
Item.query() -> tbl(id, type, status, ..., orderId?, @orders?, orderCnt?)
- orderId: AUTH_USER权限下可用, 用于标识当前用户是否下过单,如果当前用户参加过该活动(或购买过该商品),则返回最近一次订单的编号,否则返回null.
- orderCnt: 该商品的总订单数(或引申为该活动的总报名数等)
- orders: [{id, userId, userPicId}] 该商品的所有订单(或该活动的所有报名)。
为Item增加虚拟字段:api_objects.php
// AC0常常用于定义通用的逻辑。然后AC1, AC2继承于它。
class AC0_Item extends AccessControl
{
// 定义虚拟字段orderCnt
protected $vcolDefs = [
[
"res" => ["(SELECT COUNT(*) FROM Ordr WHERE itemId=t0.id AND status<>'已取消') orderCnt"]
]
];
// 定义子对象orders,注意userPicId又是Ordr对象的虚拟字段,由AC0_Ordr类负责定义。
protected $subobj = [
// use AC0_Ordr, 查询范围不受当前用户限制。
"orders" => ["obj"=>"Ordr", "cond"=>"itemId=%d AND status<>'已取消'", "AC"=>"AC0_Ordr", "res"=>"id,userId,userPicId"]
];
}
class AC1_Item extends AC_Item
{
// 定义虚拟字段orderId。由于与当前用户有关,所以放在onInit中动态添加。
protected function onInit() {
parent::onInit();
$uid = $_SESSION["uid"];
/* 注意:这样定义orderId比较易理解,但仅适用于一个用户对该商品最多有一个订单的情况。
$this->vcolDefs[] = [
"res" => ["o.id orderId"],
"join" => "LEFT JOIN Ordr o ON o.itemId=t0.id AND o.userId={$uid}"
];
*/
$this->vcolDefs[] = [
"res" => ["(SELECT id FROM Ordr WHERE itemId=t0.id AND userId={$uid} ORDER BY id DESC LIMIT 1) orderId"]
];
}
}
class AC0_Ordr extends AccessControl
{
// 定义虚拟字段userPicId等。
protected $vcolDefs = [
[
"res" => ["u.name userName", "u.phone userPhone", "u.storeId", "u.picId userPicId"],
"join" => "INNER JOIN User u ON u.id=t0.userId",
"default" => true
]
];
}
在管理端展现orderCnt字段,并且可点击,点击后显示订单详情。 在table中添加一列:pageItem.html
<th data-options="field:'orderCnt', sortable:true, sorter:intSort, formatter:ItemFormatter.orderCnt">订单数/报名数</th>
在pageItem.js中实现链接,点链接显示订单详情,最终调用order列表页的初始化函数 initPageOrder(objParam):
var ItemFormatter = {
orderCnt: function (value, row) {
if (!value)
return value;
return WUI.makeLink(value, function () {
var objParam = {type: row.type, itemId: row.id};
WUI.showPage("pageOrder", "订单-商品" + objParam.itemId, [ objParam ]);
});
},
/* 上面使用了WUI.makeLink函数生成html源码, 其原理与下面实现等同:
orderCnt: function (value, row) {
if (!value)
return value;
var p = JSON.stringify({type: row.type, itemId: row.id});
p = p.replace(/"/g, '"');
return '<a href="javascript:ItemFormatter.orderCntClick(' + p + ');">' + value + '</a>';
},
// objParam: {type, itemId}
orderCntClick: function (objParam) {
WUI.showPage("pageOrder", "订单-商品" + objParam.itemId, [ objParam ]);
}
*/
};
对于选择一行,点击查看明细,则更加简单:pageItem.js
function initPageItem()
{
...
// 自定义按钮
var btn1 = {text: "查看明细", iconCls:'icon-ok', handler: function () {
var row = WUI.getRow(jtbl);
if (row == null)
return;
var objParam = {type: row.type, itemId: row.id};
var name = '订单';
...
WUI.showPage("pageOrder", name + "-" + row.id, [ objParam ]);
// 与点击链接的处理一样
// ItemFormatter.orderCntClick(objParam);
}};
jtbl.datagrid({
url: WUI.makeUrl("Item.query", param),
toolbar: WUI.dg_toolbar(jtbl, jdlg, "export", btn1), // 工具栏上添加btn1按钮。
...
});
}
订单页显示时,支持objParam参数用于传递过滤条件:pageOrder.js
// objParam: {type, itemId?}
function initPageOrder(objParam)
{
...
var param = null;
if (objParam) {
jdlg.objParam = objParam;
param = WUI.getQueryParam(objParam);
}
jtbl.datagrid({
url: WUI.makeUrl("Ordr.query", param),
...
});
}
first/last问题。还可扩展到更通用的分组后组内排序问题。
本节较复杂,特别是在数据量大的场景下,各种场景应使用不同的解决方案,学习研究比较费时。
典型问题1:用户表User,订单表Ordr。求用户首次订单的时间、地点。User表是数千级别,Ordr表为数万级别。 典型问题2:列车表Hub, 列表数据HubData。求列车最近一次上传数据的时间和位置。Hub表为数百级别,HubData为数十万级别。
以问题2为例,提供多种方法,且适用场景均不同。 列车查询接口示例如下:
Hub.query() -> tbl(id, ..., lastDataId?, lastTm?, lastPos?, @hubData?, %lastData?)
hubData: elem={id,tm,pos}. 关联的列表数据。可用param_hubData
指定查询条件,如cond: "tm>='2020-1-1'"
。 由于关联HubData表很大,只可用于get接口,不可用于query接口(否则有数据丢失风险,必须要用于query时,应加disableSubobjOptimize=1参数,效率较低)。 hubData默认最多返回1000条。
param_lastData
指定查询条件。适合get/分页查询场景,不适合全表查询/导出等场景。lastData2: {id,tm,pos} 同上,最近一次接收的数据。可用param_lastData
指定查询条件。适用于大量数据查询(如全表查询不分页时)。
lastTm2, lastPos2: 最近一次数据。适用于大量数据查询,实现原理与lastData2相同。
各种实现参考:
class AC2_Hub extends AccessControl
{
protected $vcolDefs = [
// 使用外部查询,性能不高,适合get接口或带分页的query接口(返回主表项在几十以内数量级的分页查询)。
[
"res" => ["(SELECT pos FROM HubData WHERE hubId=t0.id ORDER BY id DESC LIMIT 1) lastPos", "(SELECT tm FROM HubData WHERE hubId=t0.id ORDER BY id DESC LIMIT 1) lastTm"],
],
// 与lastData实现相同,适合全表查询或文件导出,用于get或只查几个时性能不同上面的外部查询。
[
"res" => ["data.pos lastPos2", "data.tm lastTm2"],
"join" => "LEFT JOIN (SELECT hubId, MAX(id) hubDataId FROM HubData GROUP BY hubId) t1 ON t1.hubId=t0.id
LEFT JOIN HubData data ON data.id=t1.hubDataId"
],
// 定义lastDataId,下面lastData实现中会引用
[
"res" => ["(SELECT MAX(id) FROM HubData WHERE hubId=t0.id) lastDataId"]
],
// 奇技淫巧级用法,取决于数据库实现。仅供参考,不建议作为产品实现
[
"res" => ["data1.pos lastPos3", "data1.tm lastTm3"],
"join" => "LEFT JOIN (
SELECT hubId, pos, tm FROM (SELECT * FROM HubData, (select @n:=0) t_ ORDER BY hubId,id DESC) HubData1 GROUP BY hubId
) data1 ON data1.hubId=t0.id"
]
];
protected $subobj = [
// hubData仅用于get接口,本例中1:N关联关系中N过大,不可用于query接口,否则返回子对象可能不全
"hubData" => ["obj"=>"HubData", "cond"=>"hubId=%d", "AC"=>"AC2_HubData", "res"=>"id,tm,pos", "orderby"=>"t0.id DESC"],
// 使用了lastDataId虚字段(在vcolDefs中定义),实现方式同lastPos/lastTm,适合get/分页查询场景,不适合全表查询/导出等场景。
"lastData" => ["obj"=>"HubData", "%d"=>"lastDataId","cond"=>"id=%d", "AC"=>"AC2_HubData", "res"=>"id,tm,pos", "wantOne"=>true ]
// 使用了LastHubData虚表,实现方式同lastPos2/lastTm2,但概念更清晰,适合全表查询/导出等场景。
"lastData2" => ["obj"=>"HubData", "cond"=>"hubId=%d", "AC"=>"AC2_LastHubData", "res"=>"id,tm,pos", "orderby"=>"t0.id DESC", "wantOne"=>true ],
// 仅供参考,不建议作为产品实现。lastData3只是hubData加上了wantOne属性(也可由前端直接使用hubData并指定wantOne),其限制同hubData
"lastData3" => ["obj"=>"HubData", "cond"=>"hubId=%d", "AC"=>"AC2_HubData", "res"=>"id,tm,pos", "orderby"=>"t0.id DESC", "wantOne"=>true ]
];
}
// 定义虚表,LastHubData与Hub表形成1:1关联关系。(Hub与HubData是1:N关联,且N很大)
class AC2_LastHubData extends AccessControl
{
protected $table = "HubData";
protected function onQuery() {
if ($this->ac == "query") {
$this->addJoin("JOIN (SELECT hubId, MAX(id) id FROM HubData GROUP BY hubId) t1 ON t1.id=t0.id");
}
}
}
测试:
callSvr("Hub.query", {res:"id,lastTm,lastPos,lastTm2,lastPos2,hubData,lastData"})
callSvr("Hub.query", {
res:"id,hubData",
param_hubData: {cond: "tm>='2020-1-1'"}, // 指定子查询条件
})
callSvr("Hub.get", {
id: 5,
res:"id,lastTm,lastPos,lastTm2,lastPos2,hubData,lastData",
res_hubData: "pos",
param_hubData: {pagesz: 3}, // 取最近3条
param_lastData: {res: "tm,pos"}
})
callSvr("Hub.query", {
disableSubobjOptimize: 1, // 关联子对象多时,query接口有丢失数据风险,可以设置disableSubobjOptimize。一般不用于生产。
res:"id,hubData lastData", // 返回时hubData改名lastData
param_lastData: {wantOne:1}, // 取最新一条,从而与lastData2返回一样。
})
方式一: lastTm, lastPos使用外部关联字段,性能不高,适合返回主表项在几十级别(带分页的查询)。 而且,返回多个字段时会重复查询,在这个例子里,同时查lastTm和lastPos,则是两次独立查询。 另外,它只能用于返回最后(或最先)一条的情况,若要最后几条则不可以。
方式二: hubData是子对象查询的实现,一次可返回多个字段,而且可指定返回的条数(wantOne或pagesz=1返回1条,用pagesz可指定返回多条)。 但这只适合于get接口。本例中子查询不可用于query接口。 对于query接口,可用于一般带分页的查询,一次返回主表项几十个的情况。 例如典型的主、子表场景:一次查询不超过100个订单(分页<100),一个订单带有最多几十行明细,或最多几十条订单日志。 不可用于总查询返回(主表项数乘以子表项数)超过千行的场景,因为会丢数据。虽可以通过调节子表的maxPageSz解决,但并不建议这样做。
lastData2的实现与其相同。在本例中,一个Hub对应的子表项HubData很多(数千或万级别),故不可将lastData用于query查询。 即使指定了wantOne,也会将关联子表数据全部查出再取第一行,导致数据易丢失。 这是因为有批量子查询优化机制,例如查询100条订单,每个订单1-10个明细行,默认只查2次,而无优化会查101次。 参考API文档中disableSubobjOptimize参数,可保证在query时也正确,但效率降低。
方式三:(分组后,组内按id排序问题求解) lastData, lastTm2, lastPos2的实现通过两次关联查询实现。 它适合全表查询(或指定条件下的大量查询),典型场景是导出文件。 它与方式一结果相同,效率不同。100条以内方式一实现很快,数据多时本方式实现很快(如主、子表数据比为2000比20000的规模,方式一可能几十秒,方式三1秒左右)
方式四:(分组后,组内排序问题求解) lastTm3, lastTm4的实现是方式三的泛化(由唯一索引扩展到非唯一索引),可解决一般的分组后组内查询问题,例如求最大值(非唯一索引列)所在的子表行。 此处的实现取决于数据库的实现,可能并不总是正确,但速度很快。这里用到变量,目的是禁用MySQL对ORDER BY的优化(ORDER BY外面再GROUP BY会导致ORDER BY不执行)。 sqlserver/oracle提供over PARTITION by机制。
车辆出厂检测的结果记录在下表中:
@PdiRecord: id, tm, carId, result(2), empId
- result: Enum(Y,N)
当车辆检测不通过时,维修完成后须再次检测。所以一辆车可能存在多个检测记录。 客户要求以下格式报表:
PDI检测记录报表 2020/5/1 - 2020/6/1
车型 检测车辆数 检测通过车辆数 待处理车辆数
第一次通过 第二次通过 第三次通过
EX5 1000 995 3 1 1
对于车辆,我们关注它的最终检测结果(lastResult)和检测次数(pdiCnt),所以提供这两个虚拟字段。后端实现(java):
// class AC2_PdiRecord
this.vcolDefs = asList( ... ,
new VcolDef().res("t1.lastPdiId", "t0.result lastResult", "t1.pdiCnt")
.join(String.join("\n", "JOIN (",
"SELECT carId, MAX(id) lastPdiId, COUNT(*) pdiCnt",
"FROM PdiRecord",
"GROUP BY carId",
") t1 ON t1.lastPdiId=t0.id"))
);
通过按carId分组的t1表,再与原PdiRecord表JOIN,就可得到每辆车的最新状态了。 这时可以查询报表:
callSvr("PdiRecord.query", {
gres:"modelName, lastResult, pdiCnt",
res:"COUNT(*) totalCarCnt",
});
得到这样的表头:modelName/车型 lastResult/结果 pdiCnt/检测次数 totalCarCnt/车辆数
其数据与客户要的表已经一致了,在形式上,只要将“结果”和“检测次数”转置到列上,就与客户要的表完成一致了。
前端实现:在pagePdiRecord列表页上添加报表按钮:
var btnStat1 = {text: "检测记录报表", "wui-perm": "导出", iconCls:'icon-ok', handler: function () {
var queryParams = jtbl.datagrid("options").queryParams;
var url = WUI.makeUrl("PdiRecord.query", {
gres:"modelName 车型, lastResult 检测结果=Y:通过;N:未通过, pdiCnt 检测次数",
res:"COUNT(*) 车辆数",
});
WUI.showPage("pageSimple", "检测记录报表!", [url, queryParams]);
}};
一般会先设置报表查询条件再出结果(dlgReportCond对话框), 请参考 pageSimple.js 内使用文档.
后端支持用“pivot”参数来转置,再使用中文字段名:
callSvr("PdiRecord.query", {
gres:"modelName 车型, lastResult 检测结果=Y:通过;N:未通过, pdiCnt 检测次数",
pivot:"检测结果,检测次数",
res:"COUNT(*) 车辆数",
});
结果示例:(如果未加pivot参数)
车型 检测结果 检测次数 车辆数
ASE0120A 通过 1 62
ASE0120C 未通过 1 1
ASE0120C 通过 1 1504
ASE0120C 通过 2 1
加pivot参数后:(“通过-1”表示检测1次后通过)
车型 通过-1 未通过-1 通过-2
ASE0120A 62 (null) (null)
ASE0120C 1504 1 1
前端实现:在pagePdiRecord列表页上添加报表按钮:
var btnStat1 = {text: "检测记录报表", "wui-perm": "导出", iconCls:'icon-ok', handler: function () {
var queryParams = jtbl.datagrid("options").queryParams;
var url = WUI.makeUrl("PdiRecord.query", {
gres:"modelName 车型, lastResult 检测结果=Y:通过;N:未通过, pdiCnt 检测次数",
pivot:"检测结果,检测次数",
res:"COUNT(*) 车辆数",
});
WUI.showPage("pageSimple", "检测记录报表!", [url, queryParams]);
}};
注意:在上面的实现中,如果选择一个时间段,比如日期“>2020-5-1”,则出来的结果中,把检测次数 * 车辆数
,得到的总检测次数,与该段时间内实际总检测数次可能不一致,原因有两点:
这样的业务逻辑倒也可以解释的通。 但究其根本原因,则是JOIN的那个分组查询中,未添加主查询的条件。若想修改,解决思路可以这样:在查询中加cond参数:
.join(... "JOIN ( SELECT ... FROM ... {cond} GROUP BY ) ...")
然后在onInit中根据实际条件将其替换即可。
树状结构表设计中, 应有fatherId字段, 示例:
@ItemType: id, name, level&, fatherId, disableFlag, picId
在初始化页面时, 与datagrid类似: pageItemType.js
var dgOpt = {
// treegrid查询时不分页. 设置pagesz=-1. (注意后端默认返回1000条, 可设置放宽到10000条. 再多应考虑按层级展开)
url: WUI.makeUrl("ItemType.query", {pagesz: -1}),
toolbar: WUI.dg_toolbar(jtbl, jdlg),
onDblClickRow: WUI.dg_dblclick(jtbl, jdlg)
};
// 用treegrid替代常规的datagrid
jtbl.treegrid(dgOpt);
如果数据量非常大, 可以只显示第一层级, 展开时再查询. 仅需增加初始查询条件(只查第一级)以及一个判断是否终端结点的函数(否则都当作终端结点将无法展开):
var dgOpt = {
queryParams: {cond: "fatherId is null"},
isLeaf: function (row) {
return row.level>1;
},
// idField: "id", // 不建议修改
// fatherField: "fatherId", // 指向父结点的字段,不建议修改
// treeField: "id", // 显示树结点的字段名,可根据情况修改
...
};
jtbl.treegrid(dgOpt);
角色包括系统内置角色和自定义角色.
默认内置角色有:
如果要添加内置角色, 先在后端定义新角色: api.php
const PERM_QMGR = 0x200; // 质量管理员
function onGetPerms()
{
...
if (isset($_SESSION["empId"])) {
$perms |= AUTH_EMP;
$p = @$_SESSION["perms"];
if (is_array($p)) {
...
// 设置角色. 注意虽然名称是PERM_XXX像是权限, 实际上这里不区分权限和角色.
if (array_search("qmgr", $p) !== false)
$perms |= PERM_QMGR;
}
}
}
// 后端用hasPerm判断权限
if (hasPerm(PERM_QMGR)) {
}
前端一般先设置菜单可见性, 用perm-xxx类或nperm-xxx类为菜单项做设置, perm-xxx表示该角色可看; nperm-xxx表示该角色不可看. store.js:
<div class="perm-mgr perm-qmgr perm-pdimgr" style="display:none">
<div class="menu-expand-group">
<a class="expanded"><span><i class="fa fa-pencil-square-o"></i>运营管理</span></a>
...
</div>
</div>
<div class="nperm-pdi">
<a href="javascript:showDlgChpwd()"><span><i class="fa fa-user-times"></i>修改密码</span></a>
</div>
前端页面中一般用g_data.hasPerm做判断: pageXXX.js / dlgXXX.js
自定义角色一般只做前端菜单限制. 须先将role插件引入(根据其说明文档配置好), 然后直接由最高管理员(mgr权限用户)在角色管理中配置即可.
需求:导入商户及其LOGO图。
要点:
表定义如下:
@Store: id, name, addr, picId
先准备好非图片部分的表,存为store.csv (均使用utf-8编码)
商户名,商户地址
丽传文化传媒,三期1101
发哲文化传播,三期1102
...
先在Excel中制作好待导入的文件,拷贝到文本文件中(比如如sample.txt),其中图片或文件用相对路径表示,如果值中有多个图片,以英文逗号分隔(逗号前后可以有空格)。示例:
企业名称 商品类型 商品分类 商品名称 商品标签 产地 商品现价 商品原价 分销比例 商品单位 规格名称 规格值 规格价格 规格图片 轮播图片 轮播视频 商品详情 商品认证 市礼卡片封面
广州茗品苑贸易有限公司 商会优品 农副产品 极品红茶 婺茗 婺源 580 690 0.3 盒 净重 0.4斤/盒 280 productsimport\productbanner\page01.jpg,productsimport\productbanner\page02.jpg,productsimport\productbanner\page03.jpg,productsimport\productbanner\page04.jpg,productsimport\productbanner\page05.jpg productsimport\productbanner\003.mp4 productsimport\productdesc\page06.jpg,productsimport\productdesc\page07.jpg,productsimport\productdesc\page08.jpg,productsimport\productdesc\page09.jpg productsimport\productcert\page10.jpg
使用tool/fix-table.php命令上传文件,并生成新的文件:
php fix-table.php sample.txt O:pic P:att Q:pic R:pic -baseUrl:http://oliveche.com/shanghui/api.php
其中P:att
表示Excel中显示的P列做文件上传处理,Q:pic
表示Q列做图片上传处理(上传前调用magick软件自动压缩、上传时生成缩略图等)。 pic处理会返回缩略图id,这样列表中默认显示小图;如果想用原图即大图id可以使用pic1处理;如果不想生成缩略图可以用pic2,但上传前仍会压缩,注意压缩后只用jpg格式;若是既不用压缩也不要生成缩略图(比如一些小图标),可直接使用att当文件上传处理。
生成文件默认会加fixed后缀,比如是sample-fixed.txt,示例如下:
企业名称 商品类型 商品分类 商品名称 商品标签 产地 商品现价 商品原价 分销比例 商品单位 规格名称 规格值 规格价格 规格图片 轮播图片 轮播视频 商品详情 商品认证 市礼卡片封面
广州茗品苑贸易有限公司 商会优品 农副产品 极品红茶 婺茗 婺源 580 690 0.3 盒 净重 0.4斤/盒 280 1034,1036,1038,1040,1042 1044 1045,1047,1049,1051 1053
然后再用这个转换过的文件去上传就好了。 注意图片会默认压缩为不超过1200像素,可以通过-resize参数调整,或设置-resize:1
禁止压缩。
明细用法可打开fix-table.php程序查看注释。以下为旧方法,仅供参考,目前已不再使用。
通过upload接口批量上传,得到图片(缩略图)id列表,进而得到图片文件名,图片id
为标题的对应表。
先创建一个文件列表list.txt:
find . -name "*.jpg" | tee list.txt
list.txt示例:
./一二三四期logojpg/一期租户LOGO/上海丽传文化传媒有限公司.jpg
./一二三四期logojpg/一期租户LOGO/上海发哲文化传播有限公司.jpg
对于图片,为了避免图片尺寸太大导致服务端无法压缩处理(过大图片如6000万像素,处理它需要512M内存,一般php默认只设置128M,可处理6000x4000=2400万像素),可先压缩再上传 (convert/identify为imagemagick软件包中的命令)
# 查看1M以上的图片
find . -name '*.jpg' -size +1M -exec identify '{}' \; | tee 1.log
# 压缩到不超过1280像素
find . -name '*.jpg' -size +1M -exec convert '{}' -resize 1280 '{}' \; | tee 2.log
以list.txt为基础生成curl命令行,可用工具gen_upload工具,
php plugin/upload/tool/gen_upload.php list.txt 10 > 1.sh
传入10表示一批传10个文件,避免一次性传太多超过服务器限制,根据文件大小可调整该参数。请检查下服务器上传相关配置,比如查看tool/init.php
:
http://localhost/jdcloud/server/tool/init.php
示例:
上传文件设置 upload_max_filesize=64M, post_max_size=64M, max_execution_time=300
生成命令行大致如下,再修改upload接口参数、验证密码等。
curl -s \
-F "file1=@./一二三四期logojpg/一期租户LOGO/上海丽传文化传媒有限公司.jpg" \
-F "file2=@./一二三四期logojpg/一期租户LOGO/上海发哲文化传播有限公司.jpg" \
"http://localhost/jdcloud/server/api.php/upload?autoResize=300&genThumb=1" -H "x-daca-simple: 1234"
执行它,检查结果是否全部成功,并从结果中取出返回的图片thumbId或id:
./1.sh | tee 1.log
看到结果:1.log
[0,[{"id":29,"orgName":null,"size":74203,"thumbId":29},{"id":30,"orgName":null,"size":265427,"thumbId":30},...]]
...
在vim中取出所有thumbId值示例:
:%s/\v\_.{-}"thumbId":(\d+)/\1\r/gc
它将json等数据转成了:
29
30
...
将结果列并入文件列表list.txt,修改后形成一个新表:table1.txt
图片名 图片编号
上海丽传文化传媒有限公司 100
上海发哲文化传播有限公司 101
再通过similar_join.php工具,将新表table1.txt与原表store.csv做’join’,注意文件均是utf-8编码,格式可以是csv或tsv(逗号或tab分隔的文本): 运行前最好打开similar_join工具里面设置下参数$RE_DEL
,删除相同的短语以减少错误匹配:
php plugin/upload/tool/similar_join.php store.csv table1.txt 1 0 | tee result.csv
(表示store.csv的第1列匹配table1.txt的第0列,输出csv文件)
得到 result.csv
商户名称,地址,图片名,图片编号
丽传文化传媒,三期1101,上海丽传文化传媒有限公司,100
发哲文化传播,三期1102,上海发哲文化传播有限公司,101
(v5.4) 超级管理端(web/adm.html)中带有导入工具. 登录后打开“批量导入”对话框:
也可以在浏览器控制台中直接调用导入接口。 打开管理端,登录后打开控制台,调用batchAdd接口导入数据:
// 用反引号``赋值大量数据,第一行须为标题
var data = `商户名称,地址,图片名,图片编号
丽传文化传媒,三期1101,上海丽传文化传媒有限公司,100
发哲文化传播,三期1102,上海发哲文化传播有限公司,101`;
// 用title定义列映射,无须导入的列用"-"标识。
callSvr("Store.batchAdd", {title: "name,addr,-,picId"}, function (ret) {
app_alert("成功导入" + ret.cnt + "条数据!");
}, data, {contentType:"text/plain"});
也可以用curl工具导入:
#/bin/sh
baseUrl=http://localhost/jdcloud/server/api.php
curl -v -F "file=@result.csv" "$baseUrl/Store.batchAdd?title=name,addr,-,picId"
示例,定义任务表,一个订单(Task.orderId是外键)关联多个任务(Task):
@Task: id, orderId, city, brand, vendorId, storeId
- vendorId: 供应商编号,映射Vendor.id
- storeId: 门店编号,映射Store.id
要导入任务,已知orderId字段值,导入表的表头为:city, brand, vendorName, storeName
其中vendorName和storeName字段需要通过查阅相关表修正为vendorId和storeId字段。 如果供应商不存在(找不到vendorId),应报错;如果门店不存在(找不到storeId),则自动以storeName添加门店。
以上逻辑一般可以用add接口的onValidate回调来处理,做name到id的转换:
Task.add(orderId)(city, brand, vendorName, storeName)
调用通用的batchAdd接口,它会自动对每行内部调用add接口。
这里介绍另一种方法,即定制batchAdd接口,在导入性能需要调优等场景下可以考虑它:
Task.batchAdd(orderId)(city, brand, vendorName, storeName)
实现示例:在api_objects.php添加导入处理逻辑 TaskBatchAddLogic
class TaskBatchAddLogic extends BatchAddLogic
{
function __construct () {
// 每个对象添加时都会用的字段,加在$this->params数组中
$this->params["orderId"] = mparam("orderId", "G"); // mparam要求必须指定该字段, "G"表示通过GET传参
}
// $params为待添加数据,可在此修改,如用`$params["k1"]=val1`添加或更新字段,用unset($params["k1"])删除字段。
// $row为原始行数据数组。
function beforeAdd(&$params, $row) {
// 检查必填字段vendorName, storeName
checkParams($params, [
"vendorName" => "供应商",
"storeName" => "商户"
]);
$vendorId = queryOne("SELECT id FROM Vendor", false, ["name" => $params["vendorName"]] );
if (!$vendorId) {
throw new MyException(E_PARAM, "请添加供应商", "供应商未注册: " . $params["vendorName"]);
}
// 将vendorName换成vendorId字段:
$params["vendorId"] = $vendorId;
unset($params["vendorName"]);
// storeName -> storeId
$storeId = queryOne("SELECT id FROM Store", false, ["name" => $params["storeName"]] );
if (!$storeId) {
$storeId = callSvcInt("Store.add", null, [
"name" => $params["storeName"]
]);
}
$params["storeId"] = $storeId;
unset($params["storeName"]);
}
// 可选回调函数:处理原始标题行数据, $row1是通过title参数传入的标题数组,可能为空. 一般用的比较少
function onGetTitleRow($row, $row1) {
}
}
// 在Task类中应用导入逻辑
class AC2_Task extends AC0_Task
{
function api_batchAdd() {
$this->batchAddLogic = new TaskBatchAddLogic();
return parent::api_batchAdd();
}
}
如果导入表很大,导入较慢,且供应商重复很多,可以通过SimpleCache类来缓存结果优化性能,示例如下:
class TaskBatchAddLogic extends BatchAddLogic
{
protected $vendorCache = [];
function beforeAdd(&$params, $row) {
...
if (! $this->vendorCache)
$this->vendorCache = new SimpleCache(); // tel=>vendorId
// 通过缓存优化:如果缓存中存在,则不再查询,直接使用;否则查询数据库并插入缓存中:
$vendorId = $this->vendorCache->get($params["vendorName"], function () use ($params) {
$id = queryOne("SELECT id FROM Vendor", false, ["name" => $params["vendorName"]] );
if (!$id) {
throw new MyException(E_PARAM, "请添加供应商", "供应商未注册: " . $params["vendorName"]);
}
return $id;
});
...
}
}
需求:在管理端中开放批量导入员工。
在超级管理员中内置了导入对话框的例子:server/web/adm/dlgImport.html(.js) 可将对话框的html/js复制到管理端:server/web/page/下面。
在菜单中添加导入对话框:
<a href="javascript:WUI.showDlg('#dlgImport',{modal:false})">批量导入</a>
dlgImport可以支持多类对象的导入。例如:要定制Employee的导入,只须在dlgImport.html中定义导入模板:
<script type="text/template" class="tplEmployee">
!title=uname,phone,name,perms
登录名 手机号 姓名 权限
admin 12345678901 管理员 mgr
test1 12345678902 运营人员1
</script>
一般习惯在相应对象的列表页中添加导入按钮,只需要添加“import”指令:pageEmployee.js,函数 initPageEmployee中
可以直接用“import”是因为框架定义了dg_toolbar.import函数。 其原理如下,若要定制导入操作可以效仿这里代码:pageEmployee.js
var jtbl = jpage.find("#tblEmployee");
var btnImport = {text: "导入", "wui-perm": "新增", iconCls:'icon-ok', handler: function () {
DlgImport.show({obj: "Employee"}, function () {
// 导入后刷新列表
WUI.reload(jtbl);
});
}};
jtbl.datagrid({
toolbar: WUI.dg_toolbar(jtbl, jdlg, ..., btnImport),
});
关键词:batchAdd接口,uniKey参数。
@see AccessControl::api_batchAdd
示例:导入生产工单及相关的物料清单(BOM)。BOM指的是生产工单中的产品的原材料列表。
数据模型如下:
@Ordr(生产工单): id, code(工单编码), itemCode(物料编码), qty(生产数量)
@BOM(物料清单): id, orderId, code(子件物料编码), name(物料名), qty(基本用量)
batchAdd支持主-表子同时导入,但必须指定主表的唯一键(通过uniKey参数指定,支持多个字段的联合键)。 其原理仍是一行一行处理,每行用唯一键查询主表行是否存在,不存在做add操作,存在则做set操作。
注意:子表列名的指定方式为 “@子表名.子表字段”。主对象为Ordr, 子对象的字段名为bom,后端实现子表字段示例如下:
class AC0_Ordr extends AccessControl
{
...
protected $subobj = [
"bom" => ["obj"=>"BOM", "cond"=>"orderId={id}"],
...
];
}
在dlgImport.html中定义导入类型:
定义导入模板:
<script type="text/template" class="tplOrdr">
!title=code,itemCode,itemName,itemCate,planTm,planTm1,qty,@bom.code,@bom.name,@bom.qty&uniKey=code
生产订单号 物料编码 物料规格 MES规格型号 开工日期 完工日期 生产数量 子件编码 子件规格 基本用量
SCDD210202302 30101001010484 热像仪#Fotric 615C-L47 615C 2021-02-04 2021-02-04 1.00 20901001000052 标品#Lantern_B31-L47 1
SCDD210202302 30101001010484 热像仪#Fotric 615C-L47 615C 2021-02-04 2021-02-04 1.00 10205001000017 标签#Lantern_40*30mm铜版纸空白标签#中性#通用 1
</script>
导入时还支持列映射,即通过列名匹配(而不是固定的列顺序),若列名不存在还会报错。加参数useColMap=1
,示例:
<script type="text/template" class="tplOrdr">
!title=生产订单号->code,物料编码->itemCode,物料规格->itemName,MES规格型号->itemCate,开工日期->planTm,完工日期->planTm1,生产数量->qty,子件编码->@bom.code,子件规格->@bom.name,基本用量->@bom.qty&uniKey=code&useColMap=1
生产订单号 物料编码 物料规格 MES规格型号 开工日期 完工日期 生产数量 子件编码 子件规格 基本用量
SCDD210202302 30101001010484 热像仪#Fotric 615C-L47 615C 2021-02-04 2021-02-04 1.00 20901001000052 标品#Lantern_B31-L47 1
SCDD210202302 30101001010484 热像仪#Fotric 615C-L47 615C 2021-02-04 2021-02-04 1.00 10205001000017 标签#Lantern_40*30mm铜版纸空白标签#中性#通用 1
</script>
[示例1]:导入费用报销时需要选择员工(查询结果列表中选),选择用途(固定选项中选)后再导入。
数据模型示意:
@Expense(费用报销): id, empId, 用途
@Expense1(报销明细): id, expId(关联Expense), name, amount, ...
普通的导入是针对一张表导入,字段都在导入内容中设置; 而本示例其实质是导入子表,一次主导入一行主表,且主表字段(如员工、用途)是人工设置或选择的。 它将调用接口:
callSvr("Expense1.batchAdd", {empId, 用途}, ...)
在导入页面上设计:dlgImport.html:
<td>导入类型</td>
<td>
<select name="obj">
...
<option value="Expense1">发票/费用明细</option>
...
</select>
</td>
待填写的字段设置(optional类有特殊处理,表示自动根据for{obj}
是否匹配当前obj来显示该项):
<tr class="optional forExpense1">
<td>员工*</td>
<td>
<select name="empId" class="my-combobox" data-options="ListOptions.Employee()" required></select>
</td>
</tr>
<tr class="optional forExpense1">
<td>用途*</td>
<td>
<select name="用途" class="my-combobox" data-options="jdEnumList:费用用途List"></select>
</td>
</tr>
模板设置:
<script type="text/template" class="tplExpense1">
!title=name,amount,发票类型,发票形式,税点,进项税,dt,备注
项目 金额 发票类型 发票形式 税点(专票) 进项税(专票) 发票日期 备注
餐饮 223 普票 纸质 2019-11-15
加湿器 507.98 专票 纸质 13 58.44 2019-11-20
</script>
导入按钮逻辑:pageExpense.js
// 如果obj与当前列表页对象相同,则用字符串"export"声明按钮就可以。这里比较特别,页面对象是Expense,而导入的是明细表Expense1(即一次只导入一行主表,包含多行子表/明细表)
var btn1 = {text: "导入", iconCls:'icon-ok', handler: function () {
DlgImport.show({obj: "Expense1"}, function () {
WUI.reload(jtbl);
});
}};
为Expense1对象写一段定制导入逻辑:
class Expense1BatchAddLogic extends BatchAddLogic
{
function __construct () {
$ac = new AC2_Expense();
// 支持直接传入expId,下面例2将用到
$expId = param("expId");
if (! $expId) {
$param = [
"empId" => mparam("empId"),
"用途" => mparam("用途")
];
$expId = $ac->callSvc("Expense", "add", null, $param);
}
// 每个对象添加时都会用的字段,加在$this->params数组中
$this->params["expId"] = $expId;
}
}
class AC2_Expense1 extends AccessControl
{
// 应用上面定义的导入逻辑
function api_batchAdd() {
$this->batchAddLogic = new Expense1BatchAddLogic();
return parent::api_batchAdd();
}
}
[示例2]:如果已导入好了费用,在明细列表页面中还可以再追加导入明细项。
显然,它也是针对Expense1对象的导入,它将调用接口
callSvr("Expense1.batchAdd", {expId}, ...)
在导入页面上设计如下,注意由于对象Expense1已经用于导入时连主表一起添加,这次换个名字叫Expense1A避免重名,dlgImport.html:
<td>导入类型</td>
<td>
<select name="obj">
...
<option value="Expense1">发票/费用明细</option>
<option value="Expense1A">发票/费用明细追加</option>
...
</select>
</td>
...
待填写的字段设置(optional类有特殊处理,表示自动根据`for{obj}`是否匹配当前obj来显示该项):
<tr class="optional forExpense1A">
<td>费用编号*</td>
<td>
<input name="expId">
</td>
</tr>
模板设置:(注意Expense1, Expense1A共用相同的模板)
<script type="text/template" class="tplExpense1 tplExpense1A">
...
</script>
后端的Expense1BatchAddLogic在上例中已经考虑了expId。为支持Expense1A对象,后端:api_objects.php (TODO:这个需求应该前端处理更好)
class AC2_Expense1A extends AC2_Expense1
{
protected $table = "Expense1";
}
在uniKey字段后面加!表示只允许更新,不允许添加记录。用于批量更新; 如果一个对象有多种导入模板,在dlgImport.html中的obj中以__
分隔对象名与后缀。
示例:导入工资单,或是批量更新工资单中的考勤天数及个税;对象为PayRecord,模型如下:
@PayRecord: id, empId/员工编号, 薪资月份, ...
其中empId+薪资月份为唯一索引。
在dlgImport.html中,先定义对象,注意这几个功能都是对PayRecord表进行操作:
<select name="obj">
<option value="PayRecord">工资单</option>
<option value="PayRecord__2">考勤</option>
<option value="PayRecord__3">个税</option>
...
</select>
注意obj的基础前缀都是PayRecord对象。通过__
后面来区别。 然后分别设置模板,如:
<script type="text/template" class="tplPayRecord">
!title=empId,-empName,-depName,薪资月份,发放日期,基本工资,绩效奖金,社保基数,公积金基数,养老,医疗,失业,公积金,天数,总天数,其它应发,其它应扣,工资,个税专项扣除,个税应纳税所得,个人所得税,实发工资,备注&uniKey=薪资月份,empId
员工编号 员工 部门 薪资月份 发放日期 基本工资 绩效奖金 社保基数 公积金基数 养老8% 医疗2% 失业0.5% 公积金7% 天数 总天数 其它应发 其它应扣 工资 个税专项扣除 个税应纳税所得 个人所得税 实发工资 备注
1 高大伟 市场部 202105 2020-06-11 10000 0 10000 10000 800 200 50 700 22 22 8250 97.5 8152.5
</script>
<script type="text/template" class="tplPayRecord__2">
!title=薪资月份,员工编号->empId,天数&useColMap=1&uniKey=薪资月份,empId!
薪资月份 员工编号 员工 天数
202105 1 高大伟 22
</script>
<script type="text/template" class="tplPayRecord__3">
!title=薪资月份,员工编号->empId,个税专项扣除,个税应纳税所得,个人所得税&useColMap=1&uniKey=薪资月份,empId!
薪资月份 员工编号 员工 个税专项扣除 个税应纳税所得 个人所得税
202105 1 高大伟 1000 2000 60
</script>
第一个设置uniKey=薪资月份,empId
,因此一旦匹配这两个字段则不会添加而是更新; 第2、3个设置uniKey=薪资月份,empId!
,则表示只允许更新记录,不能添加记录(即批量更新模式)。
需求:已发布的活动如果过期,应自动关闭。
解决方案:
每天定时扫描过期活动,将其状态设置为“已完成”。 在tool/task.php中添加一个任务,名为ac_dailyWork
:
function ac_dailyWork()
{
// 定时关闭过期活动
$cnt = dbUpdate("Item", [
"status" => "已完成"
], [
"type" => "活动",
"status" => "发布中",
"tm2<=" . Q(date(FMT_DT))
]);
echo("=== Auto close $cnt Items\n");
}
测试执行可以用:
php task.php dailyWork
在tool/task.cron.php中配置定时器,本例可添加如下一行:
10 1 * * * $TASK dailyWork >> $LOG 2>&1
上面表示每天凌晨1:10执行dailyWork任务。日志记录到$LOG(一般是tool/task.log)
定时任务使用的是Linux的crontab机制,时间配置格式为:
分钟(0-59) 小时(0-23) 日(1-31) 月(1-12) 星期几(0-7,0或7是周日)
示例:
10 2 * * * 每天2:10
其它格式:“2,4,6”表示2时,4时和6时,“2-10/2”表示2:00-10:00每两小时。
上线后需要在服务器上安装定时任务:进入tool目录执行php task.crontab.php
生成一串文本,再运行crontab -e编辑计划任务,将刚刚生成的文本复制过来即安装好。
详细设置可参考tool/task.crontab.php中的注释说明。 原理请查阅Linux crontab机制。
此机制不支持执行秒级轮询任务(也不建议用于过于频繁的轮询类任务如每分钟检测),这类需求一般还是自行开个进程(可设置做为daemon进程)轮询解决。
需求: 在管理端列表上选择一行点击“审核”, 打开一个新的审核页面, 点击“审核完成”按钮, 会设置状态值. 要求在回到管理端WEB应用时, 能自动刷新列表, 以便显示正确的状态.
解决方案: 通过storage变量做为“信号量”来通信. 在新打开的外部页面中, 当点击审核完成后, 设置一个storage变量. 在回到管理端中, 监控focus事件并刷新列表.
示例: 审核完成后, 设置信号量. m2/emp/videoDetail.js
onReviewComplete: function (ev) {
callSvr("AsrReq.set", {id: this.obj.id}, function () {
MUI.setStorage("AsrReqReload", that.obj.id);
...
}, {doneFlag: 1});
}
在管理端列表中: web/page/pageAsrReq.js
function initPageAsrReq()
{
var jpage = $(this);
var jtbl = jpage.find("#tblAsrReq");
...
jpage.on("checkSignal", function () {
var val = WUI.getStorage("AsrReqRefresh");
if (val) {
WUI.delStorage("AsrReqRefresh");
WUI.reload(jtbl);
}
});
...
}
$(window).off("focus.pageOrder").on("focus.pageOrder", function () {
$(".wui-page.pageAsrReq").trigger("checkSignal");
});
注意: 监听window的focus事件, 没有放在pageinit函数中去做, 而且绑定前先做off, 可确保只绑定一次. (而pageinit函数每次都打开都会执行) 下面这样写法是有问题的:
function initPageAsrReq()
{
var jpage = $(this);
var jtbl = jpage.find("#tblAsrReq");
...
// 错误! 如果页面被remove掉, 其内部的变量或DOM元素访问很可能出问题. 可以用`jtbl.prop("isConnected")`来判断DOM是否被删除, 但该属性较新, 存在兼容性问题.
// 更好的做法是由jpage来绑定自定义事件, 当jpage被remove时, jQuery会自动删除其所有事件, 不会造成问题
$(window).on("focus.pageOrder", function () {
...
WUI.reload(jtbl);
});
...
}
初始化echart组件显示统计报表时, 当窗口大小变化时, 希望重绘echart组件, 常常代码像这样:
function initChart(chartTable, statData, seriesOpt, chartOpt)
{
...
// TODO: 何时删除事件?
$(window).on('resize', function () {
myChart.resize();
});
}
上面代码会一直添加事件绑定. 当echart所有DOM被删除或重新初始化时, 都有潜在问题. 下面是解决方案: echart所在DOM自身来监听事件(而不是直接用window来监听事件), 且先调用off保证只监听一次, 这样保存它在删除或多次初始化后也没有问题.
function initChart(chartTable, statData, seriesOpt, chartOpt)
{
...
$(chartTable).addClass("jd-echart").off("doResize").on("doResize", function () {
myChart.resize();
});
}
$(window).on('resize.echart', function () {
$(".jd-echart").trigger("doResize");
});
默认管理端对话框是2列布局, 即“字段名 字段值”. 在字段很多时, 也可以在一行中定义4列, 并把字段分组显示.
示例:物流订单对话框中分组显示字段, 按4列布局, 以及显示子表.
<form my-obj="Ordr" wui-script="dlgOrder.js" my-initfn="initDlgOrder" title="物流订单" style="width:750px;height:750px;">
<table>
<!-- 一个分组 -->
<tr>
<td colspan=4>
<div class="form-caption"><hr>订单基本信息</div>
</td>
</tr>
<tr>
<td>订单编号</td>
<td><input name="id" disabled></td>
<td>订单类型</td>
<td><input name="type" class="my-combobox" data-options="jdEnumList:'包车发运;散货发运;其他'"></td>
</tr>
<tr>
<td>客户</td>
<td colspan=3>
<select name="customerId" class="my-combobox" data-options="ListOptions.Customer()"></select>
</td>
</tr>
<!-- 另一个分组 -->
<tr>
<td colspan=4>
<div class="form-caption"><hr>客户结算信息</div>
</td>
</tr>
...
<!-- 无名分组 -->
<tr>
<td colspan=4>
<div class="form-caption"><hr></div>
</td>
</tr>
</table>
<div class="form-caption"><hr>订单明细信息</div>
<table id="tblOrder1" width="100%">
<thead><tr>
<th data-options="field:'dispatchId', formatter:Formatter.dispatchId">调度单</th>
<th data-options="field:'brand'">商品车品牌</th>
...
</tr></thead>
</table>
</form>
需求:财务每月均会录入每个员工基本工资、天数(即出勤天数)、备注等信息。 为了方便录入,希望系统可自动根据上月数据给出所有人员工资表,财务只需要调整每人的出勤天数等字段后,即可批量录入。
设计:工资列表为pageSalary,在表头上添加一个“复制最近”按钮,点击则打开一个对话框dlgCopy,可查询和展示上月工资列表, 且表格每行可进行编辑(可复用工资列表的对话框dlgSalary,但要注意在点确定后不要立即操作数据库,即需要设置对话框的offline模式),全部编辑完成后,点击确定,调用批量添加接口Salary.batchAdd()({list}),然后刷新主表pageSalary。
(还有另一种复制行/粘贴行的设计操作和开发更方便,但须添加数据后再调用,下节介绍)
实现:
在工资表中添加操作按钮,可打开对话框dlgCopy,且在批量添加后刷新列表数据:pageSalary.html
function initPageSalary()
{
...
var btn3 = {text: "复制最近", iconCls:'icon-ok', handler: function () {
WUI.showDlg("#dlgCopy", {
onOk: function () {
WUI.reload(jtbl);
}
});
}};
...
jtbl.datagrid({
toolbar: WUI.dg_toolbar(jtbl, jdlg, ..., btn3),
...
});
}
注意:按钮的权限取决于名字,也可以特别设置wui-perm属性,往往直接使用内置权限如“新增”,“删除”,“设置”,“导出”等:
var btn3 = {text: "复制最近", "wui-perm": "新增", iconCls:'icon-ok', handler: function () {...} }
系统弹出带可编辑列表的对话框:dlgCopy.html:
<form title="复制工资" wui-script="dlgCopy.js" my-initfn="initDlgCopy" style="width:800px; height:500px">
<table id="tblSalary">
<thead><tr>
<th data-options="field:'empName', sortable:true">员工</th>
<th data-options="field:'年月', sortable:true">年月</th>
<th data-options="field:'基本工资', sortable:true, sorter:numberSort">基本工资</th>
<th data-options="field:'天数', sortable:true, sorter:numberSort">天数</th>
<th data-options="field:'备注', sortable:true, sorter:numberSort">备注</th>
</tr></thead>
</table>
</form>
dlgCopy.js:
function initDlgCopy()
{
var jdlg = $(this);
var jtbl = jdlg.find("#tblSalary");
jdlg.on("show", onShow)
.on("validate", onValidate);
function onShow(ev, formMode, opt)
{
var jdlg = $("#dlgSalary");
jdlg.objParam = {
offline: true
};
// 只能修改或删除
jtbl.jdata().toolbar = "ds";
jtbl.datagrid({
url: WUI.makeUrl("Salary.query", {q: "last"}), // 取上月数据
toolbar: WUI.dg_toolbar(jtbl, jdlg),
onDblClickRow: WUI.dg_dblclick(jtbl, jdlg),
// pagination: false,
// fitColumns: true
loadFilter: function (data) {
// 处理数据并展示
var data1 = $.fn.datagrid.defaults.loadFilter.call(this, data); // 已由框架将table格式处理成datagrid的格式: {total, rows}
data1.rows.forEach(function (row) {
// 清空部分数据,比如清除原始的id,避免重复添加出错; 清除创建日期dt,在添加时设置成当前日期。
var cols = ["id", "备注", "dt"];
cols.forEach(function (k) {
delete row[k];
});
// 修改部分数据:如年月从 201910 变成 201911
row.年月 = (row.年月 % 100 == 12? row.年月+89: row.年月+1);
});
return data1;
},
});
}
// 点击确定时,做批量添加
function onValidate() {
var data = $.extend(true, {}, jtbl.datagrid("getData"));
data.rows.forEach(function (row) {
// 删除添加接口中不需要的数据
delete row.empName;
});
return callSvr("Salary.batchAdd", function (data) {
app_alert("添加" + data.cnt + "条数据!");
WUI.closeDlg(jdlg);
}, {list: data.rows} );
}
}
仍然是上面的例子,设计成在工资表上添加两个按钮:“复制行”和“粘贴行”,实现上述类似功能:
再点粘贴行,则批量添加这些行,添加前须用户确认,添加后需提示添加了几条。添加时可加些自定义的逻辑,比如清空或设置数据。
var btn4 = {text: "复制行", iconCls:'icon-ok', handler: function () {
var rows = jtbl.datagrid("getSelections");
// 把数据暂存于此:
jpage.jdata().copy = rows;
WUI.app_show("复制" + rows.length + "行");
}};
var btn5 = {text: "粘贴行", iconCls:'icon-ok', handler: function () {
var rows = $.extend(true, [], jpage.jdata().copy);
if (rows.length == 0)
return;
app_alert("粘贴添加" + rows.length + "行数据?", "q", function () {
rows.forEach(function (row) {
// 删除不适合add接口的参数:
var cols = ["id", "备注", "empName", "disableFlag"];
cols.forEach(function (k) {
delete row[k];
});
row.年月 = (row.年月 % 100 == 12? row.年月+89: row.年月+1);
});
callSvr("Salary.batchAdd", function (data) {
WUI.app_show("添加" + data.cnt + "条数据!");
WUI.reload(jtbl);
}, {list: rows} );
});
}};
jtbl.datagrid({
url: WUI.makeUrl("Salary.query"),
toolbar: WUI.dg_toolbar(jtbl, jdlg, "export", ..., btn4, btn5),
...
});
示例:在dialog上,填写门店字段(填写Id,显示名字),从门店列表中选择一个门店。
由于门店非常多,如果用my-combobox组件,操作很不方便,且最大一般只显示1000条,查询也不友好。 使用wui-combogrid(基于easyui-combogrid组件封装),录入时将匹配结果在表格中展现,由于是实时查询且支持分页,不受数据量大小的限制。
在逻辑页上添加wui-combogrid: dlgTask.html
<form my-obj="Task" title="安装任务" wui-script="dlgTask.js" my-initfn="initDlgTask">
<tr>
<td>门店</td>
<td>
<input class="wui-combogrid" name="storeId" data-options="ListOptions.StoreGrid">
</td>
</tr>
</form>
选项定义如下:全局store.js
ListOptions.StoreGrid = {
jd_vField: "storeName",
panelWidth: 450,
width: '95%',
textField: "name",
columns: [[
{field:'id',title:'编号',width:80},
{field:'name',title:'名称',width:120}
]],
url: WUI.makeUrl('Store.query', {
res: 'id,name',
})
};
属性请参考easyui-combogrid相关属性。wui-combogrid扩展属性如下:
在输入时,它会自动以url及参数q向后端发起查询,如callSvr("Store.query", {res:'id,name', q='1'})
.
在筋斗云后端须支持相应对象的模糊查询(请查阅文档qsearch)。api_objects.php
class AC2_Store extends AccessControl
{
protected function onQuery()
{
// 指定要搜索的字段数组
$this->qsearch(["id","name"], param("q"));
}
}
如果要处理从表格选择后的结果,可以用choose事件:
var jo = jdlg.find("[comboname=storeId]"); // 注意不是 "[name=storeId]"(原始的input已经变成一个hidden组件,只存储值)
jo.on("choose", function (ev, row) {
console.log('choose row: ', row);
...
});
或是直接取数据:
var row = jo.combogrid("grid").datagrid("getSelected");
获得选择的行。注意:在未选择时它可能为null.
在生产过程列表中,提供按钮,点击显示“工作量日统计表”,并可以导出。
设计:可以由后端生成统计表(中文字段),前端只须直接显示;也可以由前端直接拼出字段。 对象设计如下,SnLog表示工件日志(即生成过程):
@SnLog: id, mh, ...
虚拟字段vcol: totalOrderCnt/总工单数, totalSnCnt/总工件数, totalMh/总理论工时
接口如下:
前端自行调用:
SnLog.query(gres:"y 年,m 月,d 日", res:"totalOrderCnt 工单数, totalSnCnt 工件数, totalMh 理论工时") -> tbl(年,月,日,工单数,工件数,理论工时,...)
后端直接拼好:
SnLog.query(q=stat, t=ymd, cond?) -> tbl(年,月,日,工单数,工件数,理论工时,...)
后端会直接设置好分组字段、输出字段(res/gres等),允许前端传入cond过滤条件。
后端实现totalOrderCnt等虚拟字段:
class AC2_SnLog extends AccessControl
{
protected $requiredFields = ["procId", "stationId"];
protected $vcolDefs = [
[
"res" => ["proc.name procName", "proc.mh"],
"join" => "LEFT JOIN WorkProc proc ON proc.id=t0.procId",
"default" => true
],
[
"res" => ["SUM(proc.mh * t0.snCnt) totalMh"],
"require" => "mh"
],
[
"res" => ["COUNT(DISTINCT sn.orderId) totalOrderCnt", "SUM(t0.snCnt) totalSnCnt"],
"require" => "orderId"
],
...
]
}
后端实现统计表:
protected function onInit()
{
if (param("q") == "stat") {
$tmUnit = param("t");
if ($tmUnit == "ymd")
setParam("gres", "y 年,m 月,d 日");
else if ($tmUnit == "ym")
setParam("gres", "y 年,m 月");
setParam("res", "totalOrderCnt 工单数, totalSnCnt 工件数, totalMh 理论工时, totalMh1 实际工时, totalMh2 出勤工时");
}
}
前端利用pageSimple通用列表页,可无须为每个统计表添加新的页面,它将根据统计表的字段自动显示。 前端在SnLog列表中添加操作按钮:
// function initPageSnLog()
// 示例1:由后端生成完整报表
var btnStat1 = {text: "日统计", iconCls:'icon-ok', handler: function () {
var queryParams = jtbl.datagrid("options").queryParams;
var url = WUI.makeUrl("SnLog.query", { q: 'stat', t: 'ymd' });
WUI.showPage("pageSimple", "工作量日统计!", [url, queryParams]);
}};
// 示例2:由前端指定字段生成报表
var btnStat2 = {text: "月统计", iconCls:'icon-ok', handler: function () {
var queryParams = jtbl.datagrid("options").queryParams;
var url = WUI.makeUrl("SnLog.query", { gres: "y 年, m 月", res: "totalOrderCnt 工单数, totalSnCnt 工件数, totalMh 理论工时, totalMh1 实际工时, totalMh2 出勤工时" });
WUI.showPage("pageSimple", "工作量月统计!", [url, queryParams]);
}};
jtbl.datagrid({
...
toolbar: WUI.dg_toolbar(jtbl, jdlg, ..., btnStat1, btnStat2),
})
注意:调用WUI.showPage时,若标题以“!”结尾,则每次都重新打开页面。pageSimple.js中也有参考例子。
仪表盘(dashboard)中常常显示各种统计数字,以维修记录对象为例,想要展示以下统计值:在修故障, 今日修复故障, 昨日修复故障, 本月修复故障 表字段(含虚拟字段)定义如下:
维修记录
@RepairRecord: id, tm, name, veId, hubId, exId, result, addr, tm2, status(4), anaResultId
vcol: veName, veCode
vcol-stat: 在修故障?, 今日修复故障?, 昨日修复故障?, 本月修复故障?
查询接口:
RepairRecord.query(res="在修故障, 今日修复故障, 昨日修复故障, 本月修复故障", fmt=one) -> { 在修故障, 今日修复故障, 昨日修复故障, 本月修复故障 }
实现示例(java版本):其中在onInit中动态添加了相关虚拟字段。
class AC2_RepairRecord extends AccessControl
{
@Override
protected void onInit()
{
this.vcolDefs = asList(
new VcolDef().res("ve.name veName", "ve.code veCode")
.join("LEFT JOIN Vehicle ve ON ve.id=t0.veId")
.isDefault(true)
);
this.defaultSort = "t0.id DESC";
if (this.ac.equals("query")) {
long now = time();
String 今天 = date(FMT_D, now);
String 昨天 = date(FMT_D, now - 24*T_DAY);
String 月初 = date("yyyy-MM", now);
this.vcolDefs.add(
new VcolDef().res("SUM(status='维修中') 在修故障",
"SUM(status='完成' and t0.tm>='" + 今天 + "') 今日修复故障",
"SUM(status='完成' and t0.tm>='" + 昨天 + "' and t0.tm<'" + 今天 + "') 昨日修复故障",
"SUM(status='完成' and t0.tm>='" + 月初 + "') 本月修复故障")
);
}
}
}
在过去,为了优化移动端应用的加载体验,框架提供webcc工具,用于“缓存优化”和“JS/CSS优化”(即合并压缩)等。详细可参考文档: http://oliveche.com/jdcloud-site/jdcloud-tool.html
webcc是需要将源码编译生成发布目录的,为了更方便的直接以源码部署,已经不太使用了。
使用源码部署,只能放弃了“缓存优化”。 在server/m2/.htaccess中包含有apache缓存配置,过去是只有html不缓存,现在是html/js/css都不缓存,以避免修改源码后客户得不到及时更新。
对于“JS/CSS优化”目前建议是这样来做:
在server/m2/Makefile中定义了需要优化合并的js/css文件。 应用中引用的大量库,都应添加到这里来。而原先引入这些js/css的地方,应改为:
<!--link rel="import" href="lib.html" /-->
<link rel="stylesheet" href="lib.min.css" />
<script src="lib.min.js"></script>
如果库文件有改动,应运行make
命令重新生成lib.min.js
, lib.min.css
文件。工具同时还会生成lib.html文件,包含所有的原始js/css文件,便于调试时使用。
类似的,对于cordova插件,也做了合并优化。 这带来一个问题,框架原先是自动加载cordova/cordova.js
文件的,怎样让它自动加载新的lib-cordova.min.js
文件呢? 为了解决这个问题,在生成lib.min.js时,自动加了g_args.mergeJs变量,让框架根据它来识别是是否做了优化,以加载正确的文件。
@echo "g_args.mergeJs=1;" >> lib.min.js
注意以后对库的任何改动,是要用make编译后才能生效了。
这在调试内部库时是比较麻烦的,解决方法是在调试时,用自动生成的lib.html文件的内容替换掉lib.min.js, lib.min.css那两行引用。 (旧版本chrome支持用link标签包含html片段的功能,可惜在chrome 80版本后删除了这一功能)
管理端登录后,显示通知数,点击通知数显示通知详情。点击详情可跳转相关页面。
示例:在车辆出厂检测时,若发现缺陷问题,则质量人员登录系统时应得到消息通知,来分析处理缺陷问题。这就相当于用户的待办事项列表。
后端接口设计:
Notify.queryCnt() -> {cnt}
Notify.query() -> [{type, tm, name, relId}]
Issue
表示缺陷。前端在登录后,调用queryCnt接口显示通知数。用户查看详情时,调用Notify.query接口获取待办项列表。点一项可跳转相关对象。
安装插件jdcloud-plugin-notify,后端去修改AC2_Notify类的两个接口实现。
前端在handleLogin方法中添加调用(store.js)
前端修改 web/page/dlgNotify.js 中的显示和跳转逻辑。
如果一个记录被其它记录关联,框架默认是没有处理的,容易造成关联它的其它对象出问题。 对于删除记录有以下几种处理方式。
不做真正删除,只是通过设置deleteFlag之类的字段并隐藏记录。好处是被删除的记录仍可追溯,且关联对象仍可用,无须特别处理。
实现方法是设置delField
字段,系统将在XX.del
接口中自动设置该自动,且在XX.query
接口中过滤该字段。示例:
常见的有两种逻辑:
要两种实现方式,一种是直接利用创建数据库外键约束来解决,无须编码;另一种是代码处理。
示例:在删除物料(Item)时,检查是否有工单(Ordr)引用了物料,若有则报错。在删除工单时,同时删除工单下的日志(OrderLog)。
分析:数据关联模型为 Ordr.itemId -> Item.id
和 OrderLog.orderId -> Ordr.id
。
[方法一:创建数据库约束]
在data/init.sql中记录这些SQL语句,用于新部署
alter table Ordr
add constraint fk_itemId foreign key (itemId) references Item (id)
alter table OrderLog
add constraint fk_orderId foreign key (orderId) references Ordr (id) on delete cascade;
注意:如果当前数据已经不满足约束,则上述语句将执行失败,比如已有一些Ordr.itemId指向不存在的Item.id,这时可人工决定是更新或是删除这些不满足约束的数据:
示例:将不合约束的外键置空
update Ordr
left join Item on Ordr.itemId=Item.id
set Ordr.itemId=null
where Item.id is null
示例:将不合约束的记录删除
delete OrderLog
from OrderLog
left join Ordr on OrderLog.orderId=Ordr.id
where Ordr.id is null
在删除失败时,默认报以下错误(框架对数据库原始数据库错误信息进行了可读性加工):
操作失败:`Ordr`表中有数据引用了本记录。
可以对表名进行翻译,只要创建T函数,如在api.php中:
function T($name)
{
static $map = [
"Ordr" => "工单",
"Item" => "物料",
"Flow" => "工艺",
"Category" => "产品类别",
"Sn" => "工件",
"SnLog" => "生产过程"
];
return $map[$name];
}
这样删除失败时报错为:
操作失败:`工单`表中有数据引用了本记录。
[方法二:写代码加约束]
示例:
// class AC2_Item 删除物料时检查若有工单(Ordr)关联物料,则报错
protected function onValidateId()
{
if ($this->ac == "del") {
$rv = queryOne("SELECT id FROM Ordr WHERE itemId=" . mparam("id"));
if ($rv !== false)
jdRet(E_FORBIDDEN, "cannot delete", "工单`$rv`引用了本记录");
}
}
// class AC2_Ordr 删除工单后,自动删除关联的工单日志对象(OrderLog)
protected function onValidateId()
{
if ($this->ac == "del") {
$this->onAfterActions[] = function () {
execOne("DELETE FROM OrderLog WHERE orderId=" . $this->id);
}
}
}
案例:现有WMS(仓库管理),MES(生产管理)系统,它们可独立运行,均有独立的人员、权限管理; 其中有些接口或概念是同名甚至冲突的,例如Item在两个系统中均有,表示物料,但字段差异较大;Ordr在两个系统都有,分别表示库存请求和生产工单。
现在希望创建LES(物流执行系统)方案,以WMS为基础(称为主系统),将MES(称为子系统)的功能整合进来。 即希望融合两个同构系统的功能,能够尽可能少修改地复用两个系统的功能。
案例2:saic项目(server-pc:src/saic)是汽车运营管理系统,它里面需要对门店进行库存管理,故需要集成erp(简单库存即进销存系统)。 erp部署为erp-saic(对应数据库为erp_saic),在saic的web和m2中分别设置了moduleExt.js文件用于前端集成。 主系统为saic,子系统为erp_saic。
我们暂且称它为jdcloud微服务方案,系统有多个后端服务,每个服务可单独部署在不同的服务器,有独立的数据库。但其实概念上它并不是完整的微服务(没有服务注册、治理等)。
案例1的是整合方案: 在WMS代码库上新建分支les,新建数据库les。 要整合MES系统,前端需要解决以下问题:
moduleExt["showPage"]
函数,在主系统调用showPage时,可自动路由到子系统的页面。moduleExt["callSvr"]
函数,在主系统调用callSvr时,可自动路由到子系统的接口。后端各系统用自己的接口。而数据库有两种方案:共用,或是各用各的。 在实际项目中,我们主要目标是子系统充分复用,而非微服务划分,所以推荐直接共同一个数据库,配置和使用都简单很多,参见章节主子系统共用同一个数据库
共用数据库的后端子系统集成机制:
不共用数据库时主要机制:
FROM Employee
或JOIN Employee
改为FROM 主系统数据库.Employee
或JOIN 主系统数据库.Employee
。注意后端两种方案都要求没有重名且不兼容的对象;如果两个系统中若出现同名对象,以主系统对象为准,必要时子系统相关逻辑需要移植到主系统,若同名对象在各系统中含义完全不同且又都需要,则子系统对象需先改名。
前端(web端/移动端)逻辑页面整体链接过来(子系统/server/web/page/ -> 主系统/server/web/page/{子系统}),开发环境(windows)可以用mklink,生产环境(linux)可以用ln:
# 生产环境
cd /var/www/html/saic/web/page/
ln -s /var/www/html/erp-saic/web/page/ erp-saic
# 开发环境
cd d:\project\saic\server\web\page
mklink /j erp-saic d:\project\erp-saic\server\web\page
前端全局变量整合过来(子系统/server/web/app.js,store.js -> 主系统/server/web/moduleExt.js),以及需要的第三方库(如server/web/lib/mermaid.js,jsoneditor.js等)复制到主系统。 须去除重复部分,对于mes系统中重名变量,按xx
=> xx__{子系统名}
方式重命名,如xx__erp
。 TODO: 子系统对象加前缀,如erp:Item
而不是Item__erp
,用callSvrExt[].makeUrl替代moduleExt.callSvr
示例:将子系统系统中全局变量定义归集到server/moduleExt.js中,如
$.extend(Formatter, {
...
orderId__Mes: WUI.formatter.linkTo("orderId", "#dlgOrdr__Mes", true),
planId: WUI.formatter.linkTo("planId", "#dlgOrdr__Mes"),
snId: WUI.formatter.linkTo("snId", "#dlgSn"),
itemId__Mes: WUI.formatter.linkTo("itemId", "#dlgItem__Mes", true),
flowId: WUI.formatter.linkTo("flowId", "#dlgFlow", true),
procId: WUI.formatter.linkTo("procId", "#dlgWorkProc"),
...
});
示例:
var mesObj = [
"Flow", "Flow1", "WorkProc","WorkArea", "WorkStation", "WorkStep", "WorkPlace",
"Sn", "SnLog", "Category", "Cate_Flow", "Ex", "ExRule", "ExRuleEmp",
"Item__Mes", "Msg",
"Ordr__Mes", "OrderConf", "OrderConfRule", "PrintTpl", "SeqGen", "OrderLog",
"Fault", "BOM", "Capacity"
];
// 指定MES中的页面(与WMS重名者,加`__Mes`后缀),它是在page/mes/目录下
var mesPage = [].concat(mesObj, ["ExStat", "SnStat", "SnLogStat", "FaultStat", "OrderPlan"]);
// 指定MES中的调用(与WMS重名者,加`__Mes`后缀)
var mesCall = [].concat(mesObj, []);
WUI.options.moduleExt["showPage"] = function (name) {
var ms = name.match(/^(?:page|dlg)(\w+)/);
if (ms && mesPage.indexOf(ms[1]) >= 0)
return "page/mes/" + name.replace("__Mes", "");
};
var mesUrl = location.href.indexOf('localhost')>0? "../../../mes/server/api": "../../mestest/api";
// TODO: 用callSvrExt['mes']替代,且对象用'mes:Xxx'
WUI.options.moduleExt["callSvr"] = function (name) {
var arr = name.split('.');
if (mesCall.indexOf(arr[0]) >= 0)
return mesUrl + "/" + name.replace("__Mes", "");
};
移动端类似,将子系统/server/m2/app.js,store.js中的全局定义整合到主系统的m2/moduleExt.js,在应用主文件中(如m2/emp.html)中包含它即可。 配置项由WUI.options.moduleExt改成MUI.options.moduleExt即可。之后callSvr就可以直接调用集成模块中的接口了。 示例:
var mesObj = [
"Flow", "Flow1", "WorkProc","WorkArea", "WorkStation", "WorkStep", "WorkPlace",
"Sn", "SnLog", "Category", "Cate_Flow", "Ex", "ExRule", "ExRuleEmp",
"Item__Mes", "Msg",
"Ordr__Mes", "OrderConf", "OrderConfRule", "PrintTpl", "SeqGen", "OrderLog",
"Fault", "BOM", "Capacity"
];
// TODO: 移动端还未支持showPage扩展
// 指定MES中的页面(与WMS重名者,加`__Mes`后缀),它是在page/mes/目录下
// var mesPage = [].concat(mesObj, ["ExStat", "SnStat", "SnLogStat", "FaultStat", "OrderPlan"]);
// 指定MES中的调用(与WMS重名者,加`__Mes`后缀)
var mesCall = [].concat(mesObj, []);
var mesUrl = location.href.indexOf('localhost')>0? "../../../mes/server/api": "../../mestest/api";
MUI.options.moduleExt["callSvr"] = function (name) {
var arr = name.split('.');
if (mesCall.indexOf(arr[0]) >= 0)
return mesUrl + "/" + name.replace("__Mes", "");
};
注:主子系统数据库分离较复杂,可优先考虑共用同一个数据库。参考后面章节主子系统共用同一个数据库
以案例2为例,具体配置参考saic项目的README.md。主系统为saic,子系统为erp_saic。
解决统一登录问题,采用会话共享方案。最简单的方案,如果都是使用php开发的服务,且全都部署在同一服务器下,可直接采用session目录共享方式,即在子系统(erp_saic)中配置conf.user.php,将session目录与主系统复用:
开发环境:(windows)
putenv("P_SESSION_DIR=d:/project/saic/server/session");
测试环境:(linux)
putenv("P_SESSION_DIR=/var/www/src/saic/server/session");
新版本里更推荐直接配置子系统的conf_dataDir到主系统中,session也刚好能共享:
$GLOBALS["conf_dataDir"] = "/var/www/src/saic/server";
注意:曾试行增加了jwt生成和验证机制(对jwt的支持,已抽象出插件jdcloud-pluign-jwt),但经研究,jwt验证如果完全无状态(即不存后端redis中),达不到及时删除、防止重复等目的,导致不安全;且存在发送数据多、内容做session时只读不可写等缺点。 因此,没有理由使用jwt验证机制。服务在多机部署的情况下,可通过数据库(已支持,参考SessionInDb)或redis共享session。
解决调用不同服务时页面重入问题。其本质是各系统的版本不同导致冲突,热更新机制误判导出页面刷新。可在子系统中配置一个名字:
$GLOBALS["conf_poweredBy"] = "erp"; // 给子系统指定名字以区分于主系统,解决多系统版本号不同导致的前端错误热更新问题
如果不设置,则由于主系统和子系统都是jdcloud框架,其后端API接口均会返回版本(通过HTTP头X-Daca-Server-Rev),导致前端调用后发现后端版本变化,触发自动热更新机制自动刷新系统。 当指定调用外部系统时即callSvr(‘xxx:ac’)时,不检查版本更新,所以标准解决方案是把外部系统调用都加上前缀。 TODO: 应使用sys:ac
方式替代ac__sys
标识外部系统的方式。
解决同一份表问题。主系统和子系统中都有Employee/Role/ApiLog/ObjLog等表,合理的设置应该是子系统直接使用主系统的相关表,可在子系统上设置,假定主系统的数据库名为saic:
解决二次开发问题。 在主系统中做二次开发,涉及子系统对象的二次开发,建表中应建到子系统的数据库内。同时,二次开发在配置数据模型后,会生成后端AC类文件,子系统的对象当然应该创建到子系统目录下(php/class/ext),对子系统后端逻辑进行扩展。这些是由主系统调用子系统的DiMeta.syncAc接口来实现的。 配置时,应先在主系统中先用conf_tableAlias配置子系统表名,否则这些表就会创建在主系统里。然后用conf_subsys_url_{子系统}
指定子系统的基础API接口路径,这样主系统就可调用子系统接口生成后端AC类文件。 示例:
二次开发页面,通过obj区分是不是子系统,如果是则调用子系统接口。
TODO: 如果子系统有与主系统同名的对象,目前只能修改子系统对象(加上后缀,如Ordr改为Ordr__erp)名以区分。这样子系统已有的页面就需要适配修改。
尽管如此,还有一个问题难以解决,即主系统与子系统的关联问题。
例如:主系统中以二次开发方式添加了Part(零件包)和Part_Item(零件包与物料关联),子系统erp中有Item对象(物料)。 如果通过二次开发,给子系统的Item对象添加一个AC子表parts对应主系统中的Part_Item对象,则产生了跨系统的关联。
这时对Item操作比如Item.add/query/batchAdd中,一旦涉及到parts字段将报错,因为子系统中根本就没有Part_Item这个对象!
分析:
原则上来说,微服务是不支持对象直接关联的,必须通过接口来。然而为了使用方便,如果只是简单的需要Part_Item对象,还是可以部分支持。
解决方案:
先将主系统二次开发所生成的class/ext/AC_PartItem.php文件复制到子系统中,然后子系统配置中,指定其中哪些是主系统的表:
$GLOBALS["conf_tableAlias"] = [
...
"Part_Item" => "saic.Part_Item",
];
这样,基本的调用就可以了。如果还要再深入引用主系统的其它逻辑或对象,只能在子系统中单独编码。
上面主子系统中,需要互相指定哪些表是对方系统的,很复杂,易出错。 从微服务的原则上来说,这些强行的数据库关联是错误的设计。
但我们的这样做的目标,并不是微服务,而是保持主子系统的独立维护,便于重用。所以共用一个数据库反而成了推荐的做法。
本方法首先应确保子系统中所有核心对象与主系统中不冲突,例如主系统中有Item表示销售项,而子系统也有Item却表示物料,就会不兼容,需要现将子系统有冲突的对象改名(如改为WhItem)。 其他同名对象: - 框架对象ApiLog/ObjLog/Cinf/UiMeta/DiMeta等一般无需调整,必要时升级框架确保与主系统一致。 - 同名业务对象,如Employee等,一般直接使用主系统的逻辑,必要时需要手工将子系统部分逻辑移植到主系统。
在主系统中需配置子系统及其URL,这用于在二次开发时判断扩展对象在哪个系统。主系统conf.user.php:
$GLOBALS["conf_subsys"] = ["erp_saic"];
$GLOBALS["conf_subsys_url_erp_saic"] = "http://localhost/erp-saic/api/";
在子系统中,需要配置共用主系统的session目录(共享session)、数据库(共用数据库)、扩展AC类的目录。子系统conf.user.php:
# 与主系统共用数据库
putenv("P_DB=localhost/saic");
# 与主系统共用session目录
putenv("P_SESSION_DIR=/var/www/html/saic/session");
# 与主系统共用二次开发扩展类目录
$GLOBALS["conf_classDir"] = ["class", "/var/www/html/saic/php/class/ext"];
# 子系统标识
$GLOBALS["conf_poweredBy"] = "erp";
注意:在共用数据库时,子系统的AC类是由主系统来生成的(因为主系统并不知道这个类是否是子系统的),但在创建AC类需要检查类是否存在,如果主系统中没有这个类,它会再调用子系统的DiMeta.class_exists接口来判断子系统中是否存在该类; 而在不共用数据库时,主系统通过conf_tableAlias配置知道哪个类是子系统的,所以创建AC类的工作会直接调用子系统的DiMeta.syncAc接口来实现。
需要特别注意,尽管共用classDir,但子系统是无法访问主系统已有逻辑的,必要时需要将主系统的一些逻辑移植到子系统。 例如,二次开发中为子系统Item(物料)对象扩展一个子表字段parts,对应Part_Item(零件包与物料关联)对象,简单情况是可以直接工作的; 但如果在Part_Item对象的扩展实现比如onValidate回调中,调用了主系统才有的函数或对象(callSvc("某主系统对象接口")
),则这些被引用部分必须移植到子系统,比如相应函数或对象复制到子系统,或是接口调用主系统等。
示例:mes项目中,工单上方菜单过多,需要优化。
方案:增加“更多”菜单,将不常用的菜单项放到该菜单下。
在pageOrdr.html中增加弹出菜单:
<div class="mnuMore" style="width:150px;display:none">
<!-- 可以指定id或name, 然后在回调中用于区分菜单项 -->
<div id="mnuCreateConf" name="createConf" data-options="iconCls:'icon-save'">重新生成配置</div>
<div class="menu-sep"></div>
<div data-options="iconCls:'icon-reload'">工件关联</div>
<div data-options="iconCls:'icon-filter'">创建子工单</div>
<div class="menu-sep"></div>
<div data-options="iconCls:'icon-redo'">车间看板</div>
</div>
在pageOrdr.js中初始化菜单并添加处理函数:
// 在 initPageOrdr函数中
var jmnuMore = jpage.find(".mnuMore").menu({
onClick: function (o) {
// console.log(o);
// 一般可以通过o.id, o.name或o.text来判断;id以"mnu"开头,name使用英文,text为中文
switch (o.id || o.text) {
case "mnuCreateConf":
btnSubOrder_click();
break;
case "车间看板":
window.open('../m2/emp.html#plan');
break;
case "工件关联":
DlgScan.showForRel();
break;
case "重新生成配置":
btnCreateConf_click();
break;
}
}
});
// v6起支持指定class为splitbutton或menubutton (easyui-datagrid增强功能)
var btnMore = {text: "更多", iconCls:"icon-more", class:"menubutton", menu: jmnuMore};
/* v6以前jquery-easyui对列表上菜单项处理不够灵活(要么全部用JS指定按钮且这些按钮无法指定类型,要么用DOM id指定菜单栏), 可以类似右键菜单这样来处理
var btnMore = {text: "更多", iconCls:'icon-more', handler: function (ev) {
jmenu.menu('show', {left: ev.pageX, top: ev.pageY});
}};
*/
jtbl.datagrid({
...
toolbar: WUI.dg_toolbar(jtbl, jdlg, ..., btnMore),
});
需求:以firefly-web项目为例,在多台服务器上部署了多个实例,分别监控多个区域。 在某中心服务器上,希望在同一系统中看到所有监控点.
与一个应用开多个实例不同,一是部署在不同服务器上,不是在一台上共用一个服务,二是不想用切换实例的方式一个个查看,而是集中在同一系统中看。
实现:前端使用中心服务器的页面,分别调用各后端,调用各后端时ac添加扩展前缀,如shagang:Data.query
表示调用shagang系统的Data.query
。
要点:
instCtx={inst, name}
表示哪个系统后端,其中inst为系统名比如’G1’, ’shagang’等, 特别的’local’表示当前系统显示页面和对话框时带上instCtx参数,如
WUI.showPage('pageUi', ctxTitle(title, instCtx), {instCtx})
其中定义公共方法ctxTitle(title, instCtx)用于在title后自动加系统名后缀, 以便同一页面显示多实例时互不影响。 页面和对话框修改obj或ac,加上系统前缀.
// 页面pageXxx.js中initPageXxx(opt)
var jdlg = $("#dlgXxx");
var obj = ...
if (opt.instCtx) {
obj = opt.instCtx.inst + ':' + obj;
jdlg.objParam.instCtx = opt.instCtx;
}
jtbl.datagrid({
url: WUI.makeUrl(obj + '.query'),
...
});
// 对话框dlgXxx.js中initDlgXxx(opt)
if (opt.instCtx) {
obj = opt.instCtx.inst + ':' + obj;
}
jfrm.attr({
"my-obj": obj,
title: uiMeta.name
});
打开页面、对话框或直接调用接口:
// e.g. WUI.showPage("pageUi", "title!") => WUI.showPage("pageUi", ctxTitle("title!", instCtx));
function ctxTitle(title, instCtx) {
if (instCtx) {
if (title.substr(-1) == '!') {
var t = title.substr(0, title.length-1);
if (t != instCtx.name)
return t + "-" + instCtx.name + "!";
}
else {
if (title != instCtx.name)
return title + "-" + instCtx.name;
}
}
return title;
}
function ctxAc(ac, instCtx) {
return instCtx? (instCtx.inst + ':' + ac): ac;
}
// 1. 打开支持多实例的页面示例, title用ctxTitle处理,自动加系统后缀,在showPageOpt中带上instCtx
var showOpt = {
uimeta:'报警记录',
title:ctxTitle('报警中心', instCtx),
instCtx
};
WUI.showPage("pageUi", showOpt);
// 2. 调用接口示例: 将ac用ctxAc()处理,根据instCtx在ac前加系统前缀,如'sys1:Data.query'
callSvr(ctxAc("alarm", instCtx), {mode: 1}, () => {
app_alert("操作成功");
});
var url = WUI.makeUrl(ctxAc("Data.query", instCtx), {
res: "tm 时间,device_name 监控点,maxT 温度", // marker_name 测温对象
orderby: "tm",
pagesz: 1000,
cond: cond,
});
var showChartParam = [ {gcol: 1} ];
WUI.showPage("pageSimple", ctxTitle("数据曲线", instCtx), {url, showChartParam});
下面例子做了简化,只在初次访问时,在请求中带上token参数调用initClient,通过认证后,子系统将与主系统使用相同cookie名字。
// 首次访问时认证
g_data.insts = {};
function initInst(inst) {
if (g_data.insts[inst])
return;
g_data.insts[inst] = true;
callSvrSync(inst + ':initClient', $.noop, {token: 'monitir'});
}
WUI.callSvrExt['default'] = {
// 只需要返回接口url即可,不必拼接param
makeUrl: function(ac, params, opt) {
if (opt.ext && opt.ext != 'local') {
var inst = opt.ext;
initInst(inst);
if (ac.indexOf('.php') > 0 || ac.indexOf('.html') > 0) {
if (ac[0] == '/') {
return '/@' + inst + ac;
}
else {
return '/@' + inst + '/firefly-web/server/web/' + ac;
}
}
opt.serverUrl = '/@' + inst + '/firefly-web/server/api';
}
},
};
注意:通过enumFields机制创建的虚拟字段虽然很灵活,但不可用于查询条件(cond)、分组字段(gres)或排序字段(orderby)。 因此,我们优先直接用SQL表达式来定义虚拟字段,如果SQL表达式过于复杂,还可以用SQL函数。
示例:工艺表Flow有两个子表:工序WorkProc和流程Flow1,其中在Flow1中引用WorkProc,定义从哪个工序到哪个工序。 现在想查询被引用了的所有工序。
工艺
@Flow: id, ...
工艺中的工序
@WorkProc: id, ...
工艺流程(通过procId和toProcId引用工序)
@Flow1: id, flowId, procId, toProcId
解决方案:引入虚拟字段usedFlag,值为1表示被引用,0表示未被引用
@WorkProc: ...
vcol: usedFlag/已在工艺流程(Flow1)中被引用
实现:
class AC2_WorkProc extends AccessControl
{
protected $vcolDefs = [
[
"res" => ["EXISTS (SELECT id FROM Flow1 f1 WHERE f1.procId=t0.id OR f1.toProcId=t0.id) usedFlag"],
"default" => true
]
]
}
查询示例:查询工序10中所有在使用的工序
callSvr("WorkProc.query", {
cond: {
flowId: 10,
usedFlag: 1
}
});
有如下订单表和队列表:
@Ordr: id, ...
@Queue: id, storeId, queue
- queue: List(orderId). storeId店当前排队队列中的订单,如"100,101"
现在希望显示某个店队列中的所有订单,注意还要按队列中的顺序来显示。
分析:
事实上,Ordr与Queue通过queue字段形成了多对多关联,标准的设计应该是有个关联表:
@Queue_Ordr: id, queueId, orderId
但这里用的是简化设计,直接在queue字段中填写Ordr.id列表。 思路仍是建一个虚拟字段用于查询条件,还需要一个虚拟字段用于排序。
设计:增加虚拟字段:
@Ordr: id, ...
vcol
: queueId, storeId, seqInQueue/队列顺序(可用于orderby)
这样,查询就可以用
callSvr("Ordr.query", {
cond: "storeId=" + id,
orderby: 'seqInQueue',
});
实现:
class AC2_Ordr extends AccessControl
{
...
protected $vcolDefs = [
[
"res" => ["q.id queueId", "q.storeId", "find_in_set(t0.orderNo, q.queue) seqInQueue"],
"join" => "JOIN Queue q ON find_in_set(t0.orderNo, q.queue)"
]
];
}
这里用了find_in_set作为过滤条件。同时它刚好返回在字符串中出现的位置,刚好可以当成序号,用于排序条件。
1个Queue对应多个Ordr,上面是设置子表Ordr,用主表Queue中字段做条件来查询。
当子表设置好后,还可以利用它,为主表Queue添加子表:
@Queue: id, ...
vcol
: @orders
这样,查询就可以用
callSvr("Queue.query", {
cond: "storeId=" + id,
res: "id,orders"
});
实现:
class AC2_Queue extends AccessControl
{
...
protected $subobj = [
"orders" => ["obj"=>"Ordr", "cond" => "queueId={id}", "orderby" => "seqInQueue"]
];
}
有如下库存-物料表:
@Warehouse_Item: id, whId, itemId, ..., inTm
其中inTm表示物料入库时间,现在要根据该时间生成库龄统计表,即不要显示具体年-月,而是希望入库时间显示为“1周内”, “1个月内”, “2个月内”, … “6个月以上”。
分析:
可以用enumFields机制对inTm进行代码处理,这样的话输出显示没有问题,但inTm就无法作为汇总字段或条件查询字段。 因此,用SQL的case when语句进行判断通用性更好。
解决方案:
[
"res" => ["datediff(now(),inTm) ageDays", "case when (@a:=datediff(now(),inTm))<=7 then '1周内' when @a>180 then '6个月以上' else concat(ceil(@a/30), '个月内') end age"]
]
用datediff可得到天数,为了避免在case when语句中反复出现这个表达式,把它存入了变量@a。
注意以下写法有问题:
[
"res" => ["@ageDays:=datediff(now(),inTm) ageDays", "case when @ageDays<=7 then '1周内' when @ageDays>180 then '6个月以上' else concat(ceil(@ageDays/30), '个月内') end age"],
"require" => "ageDays"
]
当列数很多时,横向滚动条很长,这时希望前几列固定,示例:
<div wui-script="pageSalary.js" title="工资" my-initfn="initPageSalary">
<table id="tblSalary" style="width:auto;height:100%">
<!-- 固定列 -->
<thead frozen="true"><tr>
<th data-options="field:'id', sortable:true, sorter:intSort">编号</th>
<th data-options="field:'empName', sortable:true">员工</th>
</tr></thead>
<!-- 常规列 -->
<thead><tr>
<th data-options="field:'年月', sortable:true">年月</th>
...
<th data-options="field:'备注', sortable:true">备注</th>
<th data-options="field:'dt', sortable:true">创建日期</th>
</tr></thead>
</table>
</div>
动态冻结列使用frozenColumns和columns属性,如冻结到第2列:
jtbl.datagrid({
frozenColumns: [ cols.slice(0, 2) ],
columns: [ cols.slice(2) ]
...
});
(v6.1) 支持在列头右键菜单中直接设置冻结列,或在pageSimple/WUI.showDataReport中指定frozen参数。
一般详情对话框的布局方式为上方主表字段,下方以TABS展示子表字段。如果内容很多,也可以单独一个TAB页展示主表字段。
<form my-obj="Flow" title="工艺" style="width:650px;height:650px;" wui-script="dlgFlow.js" my-initfn="initDlgFlow" wui-deferred="loadMermaidLib()">
<div class="easyui-tabs">
<!-- 展示主表字段,注意加wui-form-tab和wui-form-table类 -->
<div title="基本" class="wui-form-tab">
<table class="wui-form-table">
<tr>
<td>编号</td>
<td>
<input name="id" disabled>
</td>
</tr>
...
</table>
</div>
<!-- 展示子表,用的是wui-subobj -->
<div class="wui-subobj" data-options="obj:'WorkProc', relatedKey:'flowId', valueField:'procs', dlg:'dlgWorkProc', toolbar:['r','f','a','s','d','export']" title="工序">
<table>
<thead><tr>
<!--th data-options="field:'id', sortable:true, sorter:intSort">编号</th-->
<th data-options="field:'name', sortable:true">名称</th>
...
</tr></thead>
</table>
</div>
</div>
</form>
示例:工艺如果已应用于生产,则默认只读,除非强制点击“修改工艺”。
方案:
skipOnce_
变量标识这次是有意以编辑模式打开。function initDlgFlow()
{
...
var skipOnce_ = false;
function onBeforeShow(ev, formMode, opt)
{
var objParam = opt.objParam;
var ro = (formMode == FormMode.forSet && !!opt.data.usedFlag && !skipOnce_);
objParam.readonly = ro; // 设置只读模式打开
skipOnce_ = false;
if (ro && WUI.canDo("工艺")) {
// 添加底部按钮
var btnEditFlow = {
text: "修改工艺", iconCls: "icon-edit", handler: function () {
app_alert("工艺已应用于生产,修改工艺会影响历史工单和生产过程!若工序名、理论工时不合理可以修改;若要新增工序、修改流程等,建议点击“升级工艺”创建一个新版本工艺。是否仍然要修改?", "q", function () {
skipOnce_ = true;
opt.buttons = null;
WUI.showDlg(jdlg, opt);
});
}
};
opt.buttons = [ btnEditFlow ];
}
}
}
标准query接口只能导出一张表,要同时导出子表(或关联表)需要定制。
示例:(mes系统)导出工单工时统计报表时,把用到的工艺一并导出,显示工艺中各工序的名称、工时。
解决方案:
要定制导出,可以重载onHandleExportFormat方法,输出带多表的Excel示例,可调用框架提供的table2excel方法:
// onHandleExportFormat中:
require_once("xlsxwriter.class.php");
$writer = new XLSXWriter();
// 注意$table1, $table2必须是{h,d}格式,用callSvcInt("Xx.query")调用返回的数据就是这个格式。
self::table2excel($table1, $writer, "工时统计"); // 最后一个参数指定Excel Sheet名
self::table2excel($table2, $writer, "工序1");
$writer->writeToStdOut();
为了标识处理这个特定报表,可为query接口添加一个标识参数,如{for: "工单工时统计"}
。
前端接口URL示例:
var url = WUI.makeUrl("Ordr.query", {
res: 'id 工单号, code 工单码, flowName 工艺, ...',
for: "工单工时统计", // 后端将做定制处理
});
管理端点导出时还会自动带上以下参数:
fmt: "excel",
pagesz: -1 // 注意导出时避免分页影响
为了把导出内容中用到的工艺(flowId)取出来,我们在onQuery中先用addRequireCol添加这个辅助字段,注意辅助字段是不影响最终返回结果的,即导出内容中不会有这个字段。 然后,在onHandleRow中取出{flowId => flowName}
的映射表,去除重复。 最后,在onHandleExportFormat中边查询边导出。
class AC2_Ordr extends AC0_Ordr
{
...
protected function onQuery() {
parent::onQuery();
// 设置一个标识变量,专用于处理“工单工时统计”报表
$this->forMhStat = (param("fmt") == "excel" && param("for") == "工单工时统计");
if ($this->forMhStat) {
$this->addRequireCol("flowId");
}
}
protected function onHandleRow(&$row) {
parent::onHandleRow($row);
if ($this->forMhStat) {
$flowId = $this->getAliasVal($row, "flowId");
if ($flowId)
$this->exportFlows[$flowId] = $this->getAliasVal($row, "flowName");
}
}
protected function onHandleExportFormat($fmt, $ret, $fname)
{
if (parent::onHandleExportFormat($fmt, $ret, $fname))
return;
if ($this->forMhStat) {
header("Content-disposition: attachment; filename=" . $fname . ".xlsx");
header("Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
header("Content-Transfer-Encoding: binary");
require_once("xlsxwriter.class.php");
$writer = new XLSXWriter();
self::table2excel($ret, $writer, "工时统计");
foreach ($this->exportFlows as $flowId => $flowName) {
$procs = callSvcInt("WorkProc.query", [
"res" => "name 工序名, mh 工时",
"cond" => ["flowId" => $flowId, "usedFlag" => 1],
"pagesz" => -1
]);
self::table2excel($procs, $writer, "工艺{$flowId}-$flowName");
}
$writer->writeToStdOut();
return true;
}
}
}
注意,由于AC2继承了AC0,为避免覆盖AC0中逻辑,在各回调函数中均先调用了parent的相应方法。
使用easyui-tooltip类显示提示信息,通过wui-help类加data-helpKey链接帮助文档的指定关键字。
示例:在标题上(字段左侧)链接显示提示信息,并链接到帮助文档:
<tr>
<td>
<a href="javascript:;" class="easyui-tooltip wui-help" data-helpKey="首件确认" title="根据产品首件标识设置。<br>
值为“是”的工单或工件,必须在首件确认后(即值设置为“否”)才能完工。<br>
有“首件确认”权限才可修改本字段。">
首件标识</a>
</td>
<td>
<select name="firstFlag" class="my-combobox" data-options="jdEnumMap:FlagMap"></select>
</td>
</tr>
示例:在字段下方提示区(hint)显示提示信息,并链接帮助文档:
<tr>
<td>当前工序</td>
<td>
<select name="procId" class="my-combobox" data-options="ListOptions.WorkProc()"></select>
<p class="hint"><a class="easyui-tooltip wui-help" data-helpKey="维修" title="具有'维修'权限,或在浏览器中配置过维修模式">维修模式</a>下,可以设置和重置工序</p>
</td>
</tr>
使用wui-picker-help可以在输入框后添加一个帮助按钮,示例:
<input name="value" class="wui-picker-help" data-helpKey="取消工单">
显示一个帮助按钮:
<a href="javascript:;" class="easyui-tooltip wui-help easyui-linkbutton" data-options="iconCls:'icon-help',plain:true" data-helpKey="首件确认" title="根据产品首件标识设置。<br>
值为“是”的工单或工件,必须在首件确认后(即值设置为“否”)才能完工。<br>
有“首件确认”权限才可修改本字段。"></a>
由于对话框存在多种模式(添加、更新、查找),正确写出适配多种模式的代码较为困难。 同时,各组件的取值、禁用、只读等接口不一,比如普通组件用$(frm.xxx).prop("disabled", true)
禁用,combogrid用jdlg.find("[name=orderId]").combogrid("disable")
禁用,差异很大。
v6引入了WUI.setDlgLogic函数,以配置式写法,意图减少对话框上写逻辑的复杂度。 同时引入了gn函数返回统一组件操作接口,简化了对组件的操作。
下面是新旧代码的对比,新代码分别以setDlgLogic和统一组件接口两种风格来写:
function initDlgOrdr()
{
...
// onShow事件回调中:
function onShow(ev, formMode, initData) {
var forAdd = formMode == FormMode.forAdd;
var forSet = formMode == FormMode.forSet;
// firstFlag字段:在添加时不可设置,在更新时,只有具有“首件确认权限”才可以设置。
$(frm.firstFlag).prop("disabled", forAdd || (forSet && !WUI.canDo(null, "首件确认")));
// flowId字段,添加时不可设置,更新、查找时可设置
// 由于是combogrid,其DOM获取和设置方法都比较特别,不能用$(frm.flowId)或jdlg.find("[name=flowId]")
jdlg.find("[comboname=flowId]").combogrid(forAdd? "disable": "enable");
// status字段,添加时不可设置,但显示为"CR"。更新、查找时可设置
$(frm.status).prop("readonly", forAdd);
if (forAdd) {
$(frm.status).val("CR");
}
}
}
以firstFlag为例,如果根据需求“有首件确认权限才能编辑”直接写成
if (!WUI.canDo(null, "首件确认"))
$(frm.firstFlag).prop("disabled", true);
是不对的,显然它没考虑添加的情况。假如添加时不可编辑,那么是不是可以这么写:
if (forAdd || !WUI.canDo(null, "首件确认"))
$(frm.firstFlag).prop("disabled", true);
也不对,因为忽略了查找模式。查找模式下不应该受影响,那么:
if (forAdd || (forSet && !WUI.canDo(null, "首件确认")))
$(frm.firstFlag).prop("disabled", true);
这样对吗?还不对。因为它只将disabled设置为true,由于对话框是不销毁的,当打开下个对象时状态仍会保持,这样一旦被禁用就总是禁用了。 所以绝不能把操作放在判断条件中:
var isDisabled = forAdd || (forSet && !WUI.canDo(null, "首件确认"));
$(frm.firstFlag).prop("disabled", isDisabled); // 组件操作绝不能放在判断中
新的写法,不用同时考虑添加、更新、查找三种情况,可更清晰地分别设置: 没有设置的会用默认值。
function initDlgOrdr()
{
// firstFlag字段:在添加时不可设置,在更新时,只有具有“首件确认权限”才可以设置。
WUI.setDlgLogic(jdlg, "firstFlag", {
disabledForAdd: true,
disabledForSet: !WUI.canDo(null, "首件确认")
});
// flowId字段,添加时不可设置,更新、查找时可设置
WUI.setDlgLogic(jdlg, "flowId", {
disabledForAdd: true,
});
WUI.setDlgLogic(jdlg, "status", {
readonlyForAdd: true,
valueForAdd: "CR"
});
}
详见setDlgLogic函数说明。而且还可以支持以wui-dialog-logic类标识,直接在DOM上设置选项,如:
<select name="status" class="wui-dialog-logic" data-options="disabledForAdd:true, valueForAdd:'CR'">
注意disabled字段与readonly字段虽然在前端都不可编辑,但有重要区别:disabled字段内容不会提交到后端了,而readonly组件仍会提交。
新的写法二,即使用与之前相同的书写框架,但组件访问接口统一了:
// 旧写法,设置禁用和设置值
$(frm.firstFlag).prop("disabled", forAdd || (forSet && !WUI.canDo(null, "首件确认")));
jdlg.find("[comboname=flowId]").combogrid(forAdd? "disable": "enable");
$(frm.status).val("CR");
// 新写法使用gn函数统一操作接口
jdlg.gn("firstFlag").disabled(forAdd || (forSet && !WUI.canDo(null, "首件确认")));
jdlg.gn("flowId").readonly(forAdd);
// 如果是不显示,写成 jdlg.gn("flowId").visible(false);
var it = jdlg.gn("status");
it.val("CR");
详见jQuery.fn.gn和getFormItem函数介绍。
列转置后,要求增加一列,为某列-合计列的值。 前端通过WUI.showDataReport来实现。
分析: 这个字段难以通过虚拟字段或计算字段定义,可以考虑在后端改写结果(也可前端改写,但是为了导出时也能计算放后端更好)。 在前端query接口调用时在res
参数中加一个占位字段,比如null 差值
:
WUI.showDataReport({
ac: "InvRecord1.query",
res: "itemCode 物料编码,itemName 物料名称, unit 单位, whName 仓库名称, workLogicQty 工单需求数量, null 差值, sum(logicQty) 已发",
...
pivotSumField:'生产领料总数',
queryParam: {for: "生产领料情况"}
});
改写结果一般可以考虑onAfterActions
或onAfter
回调,但对于有列转置(pivot)的情况,转置是在onAfter等操作后面执行的(计算字段enumField机制也是)。示例:
也不能通过重载api_query
来实现,比如:
更不能通过env->onAfterActions
来改写ret,因为那时已经输出结果了,不能再改写。
解决方案:重载queryRet函数,它刚好在pivot处理之后和导出操作之前,示例:
```php
// 定制生产领料情况报表: 填写“差值”字段
protected function queryRet($ret, $nextkey=null, $totalCnt=null, $fixedColCnt=0) {
if (param("for") == "生产领料情况") {
foreach ($ret as &$e) {
$e["差值"] = $e["工单需求数量"]-$e["生产领料总数"];
}
unset($e);
}
return parent::queryRet($ret, $nextkey, $totalCnt, $fixedColCnt);
}
```
适用于某个字段关联多个其它对象,比如字段“关联型号”(支持多个型号),可通过逗号分隔的id列表(如“3,4,5”)来保存。
使用这几个复选组件时应注意:它须将可选项全部列出后来多选,而且与wui-combogrid
组件相比不支持查询,所以选项条目不宜过多,一般建议在100以内(树/树表由于有层次,总数可以再多些)。
示例:(mes/工程变更/dlgEC) 工程变更对话框,在选择影响范围时,需要选择若干产品类别(产品类别分为三级:产品线、系列和型号,一般应选择若干型号)。
设计:
工程变更表:
@EC: id, cateIds(t), cateNames, ...
- cateIds: List(cateId)。变更范围,即型号列表。
- cateNames: List(cateName). 变更范围的文字描述。
产品类别表:
@Category: id, level, name, fatherId, ...
这是个典型的树表,按惯例使用fatherId字段指向父亲,level保存层级(这里只用1,2,3三级,对应产品线、系列、型号)。
EC表通过cateIds关联多个型号,在对话框中可通过下拉复选组件来多选。 如果想在列表显示时可以直接看到型号列表(而不是Id列表),可增加了cateNames字段,只用于显示,记录型号列表文本,最多50字符。
实现:dlgEC.html
<tr>
<td>变更范围</td>
<td>
<input id="cateIds">
<input type="hidden" name="cateIds">
<input type="hidden" name="cateNames">
</td>
</tr>
这里用一个id=cateIds做为复选组件,将在js中初始化;用两个指定name的hidden组件与数据模型中字段对应,在打开对话框(beforeshow)和点确定(validate)时取值或赋值到复选组件。
注意:由于复选组件实现的特殊性,不要为它指定name属性,否则可能与框架冲突。
以combotree为例,用法大致是:
var opt = {
// 多选
multiple: true,
// 以下2项是默认值,可缺省
idField: "id",
textField: "name",
// 注意:加`pagesz:-1`确保尽量取全部数据!!!
url: WUI.makeUrl('Category.query', {
res: 'id,name,fatherId',
pagesz: -1
})
}
// 初始化
var jcateIds = jdlg.find("#cateIds");
jcateIds.combotree(opt);
// 取值(多选)
var val = jcateIds.combotree("getValues"); // val是一个id数组,如[3,4,5]
var text = jcateIds.combotree("getText"); // text是一个逗号分隔的文本,如`选项1,选项2,选项3`
// 设置值(多选)
jcateIds.combotree("setValues", [3,4,5]);
下面演示了三种组件的用法,除了options不同,其它是类似地,所以为了通用化,用了变量combo来表示组件类型,即:
jcateIds.combotree(opt);
var val = jcateIds.combotree("getValues");
改写为了:
var combo = "combotree";
jcateIds[combo](opt);
var val = jcateIds[combo]("getValues");
实现:dlgEC.js
// function initDlgEC()
// 方式1:使用树
var combo = "combotree";
var opt = {
// 多选
multiple: true,
// 以下2项是默认值,可缺省
idField: "id",
textField: "name",
url: WUI.makeUrl('Category.query', {
res: 'id,name,fatherId',
pagesz: -1
})
};
/*
// 方式2:使用数据表
var combo = "combogrid";
var opt = {
// 多选
multiple: true,
// 以下2项是默认值,可缺省
idField: "id",
textField: "name",
panelWidth: 450,
width: '95%',
columns: [[
{field:'id',title:'编号',width:90, checkbox:true},
{field:'name',title:'类别',width:120},
{field:'fatherName',title:'父类别',width:120},
{field:'level',title:'层级',width:100,formatter:Formatter.cateLevel},
]],
url: WUI.makeUrl('Category.query', {
res: 'id,name,fatherName,level',
pagesz: -1
})
};
*/
/*
// 方式3:使用树表
var combo = "combotreegrid";
var opt = {
// 多选
multiple: true,
// 以下4项是默认值,可缺省
idField: "id",
textField: "name",
treeField: "name",
fatherField: "fatherId",
panelWidth: 450,
width: '95%',
columns: [[
{field:'name',title:'类别',width:120},
{field:'fatherName',title:'父类别',width:120},
{field:'level',title:'层级',width:100,formatter:Formatter.cateLevel},
]],
url: WUI.makeUrl('Category.query', {
res: 'id,name,fatherId,fatherName,level',
pagesz: -1
})
};
*/
var jcateIds = jdlg.find("#cateIds");
jcateIds[combo](opt);
jdlg.on("beforeshow", onBeforeShow)
.on("validate", onValidate);
function onBeforeShow(ev, formMode, opt) {
var objParam = opt.objParam;
var forAdd = formMode == FormMode.forAdd;
setTimeout(onShow);
function onShow() {
// 打开对话框时,为组件赋值
var val = (opt.data && opt.data.cateIds)? opt.data.cateIds.split(','): [];
jcateIds[combo]("setValues", val);
}
}
function onValidate(ev, mode, oriData, newData) {
// 点确定后,赋值到cateIds, cateNames两个字段
var val = jcateIds[combo]("getValues");
$(frm.cateIds).val(val.join(','));
// cateNames字段,只保存最多50字符
var text = jcateIds[combo]("getText");
var maxLen = 50;
if (text.length > maxLen) {
text = text.substr(0,maxLen-3) + "...";
}
$(frm.cateNames).val(text);
}
与子表(wui-subobj)类似,将table放在easyui-tabs中,比如:
<div class="easyui-tabs" style="height:100%">
<div title="版本日志" >
<table id="tblRevLog" style="width:100%;height:100%">
<thead>
<tr>
<th data-options="field:'tm'">时间</th>
<th data-options="field:'author'">提交人</th>
<th data-options="field:'dscr'">描述</th>
</tr>
</thead>
</table>
</div>
</div>
注意:
table的style中指定了width:100%,这样数据表可以自适应对话框宽度。注意wui-subobj组件中,若未指定width则会自动这样设置。 当行数较多时,对话框会出现滚动条,但下拉后主表和子表标题部分也随之移动,体验不好; 所以在table上还指定了height:100%,这样行数多时则出现垂直滚动条,且主表及子表标题行固定不动; 但这时数据表所在容器必须固定高度,所以可设置easyui-tabs的“height:100%”,这样对话框下拉到底时,刚好完全显示子表。
在对话框onShow中,为datagrid指定url链接,刷新数据:
// onShow
jtbl = jdiv.find("#tblRevLog");
var url = WUI.makeUrl('App.queryLog', {name: data.name});
jtbl.datagrid({
url: url,
pagination: false
});
需求:UserTraff表记录了A系统内每周内的用户流量。PositionRecord表记录了B系统内用户流量。 现需要将两个表合并,再按月统计用户的流量。
分析:为了便于前端使用WUI.showDataReport显示报表,应封装为对象query接口,这样便于列转置(pivot)、附加条件等操作。 可以添加虚拟表 UserTraffStat 通过对$table
子段给一个复杂查询来实现。
// Virtual @UserTraffStat: userId,y,m,netTraff
class AC2_UserTraffStat extends AccessControl
{
protected $table = "(
select userId, year(dt) y, month(dt) m, sum(netTraff) netTraff
from UserTraff
group by userId, y, m
union all
select user2Id, year(tm) y, month(tm) m, COUNT(1) val
from PositionRecord
where user2Id is not null
group by user2Id, y, m
)";
protected $defaultSort = "y,m";
protected $allowedAc = ["query"];
// 还可以正常添加虚拟字段
protected $vcolDefs = [
[
"res" => ["u.name userName"],
"join" => "left join User u on u.id=t0.userId",
"default" => true
]
];
}
注意:
defaultSort
来指定排序字段。 此外,用allowedAc
限定只能使用query接口。然后可以在管理端操作,示例:
{orderby: "y,m"}
即可。 勾选“复制报表代码”后点确定,获得如下代码,可在此基础上修改代码,并通过自定义菜单加到菜单中。统计示例:WUI.showDataReport({
"title": "用户流量月报表",
"ac": "UserTraffStat",
"res": "SUM(netTraff) 总和",
"cond": "",
"resFields": "userId,y,m,netTraff",
"queryParam": {
"orderby": "y DESC,m DESC"
},
"gres": [
"userId"
],
"gres2": [
"y",
"m"
],
"showSum": 1,
})
如果想显示userName来替代userId, 可将userId改名为下划线结尾变成辅助列userId_,这样可以不显示、不导出:
WUI.showDataReport({
"title": "用户流量月报表",
"ac": "UserTraffStat",
"res": "SUM(netTraff) 总和",
"cond": "",
"resFields": "userId userId_,userName 用户,y 年,m 月,netTraff 流量",
"queryParam": {
"orderby": "年 DESC,月 DESC",
},
"gres": [
"userId userId_", "userName 用户"
],
"gres2": [
"y 年",
"m 月"
],
"showSum": 1,
})
注意:后端还支持用hiddenFields参数指定隐藏列,但列隐藏后,如果做列转置可能导致分组不正确,不建议使用。
当某一处逻辑需要根据场景进行灵活处理时,往往提供自定义代码是最佳方案,具有最高灵活性和最简单的实现。
这也称为DSL,即领域专用语言(domain-specific language)。
选自tms运输管理系统,运费模板功能,为了能灵活地处理运费计算规则,设计运费计算功能:
运费计算表:
@Freight: id, ..., amount@, calcType(1), volumnType(1), volumnMul@, fn(t)
可设置重量与体积的计费。分别支持阶段式(不连续函数)或阶梯式(连续函数)两种。
- calcType: 计算方式: Enum(A-固定值,B-重量体积分别计算取较大值,C-体积折算为重量计算,X-自定义计算公式)
- amount: 运费,当calcType=A时使用
- volumnMul: 体积转重量系数。当calcType=C时使用,则将体积乘以系数转成重量,与实际重量比较后取较大者用于计算运费。
- fn: 计算公式,当calcType=X时使用. 前端将用户输入的领域代码保存到 Freight.fn 字段中。
若calcType=B或C时,须使用重量或体积计算时的阶梯(或阶段)表定义:
@Freight1: id, freId, type(2), basePrice@, baseQty@, unitPrice@, unit(s)
- type: Enum(W-重量,V-体积)
按阶段计费:当重量小于basePrice时,按basePrice,否则按每单位增加unitPrice计算
- basePrice: 首重、起步价格,如30元/每kg;
- baseQty: 阶段值, 起步价包含数量
- unitPrice: 超过首重后的单价,如10元/每kg
- volumnMul: 体积转重量系数。
type=B时,当重量小于baseQty时,按basePrice; unitPrice不使用.
从上面支持阶段、阶梯等定义看,设计与实现都很复杂。 而基于领域代码的设计,则非常简单灵活,只需要定义代码执行环境(可读写的变量、可调用的函数等)。 比如可设计为:
只读变量: weight-重量,volumn-体积,vendor-供应商编码; 可读写变量: $_POST; 返回运费值。示例:
if (weight < 3)
return 30;
return 30 + (weight-1) * 10;
实现:
class AC1_Ordr
{
protected function onValidate()
{
...
if (issetval("startAddrCode")) {
// 自动计算运费
$rv = callSvcInt("Freight.query", [
"cond" => [
"startAddrCode" => $_POST["startAddrCode"],
"endAddrCode" => $_POST["endAddrCode"]
],
"fmt" => "one?",
"res" => "id,fn",
]);
// 执行计算函数 $rv["fn"]
if ($rv && $rv["fn"]) {
$_POST["amount"] = calcFreight($_POST, $rv["fn"]);
}
}
}
}
// 注意保存代码字段时,避免被转义。
class AC1_Freight extends AccessControl
{
protected function onValidate()
{
if (issetval("fn")) {
$_POST["fn"] = dbExpr(Q($_POST["fn"]));
}
}
}
function calcFreight($env, $code)
{
$weight = $env["weight"];
$volumn = $env["volumn"];
try {
return eval($code);
} catch (Exception $ex) {
jdRet(E_SERVER, "bad Freight.fn", "运费计算公式错误");
}
}
示例:某物流供应商的运费计算规则如下:
运费计算具体定义为:
if ($_POST["urgentLevel"] == "是") {
$amount_v = $volumn<=1? $volumn*600
: $volumn<=5 ? $volumn*580
: $volumn<=15 ? $volumn*560
: $volumn * 520;
$ret = $amount_v < 600? 600: $amount_v;
}
else {
$amount_v = $volumn<=1? $volumn*200
: $volumn<=5 ? $volumn*180
: $volumn<=15 ? $volumn*160
: $volumn * 140;
$ret = $amount_v < 200? 200: $amount_v;
}
return $ret;
TODO
需求:已有wms系统,对应链接为 http://oliveche.com/wms/,数据库为wms; 现在想新开一个实例,代码与已有系统共用(系统升级也会影响所有实例),但使用环境是隔离的。
解决方案:
先创建新链接: http://oliveche.com/wms-inst1/
cd /var/www/html
ln -sf /var/www/src/wms/server wms-inst1
它将创建和使用新数据库:wms_inst1 (注意:数据库名用下划线不同中划线),这样该实例数据是独立的。
修改php/conf.user.php配置新实例:
// 以"/wms-inst1/"开头的URL,使用数据库"wms_inst1",程序数据保存在"data-wms-inst1"下,包括session目录, 日志文件, upload目录等
if (strpos($_SERVER["SCRIPT_NAME"], "/wms-inst1/") === 0) {
putenv("P_DB=localhost/wms_inst1");
$GLOBALS["conf_dataDir"] = __DIR__ . "/../data-wms-inst1"
// putenv("P_SESSION_DIR=session-inst1"); // 旧版本不支持conf_dataDir时配置P_SESSION_DIR来隔离session,现已不用
}
else {
...
}
注意用conf_dataDir隔离各个环境下的数据。
在线初始化数据库,先手工在mysql中创建数据库: create database wms_inst1
,然后打开初始化页面:
http://oliveche.com/wms-inst1/tool/init.php
点击“数据库升级”,如果有二开插件,再点击“插件升级”。
如果conf.user.php中指定的数据库用户有建库权限,可以直接加createdb参数一步创建数据库并升级,如果有二次开发的插件(addon)也将自动安装:
http://oliveche.com/wms-inst1/tool/upgrade/?createdb
例如部署了两个实例,分别为“C区”和“D区”,分别对应URL: “jt-wms-c” 和 “/jt-wms/”。 希望在管理端上可以看到当前是哪个区域,并可以切换区域。
先在后端conf.user.php中配置当前实例。用confarea配置实例代码, 用P_initClient[“area”]传到前端(前端用g_data.initClient.area取值)。
// C区
if (strpos($_SERVER["SCRIPT_NAME"], "/jt-wms-c/") === 0) {
putenv("P_DB=localhost/jt_wms_c");
$GLOBALS["conf_dataDir"] = __DIR__ . "/../data-jt-wms-c";
// 一般多实例建议是共享一套二开代码(即addon.xml),通过实施配置(如conf_area)来区分
// 二开前端天然是隔离的(在数据库中),但二开后端若想实现隔离(不建议),须配置conf_classDir
// $GLOBALS["conf_classDir"][1] = $GLOBALS["conf_dataDir"] . "/class/ext";
// 共享主实例(即默认区域D区)的一些表
$GLOBALS["conf_tableAlias"] = [
"Sn" => "jt_wms.Sn",
"Item" => "jt_wms.Item",
"Plan" => "jt_wms.Plan",
];
// 设置"conf_subsys_url_{子系统}"为空,否则安装addon时会出错。后面解释原因。
$GLOBALS["conf_subsys_url_jt_wms"] = "";
$GLOBALS["conf_area"] = "c";
}
// D区(默认区域),即主实例
else {
putenv("P_DB=localhost/jt_wms");
$GLOBALS["conf_area"] = "d";
}
$GLOBALS["P_initClient"] = [
"area" => $GLOBALS["conf_area"]
];
web管理端中,在登录页以及标题栏右侧添加实例列表,点击可以切换,这样在登录前和登录后都可以切换。store.html中: (这里使用my-combobox组件更易维护,用AreaMap定义下拉选项)
<!-- 登录页上添加下拉列表 -->
<div id="dlgLogin" title="登录" data-options="cls:'loginPanel',collapsible:false,maximizable:false" style="width:350px">
...
<tr>
<td>区域</td>
<td>
<select class="my-combobox jt-area" data-options="jdEnumMap:AreaMap">
</select>
</td>
</tr>
<!-- 标题栏右侧添加下拉列表 -->
<div class="header-bar_right">
...
<select class="my-combobox jt-area" data-options="jdEnumMap:AreaMap">
</select>
上面两处列表的逻辑一样,设置了class=“jt-area”, 在store.js(或app.js)中为它添加处理逻辑,使用WUI的m_enhanceFn机制:
// my-combobox中用于显示下拉选项
var AreaMap = {
c: "C区",
d: "D区"
};
...
WUI.m_enhanceFn["select.jt-area"] = function (jo) {
var map = {
c: "jt-wms-c",
d: "jt-wms"
};
// 初始化完成后再做
$(function() {
// 点击切换
jo.change(function () {
if (this.value) {
location.href = "../../" + map[this.value] + "/web/store.html";
}
});
// 赋初值. g_data.initClient.area为后端配置$P_initClient["area"]自动传过来的
if (g_data.initClient.area) {
jo.val(g_data.initClient.area);
}
})
}
上面子实例共享了主实例的Sn等若干张表。但由于Sn也是addon对象,在安装addon时框架发现conf_tableAlias指定了它是另一系统(jt_wms)的对象,则认为当前系统依赖名为jt_wms的子系统,要求指定该子系统的接口地址来初始化这个addon对象。 这个逻辑仅在主子系统(且共享登录信息时)环境下才有意义,而此处是多实例,相互间登录是独立的。 通过指定url为空,跳过子系统(其实是主实例)对该addon对象的初始化,继续由当前实例来创建该对象。而如果不指定该url则会在安装addon时报错。
详细参考系统复用与微服务方案
conf_subsys_url_{子系统名}
如果是定制开发:管理端在web目录下
APP_TITLE
变量配置logo图标(浏览器页面图标显示):默认使用logo.png,可在store.html中修改:
<link rel="icon" href="logo.png"><!-- chrome use it -->
登录页背景:更换bg.jpg,默认是可重复地平铺,可调整style.css中的配置,如:
.loginPanel-mask { background: url(bg.jpg) no-repeat center; background-size: cover; opacity: initial; }
也可以用二次开发指定标题、logo图等:
setAppTitle(title, logo, logoIcon);
示例:库存请求上添加“出入库”按钮,以当前数据来生成库存记录。
var btnInvAdd = {text: "出入库", iconCls:'icon-ok', handler: async function () {
var row = WUI.getRow(jtbl);
if (! row)
return;
var row1 = $.extend({}, row); // 复制一份
delete row1.id;
WUI.showObjDlg('#dlgInvRecord', FormMode.forAdd, {
data: row1,
onShow: async function (formMode, data) {
var rv = await callSvr("InvOrder1.query", {cond: "invId=" + row.id, res: "t0.*", hiddenFields: "id,invId"});
this.gn("inv1").val(rv);
}
});
}};
jtbl.datagrid({
url: WUI.makeUrl("InvRecord.query"),
toolbar: WUI.dg_toolbar(jtbl, jdlg, ... (orderFlag==1 && btnInvAdd)),
...
});
对话框上右键选择“再次新增”(或Ctrl-D)也可以添加记录,支持子表,但不能修改数据。
示例:InvOrder(库存请求)与InvRecord(库存记录)共同使用表InvRecord。在产品代码中AC2_InvOrder继承AC2_InvRecord。
二次开发在做扩展时,应注意:
服务器默认支持函数类接口和对象接口,如URL /api/hello
中hello
是个小写开头的单个词,当成函数接口,路由到api_hello
函数; URL /api/Hello/world
(等价于/api/Hello.world
)则是一个大写开头的对象加一个小写开头的方法,会路由到Hello对象的api_world
方法。
其它情况,如/api/hello/world
则会报错ac hello/world
不支持。 这时可以在Conf.onApiInit中进行URL修正,转为标准格式,比如hello/world
转为Hello/world
:
class Conf
{
static function onApiInit(&$ac) {
if ($ac == "hello/world") {
$ac = "Hello.world"; // 不要用"Hello/world"
}
}
}
class AC_Hello extends JDApiBase
{
function api_world() {
return $_GET["ac"];
}
}
更灵活地,比如URL还可能是hello/3
,hello/3/world
,还可在比如hello
接口中做更通用的处理:
class Conf
{
static function onApiInit(&$ac) {
// 模糊匹配,然后路由到hello接口
if (fnmatch("hello/*", $ac)) {
$ac = "hello";
}
}
}
function api_hello()
{
// 此时的ac仍然是原始的如`hello/3/world`
return $_GET["ac"];
}
目前在URL的非参数部分并不支持中文(路由时不支持),在需要时可修改框架,并不难。
当筋斗云应用被其它应用内嵌时(比如通过iframe或手机APP的webview等),外层应用已经登录认证过,要实现直接进入筋斗云应用的功能。
可以由外层应用通过url或cookie或postMessage机制传入token,使用该token调用外层应用的后端,可以获取到用户信息。 注意使用使用cookie机制传token时,会受跨域策略影响,须同域名(或至少相同的基础域名)才能获取,建议使用最简单的URL参数方式; 若使用iframe内嵌站点,还会受到跨站策略影响(SameSite),内部应用连自身的cookie可能都没法用(如内外层http/https混用时)。
假设内层的筋斗云应用可以拿到外层应用传入的token,可通过initClient接口传到后端。 示例代码:(管理端在store.js中,或移动端的m2/index.js中有myInit类型函数调用initClient,加个参数就行)
function main()
{
...
WUI.initClient({ token: g_args.token });// 支持第三方认证登录,从URL中拿token参数
WUI.tryAutoLogin(handleLogin, "Employee.get");
...
}
WUI.initClient函数会同步调用后台initClient接口:
callSvr("initClient", $.noop, {token: xxx}); // 返回内容中以userInfo对象返回用户信息,与登录相同,框架就会跳过login接口实现自动登录
后台只要在Conf::onInitClient回调处理中,处理token关联到用户,并通过userInfo返回用户信息即可,示例:
class Conf
{
static function onInitClient(&$ret) {
$token = param("token", null, "P");
if (isset($token)) {
// 从第三方取用户信息,更新到Employee表并返回id
$empId = getAuthUser_TODO($token);
$_SESSION["empId"] = $empId;
$ret["userInfo"] = callSvcInt("Employee.get");
}
}
}
我方前端 - 我方后端 - 第三方认证系统
【特性】
【实现方案】
第三方认证系统在登录后应跳转回我方首页并通过cookie或URL参数(习惯上参数名就定为token)传入登录token。 注意使用cookie时第三方应合理配置跨域,使得我前端可使用该cookie,即调用后端时应能自动带上该cookie。
前端进入时须调用WUI.initClient()
函数:
前端须重写showDlgLogin函数,在其中跳转第三方认证系统的登录页。当首次登录(WUI.tryAutoLogin中调用到)、会话过期(WUI.defDataProc处理E_NOAUTH错误)时,会自动调用该函数显示登录页面。 登录页面可以在后端配置,通过g_data.initClient.loginUrl取到。
function showDlgLogin()
{
// token=0时不使用第三方认证. 后端配置$P_initClient["loginUrl"]自动传入前端
if ((g_data.initClient && g_data.initClient.loginUrl) && g_args.token !== 0) {
location.href = g_data.initClient.loginUrl;
WUI.app_abort();
}
}
后端配置登录页地址示例:
后端在conf.php中实现Conf::onInitClient(),如果cookie中有token则向认证方查询用户信息,查到则设置诸如$_SESSION[“empId”]等会话变量表示登录成功,以及调用并返回userInfo给前端。
static function onInitClient(&$ret)
{
// 用于测试,前端加URL参数token=0时,不对接第三方认证。
if ($_POST["token"] == "0") {
return;
}
$appType = getJDEnv()->appType;
// 下面实现emp类型的第三方认证
if ($appType == "emp") {
// session存在表示已认证过。注意为后端设置合理的session时间(session.gc_maxlifetime,PHP默认为24分钟)。
if (isset($_SESSION['empId'])) {
return;
}
// 带着cookie中的token从第三方获取用户信息
$headers = ["Cookie: " . $_SERVER["HTTP_COOKIE"]];
$rv = httpCall($conf_auth3LoginUrl, null, ["headers" => $headers]);
// 与本系统用户关联:假如返回的是用户代码,与Employee.uname字段对应,查询或自动添加该用户
if ($rv) {
list($empId, $perms) = queryOne("SELECT id,perms FROM Employee WHERE uname=" . Q($rv), true);
}
else {
$empId = dbInsert("Employee", ["uname" => $rv]);
$perms = 某默认角色如"emp";
}
// 获取用户信息
$userInfo = callSvcInt("Employee.get", ["id" => $empId]);
$imp = LoginImpBase::getInstance();
$imp->onLogin("emp", $empId, $userInfo);
// 设置session中两个必要属性,完成登录
$_SESSION["empId"] = $empId;
$_SESSION["perms"] = $perms;
// 标识为第三方认证,后面logout接口可以用
$_SESSION["auth3"] = 1;
// 返回用户信息给前端
$ret["userInfo"] = $userInfo;
}
}
后端修改logout接口(该接口在plugin/login/plugin.php中实现,扩展时请在php/class/LoginImp中实现onLogout回调函数),调用第三方logout接口。
【完整过程细节】
前端进入应用时调用WUI.initClient函数,它将调用initClient接口(首次调用时无信息,认证后则带有cookie),调用完成后会将返回数据存到g_data.initClient变量中。 前端可通过g_data.initClient.userInfo非空来检查是否有用户信息,如果有则说明已登录成功,调用WUI.handleLogin函数后直接进入应用。 如果没有用户信息(g_data.initClient.userInfo为空),则会调用到WUI.tryAutoLogin() -> WUI.showLogin() -> WUI.options.onShowLogin() -> showDlgLogin()中显示第三方认证系统登录页(修改location.href直接跳转)
后端在Conf::onInitClient()实现initClient接口,若未传入token则不处理;否则使用传入的token去第三方认证系统查询用户信息,完成登录,并在接口返回时设置userInfo字段。
用户登录后,第三方会跳转回应用首页,同时设置前端cookie(需要跨域配置)或传入token。此时前端再次调用initClient接口(带上token或cookie),后端取到用户信息,前端进入系统。 用户登录后再次打开应用与这个过程完全一样(前端若收到token,应将token保存到sessionStorage中备用;若收到cookie则会自动保存无须额外处理)。
用户点退出时,前端调用WUI.logout()函数、调用logout接口,后端先调用第三方logout接口,然后删除自己的会话。
前端一直开着到会话过期,这时再做前端的操作时,一旦调用后端接口,后端会报未认证错(E_NOAUTH),前端在WUI.defDataProc()中会处理并最终调用WUI.showLogin() -> … -> showDlgLogin()。
生产环境下不要设置测试模式(P_TEST_MODE),测试环境最好不要开测试模式,开发环境下开测试模式。 测试模式时前端请求不加密,而且后端接口输出中带调试信息。调试信息在后端通过addLog添加,受调试等级(P_DEBUG)影响,等级为9时记录所有,尤其是SQL日志。
后端默认提供数据库ApiLog记录所有接口调用(Conf::enableApiLog = 1),但请求长度只记录2000字节, 响应长度记录200字节(出错时限2000字节)。对某些轮询类调用,可在Conf : : onApiInit中动态设置Conf : :enableApiLog=2,表示只记录出错。
后端提供数据库ObjLog,记录对象的修改。特殊情况下可通过Conf::enableObjLog=0关闭。
文件日志有多个,在后端通过logit来记录,默认trace日志(trace.log)为常规日志,debug日志为接口被调用的日志,含有调试信息特别是SQL日志(debug等级开到9),ext日志为调用其它系统的日志。
在线打开tool/log.php
默认就是看trace日志。trace日志由应用记录各种错误或报警,应确保干净整洁。
debug日志建议配置为P_DEBUG_LOG=2,由系统自动记录出错日志; 必要时在重要接口中(比如某些对外接口),可设置$env->DEBUG_LOG=1
强制记日志(虽然ApiLog中也会记,但请求或返回可能不全,而且生产环境中默认不开测试模式,则没有调试信息)。
ext日志为调用第三方系统(如短信、微信、其它系统集成)的日志,对外部系统的所有业务相关的调用建议将请求、响应记录到ext日志,便于调试。 被外部调用的重要接口,建议设置$env->DEBUG_LOG=1
记录debug日志。
在生产系统中,如果要临时打开调试,可以在前端调用时加_debug=9
参数,或筋斗云前端可直接支持该URL参数(比如打开http://myserver/myapp/web/store.html?debug=9),会强制写后端debug日志。 通过tool/log.php
在线查看日志,或直接查看服务器上的debug.log文件。
在后端可以将动态菜单项配置在P_initClient变量中,筋斗云前端会自动将它放在g_data.initClient变量中(默认有WUI.initClient()操作)。 后端配置示例(conf.user.php):
$GLOBALS["P_initClient"] = [
"menuItems" => [
[ "name" => "炼钢厂", "value" => [
[ "name" => "G1", "server" => "10.80.32.175", "fiss" => 1 ],
[ "name" => "G2", "server" => "10.80.32.176", "fiss" => 1 ],
[ "name" => "G3", "server" => "10.80.32.177", "fiss" => 1 ],
[ "name" => "G4", "server" => "10.80.32.178", "fiss" => 1 ],
[ "name" => "包号记录", "value" => "WUI.showPage(\"pageUi\", \"包号记录\")" ],
]],
[ "name" => "炼铁厂", "value" => [
[ "name" => "T1", "server" => "10.80.140.30", "fiss" => 1 ],
[ "name" => "T2", "server" => "10.80.140.31", "fiss" => 1 ],
[ "name" => "T3", "server" => "10.80.140.32", "fiss" => 1 ],
]],
]
];
上面带name和value的项就是二开菜单项处理函数UiMeta.handleMenu
支持的标准菜单项(除了value选项,还可以有attr, data选项,会自动加到DOM中),value是数组时表示有子菜单,一般应该是字符串,value为空表示不处理,一般会自行绑定事件来处理。 在前端store.js文件的main函数中,可以调用UiMeta.handleMenu(menu, doAppend, doKeep)
将它们加入主菜单中。 注意添加菜单会发生在登录后,支持根据当前用户权限进行过滤,或用二开菜单项的cond参数(参考二开菜单设置选项)来动态生成。
function main()
{
WUI.initClient();
WUI.tryAutoLogin(handleLogin, "Employee.get");
if ($.isArray(g_data.initClient.servers)) {
// getMenu将配置数据转为UiMeta.handleMenu支持的menu树型结构,比如设置data, attr等。
var menuData = getMenu(g_data.initClient.servers);
// 参数doAppend=true避免覆盖已有动态菜单; doKeep=true为添加静态菜单项,不会在刷新addon时被清除
UiMeta.handleMenu(menuData, true, true);
// getMenu中某些菜单项没有通过value指定处理函数,而是设置了class属性(添加了menu-server CSS类)和data属性,然后在这里绑定事件处理:
// 注意这里不能直接用 $(".menu-server").click(...),而要用委托机制。因为菜单会在登录后处理,此时可能尚未登录,菜单尚未生成。
$("#menu").on("click", ".menu-server", function () {
var e = $(this).data();
g_data.permSet[e.name + ".只读"] = 1; // 配置只读权限
WUI.showPage("pageUi", {uimeta: "数据中心", title: e.name, pageFilter: {fiss:e.fiss, server: e.server}, serverConf: e});
});
}
function getMenu(e) {
// 多个菜单项
if ($.isArray(e)) {
return $.map(e, getMenu);
}
// 带子菜单项
if ($.isArray(e.value)) {
return {
name: e.name,
value: $.map(e.value, getMenu)
}
}
if (e.value)
return e;
return {
name: e.name,
attr: { class: "menu-server" },
data: e
};
}
}
连接第三方数据库
如果是同一个数据库服务实例中的其它数据库,是可以直接访问的,只要访问时带上数据库名前缀即可。如:
如果是在其它数据库服务器上,则可以通过在onInit中修改$this->env
来实现,示例:
class AC2_Data extends AccessControl
{
protected $table = "fiss.aiobjectdata";
protected function onInit() {
$db = "mysql:host=10.80.140.32;port=3306;dbname=fiss"; // 也可以连oracle, mssql等各种其它类型数据库,参考DBEnv
$this->env = new DBEnv("mysql", $db, "root", "123456");
// 这里是直接打开新连接的,如果一次接口调用中访问多次,则应全局缓存该连接
}
}
可以添加参数便于控制连哪台服务器,同时table也可以更复杂,比如是个sql语句,示例:
class AC2_Data extends AccessControl
{
protected $allowedAc = ["query", "get"];
// 注意复杂查询要加括号,按惯例需要有id字段。
protected $table = "(select t0.id, t0.shootTime tm, t0.deviceId device_id, t1.name device_name, t2.name marker_name, (t0.MaxTemperature-273.15) maxT, (t0.MinTemperature-273.15) minT, (t0.AvgTemperature-273.15) avgT, t0.isAlarm, if(t0.isAlarm, 1, null) alarm_level, ImagePath images
from fiss.aiobjectdata t0
left join fiss.device t1 on t0.deviceId=t1.id
left join fiss.aiobject t2 on t0.aiObjectId=t2.id
)";
protected function onInit() {
$server = param("server");
if ($server) {
$db = "mysql:host=$server;port=3306"; # dbname=fiss
$this->env = new DBEnv("mysql", $db, "root", "123456");
}
}
}
调用接口:
callSvr("Data.query", {server: "10.80.32.178"});
管理前端显示页面示例,以二次开发页面为例,假如二开页面名为“数据中心”(对象名填写Data,数据模型不用设置,因为已经实现在代码里了),通过pageFilter传查询参数:
WUI.showPage("pageUi", {uimeta: "数据中心", title: "炼钢1号炉", pageFilter: {server: "10.80.32.178"}});
以vinfast项目为例,我方为WMS(含有WCS)系统,需要对接RCS系统(AGV路径规划和控制系统)、立体库控制系统中的PLC、充电控制系统、温度报警传感器。
第三方接口文档写在if.md中。参考vinfast项目下的if.md文件范例。引用的其它文档放在ref目录下 分章节分别记录与每个第三方系统的接口。统一回调接口须命名为notify. 示例:
## wms为rcs提供接口
rcs系统用于调度agv小车运送托盘。
...
### 进入工作区时安全确认
GET $BASE_URL/rcs/getDeviceState?deviceId={deviceId}
参数:
返回:
返回示例:
### rcs回调
GET $BASE_URL/Rcs.notify
- 参照RCS协议文档 ref/rcs协议接口.doc。
每个系统有个简称,如Rcs,则对方调我的接口由AC_Rcs提供,我调对方接口时,最终调用callRcs函数(且一般应支持异步),代码写在文件php/api_if.php中。 示例:AC_Rcs,AC_Plc,AC_Charge, AC_TempSensor分别为RCS, 立库控制PLC、充电控制系统、温度报警传感器系统提供接口。 在api_if.php中写提供给第三方系统的接口。
function callRcs($ac, $postParam, $callSync=false)
{
if (! $callSync) {
$env = getJDEnv();
$env->onAfterActions[] = function () use ($ac, $postParam) {
callRcs($ac, $postParam, true);
};
return;
}
$baseUrl = getConf("conf_rcsUrl");
$url = $baseUrl . $ac;
logext("callRcs: $url, param=" . jsonEncode($postParam, true));
$headers = [
"Content-type: application/json",
];
$rv = httpCall($url, jsonEncode($postParam), ["headers"=>$headers]);
logext("callRcs return: $rv");
$ret = jsonDecode($rv);
if (!$ret)
jdRet(E_EXT, null, "callRcs fails");
if ($ret["code"] != 0)
jdRet(E_EXT, null, "callRcs fails: code=" . $ret["code"] . ',msg=' . $ret["message"]);
return $ret;
}
class AC_Rcs extends JDApiBase
{
// 处理第三方系统的回调。最终提供给第三方的接口为: http://server1/app1/api/Rcs/notify
function api_notify() {
}
// 这是个测试接口,用于在测试页面中点击测试
function api_containerIn() {
self::containerIn(mparam("trayCode"), mparam("posCode"));
}
function containerIn() { ... }
}
重要过程信息应使用logext记录到ext日志。对第三方系统的所有调用,建议也将请求、响应记录到ext日志,便于调试。 被外部调用的重要接口,建议设置$env->DEBUG_LOG=1
记录debug日志。(虽然ApiLog中也会记,但请求或返回可能不全,而且生产环境中默认不开测试模式,则没有调试信息)。
TODO: 对于轮询类接口,为避免ApiLog被其污染,可默认设置Conf::$enableApiLog=2
仅当出错时记录。
class Conf extends ConfBase
{
static $authKeys = [
// ["authType"=>"basic", "key" => "charge:1234", "SESSION" => ["empId"=>-9999], "allowedAc" => ["Charge.*"] ],
["authType"=>"none", "key" => "", "SESSION" => ["empId"=>-9999], "allowedAc" => ["Charge.*", "Rcs.*", "Plc.*", "TempSensor.*", "Datav.*"] ],
];
}
function uniqueId()
{
static $idx = 0;
return "API-" . ApiLog::$lastId . '-' . (++$idx);
}
示例:调用RCS系统调度agv小车时,requestId字段要求每次不同:
..
$reqId = uniqueId();
$missionCode = "SN-$snId-$reqId";
callRcs("agvTaskSubmit", [
"orgId" => 1,
"requestId" => $reqId,
"missionCode" => $missionCode,
"containerCode" => $trayCode,
"positionCodePath" => [
["positionCode" => $startPos, "sequence" => "1"],
["positionCode" => $endPos, "sequence" => "2"]
]
]);
测试和生产环境的配置,应记录到conf.user.template.php,示例:
$baseUrl = "http://localhost/vinfast/server/";
$GLOBALS["conf_rcsUrl"] = "$baseUrl/api/RcsMock/";
$GLOBALS["conf_plcUrl"] = "$baseUrl/api/PlcMock/";
我方系统必须可以独立完成全流程测试,这意味着对第三方系统接口,每个系统须支持模拟接口,比如Rcs的模拟类由AC_RcsMock提供,文件在php/class/AC_RcsMock.php。 管理端提供“模拟测试”入口(如系统设置-模拟测试),在pageSimulate中实现,可以模拟运行各个流程,如模拟PLC信号发产品下线通知,开始上架,调度AGV小车从A到B点等。
class AC_RcsMock extends JDApiBase
{
// 模拟第三方接口
function api_agvTaskSubmit() {
$this->checkParam($startPos, $endPos);
// 模拟异步执行
callSvcAsync("RcsMock.autoRun", null, $_POST);
}
// 模拟第三方接口的执行
function api_autoRun() {
$this->checkParam($startPos, $endPos);
sleep(1);
// 模拟回调我方接口
$rv = callWms("Rcs.notify", null, [
"missionCode" => $_POST["missionCode"],
"containerCode" => $_POST["containerCode"],
"currentNodeCode" => "mock-pos",
"missionStatus" => "START"
]);
// 快到达起点时,确认进入
sleep(3);
$this->confirmEnter($startPos);
logext("RcsMock: enter $startPos");
// 到达起点并取货
sleep(1);
callWms("Rcs.notify", null, [
"missionCode" => $_POST["missionCode"],
"containerCode" => $_POST["containerCode"],
"currentNodeCode" => "mock-pos",
"missionStatus" => "UP_CONTAINER"
]);
// 快到达终点时,确认进入
sleep(3);
$this->confirmEnter($endPos);
logext("RcsMock: enter $endPos");
// 到达终点
sleep(1);
callWms("Rcs.notify", null, [
"missionCode" => $_POST["missionCode"],
"containerCode" => $_POST["containerCode"],
"currentNodeCode" => "mock-pos",
"missionStatus" => "END"
]);
// 卸货完成
sleep(1);
callWms("Rcs.notify", null, [
"missionCode" => $_POST["missionCode"],
"containerCode" => $_POST["containerCode"],
"currentNodeCode" => "mock-pos",
"missionStatus" => "DOWN_CONTAINER"
]);
// 任务完成离开
sleep(1);
callWms("Rcs.notify", null, [
"missionCode" => $_POST["missionCode"],
"containerCode" => $_POST["containerCode"],
"currentNodeCode" => "mock-pos",
"missionStatus" => "COMPLETED"
]);
}
}
模拟系统调用我系统(WMS),在api_if.php中写callWms函数。
function callWms($ac, $param=null, $postParam=null)
{
// `jdcloud:1`强制jdcloud返回格式
$param["jdcloud"] = 1;
$url = makeUrl(getBaseUrl() . "/api.php/$ac", $param);
if ($postParam) {
$headers = [
"Content-type: application/json",
];
$rv = httpCall($url, jsonEncode($postParam), ["headers"=>$headers]);
}
else {
$rv = httpCall($url);
}
$ret = jsonDecode($rv);
if ( $ret[0] !== 0 ) {
jdRet(E_PARAM, null, "callWms fails: " . $rv);
}
return $ret[1];
}
使用多台服务器时,应尽量使用共享存储,例如共享网盘,或共享的数据库,小心解决应用服务写的文件是否要共享的问题,如会话、上传文件、自动生成的状态文件、缓存等。
上传的附件或图片可存储到数据库:jdcloud-plugin-upload插件的conf_upload_storeInDb配置项 (Attachment表添加了data字段) 详见登录插件文档(plugin/login/DESIGN.md)。
会话可存储到数据库:(新增Session表) 参考SessionInDb。 TODO: 考虑是否使用配置项conf_file_storeInDb自动升级!
使用状态文件JDStatusFile和缓存FileCache会有问题。 TODO: 可配置 conf_file_storeInDb选项实现透明处理,自动将文件存到Cinf表。 替代实现file_get_content/file_put_content/filemtime几个函数即可 底层实现, Cinf表增加tm字段, Cinf::getValue扩展。
二次开发生成的后端文件(AC_xxx_Imp.php)存储到数据库(借用了Cinf表)。 TODO: 参考wms的pingan分支中的实现。 TODO: 考虑是否改为上面的替代file函数来实现。
jdcloud-plugin-conf中的JDConf::export会使用json/php文件来保存配置,多机时不应使用,而是应直接保存到Cinf表中。
jdcloud前端有一定的防重复措施,当发起后端调用时,界面上会显示转圈等待,蒙板会将按钮挡住,使得用户无法立即重复点击按钮。
对于仍可能出现的重复调用接口问题,可在后端接口处使用limitApiCall进行校验,示例:
function api_printSheet() {
// 对接口printSheet限制10秒内只能调用1次(按session限制,即限制用户登录后的连续调用):
limitApiCall("printSheet", 10); // 10s内不允许重复调用
}
默认使用session机制,只对正常登录操作的用户生效。
TODO: 若需要全局生效,可使用参数useSession=>false
;若还有其它限定条件,如同一单号10s内限制操作一次,如同一手机号1分钟内只能发1次验证码,可以用key
选项:
function api_genCode() {
$phone = mparam("phone");
// 同一手机号60s只能调用一次
limitApiCall("genCode", 60, [
"useSession" => false,
"key" => $phone
]);
}
使用application/form-data
类型的POST请求,可以调用一次接口同时添加数据与文件。 例如FoItem.add
接口,同时接收字段信息如shotTm(拍摄时间)等以及文件名为ir(红外图片)和dc(可见光图片),文件保存到附件表(Attachment),再将id保存到irPicId和dcPicId字段。
在form-data块中指定字段值及文件示例:
curl -s -v -H "Content-Type: multipart/form-data; boundary=------------------------357185ac2c5d2929" --data-binary @form1.txt $url
或
curl -s -F "ir-raw=@1.txt" -F "t1=t1value" -F 'result={"test":1,"test2":"hello"}' -F 'result2="test=1&test2=hello"'
form1.txt:
--------------------------357185ac2c5d2929
Content-Disposition: form-data; name="ir-raw"; filename="1.txt"
Content-Type: text/plain
a text file here
.........
end.
--------------------------357185ac2c5d2929
Content-Disposition: form-data; name="t1"
t1value
--------------------------357185ac2c5d2929
Content-Disposition: form-data; name="result"
Content-Type: application/json
{"test":1,"test2":"hello"}
--------------------------357185ac2c5d2929
Content-Disposition: form-data; name="result2"
Content-Type: application/x-www-form-urlencoded
test=1&test2=hello
上面name=ir-raw指定了filename是个文件;name=t1是个简单字符串;name=result或result2是个复杂字符串,应相应再做解码。
php解析结果为:
// form-data中没有指定filename的项进入$_POST,无论是否指定content-type都是字符串。
$_POST = [
'result' => '{"test":1,"test2":"hello"}',
'result2' => 'test=1&test2=hello',
't1' => 't1value',
];
// form-data中指定了filename的项进入$_FILES,建议指定name
$_FILES = [
'ir-raw' => [
'name' => '1.txt',
'type' => 'text/plain',
'tmp_name' => '/tmp/phpDCpG68',
'error' => 0,
'size' => 35,
]
];
示例:在添加操作中,从name=result的数据块(假如有filename=1.json
则须从)中取出json格式的字符串,重建$_POST; 同时添加name=ir或dc的文件并记录在附件表(Attachment),并将结果Id分别存到irPicId和dcPicId两个字段中。
// form-data中指定filename则进入$_FILES, 否则进入$_POST,这里兼容两种情况; 都须取出内容用jsonDecode解码。
if ($this->ac == 'add' && ($_POST["result"] || $_FILES["result"])) {
$rv0 = $POST["result"] ?: file_get_contents($_FILES["result"]["tmp_name"]);
$rv = jsonDecode($rv0);
$_POST = [
"shotTm" => date(FMT_DT, $rv["shotTime"]/1000),
"targets" => $rv["targets"];
];
// form-data中的name与表中字段名对应关系:
$kmap = [
"ir" => "irPicId",
"dc" => "dcPicId",
];
$dir = "upload/foitem/";
if (!is_dir($dir))
mkdir($dir, 0777, true);
foreach ($_FILES as $k=>$f) {
if (! array_key_exists($k, $kmap))
continue;
$fname = $dir . $f["name"];
move_uploaded_file($f["tmp_name"], $fname);
$attId = dbInsert("Attachment", [
"path" => $fname,
"orgName" => $f["name"],
"tm" => date(FMT_DT),
]);
$k1 = $kmap[$k];
$_POST[$k1] = $attId;
}
}
使用Guard类,注册一个回调函数,一般用于一段过程结束时确保一定会调用一段清理代码。
示例一:按一个按钮用于叫车过来,按后控制信号灯亮,直到车到达后灭灯;但如果叫车时出错(比如点位已被占用),灯亮后应随即灭掉,不应常亮。
// 去维修区取走空托盘
static function emptyTrayFromRepairArea($which) {
// 亮灯
callPlc("write", null, ["button1Status" => 1, "repairFlag" => 0]);
// 出问题时灭灯
$ok = false;
$g = new Guard(function () use (&$ok) {
if ($ok)
return;
usleep(200000); // 让灯稍稍亮一会再灭 200ms
callPlc("write", null, ["button1Status" => 0]);
});
// 叫车控制,注意中间可能会return或调用jdRet(子函数也可能调用jdRet等跳回),但无论如何出函数时会执行上面回调以确保灭灯
$pos = 'repair-' . $which; // repair-1
$rv = queryOne("SELECT id,topos,code,snId FROM Tray WHERE pos='$pos'", true);
if (!$rv)
jdRet(E_PARAM, null, "no tray at pos '$pos'");
// 已经叫过车、分派过目的地了。避免人为重复按按钮
if ($rv["topos"])
return;
...
// 标记成功,回调中将跳过失败处理
$ok = true;
}
示例:后端接口
Data.query -> tbl(id, ..., images, thumb?)
其中images是个数组,如果第一张图存在,则生成缩略图字段thumb,以base64串返回。
后端实现在返回“images”字段时自动生成“thumb”字段,使用resizeImage方法(在jdcloud-plugin-upload中):
// 在onInit或onQuery中:
$this->enumFields["images"] = function ($v, &$row) {
$ret = jsonDecode($v);
$f = $_SERVER['DOCUMENT_ROOT'] . '/' . $ret[0];
if (is_file($f)) {
ob_start();
resizeImage($f, 64, 64);
$bin = ob_get_clean();
$b64str = base64_encode($bin);
$row["thumb"] = $b64str;
}
return $ret;
};
前端显示图片,列表中images字段处理函数如下,它显示缩略图(不存在时显示’图片’字样),点击时打开对话框显示图片:
{
formatter: function (v, row) {
if (!v)
return v;
var code = "图片";
if (row.thumb) {
code = `<img src="data:image/jpeg;base64,${row.thumb}">`;
}
return WUI.makeLink(code, function () {
WUI.showDlg("#dlgPhoto", {row: row, fn: function (row) {
return row.images;
}});
});
}
}
默认应采用后端排序,即使用数据库的order by排序,须在数据库里加函数(TODO);
此处示例使用前端排序来实现,尽量让数据一页全部显示(示例中设置了1000行返回),然后利用easyui-datagrid对列的自定义排序(对IP地址列自定义sorter)来实现。
WUI.showPage('pageUi', {
uimeta:'设备',
title: '监控点',
// pageUi中支持dgOpt参数指定datagrid选项
dgOpt: {
// 指定分页
pageSize: 1000, pageList: [100,1000],
// 指定前端排序
remoteSort: false,
// 指定按IP地址列排序
sortName: "addr", sortOrder: "asc"
}
};
对IP地址列设置sorter如下: