TommyCool博客

enjoy everyday.

在 Bootstraptable 插件基础上新增可编辑行

为什么调用 bootstraptable 原生方法会有问题

  首先我必须肯定, bootstraptable 是一款很强大的基于 bootstrap 的插件,下载它的 源码 后,你可以学到很多,开源真的很好。好了,我们回到怎么动态添加可编辑行的问题上,你可以在它的 说明文档 上很容易就找到动态增加行的方法, source insertRow 于是我们欢欣鼓舞的去使用了,效果很好, API method show 但当我们填好这一行的内容后,再加一行的时候,坑爹的事情发生了,第二个新增可编辑行把第一个给还原了,即变成最初新增编辑行的初始状态,一开始百思不得其解,后来打开源码一看(开源就是好啊),发现是这里的问题

1
2
3
4
5
6
7
8
9
10
11
BootstrapTable.prototype.insertRow = function (params) {
        if (!params.hasOwnProperty('index') || !params.hasOwnProperty('row')) {
            return;
        }
        this.data.splice(params.index, 0, params.row);
        // 调用 initSearch() 重新把 this.options.data 数据初始化了
        this.initSearch();
        this.initPagination();
        this.initSort();
        this.initBody(true);
    };

this.initSearch() 重新把 this.options.data 数据初始化了,因为我们是在动态编辑行的文本框里填写的内容,这个内容并没有同步到 this.options.data 里,所以调用 this.initSearch(); 这个方法后,被还原也是板上钉钉的事了。既然我们知道了原因,那就开动脑筋去实践解决方案。

怎么解决

  我选择的方法是从根上解决,在填写动态增加可编辑行的文本或下拉内容时加上 onchange 事件,将填好的内容,同步更新到 this.options.data 里,这样下次新增可编辑行的时候就不会还原成初始状态了。下面我附上源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 这是bootstraptable代码部分
<table id="dbtableDefTb" data-toggle="table" data-toolbar="#toolbar"
           data-search="true"
           data-show-refresh="true"
           data-show-toggle="true"
           data-show-columns="true"
           data-show-export="true"
           data-detail-view="true"
           data-detail-formatter="detailFormatter"
           data-pagination="true"
           data-page-size="50"
           data-page-list="[50, 100, ALL]"
           data-show-footer="false"
           data-side-pagination="server"
           data-url="${pageContext.request.contextPath}/dbtableDef/select/cols.oem">
        <thead>
        <tr>
            <th data-field="" data-checkbox="true"></th>
            <th data-field="" data-formatter="viewIndex">序号</th>
            <th data-field="" data-formatter="viewOptions">操作</th>
            <th data-field="dbColName" data-formatter="editCol">数据表字段名称</th>
            <th data-field="mdColName" data-formatter="editCol">模型表字段名称</th>
            <th data-field="dataType" data-formatter="editColDataType">数据类型</th>
            <th data-field="dataTypeLen" data-formatter="editCol">数据长度</th>
            <th data-field="isNull" data-formatter="editColIsNull">是否可空</th>
            <th data-field="colDescribe" data-formatter="editCol">描述</th>
            <th data-field="orderBy" data-formatter="editCol">排序值</th>
        </tr>
        </thead>
    </table>

上面的这些函数,在下面的代码里都可以找到对应的出处

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
function editCol(value, row, index, key){
    return "<div><input type=\"text\" value=\"" + value + "\" onchange='reloadRowData($(this), " + JSON.stringify(row) + ", \"" + key + "\", " + index + ")' /></div>";
}

function editColDataType(value, row, index, key){
    var selectNotNullStr = 'selected="selected"';
    var selectNullStr = '';
    var html = [];
    html.push("<select onchange='reloadRowData($(this), " + JSON.stringify(row) + ", \"" + key + "\", " + index + ")'>");
    // dataTypeSelect 是在加载页面的时候用 ajax 到后台取出的值,作为这个页面的全局变量赋给了 dataTypeSelect 
    $.each(dataTypeSelect, function(index, content){
        html.push('<option value="' + content.id + '" ' + (value==content.id ? selectNotNullStr : selectNullStr) + '>' + content.text + '</option>');
    });
    html.push('</select>');

    return html.join('');
}

function editColIsNull(value, row, index, key){
    var selectNotNullStr = 'selected="selected"';
    var selectNullStr = '';
    return [
            "<select onchange='reloadRowData($(this), " + JSON.stringify(row) + ", \"" + key + "\", " + index + ")'>",
            '<option value="1" ' + (value==1 ? selectNotNullStr : selectNullStr) + '>可以为空</option>',
            '<option value="0" ' + (value==0 ? selectNotNullStr : selectNullStr) + '>不可为空</option>',
            '</select>'
        ].join('');
}

function viewIndex(value, row, index){
    return '<div>' + (index + 1) + '</div>';
}

// 改变 input 编辑框值时,更新 data 数据,便于新增行时原来填写好的数据不会被新增行强制覆盖
function reloadRowData(obj, row, key, index){
    row[key] = obj.val();
    $('#dbtableDefTb').bootstrapTable('getOptions').data.splice(index, 1, row);
}

function viewOptions(value, row, index){
    return [
            "<a class=\"like\" href='javascript:addRow(" + index + ", " + JSON.stringify(row) + ")' title=\"新增行\">",
            '<i class="glyphicon glyphicon-plus"></i>',
            '</a>  ',
            '<a class="remove" href="javascript:removeRow(\'' + row.dbColName + '\')" title="删除行">',
            '<i class="glyphicon glyphicon-remove"></i>',
            '</a>'
        ].join('');
}

function addRow(insertIndex, rowObj){
    var insertRow = rowObj;
    $.each(insertRow, function(name, value){
        insertRow[name] = '';
    });

    var params = {index:insertIndex + 1, row:insertRow};
    $('#dbtableDefTb').bootstrapTable('insertRow', params);
}

细心的朋友会发现, bootstraptable documentation 描述的 column method ,在 data-formatter 回调函数执行的时候是只有三个参数的 source formatter ,怎么我这里 function editColIsNull(value, row, index, key) 是四个参数,那个 key 是哪里来的?答案就是,这是我为了实现方便,改了 bootstraptable 源码,

1
2
3
// 添加 field 参数给 formatters 属性
value = calculateObjectValue(column,
    that.header.formatters[j], [value, item, i, field], value);

以上代码写好后,再去新增就不会有问题了。

额外的好处

  因为是动态编辑行,所以在我们编辑好后,需提交后台保存的时候,如果按传统的模式,我们还需逐个迭代这个 $(‘#dbtableDefTb’) 去把填好的内容打包,在后台再解包,很多不优雅的代码,但是通过在编辑的时候加入 onchange 事件,基本上就是实时更新 table data 数据,因为 data 天然就是 json 数据,所以我们在提交的 时候不用做什么处理,就可以直接提交

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
function addModel(){
    // $('#qry_db_form') 是主表的 form 数据,通过 serializeObject() 方法转换成对象
    var addObj = $('#qry_db_form').serializeObject();
    // 在 addObj 对象里添加名称为 hiddenDbtableColDefs 数组,即为 bootstraptable data 数组对象
    addObj['hiddenDbtableColDefs'] = $('#dbtableDefTb').bootstrapTable('getData');
    var url = '${pageContext.request.contextPath}/dbtableDef/addModelDb?${_csrf.parameterName}=${_csrf.token}';
    $.postJSON(url, addObj, function(result){
        if(result.state == 1){
            $.messager.alert("成功", result.message);
        }else{
            $.messager.alert("失败", result.message);
        }
    });
}

$.postJSON = function(url, data, callback) {
    return jQuery.ajax({
        'type': 'POST',
        'url': url,
        'contentType': 'application/json',
        'data': JSON.stringify(data),
        'dataType': 'json',
        'success': callback,
        'error': errorHandler
    });
};

后台 spring mvc 代码如下

1
2
3
4
5
6
7
8
9
10
11
@RequestMapping(value = "addModelDb", method = RequestMethod.POST, produces = "application/json")
@ResponseBody
public Message addModelDb(@RequestBody DbtableDef dbtableDef) throws Exception {
    try {
        this.dbtableDefService.insert(dbtableDef);
        return new Message(Message.SUCCESS, "保存数据模型成功!");
    } catch (Exception e) {
        e.printStackTrace();
        return new Message(Message.FAILED, "保存数据模型失败!");
    }
}

实体 bean 记得添加名为 hiddenDbtableColDefs 的 list 对象,并设置 get 和 set 方法

1
2
3
4
5
6
7
8
9
10
@Transient
private List<DbtableColDef> hiddenDbtableColDefs;

public List<DbtableColDef> getHiddenDbtableColDefs() {
    return hiddenDbtableColDefs;
}

public void setHiddenDbtableColDefs(List<DbtableColDef> hiddenDbtableColDefs) {
    this.hiddenDbtableColDefs = hiddenDbtableColDefs;
}

说到这里,相信大部分人都会了,如果有什么疑问可以留言

分布式文件上传跨域解决方案

跨域问题的产生

  随着微服务、分布式等架构体系的发展,很多人在传统 web 开发上会遇到很多跨域的问题,特别是你的系统对接的服务不是你们自己公司的服务的时候,就更难办了。例如,用户要求所有厂家的文件服务都对应某一家厂家提供的服务,而这时这家厂家提供的仅仅是单纯的 HTTP 服务,只给了你一个地址 URL ,外加一个范例贴图。如果你没有接触过跨域问题,想着大不了就是前端 form 提交的 action 地址变一下就可以了,那么前面会有个坑等着你,而且是看得见,摸不着的。下面是具体描述:这里采用的是 jquery form 表单提交,

1
2
3
4
5
6
7
8
9
$('#upload_form').form('submit',{
                // 文件服务调用发送
                url: 'http://192.168.1.101:8091/weedfs/upload',
                async: false,
                success:function(data) {
                    debugger;
                    var obj = eval('(' + data + ')');
                }
            });

原来 URL 地址是本地服务,后改为第三方文件服务地址,提交后,始终进入不了 success 回调里,打开 Chrome 调试,设了断点,显示的是这个样子:

cross-domain response

你是不是很奇怪,怎么有返回,但就是回调函数没有值,我们再深挖一下,顺着回调往上走,我们可以追踪到 jquery 是怎么创建一个 iframe 去执行文件上传的,

iframe code

调试的时候这里 f.contents() 获取的是null,导致 .find(“body”) 为 undefined ,所以回调没有值,但是点 Elements 查看后发现里面是有值的,可以看一下,返回的 responseStr 已经在 iframe 里了

1
2
3
4
5
6
7
8
9
10
11
<iframe id="easyui_frame_1472175408181" name="easyui_frame_1472175408181" src="about:blank" style="position: absolute; top: -1000px; left: -1000px;">
    <html>
        <head></head>
        <body>
            <pre style="word-wrap: break-word; white-space: pre-wrap;">
            {"responseUpload":{"fid":"Z3JvdXAxL00wMC8wMC8wOS9DbThNRGxlX25PcUVYMkhWQUFBQUFMSTZHTm83MTgucG5n","error":null,"fileName":"QQ20160805-0@2x.png",
            "fileUrl":"http://192.168.1.101:8091/weedfs/download/Z3JvdXAxL00wMC8wMC8wOS9DbThNRGxlX25PcUVYMkhWQUFBQUFMSTZHTm83MTgucG5n","size":"219678"}}
            </pre>
        </body>
    </html>
</iframe>

  这就解释了为什么 repsonse 有值,而回调没有值的问题,因为返回值已经传过来了,但是是在跨域的 iframe 里,通过 f.contents() 方法是获取不了的,后来我也去网上找了很多,大部分都是改造 response header ,而且还有浏览器兼容性的问题,况且文件服务不是我们的,不可能去让厂家改的,磨了好半天,找到了一个解决问题的思路:请求提交到本地不变,文件存储过后,再由后台打包提交重新发起一次 HTTP POST 请求去对接文件服务 URL ,在后台获取返回值,解析以后再传到前台。这样做还有个好处,即文件服务地址没有暴露在前端,而是配置在后端字典表,保证了安全,既然思路有了,就说一下怎么实现吧。

实现原理

为了更直观,我直接上代码了,其实不是很难,核心代码就20几行。 首先是 controller 层调用(使用的是spring mvc架构),下面贴出代码片段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@RequestMapping(value = "uploadFileTmpRemote", method = RequestMethod.POST)
@ResponseBody
public Map<String, String> uploadFileTmpRemote(MultipartHttpServletRequest request) throws Exception {
    Map<String, String> messageMap = new HashMap<String, String>();
    // 获取字典表文件服务地址
    String httpRemoteUrl = AppConfig.DICT_MAP_VALUE.get("httpRemoteUrl").get("1");
    if (httpRemoteUrl != null && !"".equals(httpRemoteUrl)) {
        List<MultipartFile> files = request.getFiles("fileList");
        RemoteFileUpload remoteFileUpload = new RemoteFileUpload();
        messageMap = remoteFileUpload.executeRequest(files, httpRemoteUrl);
    }

    return messageMap;
}

接下来是 RemoteFileUpload 核心实现,先在 maven 配置文件 pom.xml 添加依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpclient</artifactId>
    <version>4.5.2</version>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpmime</artifactId>
    <version>4.5.2</version>
</dependency>
<dependency>
    <groupId>org.apache.httpcomponents</groupId>
    <artifactId>httpcore</artifactId>
    <version>4.4.4</version>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>fastjson</artifactId>
    <version>1.2.7</version>
</dependency>

自动下载成功后,便可运行下面代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
package com.summer.web.util;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.math.BigDecimal;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.mime.HttpMultipartMode;
import org.apache.http.entity.mime.MultipartEntityBuilder;
import org.apache.http.entity.mime.content.FileBody;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClients;
import org.springframework.web.multipart.MultipartFile;
import com.alibaba.fastjson.JSONObject;

public class RemoteFileUpload {
    /*
    *  @param files: form表单上传后获得的files(因为使用的是spring上传组件,所以是MultipartFile类型)
    *  @param httpUrl: 文件服务url地址
    */
    public Map<String, String> executeRequest(List<MultipartFile> files, String httpUrl) throws Exception {
        Map<String, String> messageMap = new HashMap<String, String>();
        CloseableHttpClient httpclient = null;
        try {
            for (MultipartFile multipartFile : files) {
                if (multipartFile.isEmpty()) {
                    continue;
                }

                String nameFull = multipartFile.getOriginalFilename();
                String size = Long.toString(multipartFile.getSize());
                String name = nameFull.substring(0, nameFull.lastIndexOf("."));
                String type = nameFull.substring(nameFull.lastIndexOf(".") + 1);
                String tmpName = Long.toString(new Date().getTime()) + "." + type;

                multipartFile.transferTo(new File(AppConfig.tempPath, tmpName));

                httpclient = HttpClients.createDefault();
                // 文件服务调用发送
                HttpPost httppost = new HttpPost(httpUrl);

                // 判断是否有代理配置,如果有去字典表读取,如果请求无需代理,可以忽略这段
                if (AppConfig.DICT_MAP_VALUE.get("proxyHttp") != null && AppConfig.DICT_MAP_VALUE.get("localHostName") != null) {
                    if(AppConfig.localHostName.indexOf(AppConfig.DICT_MAP_VALUE.get("localHostName").get("1")) != -1){
                        String[] proxyHttp = AppConfig.DICT_MAP_VALUE.get("proxyHttp").get("1").split(":");
                        HttpHost proxy = new HttpHost(proxyHttp[0], Integer.parseInt(proxyHttp[1]), "http");
                        RequestConfig config = RequestConfig.custom().setProxy(proxy).build();
                        httppost.setConfig(config);
                    }
                }

                FileBody bin = new FileBody(new File(AppConfig.tempPath, tmpName));
                MultipartEntityBuilder multipartEntity = MultipartEntityBuilder.create();
                multipartEntity.setMode(HttpMultipartMode.BROWSER_COMPATIBLE);
                multipartEntity.addPart("file", bin);
                httppost.setEntity(multipartEntity.build());

                HttpResponse response = httpclient.execute(httppost);
                HttpEntity entity = response.getEntity();
                InputStream inStream = entity.getContent();

                ByteArrayOutputStream outSteam = new ByteArrayOutputStream();
                byte[] buffer = new byte[1024];
                int len = -1;
                while ((len = inStream.read(buffer)) != -1) {
                    outSteam.write(buffer, 0, len);
                }
                outSteam.close();

                JSONObject jsonObj = JSONObject.parseObject(new String(outSteam.toByteArray()));
                Map<String, String> responsMap = (Map) jsonObj.get("responseUpload");

                messageMap.put("name", name);
                messageMap.put("type", type);
                messageMap.put("size", new BigDecimal(size).divide(new BigDecimal("1000"))
                        .setScale(0, BigDecimal.ROUND_HALF_UP).toString() + "KB");
                messageMap.put("tmpName", responsMap.get("fileName"));
                messageMap.put("fileUrl", responsMap.get("fileUrl"));
            }
        } finally {
            httpclient.close();
        }

        return messageMap;
    }

}

以上代码已经很清晰的给出了解决方案,而且如果你需代理还可以简单的配置一下即可,如果还有什么不清楚的地方,可以留言

Thanks for

  在这里诚挚的感谢破船兄的指点,他给了我很大的帮助,这里我参考了他的博客才得以完成我的博客系统的搭建,利用Octopress搭建一个Github博客,这是位iOS大神,大家可以去搜罗一些干货。