Packaging and Use of Bootstrap-fileinput Components

Keywords: github Java Hibernate xml

introduce

Through this article, you can learn how to encapsulate or develop a front-end component, and learn how to use Bootstrap-fileinput component. It is easier and more convenient to use after encapsulation.


BaseFile is an attachment upload component based on Bootstrap-fileinput in AdminEAP framework. It supports supporting multi-file, online preview, drag-and-drop upload and other functions. After encapsulation, BaseFile mainly includes the following functions:

  • Appendix upload of pop-up window
  • Appendix upload of current interface
  • Display attachment details
  • Details of editable attachments (delete, preview, not add)

API documentation for Bootstrap-fileinput can be referred to http://plugins.krajee.com/file-input

The source code of this article has been open source in the AdminEAP framework (a Java development platform based on AdminLTE). It can be downloaded in Github.

Github: https://github.com/bill1012/AdminEAP

AdminEAP: http://www.admineap.com

Instructions

1. Initialization

If you need to use the attachment upload function (non-pop-up window mode) in the current interface, you need to introduce relevant css and js files in the header

  • css file
<link rel="stylesheet" href="./resources/common/libs/fileinput/css/fileinput.min.css">
  • js file
<script src="./resources/common/libs/fileinput/js/fileinput.js"></script>
<script src="./resources/common/libs/fileinput/js/locales/zh.js"></script>
<!--BaseFile assembly-->
<script src="./resources/common/js/base-file.js"></script>

The enctype="multipart/form-data" property also needs to be configured on the form

2. Bullet Window Call

BaseFile supports pop-up window to open an attachment upload window. After clicking on the attachment upload, pop-up window will appear. After the attachment upload closes the window, the uploaded attachment will be filled back in the control of type=file.

In the form, click on the bullet window to upload the attachment:

After uploading, close the window and fill in the attachment

When the upload window is opened again, the existing attachments will be filled back into the attachment upload window.

The configuration is as follows:

  • html code
          <input type="hidden" name="fileIds" id="fileIds">
          <div class="form-group">
                <div class="btn btn-default btn-file" id="uploadFile">
                    <i class="fa fa-paperclip"></i> Upload attachments(Max. 10MB)
                </div>
            </div>
            <div class="form-group" id="file_container">
                <input type="file" name="file"  id="attachment">
            </div>    
  • js code
$("#uploadFile").file({
            title: "Please upload the attachment.",
            fileinput: {
                maxFileSize: 10240,
                maxFileCount:3
            },
            fileIdContainer:"[name='fileIds']",
            showContainer:'#attachment',
            //Display file type edit = editable detail = detail by default
            showType:'edit',
            //The callback function after the pop-up window executes the upload attachment (window:false does not call this method)
            window:true,
            callback:function(fileIds,oldfileIds){
                //Update fileIds
                this.showFiles({
                    fileIds:fileIds
                });
            }
        });

3. Local interface call

The local interface calls the attachment upload, as shown in the following figure:

Embedding upload attachments into the current interface

Uploaded attachments can be deleted and previewed


(At present, picture files can be previewed, other files can not be previewed, and the preview function of txt/xml/html/pdf will be integrated later.)

The configuration is as follows:

  • html code
<div class="form-group" id="file_container">
      <input type="file" name="file"  id="attachment">
</div>
  • js code
    $("#attachment").file({
            fileinput: {
                maxFileSize: 10240,
                maxFileCount:3
            },
            fileIdContainer:"[name='fileIds']",
            window:false
        });

4. Description of Control Parameters

  • Windows defaults to true, pop-up window opens

  • Configuration when title window=true, pop-up window title, default to "file upload"

  • width window=true configuration, bounce window width, default 900

  • Configuration when winId window=true, pop-up id, default fileWin

  • The configuration parameters of fileinput Bootstrap-fileinput override the default configuration, such as which type of attachment allowedFileTypes are allowed to upload, maxFileSize is allowed to upload the largest attachment size, maxFileCount is allowed to upload the number of attachments, etc. Specific configuration parameters can query the API documents of Bootstrap-filein.

  • fileIdContainer must store the location of the uploaded attachment id separated by commas

  • showContainer window=true must be configured. The area filled back after file upload. If window=false is not configured, the initial object of base-file is taken.

  • showType window=true configuration, value edit or detail,edit means that data can be deleted and previewed after backfilling. Detail can only be displayed, not deleted.

  • callback window=true configuration, callback functions (such as updating the current file list), fileIds, and oldfileIds are file ids after updating and file ids before updating, respectively.

  • BaseFile default configuration, more implementations of BaseFile, see BaseFile source code

BaseFile.prototype.default = {
        winId: "fileWin",
        width: 900,
        title: "File upload",
        //General File Upload Interface
        url: basePath + "/file/uploader",
        //Support multi-file upload by default
        multiple: true,
        //Default pop-up attachment upload window
        window:true,
        showType:"detail",
        fileinput: {
            language: 'zh',
            uploadUrl: basePath + "/file/uploadMultipleFile",
            deleteUrl:basePath+"/file/delete",
            uploadAsync:false,
            validateInitialCount:true,
            overwriteInitial: false,
            allowedPreviewTypes: ['image'],
            previewFileIcon:'<i class="fa fa-file-o"></i>',
            previewFileIconSettings: null,
            slugCallback: function (text) {
                var newtext=(!text||text=='') ? '' : String(text).replace(/[\-\[\]\/\{}:;#%=\(\)\*\+\?\\\^\$\|<>&"']/g, '_');
                //Remove blank space
                return newtext.replace(/(^\s+)|(\s+$)/g,"").replace(/\s/g,"");
            }
        }
    }

5. BaseFile Control Source Code

/**
 * General File Management Component
 * @author billjiang qq:475572229
 */
(function ($, window, document, undefined) {
    'use strict';

    var pluginName = 'file';

    $.fn[pluginName] = function (options) {
        var self = $(this);
        if (this == null)
            return null;
        var data = this.data(pluginName);
        if (!data) {
            data = new BaseFile(this, $.extend(true, {}, options));
            self.data(pluginName, data);
        }
    };


    var BaseFile = function (element, options) {
        this.element = element;
        //The extend priority will override the previous one
        //alert(this.element.selector);
        //Passing the container ID is easy to get the BaseFile object from the pop-up window. If the jquery.load method is not used in the page layout, the method will fail because it is not a page.
        options.container = options.container || this.element.selector.replace("#", "");
        //Initialize file icon information
        this.getFileIconSettings();
        this.options = $.extend(true, {}, this.default, options);
        //Initialize icon information
        this.initFileIds();

        if(this.options.window) {
            this.element.click(function () {
                $(this).data('file').openWin();
            });
        }else{
            //Non-elastic window form
            if(this.options.multiple)
                this.element.attr("multiple","multiple");
        }

        //If the attachment editing container showContainer is configured (attachment list can be deleted individually), initialization is performed
        if(this.hasDisplayZone()){
            this.showFiles();
        }


    }

    BaseFile.prototype.default = {
        winId: "fileWin",
        width: 900,
        title: "File upload",
        //General File Upload Interface
        url: basePath + "/file/uploader",
        //Support multi-file upload by default
        multiple: true,
        //Default pop-up attachment upload window
        window:true,
        showType:"detail",
        fileinput: {
            language: 'zh',
            uploadUrl: basePath + "/file/uploadMultipleFile",
            deleteUrl:basePath+"/file/delete",
            uploadAsync:false,
            validateInitialCount:true,
            overwriteInitial: false,
            allowedPreviewTypes: ['image'],
            previewFileIcon:'<i class="fa fa-file-o"></i>',
            previewFileIconSettings: null,
            slugCallback: function (text) {
                var newtext=(!text||text=='') ? '' : String(text).replace(/[\-\[\]\/\{}:;#%=\(\)\*\+\?\\\^\$\|<>&"']/g, '_');
                //Remove blank space
                return newtext.replace(/(^\s+)|(\s+$)/g,"").replace(/\s/g,"");
            }
        }
    }

    BaseFile.prototype.getFileInputConfig=function () {
        return this.options.fileinput;
    }
    BaseFile.prototype.getFileIconSettings = function () {
        var self = this;
        ajaxPost(basePath + "/file/icons", null, function (icons) {
            self.previewFileIconSettings = icons;
            //console.log(self.previewFileIconSettings);
        })
    }


    BaseFile.prototype.openWin = function () {
        var that = this;
        var self = $.extend(true, {}, this.options);
        //Delete attributes after deep copy so that they are not transmitted back-end to prevent special characters from being filtered out by XSS
        //Parameters that do not need to be passed to the window through parameter config = can be deleted with delete
        delete self.callback;
        delete self.fileIds;
        delete self.showContainer;
        delete self.fileIdContainer;
        delete self.fileinput;

        /*console.log(this.options);
         console.log("=============");
         console.log(self);*/
        modals.openWin({
            winId: that.options.winId,
            url: that.options.url + "?config=" + JSON.stringify(self),
            width: that.options.width + "px",
            title: that.options.title,
            backdrop: "static"
        });
    }

    BaseFile.prototype.callbackHandler = function (fileIds) {
        //Update fileIds and execute callback functions
        var oldfileIds = this.options.fileIds;
        this.options.fileIds = fileIds;
        this.updateFileIds();
        if (this.options.callback) {
            this.options.callback.call(this, fileIds, oldfileIds);
        }
    }

    //Execute display attachment after successful invocation
    BaseFile.prototype.showFiles=function(options){
        options=options||{};
        if(!this.hasDisplayZone()){
            modals.error("Please configure showContainer Attributes and configure them under containers type=file Of input assembly");
            return;
        }
        var fileIds=options.fileIds||this.options.fileIds;
        if(!fileIds&&this.options.window){
           $(this.options.showContainer).hide();
            return;
        }
        //display
        $(this.options.showContainer).show();
        var fileComponet=$(this.options.showContainer);
        var fileResult=this.getFileResult(fileIds),preview=fileResult.initialPreview,previewConfig=fileResult.initialPreviewConfig,self=this;
        //Configure three parameters edit = attachment list (deletable) detail = attachment list (display) uploadable
        var defaultConfig={
            initialPreview:preview,
            initialPreviewConfig:previewConfig
        };
        var config;
        if(this.options.window){
            if(this.options.showType=="edit"){
                //Global configuration - > default configuration - > configuration under edit property - > external parameters
                config=$.extend({},self.options.fileinput,defaultConfig,{
                    showRemove:false,
                    showUpload:false,
                    showClose:false,
                    showBrowse:false,
                    showCaption:false
                },options);
            }else if(this.options.showType=="detail"){
                config=$.extend({},self.options.fileinput,defaultConfig,{
                    showRemove:false,
                    showUpload:false,
                    showClose:false,
                    showBrowse:false,
                    showCaption:false,
                    initialPreviewShowDelete:false
                },options);
            }
        }else{
            config=$.extend({},self.options.fileinput,defaultConfig,{
                showClose:false
            },options);
        }

        if(!config){
            modals.error("not found showFiles Relevant configuration in");
            return;
        }
        //console.log("config=========="+JSON.stringify(config));
        fileComponet.fileinput('destroy');
        fileComponet.fileinput(config).on("filedeleted",function (event,key) {
            var newfids=self.deleteFileIds(key,self.options.fileIds);
            self.options.fileIds=newfids;
            self.updateFileIds();
        }).on("fileuploaded",function(event,data,previewId,index){
            var newfids=self.addFileIds(data.response.fileIds,self.options.fileIds);
            self.options.fileIds=newfids;
            self.updateFileIds();
        }).on("filebatchuploadsuccess",function (event,data,previewId,index) {
            var newfids=self.addFileIds(data.response.fileIds,self.options.fileIds);
            self.options.fileIds=newfids;
            self.updateFileIds();
        }).on("filezoomhidden", function(event, params) {
            $(document.body).removeClass('modal-open');
            $(document.body).css("padding-right","0px");
        });
    } 

    /**
     * Delete data fileIds from targetIds
     * @param fileIds
     * @param targetIds
     */
    BaseFile.prototype.deleteFileIds=function(fileIds,targetIds){
        if(!fileIds) return targetIds;
        //No file deletion, there must be a problem
        if(!targetIds){
            modals.error("There are no files to delete. Please check if the data is not initialized.");
            return;
        }
        var fileIdArr=fileIds.split(",");
        var fresult=targetIds.split(",");
        $.each(fileIdArr,function (index,fileId){
            //Existence deletes
            if($.inArray(fileId,fresult)>-1){
                fresult.splice($.inArray(fileId,fresult),1);
            }
        })
        return fresult.join();
    }

    /**
     * Add data fileIds to targetIds
     * @param fileIds
     * @param targetIds
     */
    BaseFile.prototype.addFileIds=function (fileIds,targetIds) {
        if(!fileIds)return targetIds;
        var fileIdArr=fileIds.split(",");
        var fresult=[];
        if(targetIds){
            fresult=targetIds.split(",");
        }
        $.each(fileIdArr,function (index,fileId){
            //No, NEW
            if($.inArray(fileId,fresult)==-1){
                fresult.push(fileId);
            }
        })
        return fresult.join();
    }

    BaseFile.prototype.updateFileIds=function(){
        if(this.options.fileIdContainer)
            $(this.options.fileIdContainer).val(this.options.fileIds);
    }

    BaseFile.prototype.initFileIds=function(){
        //Be sure to bind fileIdContainer if you don't pop up the window
        if(!this.options.window){
            if(!this.options.fileIdContainer||!$(this.options.fileIdContainer)){
                modals.info("Please set up fileIdContainer attribute");
                return;
            }
        }
        if(!this.options.fileIds){
            if(this.options.fileIdContainer){
                this.options.fileIds=$(this.options.fileIdContainer).val();
            }
        }
    }

    BaseFile.prototype.getFileResult=function(fileIds){
        var ret=null;
        ajaxPost(basePath+"/file/getFiles",{fileIds:fileIds},function(result){
            ret=result;
        });
        return ret;
    };

    /**
     * Is there a display area?
     * @returns {boolean}
     */
    BaseFile.prototype.hasDisplayZone=function(){
        if(!this.options.showContainer){
           this.options.showContainer=this.element.selector;
        }
        if(!this.options.showContainer||!$(this.options.showContainer)){
            return false;
        }
        return true;
    }



})(jQuery, window, document);

6. Backend source code

package com.cnpc.framework.base.controller;


import com.cnpc.framework.base.entity.SysFile;
import com.cnpc.framework.base.entity.User;
import com.cnpc.framework.base.pojo.AvatarResult;
import com.cnpc.framework.base.pojo.FileResult;
import com.cnpc.framework.base.pojo.MarkDownResult;
import com.cnpc.framework.base.pojo.Result;
import com.cnpc.framework.base.service.UploaderService;
import com.cnpc.framework.util.SecurityUtil;
import com.cnpc.framework.utils.DateUtil;
import com.cnpc.framework.utils.FileUtil;
import com.cnpc.framework.utils.PropertiesUtil;
import com.cnpc.framework.utils.StrUtil;
import org.apache.commons.fileupload.util.Streams;
import org.hibernate.criterion.DetachedCriteria;
import org.hibernate.criterion.Order;
import org.hibernate.criterion.Restrictions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.multipart.MultipartHttpServletRequest;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.*;
import java.util.*;

@Controller
@RequestMapping("/file")
public class UploaderController {

    private static Logger logger= LoggerFactory.getLogger(UploaderController.class);

    //previewFileIconSettings
    public static Map fileIconMap=new HashMap();
    @Resource
    private UploaderService uploaderService;

    static {
        fileIconMap.put("doc" ,"<i class='fa fa-file-word-o text-primary'></i>");
        fileIconMap.put("docx","<i class='fa fa-file-word-o text-primary'></i>");
        fileIconMap.put("xls" ,"<i class='fa fa-file-excel-o text-success'></i>");
        fileIconMap.put("xlsx","<i class='fa fa-file-excel-o text-success'></i>");
        fileIconMap.put("ppt" ,"<i class='fa fa-file-powerpoint-o text-danger'></i>");
        fileIconMap.put("pptx","<i class='fa fa-file-powerpoint-o text-danger'></i>");
        fileIconMap.put("jpg" ,"<i class='fa fa-file-photo-o text-warning'></i>");
        fileIconMap.put("pdf" ,"<i class='fa fa-file-pdf-o text-danger'></i>");
        fileIconMap.put("zip" ,"<i class='fa fa-file-archive-o text-muted'></i>");
        fileIconMap.put("rar" ,"<i class='fa fa-file-archive-o text-muted'></i>");
        fileIconMap.put("default" ,"<i class='fa fa-file-o'></i>");
    }

    //From setting.propertiesFile injection relative directory (relative directory is display file)
    //@Value("${uploaderPath}") Only configuration@Config Inability to infuse
    private static final String uploaderPath=PropertiesUtil.getValue("uploaderPath");



    /**
     * Jump to Common File Upload Window
     * @return
     */
    @RequestMapping(value="/uploader",method = RequestMethod.GET)
    public String uploader(String config,HttpServletRequest request){
        request.setAttribute("config",config);
        return "base/file/file_uploader";
    }


    /**
     * Universal file upload interface, stored to fixed address, later stored to file server address
     */
    @RequestMapping(value = "/uploadFile", method = RequestMethod.POST)
    @ResponseBody
    public SysFile uploadFile(@RequestParam(value = "file", required = false) MultipartFile file,
                              HttpServletRequest request, HttpServletResponse response) {
        //TODO dosomething
        return new SysFile();
    }

    /**
     * Multi-file upload for uploadAsync = false (synchronous multi-file upload)
     * @param files
     * @param request
     * @param response
     * @return
     */
    @RequestMapping(value = "/uploadMultipleFile", method = RequestMethod.POST)
    @ResponseBody
    public FileResult uploadMultipleFile(@RequestParam(value = "file", required = false) MultipartFile[] files,
                                         HttpServletRequest request, HttpServletResponse response) throws IOException {
        System.out.println("the num of file:"+files.length);

        FileResult msg = new FileResult();

        ArrayList<Integer> arr = new ArrayList<>();
        //Caching the current file
        List<SysFile> fileList=new ArrayList<>();
        String dirPath = request.getRealPath("/");
        for (int i = 0; i < files.length; i++) {
            MultipartFile file = files[i];

            if (!file.isEmpty()) {
                InputStream in = null;
                OutputStream out = null;
                try {
                    File dir = new File(dirPath+uploaderPath);
                    if (!dir.exists())
                        dir.mkdirs();
                    //So you can upload files with the same name.
                    String filePrefixFormat="yyyyMMddHHmmssS";
                    System.out.println(DateUtil.format(new Date(),filePrefixFormat));
                    String savedName=DateUtil.format(new Date(),filePrefixFormat)+"_"+file.getOriginalFilename();
                    String filePath=dir.getAbsolutePath() + File.separator + savedName;
                    File serverFile = new File(filePath);
                    //Write the file to the server
                    //FileUtil.copyInputStreamToFile(file.getInputStream(),serverFile);
                    file.transferTo(serverFile);
                    SysFile sysFile=new SysFile();
                    sysFile.setFileName(file.getOriginalFilename());
                    sysFile.setSavedName(savedName);
                    sysFile.setCreateDateTime(new Date());
                    sysFile.setUpdateDateTime(new Date());
                    sysFile.setCreateUserId(SecurityUtil.getUserId());
                    sysFile.setDeleted(0);
                    sysFile.setFileSize(file.getSize());
                    sysFile.setFilePath(uploaderPath+File.separator+savedName);
                    uploaderService.save(sysFile);
                    fileList.add(sysFile);
                    /*preview.add("<div class=\"file-preview-other\">\n" +
                            "<span class=\"file-other-icon\"><i class=\"fa fa-file-o text-default\"></i></span>\n" +
                            "</div>");*/

                    logger.info("Server File Location=" + serverFile.getAbsolutePath());
                } catch (Exception e) {
                    logger.error(   file.getOriginalFilename()+"Abnormal upload, abnormal reasons:"+e.getMessage());
                    arr.add(i);
                } finally {
                    if (out != null) {
                        out.close();
                    }
                    if (in != null) {
                        in.close();
                    }
                }
            } else {
                arr.add(i);
            }
        }

        if(arr.size() > 0) {
            msg.setError("File upload failed!");
            msg.setErrorkeys(arr);
        }
        FileResult preview=getPreivewSettings(fileList,request);
        msg.setInitialPreview(preview.getInitialPreview());
        msg.setInitialPreviewConfig(preview.getInitialPreviewConfig());
        msg.setFileIds(preview.getFileIds());
        return msg;
    }

    //Delete a file
    @RequestMapping(value="/delete",method = RequestMethod.POST)
    @ResponseBody
    public Result delete(String id,HttpServletRequest request){
        SysFile sysFile=uploaderService.get(SysFile.class,id);
        String dirPath=request.getRealPath("/");
        FileUtil.delFile(dirPath+uploaderPath+File.separator+sysFile.getSavedName());
        uploaderService.delete(sysFile);
        return new Result();
    }

    /**
     * Get the Font Icon map, using the base-file control
     */
    @RequestMapping(value="/icons",method = RequestMethod.POST)
    @ResponseBody
    public Map getIcons(){
        return fileIconMap;
    }

    /**
     * Get icon by file name
     * @param fileName file
     * @return
     */
    public String getFileIcon(String fileName){
        String ext= StrUtil.getExtName(fileName);
        return fileIconMap.get(ext)==null?fileIconMap.get("default").toString():fileIconMap.get(ext).toString();
    }

    /**
     * Obtain files based on attachment IDS
     * @param fileIds
     * @param request
     * @return
     */
    @RequestMapping(value="/getFiles",method = RequestMethod.POST)
    @ResponseBody
    public FileResult getFiles(String fileIds,HttpServletRequest request){
        String[] fileIdArr=fileIds.split(",");
        DetachedCriteria criteria=DetachedCriteria.forClass(SysFile.class);
        criteria.add(Restrictions.in("id",fileIdArr));
        criteria.addOrder(Order.asc("createDateTime"));
        List<SysFile> fileList=uploaderService.findByCriteria(criteria);
        return getPreivewSettings(fileList,request);
    }


    /**
     * Backfill thumbnails of existing documents
     * @param fileList File list
     * @param request
     * @return initialPreiview initialPreviewConfig fileIds
     */
    public FileResult getPreivewSettings(List<SysFile> fileList,HttpServletRequest request){
        FileResult fileResult=new FileResult();
        List<String> previews=new ArrayList<>();
        List<FileResult.PreviewConfig> previewConfigs=new ArrayList<>();
        //Caching the current file
        String dirPath = request.getRealPath("/");
        String[] fileArr=new String[fileList.size()];
        int index=0;
        for (SysFile sysFile : fileList) {
            //Preview TODO after uploading. This preview style does not support theme:explorer for the time being. It can be extended again later.
            //If other files can be predicated txt, xml, html, pdf, etc., they can be configured here
            if(FileUtil.isImage(dirPath+uploaderPath+File.separator+sysFile.getSavedName())) {
                previews.add("<img src='." + sysFile.getFilePath().replace(File.separator, "/") + "' class='file-preview-image kv-preview-data' " +
                        "style='width:auto;height:160px' alt='" + sysFile.getFileName() + " title='" + sysFile.getFileName() + "''>");
            }else{
                previews.add("<div class='kv-preview-data file-preview-other-frame'><div class='file-preview-other'>" +
                        "<span class='file-other-icon'>"+getFileIcon(sysFile.getFileName())+"</span></div></div>");
            }
            //Preview configuration after upload
            FileResult.PreviewConfig previewConfig=new FileResult.PreviewConfig();
            previewConfig.setWidth("120px");
            previewConfig.setCaption(sysFile.getFileName());
            previewConfig.setKey(sysFile.getId());
            // previewConfig.setUrl(request.getContextPath()+"/file/delete");
            previewConfig.setExtra(new FileResult.PreviewConfig.Extra(sysFile.getId()));
            previewConfig.setSize(sysFile.getFileSize());
            previewConfigs.add(previewConfig);
            fileArr[index++]=sysFile.getId();
        }
        fileResult.setInitialPreview(previews);
        fileResult.setInitialPreviewConfig(previewConfigs);
        fileResult.setFileIds(StrUtil.join(fileArr));
        return fileResult;
    }
}

summary

The source code of this article has been open source in the AdminEAP framework (a Java development platform based on AdminLTE). It can be downloaded in Github.

Github: https://github.com/bill1012/AdminEAP

AdminEAP: http://www.admineap.com

Posted by kuliksco on Sun, 14 Apr 2019 15:33:33 -0700