筋斗云开发实例讲解

准备好数据模型描述(参考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

注意:符号最好用半角英文符号,尽管为了方便也兼容中文逗号和冒号。

1 输入字段

对话框中:(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>

2 下拉框:枚举类型字段

如type,status这种字段,由若干固定值构成。展示的需求为:

2.1 使用Map定义枚举

在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中定义全局常量:

var OrderStatusMap = {
    CR: "未付款", 
    PA: "待服务", 
    RE: "已服务", 
    RA: "已评价", 
    CA: "已取消", 
    ST: "正在服务"
};

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组件。

2.2 使用List定义枚举

新的设计中,建议直接使用中文来定义枚举,不必做转换,即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选项格式不同。

2.3 flag字段

示例:是否“企业管理员”字段 - 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>

3 下拉框:关联字段(外键)

示例:用户关联所在企业(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 { }

注意:

列表页 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;
    }
}

4 图片字段 / 视频字段 / 附件字段

需求:

参考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>

多图:

<tr>
    <td>图片</td>
    <td class="wui-upload">
        <input name="pics">
    </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>

5 复选框字段

系统用户(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;
    }
}

6 添加时自动完成某些字段

示例:添加用户时,自动填写:

在后端完成自动补全: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)就可以成功操作。

7 密码字段 / 格式化显示

需求:pwd字段,要求在对话框显示成****

在dlgUser.html中,

function onBeforeShow(ev, formMode, opt) 
{
    if (formMode == FormMode.forSet)
        opt.data.pwd = "****";
}

注意:和前面章节在添加时给初值不同,当时是在onShow中设置UI组件;而这里是在onBeforeShow中修改初始数据opt.data。 因为,如果设置UI组件,则提交时判断UI与初值不同,就会提交修改;而修改了初值,在提交时,如果在UI上未修改,就不会做提交。

8 后端查询时加限制条件

示例:条目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等接口,如:

protected function onQuery()
{
    $q = mparam("q");   // 这个显然是为query接口的强制参数,它会影响get/set/del等接口出错。
    ...
}

应改为:

protected function onQuery()
{
    if ($this->ac == "query") {
        $q = mparam("q");
        ...
    }
}

9 更新操作与特定权限

[需求]

[数据模型]

@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不正确,就会直接报错。

10 查询时的字段隐藏

示例:查询用户时,隐藏微信数据等字段。

@User: id, name, weixinKey, weixinData(2000)

后端实现:api_objects.php 设置$hiddenFields.

class AC1_User extends AC0_User
{
    protected $hiddenFields = ["weixinData", "weixinKey"];
}

11 查询或更改时限制操作内容

在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");
        }
    }
}

12 查询时动态添加字段

使用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<>'已取消') 已用时段");
        }
    }
}

13 内部调用接口

示例:通过手机号发优惠券时,支持批量发量,用逗号分隔的多个手机号。

接口:

手机号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();
    }
}

14 WEB端页面共用

类似例子可参考: 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, 时间, 地点, 联系人, 联系方式, 积分&, 广告位优先级&, 公告优先级&

14.1 菜单项一个变成多个

假如已经生成了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}}
        });
    });
}

14.2 列表页根据类型定制字段(列)显示

在列表页初始化函数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=="停车券"
    });
}

14.3 对话框上根据类型显示不同的字段及下拉列表

在 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中定义常量:

var ItemStatusMap = { ... }
var ItemStatusMap_其它 = { ... }

15 后端表合并/Union

订单日志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)";
    ...
}

16 字段显示格式调整

在显示时段长度时,数据模型中使用秒来计算:

@ReviewLog: id, empId, asrReqId, tm, t&

- t: 审核时长(秒)

在前端展示如下:pageReviewLog.html

        <th data-options="field:'t', sortable:true, sorter:intSort">审核时长(秒)</th>

客户希望不要直接展示秒数,而是以“时:分:秒”的习惯方式来显示。

有两种解决方案:一是在后端做格式转换,二是在前端做(但如果考虑到导出操作,后端也需要做转换)。

16.1 后端调整字段显示格式

一般建议在后端做转换,这样查询和导出文件均可以兼顾:

// 支持毫秒和秒转成 时:分:秒 格式
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条数据的列表排序有优化,这时不会发送后端(在本例中,将按显示的字符串排序,会有些小问题)。

16.2 前端调整字段显示格式

在某些情况下(比如,处理过于复杂,想节约后端的处理资源;或是为了兼容以前代码等),由前端为字段添加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来判断是否是文件导出操作。

17 展示子表

示例图样:

17.1 通用子表示例

考虑订单表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>

然后,在子表对话框中须将主表关联字段(此处即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以前的实现见下节,可以了解其原理。 若对子表进行复杂的控制,例如随着主表一起添加,但添加后只读,参考后面例子。

17.2 通用子表示例(旧版)

(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>

17.3 只读子表示例

(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;
    });
}

18 关联字段与关联子表

考虑商品(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, '&quot;');
        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),
        ...
    });
}

18.1 最先、最后关联问题

first/last问题。还可扩展到更通用的分组后组内排序问题。

本节较复杂,特别是在数据量大的场景下,各种场景应使用不同的解决方案,学习研究比较费时。

典型问题1:用户表User,订单表Ordr。求用户首次订单的时间、地点。User表是数千级别,Ordr表为数万级别。 典型问题2:列车表Hub, 列表数据HubData。求列车最近一次上传数据的时间和位置。Hub表为数百级别,HubData为数十万级别。

以问题2为例,提供多种方法,且适用场景均不同。 列车查询接口示例如下:

Hub.query() -> tbl(id, ..., lastDataId?, lastTm?, lastPos?, @hubData?, %lastData?)

各种实现参考:

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机制。

18.2 示例:检测记录报表

车辆出厂检测的结果记录在下表中:

@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中根据实际条件将其替换即可。

19 树形结构展示 / treegrid

树状结构表设计中, 应有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);

20 角色定义

角色包括系统内置角色自定义角色.

默认内置角色有:

如果要添加内置角色, 先在后端定义新角色: 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

if (g_data.hasPerm("mgr") || g_data.hasPerm("qmgr")) {
    ...
}

自定义角色一般只做前端菜单限制. 须先将role插件引入(根据其说明文档配置好), 然后直接由最高管理员(mgr权限用户)在角色管理中配置即可.

21 批量导入 / 初始化导入

需求:导入商户及其LOGO图。

要点:

表定义如下:

@Store: id, name, addr, picId

先准备好非图片部分的表,存为store.csv (均使用utf-8编码)

商户名,商户地址
丽传文化传媒,三期1101
发哲文化传播,三期1102
...

21.1 批量导入图片或上传文件

先在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程序查看注释。以下为旧方法,仅供参考,目前已不再使用。

21.1.1 旧方法:手工处理批量上传文件

通过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

21.2 批量导入 - batchAdd

(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"

21.3 定制批量导入 - BatchAddLogic

示例,定义任务表,一个订单(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;
        });
        ...
    }
}

22 批量导入 - 管理端设计

需求:在管理端中开放批量导入员工。

在超级管理员中内置了导入对话框的例子: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中

jtbl.datagrid({
    toolbar: WUI.dg_toolbar(jtbl, jdlg, ..., "import"),
});

可以直接用“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),
});

22.1 导入带子表的表

关键词: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中定义导入类型:

<td>导入类型</td>
<td>
    <select name="obj">
        <option value="Ordr">工单</option>
        ...
    </select>
</td>

定义导入模板:

<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>

22.2 导入时需要填写参数

[示例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";
}

22.3 批量更新以及对一个对象多种导入

在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!,则表示只允许更新记录,不能添加记录(即批量更新模式)。

23 定时任务开发与设置

需求:已发布的活动如果过期,应自动关闭。

解决方案:

每天定时扫描过期活动,将其状态设置为“已完成”。 在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进程)轮询解决。

24 与外部页面通信

需求: 在管理端列表上选择一行点击“审核”, 打开一个新的审核页面, 点击“审核完成”按钮, 会设置状态值. 要求在回到管理端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);
    });
    ...
}

24.1 绑定事件后可能删除问题

初始化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");
});

25 管理端对话框多列布局

默认管理端对话框是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>

26 对话框示例 / WUI.showDlg

需求:财务每月均会录入每个员工基本工资、天数(即出勤天数)、备注等信息。 为了方便录入,希望系统可自动根据上月数据给出所有人员工资表,财务只需要调整每人的出勤天数等字段后,即可批量录入。

设计:工资列表为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} );
    }
}

26.1 复制行/粘贴行

仍然是上面的例子,设计成在工资表上添加两个按钮:“复制行”和“粘贴行”,实现上述类似功能:

27 管理端下拉列表组件 wui-combogrid

示例:在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.

28 统计表显示与导出

在生产过程列表中,提供按钮,点击显示“工作量日统计表”,并可以导出。

设计:可以由后端生成统计表(中文字段),前端只须直接显示;也可以由前端直接拼出字段。 对象设计如下,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中也有参考例子。

29 仪表盘统计数字接口

仪表盘(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>='" + 月初 + "') 本月修复故障")
            );
        }
    }
}

30 移动端部署与应用优化

在过去,为了优化移动端应用的加载体验,框架提供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版本后删除了这一功能)

31 消息通知的实现

管理端登录后,显示通知数,点击通知数显示通知详情。点击详情可跳转相关页面。

示例:在车辆出厂检测时,若发现缺陷问题,则质量人员登录系统时应得到消息通知,来分析处理缺陷问题。这就相当于用户的待办事项列表。

后端接口设计:

Notify.queryCnt() -> {cnt}
Notify.query() -> [{type, tm, name, relId}]

前端在登录后,调用queryCnt接口显示通知数。用户查看详情时,调用Notify.query接口获取待办项列表。点一项可跳转相关对象。

安装插件jdcloud-plugin-notify,后端去修改AC2_Notify类的两个接口实现。

前端在handleLogin方法中添加调用(store.js)

function handleLogin(data)
{
    WUI.handleLogin(data);
    ...
    Notify.init();
}

前端修改 web/page/dlgNotify.js 中的显示和跳转逻辑。

32 删除记录

如果一个记录被其它记录关联,框架默认是没有处理的,容易造成关联它的其它对象出问题。 对于删除记录有以下几种处理方式。

32.1 标记删除

不做真正删除,只是通过设置deleteFlag之类的字段并隐藏记录。好处是被删除的记录仍可追溯,且关联对象仍可用,无须特别处理。

实现方法是设置delField字段,系统将在XX.del接口中自动设置该自动,且在XX.query接口中过滤该字段。示例:

class AC2_Ordr
{
    protected $delField = "disableFlag";
}

32.2 约束删除

常见的有两种逻辑:

要两种实现方式,一种是直接利用创建数据库外键约束来解决,无须编码;另一种是代码处理。

示例:在删除物料(Item)时,检查是否有工单(Ordr)引用了物料,若有则报错。在删除工单时,同时删除工单下的日志(OrderLog)。

分析:数据关联模型为 Ordr.itemId -> Item.idOrderLog.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);
            }
        }
    }

33 系统复用与微服务方案

案例:现有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系统,前端需要解决以下问题:

后端各系统用自己的接口。而数据库有两种方案:共用,或是各用各的。 在实际项目中,我们主要目标是子系统充分复用,而非微服务划分,所以推荐直接共同一个数据库,配置和使用都简单很多,参见章节主子系统共用同一个数据库

共用数据库的后端子系统集成机制:

不共用数据库时主要机制:

注意后端两种方案都要求没有重名且不兼容的对象;如果两个系统中若出现同名对象,以主系统对象为准,必要时子系统相关逻辑需要移植到主系统,若同名对象在各系统中含义完全不同且又都需要,则子系统对象需先改名。

33.1 系统复用:前端整合方案

示例:将子系统系统中全局变量定义归集到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", "");
};

33.2 系统复用:后端整合方案

注:主子系统数据库分离较复杂,可优先考虑共用同一个数据库。参考后面章节主子系统共用同一个数据库

以案例2为例,具体配置参考saic项目的README.md。主系统为saic,子系统为erp_saic。

新版本里更推荐直接配置子系统的conf_dataDir到主系统中,session也刚好能共享:

$GLOBALS["conf_dataDir"] = "/var/www/src/saic/server";

注意:曾试行增加了jwt生成和验证机制(对jwt的支持,已抽象出插件jdcloud-pluign-jwt),但经研究,jwt验证如果完全无状态(即不存后端redis中),达不到及时删除、防止重复等目的,导致不安全;且存在发送数据多、内容做session时只读不可写等缺点。 因此,没有理由使用jwt验证机制。服务在多机部署的情况下,可通过数据库(已支持,参考SessionInDb)或redis共享session。

如果不设置,则由于主系统和子系统都是jdcloud框架,其后端API接口均会返回版本(通过HTTP头X-Daca-Server-Rev),导致前端调用后发现后端版本变化,触发自动热更新机制自动刷新系统。 当指定调用外部系统时即callSvr(‘xxx:ac’)时,不检查版本更新,所以标准解决方案是把外部系统调用都加上前缀。 TODO: 应使用sys:ac方式替代ac__sys标识外部系统的方式。

33.3 跨系统关联问题

尽管如此,还有一个问题难以解决,即主系统与子系统的关联问题。

例如:主系统中以二次开发方式添加了Part(零件包)和Part_Item(零件包与物料关联),子系统erp中有Item对象(物料)。 如果通过二次开发,给子系统的Item对象添加一个AC子表parts对应主系统中的Part_Item对象,则产生了跨系统的关联。

    $subobj = [
        "parts" => ["obj" => 'Part_Item', "cond" => 'itemId={id}']
    ];

这时对Item操作比如Item.add/query/batchAdd中,一旦涉及到parts字段将报错,因为子系统中根本就没有Part_Item这个对象!

分析:

原则上来说,微服务是不支持对象直接关联的,必须通过接口来。然而为了使用方便,如果只是简单的需要Part_Item对象,还是可以部分支持。

解决方案:

先将主系统二次开发所生成的class/ext/AC_PartItem.php文件复制到子系统中,然后子系统配置中,指定其中哪些是主系统的表:

$GLOBALS["conf_tableAlias"] = [
    ...
    "Part_Item" => "saic.Part_Item",
];

这样,基本的调用就可以了。如果还要再深入引用主系统的其它逻辑或对象,只能在子系统中单独编码。

33.4 主子系统共用同一个数据库

上面主子系统中,需要互相指定哪些表是对方系统的,很复杂,易出错。 从微服务的原则上来说,这些强行的数据库关联是错误的设计。

但我们的这样做的目标,并不是微服务,而是保持主子系统的独立维护,便于重用。所以共用一个数据库反而成了推荐的做法。

本方法首先应确保子系统中所有核心对象与主系统中不冲突,例如主系统中有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("某主系统对象接口")),则这些被引用部分必须移植到子系统,比如相应函数或对象复制到子系统,或是接口调用主系统等。

34 管理端列表页上方菜单太多如何处理

示例: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),
    });

35 系统复用与微服务方案2 - 相同应用多实例系统

需求:以firefly-web项目为例,在多台服务器上部署了多个实例,分别监控多个区域。 在某中心服务器上,希望在同一系统中看到所有监控点.

一个应用开多个实例不同,一是部署在不同服务器上,不是在一台上共用一个服务,二是不想用切换实例的方式一个个查看,而是集中在同一系统中看。

实现:前端使用中心服务器的页面,分别调用各后端,调用各后端时ac添加扩展前缀,如shagang:Data.query表示调用shagang系统的Data.query

要点:

其中定义公共方法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';
        }
    },
};

36 高级虚拟字段定义

注意:通过enumFields机制创建的虚拟字段虽然很灵活,但不可用于查询条件(cond)、分组字段(gres)或排序字段(orderby)。 因此,我们优先直接用SQL表达式来定义虚拟字段,如果SQL表达式过于复杂,还可以用SQL函数。

36.1 带exists查询的虚拟字段

示例:工艺表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
    }
});

36.2 find_in_set关联

有如下订单表和队列表:

@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"]
    ];
}

36.3 使用SQL变量作为中间值

有如下库存-物料表:

@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"
]

37 固定列头 / 冻结列

当列数很多时,横向滚动条很长,这时希望前几列固定,示例:

    <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参数。

38 详情对话框TABS布局

一般详情对话框的布局方式为上方主表字段,下方以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>

39 只读模式打开对话框,并可切换回编辑模式

示例:工艺如果已应用于生产,则默认只读,除非强制点击“修改工艺”。

方案:

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 ];
        }
    }
}

40 导出Excel时包含多表

标准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的相应方法。

41 管理端业务逻辑提示与帮助链接

使用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>

42 对话框逻辑新写法

由于对话框存在多种模式(添加、更新、查找),正确写出适配多种模式的代码较为困难。 同时,各组件的取值、禁用、只读等接口不一,比如普通组件用$(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函数介绍。

43 定制带列转置的报表字段

列转置后,要求增加一列,为某列-合计列的值。 前端通过WUI.showDataReport来实现。

分析: 这个字段难以通过虚拟字段或计算字段定义,可以考虑在后端改写结果(也可前端改写,但是为了导出时也能计算放后端更好)。 在前端query接口调用时在res参数中加一个占位字段,比如null 差值:

WUI.showDataReport({
    ac: "InvRecord1.query",
    res: "itemCode 物料编码,itemName 物料名称, unit 单位, whName 仓库名称, workLogicQty 工单需求数量, null 差值, sum(logicQty) 已发",
    ...
    pivotSumField:'生产领料总数',
    queryParam: {for: "生产领料情况"}
});

解决方案:重载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);
}
```

44 下拉复选组件

适用于某个字段关联多个其它对象,比如字段“关联型号”(支持多个型号),可通过逗号分隔的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);
    }

45 对话框中展示数据表

与子表(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
    });

46 虚拟表用于统计

需求: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
        ]
    ];
}

注意:

然后可以在管理端操作,示例:

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参数指定隐藏列,但列隐藏后,如果做列转置可能导致分组不正确,不建议使用。

47 用户自定义后端代码 / ScriptEnv

当某一处逻辑需要根据场景进行灵活处理时,往往提供自定义代码是最佳方案,具有最高灵活性和最简单的实现。

这也称为DSL,即领域专用语言(domain-specific language)。

47.1 示例1:运费计算

选自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;

47.2 示例2:c2m打印模板定制

TODO

48 一个应用开多个实例

需求:已有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

48.1 前端支持多实例切换 / 多套切换

例如部署了两个实例,分别为“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);
        }
    })
}

48.1.1 关于将conf_subsys_url_xxx配置为空的解释

上面子实例共享了主实例的Sn等若干张表。但由于Sn也是addon对象,在安装addon时框架发现conf_tableAlias指定了它是另一系统(jt_wms)的对象,则认为当前系统依赖名为jt_wms的子系统,要求指定该子系统的接口地址来初始化这个addon对象。 这个逻辑仅在主子系统(且共享登录信息时)环境下才有意义,而此处是多实例,相互间登录是独立的。 通过指定url为空,跳过子系统(其实是主实例)对该addon对象的初始化,继续由当前实例来创建该对象。而如果不指定该url则会在安装addon时报错。

详细参考系统复用与微服务方案

49 管理端更换logo、登录页背景图等

如果是定制开发:管理端在web目录下

也可以用二次开发指定标题、logo图等:

setAppTitle(title, logo, logoIcon);

50 管理端复制一行数据并添加

示例:库存请求上添加“出入库”按钮,以当前数据来生成库存记录。

    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)也可以添加记录,支持子表,但不能修改数据。

51 关于继承关系的配置 / 共用表

示例:InvOrder(库存请求)与InvRecord(库存记录)共同使用表InvRecord。在产品代码中AC2_InvOrder继承AC2_InvRecord。

二次开发在做扩展时,应注意:

52 服务端URL处理 / Conf.onApiInit

服务器默认支持函数类接口和对象接口,如URL /api/hellohello是个小写开头的单个词,当成函数接口,路由到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/3hello/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的非参数部分并不支持中文(路由时不支持),在需要时可修改框架,并不难。

53 支持单点登录(SSO/single-sign-on/第三方认证)

53.1 模式一:在其它应用中内嵌

当筋斗云应用被其它应用内嵌时(比如通过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");
        }
    }
}

53.2 模式二:使用第三方认证登录后跳转到我前端首页

我方前端 - 我方后端 - 第三方认证系统

【特性】

【实现方案】

【完整过程细节】

54 日志使用惯例

生产环境下不要设置测试模式(P_TEST_MODE),测试环境最好不要开测试模式,开发环境下开测试模式。 测试模式时前端请求不加密,而且后端接口输出中带调试信息。调试信息在后端通过addLog添加,受调试等级(P_DEBUG)影响,等级为9时记录所有,尤其是SQL日志。

后端默认提供数据库ApiLog记录所有接口调用(Conf::enableApiLog = 1),2000, 2002000)。Conf :  : onApiInitConf : :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文件。

55 根据后端配置动态生成管理端的菜单项

在后端可以将动态菜单项配置在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
        };
    }
}

56 管理端在一个系统中展示其它第三方数据库的对象

连接第三方数据库

如果是同一个数据库服务实例中的其它数据库,是可以直接访问的,只要访问时带上数据库名前缀即可。如:

class AC2_Data extends AccessControl
{
    protected $table = "fiss.aiobjectdata";
}

如果是在其它数据库服务器上,则可以通过在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"}});

57 第三方系统接口对接惯例

以vinfast项目为例,我方为WMS(含有WCS)系统,需要对接RCS系统(AGV路径规划和控制系统)、立体库控制系统中的PLC、充电控制系统、温度报警传感器。

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"]
        ]
    ]);
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];
}

58 负载均衡设备代理到多台服务器 / 多机共享数据

使用多台服务器时,应尽量使用共享存储,例如共享网盘,或共享的数据库,小心解决应用服务写的文件是否要共享的问题,如会话、上传文件、自动生成的状态文件、缓存等。

上传的附件或图片可存储到数据库: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表中。

59 前端多次点击造成重复下单问题 / 重复提交问题

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
    ]);
}

60 add接口同时添加数据与文件

使用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;
    }
}

61 后端确保一段代码一定执行的方法(Guard用法)

使用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;
}

62 前端列表中显示实时生成的缩略图

示例:后端接口

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;
            }});
        });
    }
}

63 对IP地址列进行排序

默认应采用后端排序,即使用数据库的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如下:

    // 按IP比较
    sorter: (ip1,ip2) => {
        var a=ip1.split('.'), b=ip2.split('.');
        for (var i=0; i<a.length; ++i) {
            if (a[i] === b[i])
                continue;
            if (b[i] === undefined)
                return 1;
            return parseInt(a[i]) - parseInt(b[i]);
        }
        return 0;
    },