Browse Source

init:初始化

super-yimizi 3 months ago
commit
727c31601a
100 changed files with 12494 additions and 0 deletions
  1. 14 0
      .bowerrc
  2. 11 0
      .env.sample
  3. 16 0
      .gitignore
  4. 4 0
      .npmrc
  5. 139 0
      Gruntfile.js
  6. 191 0
      LICENSE
  7. 93 0
      README.md
  8. 1 0
      addons/.gitkeep
  9. 1 0
      addons/.htaccess
  10. 1 0
      addons/adminlogin/.addonrc
  11. 147 0
      addons/adminlogin/Adminlogin.php
  12. 78 0
      addons/adminlogin/config.php
  13. 6 0
      addons/adminlogin/config/cache.php
  14. 7 0
      addons/adminlogin/config/menu.php
  15. 15 0
      addons/adminlogin/controller/Index.php
  16. 10 0
      addons/adminlogin/info.ini
  17. 0 0
      addons/adminlogin/install.sql
  18. 84 0
      addons/adminlogin/library/Service.php
  19. 1 0
      addons/apilog/.addonrc
  20. 272 0
      addons/apilog/Apilog.php
  21. 412 0
      addons/apilog/config.html
  22. 105 0
      addons/apilog/config.php
  23. 24 0
      addons/apilog/controller/Index.php
  24. 10 0
      addons/apilog/info.ini
  25. 28 0
      addons/apilog/install.sql
  26. 278 0
      addons/apilog/model/Apilog.php
  27. 44 0
      addons/apilog/view/index/index.html
  28. 1 0
      addons/betterform/.addonrc
  29. 113 0
      addons/betterform/Betterform.php
  30. 27 0
      addons/betterform/bootstrap.js
  31. 102 0
      addons/betterform/config.php
  32. 15 0
      addons/betterform/controller/Index.php
  33. 10 0
      addons/betterform/info.ini
  34. 1 0
      addons/command/.addonrc
  35. 70 0
      addons/command/Command.php
  36. 4 0
      addons/command/config.php
  37. 15 0
      addons/command/controller/Index.php
  38. 10 0
      addons/command/info.ini
  39. 12 0
      addons/command/install.sql
  40. 28 0
      addons/command/library/Output.php
  41. 1 0
      addons/crontab/.gitignore
  42. 80 0
      addons/crontab/Crontab.php
  43. 19 0
      addons/crontab/config.php
  44. 125 0
      addons/crontab/controller/Autotask.php
  45. 16 0
      addons/crontab/controller/Index.php
  46. 10 0
      addons/crontab/info.ini
  47. 40 0
      addons/crontab/install.sql
  48. 130 0
      addons/crontab/library/CommandRunnable.php
  49. 252 0
      addons/crontab/library/Cron/AbstractField.php
  50. 402 0
      addons/crontab/library/Cron/CronExpression.php
  51. 131 0
      addons/crontab/library/Cron/DayOfMonthField.php
  52. 170 0
      addons/crontab/library/Cron/DayOfWeekField.php
  53. 54 0
      addons/crontab/library/Cron/FieldFactory.php
  54. 40 0
      addons/crontab/library/Cron/FieldInterface.php
  55. 69 0
      addons/crontab/library/Cron/HoursField.php
  56. 60 0
      addons/crontab/library/Cron/MinutesField.php
  57. 38 0
      addons/crontab/library/Cron/MonthField.php
  58. 183 0
      addons/crontab/library/SimpleFork/AbstractPool.php
  59. 53 0
      addons/crontab/library/SimpleFork/Cache/CacheInterface.php
  60. 263 0
      addons/crontab/library/SimpleFork/Cache/FileCache.php
  61. 126 0
      addons/crontab/library/SimpleFork/Cache/RedisCache.php
  62. 176 0
      addons/crontab/library/SimpleFork/Cache/SharedMemory.php
  63. 67 0
      addons/crontab/library/SimpleFork/FixedPool.php
  64. 126 0
      addons/crontab/library/SimpleFork/Lock/FileLock.php
  65. 40 0
      addons/crontab/library/SimpleFork/Lock/LockInterface.php
  66. 163 0
      addons/crontab/library/SimpleFork/Lock/Semaphore.php
  67. 106 0
      addons/crontab/library/SimpleFork/ParallelPool.php
  68. 38 0
      addons/crontab/library/SimpleFork/Pool.php
  69. 56 0
      addons/crontab/library/SimpleFork/PoolFactory.php
  70. 373 0
      addons/crontab/library/SimpleFork/Process.php
  71. 143 0
      addons/crontab/library/SimpleFork/Queue/Pipe.php
  72. 104 0
      addons/crontab/library/SimpleFork/Queue/PipeQueue.php
  73. 34 0
      addons/crontab/library/SimpleFork/Queue/QueueInterface.php
  74. 144 0
      addons/crontab/library/SimpleFork/Queue/RedisQueue.php
  75. 293 0
      addons/crontab/library/SimpleFork/Queue/SystemVMessageQueue.php
  76. 20 0
      addons/crontab/library/SimpleFork/Runnable.php
  77. 26 0
      addons/crontab/library/SimpleFork/SinglePool.php
  78. 48 0
      addons/crontab/library/SimpleFork/Utils.php
  79. 19 0
      addons/crontab/licenses/cron/LICENSE
  80. 21 0
      addons/crontab/licenses/simplefork/LICENSE
  81. 54 0
      addons/crontab/model/Crontab.php
  82. 1 0
      addons/cropper/.addonrc
  83. 40 0
      addons/cropper/Cropper.php
  84. 306 0
      addons/cropper/assets/css/cropper.css
  85. 263 0
      addons/cropper/assets/css/main.css
  86. 21 0
      addons/cropper/assets/js/cropper-license.txt
  87. 3763 0
      addons/cropper/assets/js/cropper.js
  88. 86 0
      addons/cropper/bootstrap.js
  89. 296 0
      addons/cropper/config.php
  90. 95 0
      addons/cropper/controller/Index.php
  91. 10 0
      addons/cropper/info.ini
  92. 630 0
      addons/cropper/view/index/cropper.html
  93. 169 0
      addons/cropper/view/index/index.html
  94. 1 0
      addons/epay/.addonrc
  95. 100 0
      addons/epay/Epay.php
  96. 0 0
      addons/epay/certs/alipayCertPublicKey.crt
  97. 0 0
      addons/epay/certs/alipayRootCert.crt
  98. 0 0
      addons/epay/certs/apiclient_cert.pem
  99. 0 0
      addons/epay/certs/apiclient_key.pem
  100. 0 0
      addons/epay/certs/appCertPublicKey.crt

+ 14 - 0
.bowerrc

@@ -0,0 +1,14 @@
+{
+  "directory": "public/assets/libs",
+  "ignoredDependencies": [
+    "es6-promise",
+    "file-saver",
+    "html2canvas",
+    "jspdf",
+    "jspdf-autotable",
+    "pdfmake"
+  ],
+  "scripts":{
+    "postinstall": "node bower-cleanup.js"
+  }
+}

+ 11 - 0
.env.sample

@@ -0,0 +1,11 @@
+[app]
+debug = false
+trace = false
+
+[database]
+hostname = 127.0.0.1
+database = fastadmin
+username = root
+password = root
+hostport = 3306
+prefix = fa_

+ 16 - 0
.gitignore

@@ -0,0 +1,16 @@
+/nbproject/
+/thinkphp/
+/vendor/
+/runtime/*
+/public/uploads/*
+.DS_Store
+.idea
+composer.lock
+*.log
+*.css.map
+!.gitkeep
+.env
+.svn
+.vscode
+node_modules
+.user.ini

+ 4 - 0
.npmrc

@@ -0,0 +1,4 @@
+# 使用自定义镜像源
+registry=http://mirrors.tencent.com/npm/
+#关闭SSL验证
+strict-ssl=false

+ 139 - 0
Gruntfile.js

@@ -0,0 +1,139 @@
+module.exports = function (grunt) {
+
+    grunt.initConfig({
+        pkg: grunt.file.readJSON('package.json'),
+        copy: {
+            main: {
+                files: []
+            }
+        }
+    });
+
+    var build = function (module, type, callback) {
+        var config = {
+            compile: {
+                options: type === 'js' ? {
+                    optimizeCss: "standard",
+                    optimize: "uglify",   //可使用uglify|closure|none
+                    preserveLicenseComments: true,
+                    removeCombined: false,
+                    baseUrl: "./public/assets/js/",    //JS文件所在的基础目录
+                    name: "require-" + module, //来源文件,不包含后缀
+                    out: "./public/assets/js/require-" + module + ".min.js"  //目标文件
+                } : {
+                    optimizeCss: "default",
+                    optimize: "uglify",   //可使用uglify|closure|none
+                    cssIn: "./public/assets/css/" + module + ".css",    //CSS文件所在的基础目录
+                    out: "./public/assets/css/" + module + ".min.css"  //目标文件
+                }
+            }
+        };
+
+
+        var content = grunt.file.read("./public/assets/js/require-" + module + ".js"),
+            pattern = /^require\.config\(\{[\r\n]?[\n]?(.*?)[\r\n]?[\n]?}\);/is;
+
+        var matches = content.match(pattern);
+        if (matches) {
+            if (type === 'js') {
+                var data = matches[1].replaceAll(/(urlArgs|baseUrl):(.*)\n/gi, '');
+                const parse = require('parse-config-file'), fs = require('fs');
+                require('jsonminify');
+
+                data = JSON.minify("{\n" + data + "\n}");
+                let options = parse(data);
+                options.paths.tableexport = "empty:";
+                Object.assign(config.compile.options, options);
+            }
+            let requirejs = require("./application/admin/command/Min/r");
+
+            try {
+                requirejs.optimize(config.compile.options, function (buildResponse) {
+                    // var contents = require('fs').readFileSync(config.compile.options.out, 'utf8');
+                    callback();
+                }, function (err) {
+                    console.error(err);
+                    callback();
+                });
+            } catch (err) {
+                console.error(err);
+                callback();
+            }
+        }
+    };
+
+    // 加载 "copy" 插件
+    grunt.loadNpmTasks('grunt-contrib-copy');
+
+    grunt.registerTask('frontend:js', 'build frontend js', function () {
+        var done = this.async();
+        build('frontend', 'js', done);
+    });
+
+    grunt.registerTask('backend:js', 'build backend js', function () {
+        var done = this.async();
+        build('backend', 'js', done);
+    });
+
+    grunt.registerTask('frontend:css', 'build frontend css', function () {
+        var done = this.async();
+        build('frontend', 'css', done);
+    });
+
+    grunt.registerTask('backend:css', 'build frontend css', function () {
+        var done = this.async();
+        build('backend', 'css', done);
+    });
+
+    // 注册部署JS和CSS任务
+    grunt.registerTask('deploy', 'deploy', function () {
+        const fs = require('fs');
+        const path = require("path")
+        const nodeModulesDir = path.resolve(__dirname, "./node_modules");
+
+        const getAllFiles = function (dirPath, arrayOfFiles) {
+            files = fs.readdirSync(dirPath)
+
+            arrayOfFiles = arrayOfFiles || []
+
+            files.forEach(function (file) {
+                if (fs.statSync(dirPath + "/" + file).isDirectory()) {
+                    arrayOfFiles = getAllFiles(dirPath + "/" + file, arrayOfFiles)
+                } else {
+                    arrayOfFiles.push(path.join(__dirname, dirPath, "/", file))
+                }
+            });
+
+            return arrayOfFiles
+        };
+        const mainPackage = grunt.config.get('pkg');
+        let dists = mainPackage.dists || [];
+        let files = [];
+
+        // 兼容旧版本bower使用的目录
+        let specialKey = {
+            'fastadmin-bootstraptable': 'bootstrap-table',
+            'sortablejs': 'Sortable',
+            'tableexport.jquery.plugin': 'tableExport.jquery.plugin',
+        };
+        Object.keys(dists).forEach(key => {
+            let src = ["**/*LICENSE*", "**/*license*"];
+            src = src.concat(Array.isArray(dists[key]) ? dists[key] : [dists[key]]);
+            files.push({expand: true, cwd: nodeModulesDir + "/" + key, src: src, dest: 'public/assets/libs/' + (specialKey[key] || key) + "/"});
+        });
+
+        // 兼容bower历史路径文件
+        files = [...files,
+            {src: nodeModulesDir + "/toastr/build/toastr.min.css", dest: "public/assets/libs/toastr/toastr.min.css"},
+            {src: nodeModulesDir + "/bootstrap-slider/dist/css/bootstrap-slider.css", dest: "public/assets/libs/bootstrap-slider/slider.css"},
+            {expand: true, cwd: nodeModulesDir + "/bootstrap-slider/dist", src: ["*.js"], dest: "public/assets/libs/bootstrap-slider/"}
+        ]
+
+        grunt.config.set('copy.main.files', files);
+        grunt.task.run("copy:main");
+    });
+
+    // 注册默认任务
+    grunt.registerTask('default', ['deploy', 'frontend:js', 'backend:js', 'frontend:css', 'backend:css']);
+
+};

+ 191 - 0
LICENSE

@@ -0,0 +1,191 @@
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+"License" shall mean the terms and conditions for use, reproduction, and
+distribution as defined by Sections 1 through 9 of this document.
+
+"Licensor" shall mean the copyright owner or entity authorized by the copyright
+owner that is granting the License.
+
+"Legal Entity" shall mean the union of the acting entity and all other entities
+that control, are controlled by, or are under common control with that entity.
+For the purposes of this definition, "control" means (i) the power, direct or
+indirect, to cause the direction or management of such entity, whether by
+contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the
+outstanding shares, or (iii) beneficial ownership of such entity.
+
+"You" (or "Your") shall mean an individual or Legal Entity exercising
+permissions granted by this License.
+
+"Source" form shall mean the preferred form for making modifications, including
+but not limited to software source code, documentation source, and configuration
+files.
+
+"Object" form shall mean any form resulting from mechanical transformation or
+translation of a Source form, including but not limited to compiled object code,
+generated documentation, and conversions to other media types.
+
+"Work" shall mean the work of authorship, whether in Source or Object form, made
+available under the License, as indicated by a copyright notice that is included
+in or attached to the work (an example is provided in the Appendix below).
+
+"Derivative Works" shall mean any work, whether in Source or Object form, that
+is based on (or derived from) the Work and for which the editorial revisions,
+annotations, elaborations, or other modifications represent, as a whole, an
+original work of authorship. For the purposes of this License, Derivative Works
+shall not include works that remain separable from, or merely link (or bind by
+name) to the interfaces of, the Work and Derivative Works thereof.
+
+"Contribution" shall mean any work of authorship, including the original version
+of the Work and any modifications or additions to that Work or Derivative Works
+thereof, that is intentionally submitted to Licensor for inclusion in the Work
+by the copyright owner or by an individual or Legal Entity authorized to submit
+on behalf of the copyright owner. For the purposes of this definition,
+"submitted" means any form of electronic, verbal, or written communication sent
+to the Licensor or its representatives, including but not limited to
+communication on electronic mailing lists, source code control systems, and
+issue tracking systems that are managed by, or on behalf of, the Licensor for
+the purpose of discussing and improving the Work, but excluding communication
+that is conspicuously marked or otherwise designated in writing by the copyright
+owner as "Not a Contribution."
+
+"Contributor" shall mean Licensor and any individual or Legal Entity on behalf
+of whom a Contribution has been received by Licensor and subsequently
+incorporated within the Work.
+
+2. Grant of Copyright License.
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable copyright license to reproduce, prepare Derivative Works of,
+publicly display, publicly perform, sublicense, and distribute the Work and such
+Derivative Works in Source or Object form.
+
+3. Grant of Patent License.
+
+Subject to the terms and conditions of this License, each Contributor hereby
+grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free,
+irrevocable (except as stated in this section) patent license to make, have
+made, use, offer to sell, sell, import, and otherwise transfer the Work, where
+such license applies only to those patent claims licensable by such Contributor
+that are necessarily infringed by their Contribution(s) alone or by combination
+of their Contribution(s) with the Work to which such Contribution(s) was
+submitted. If You institute patent litigation against any entity (including a
+cross-claim or counterclaim in a lawsuit) alleging that the Work or a
+Contribution incorporated within the Work constitutes direct or contributory
+patent infringement, then any patent licenses granted to You under this License
+for that Work shall terminate as of the date such litigation is filed.
+
+4. Redistribution.
+
+You may reproduce and distribute copies of the Work or Derivative Works thereof
+in any medium, with or without modifications, and in Source or Object form,
+provided that You meet the following conditions:
+
+You must give any other recipients of the Work or Derivative Works a copy of
+this License; and
+You must cause any modified files to carry prominent notices stating that You
+changed the files; and
+You must retain, in the Source form of any Derivative Works that You distribute,
+all copyright, patent, trademark, and attribution notices from the Source form
+of the Work, excluding those notices that do not pertain to any part of the
+Derivative Works; and
+If the Work includes a "NOTICE" text file as part of its distribution, then any
+Derivative Works that You distribute must include a readable copy of the
+attribution notices contained within such NOTICE file, excluding those notices
+that do not pertain to any part of the Derivative Works, in at least one of the
+following places: within a NOTICE text file distributed as part of the
+Derivative Works; within the Source form or documentation, if provided along
+with the Derivative Works; or, within a display generated by the Derivative
+Works, if and wherever such third-party notices normally appear. The contents of
+the NOTICE file are for informational purposes only and do not modify the
+License. You may add Your own attribution notices within Derivative Works that
+You distribute, alongside or as an addendum to the NOTICE text from the Work,
+provided that such additional attribution notices cannot be construed as
+modifying the License.
+You may add Your own copyright statement to Your modifications and may provide
+additional or different license terms and conditions for use, reproduction, or
+distribution of Your modifications, or for any such Derivative Works as a whole,
+provided Your use, reproduction, and distribution of the Work otherwise complies
+with the conditions stated in this License.
+
+5. Submission of Contributions.
+
+Unless You explicitly state otherwise, any Contribution intentionally submitted
+for inclusion in the Work by You to the Licensor shall be under the terms and
+conditions of this License, without any additional terms or conditions.
+Notwithstanding the above, nothing herein shall supersede or modify the terms of
+any separate license agreement you may have executed with Licensor regarding
+such Contributions.
+
+6. Trademarks.
+
+This License does not grant permission to use the trade names, trademarks,
+service marks, or product names of the Licensor, except as required for
+reasonable and customary use in describing the origin of the Work and
+reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty.
+
+Unless required by applicable law or agreed to in writing, Licensor provides the
+Work (and each Contributor provides its Contributions) on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied,
+including, without limitation, any warranties or conditions of TITLE,
+NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are
+solely responsible for determining the appropriateness of using or
+redistributing the Work and assume any risks associated with Your exercise of
+permissions under this License.
+
+8. Limitation of Liability.
+
+In no event and under no legal theory, whether in tort (including negligence),
+contract, or otherwise, unless required by applicable law (such as deliberate
+and grossly negligent acts) or agreed to in writing, shall any Contributor be
+liable to You for damages, including any direct, indirect, special, incidental,
+or consequential damages of any character arising as a result of this License or
+out of the use or inability to use the Work (including but not limited to
+damages for loss of goodwill, work stoppage, computer failure or malfunction, or
+any and all other commercial damages or losses), even if such Contributor has
+been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability.
+
+While redistributing the Work or Derivative Works thereof, You may choose to
+offer, and charge a fee for, acceptance of support, warranty, indemnity, or
+other liability obligations and/or rights consistent with this License. However,
+in accepting such obligations, You may act only on Your own behalf and on Your
+sole responsibility, not on behalf of any other Contributor, and only if You
+agree to indemnify, defend, and hold each Contributor harmless for any liability
+incurred by, or claims asserted against, such Contributor by reason of your
+accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work
+
+To apply the Apache License to your work, attach the following boilerplate
+notice, with the fields enclosed by brackets "{}" replaced with your own
+identifying information. (Don't include the brackets!) The text should be
+enclosed in the appropriate comment syntax for the file format. We also
+recommend that a file or class name and description of purpose be included on
+the same "printed page" as the copyright notice for easier identification within
+third-party archives.
+
+   Copyright 2017 Karson
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+     http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.

+ 93 - 0
README.md

@@ -0,0 +1,93 @@
+FastAdmin是一款基于ThinkPHP+Bootstrap的极速后台开发框架。
+
+
+## 主要特性
+
+* 基于`Auth`验证的权限管理系统
+    * 支持无限级父子级权限继承,父级的管理员可任意增删改子级管理员及权限设置
+    * 支持单管理员多角色
+    * 支持管理子级数据或个人数据
+* 强大的一键生成功能
+    * 一键生成CRUD,包括控制器、模型、视图、JS、语言包、菜单、回收站等
+    * 一键压缩打包JS和CSS文件,一键CDN静态资源部署
+    * 一键生成控制器菜单和规则
+    * 一键生成API接口文档
+* 完善的前端功能组件开发
+    * 基于`AdminLTE`二次开发
+    * 基于`Bootstrap`开发,自适应手机、平板、PC
+    * 基于`RequireJS`进行JS模块管理,按需加载
+    * 基于`Less`进行样式开发
+* 强大的插件扩展功能,在线安装卸载升级插件
+* 通用的会员模块和API模块
+* 共用同一账号体系的Web端会员中心权限验证和API接口会员权限验证
+* 二级域名部署支持,同时域名支持绑定到应用插件
+* 多语言支持,服务端及客户端支持
+* 支持大文件分片上传、剪切板粘贴上传、拖拽上传,进度条显示,图片上传前压缩
+* 支持表格固定列、固定表头、跨页选择、Excel导出、模板渲染等功能
+* 强大的第三方应用模块支持([CMS](https://www.fastadmin.net/store/cms.html)、[CRM](https://www.fastadmin.net/store/facrm.html)、[企业网站管理系统](https://www.fastadmin.net/store/ldcms.html)、[知识库文档系统](https://www.fastadmin.net/store/knowbase.html)、[在线投票系统](https://www.fastadmin.net/store/vote.html)、[B2C商城](https://www.fastadmin.net/store/shopro.html)、[B2B2C商城](https://www.fastadmin.net/store/wanlshop.html))
+* 整合第三方短信接口(阿里云、腾讯云短信)
+* 无缝整合第三方云存储(七牛云、阿里云OSS、腾讯云存储、又拍云)功能,支持云储存分片上传
+* 第三方富文本编辑器支持(Summernote、百度编辑器)
+* 第三方登录(QQ、微信、微博)整合
+* 第三方支付(微信、支付宝)无缝整合,微信支持PC端扫码支付
+* 丰富的插件应用市场
+
+## 安装使用
+
+https://doc.fastadmin.net
+
+## 在线演示
+
+https://demo.fastadmin.net
+
+用户名:admin
+
+密 码:123456
+
+提 示:演示站数据无法进行修改,请下载源码安装体验全部功能
+
+## 界面截图
+![控制台](https://images.gitee.com/uploads/images/2020/0929/202947_8db2d281_10933.gif "控制台")
+
+## 问题反馈
+
+在使用中有任何问题,请使用以下联系方式联系我们
+
+问答社区: https://ask.fastadmin.net
+
+Github: https://github.com/fastadminnet/fastadmin
+
+Gitee: https://gitee.com/fastadminnet/fastadmin
+
+## 特别鸣谢
+
+感谢以下的项目,排名不分先后
+
+ThinkPHP:http://www.thinkphp.cn
+
+AdminLTE:https://adminlte.io
+
+Bootstrap:http://getbootstrap.com
+
+jQuery:http://jquery.com
+
+Bootstrap-table:https://github.com/wenzhixin/bootstrap-table
+
+Nice-validator: https://validator.niceue.com
+
+SelectPage: https://github.com/TerryZ/SelectPage
+
+Layer: https://layuion.com/layer/
+
+DropzoneJS: https://www.dropzonejs.com
+
+
+## 版权信息
+
+FastAdmin遵循Apache2开源协议发布,并提供免费使用。
+
+本项目包含的第三方源码和二进制文件之版权信息另行标注。
+
+版权所有Copyright © 2017-2024 by FastAdmin (https://www.fastadmin.net)
+
+All rights reserved。

+ 1 - 0
addons/.gitkeep

@@ -0,0 +1 @@
+

+ 1 - 0
addons/.htaccess

@@ -0,0 +1 @@
+deny from all

+ 1 - 0
addons/adminlogin/.addonrc

@@ -0,0 +1 @@
+{"files":["application\/admin\/controller\/Adminlogin.php","application\/admin\/lang\/zh-cn\/adminlogin.php","application\/admin\/view\/adminlogin\/login3.html","application\/admin\/view\/adminlogin\/login2.html","application\/admin\/view\/adminlogin\/login5.html","application\/admin\/view\/adminlogin\/login.html","public\/assets\/js\/backend\/adminlogin.js","public\/assets\/addons\/adminlogin\/login3\/leftBottomImg.png","public\/assets\/addons\/adminlogin\/login3\/check_icon.png","public\/assets\/addons\/adminlogin\/login3\/rightTopImg.png","public\/assets\/addons\/adminlogin\/login3\/success_icon.png","public\/assets\/addons\/adminlogin\/login3\/warn_icon.png","public\/assets\/addons\/adminlogin\/login3\/mainImg.png","public\/assets\/addons\/adminlogin\/login2\/square-left.png","public\/assets\/addons\/adminlogin\/login2\/202111221526557.png","public\/assets\/addons\/adminlogin\/login2\/square-bottom.png","public\/assets\/addons\/adminlogin\/login2\/square-top.png","public\/assets\/addons\/adminlogin\/login2\/square-right.png","public\/assets\/addons\/adminlogin\/login5\/square-left.png","public\/assets\/addons\/adminlogin\/login5\/square-bottom.png","public\/assets\/addons\/adminlogin\/login5\/square-top.png","public\/assets\/addons\/adminlogin\/login5\/bgg.jpg","public\/assets\/addons\/adminlogin\/login5\/square-right.png"],"license":"extended","licenseto":"45835","licensekey":"nHhu5WiLC0J74yAx ksHQ7e5LD+dGjYyUYqGDSthbS8b3gLGBidt6hsMIH2w=","domains":["thinkphp_fastadmin_tailored.test"],"licensecodes":[],"validations":["849901681e419b30a3ed3e4629b8f4ac"]}

+ 147 - 0
addons/adminlogin/Adminlogin.php

@@ -0,0 +1,147 @@
+<?php
+
+namespace addons\adminlogin;
+
+use addons\adminlogin\library\Service;
+use app\admin\library\Auth;
+use think\Addons;
+use think\Config;
+use think\Hook;
+use think\Loader;
+use think\Request;
+use think\Validate;
+use think\View;
+
+/**
+ * 插件
+ */
+class Adminlogin extends Addons
+{
+
+    public function adminNologin()
+    {
+        $config = $this->getConfig();
+        if ($config['is_origin'])  return true;
+
+        Service::error();
+    }
+
+    static  $isViewFilter = false;
+
+    public function moduleInit() {
+        $config = $this->getConfig();
+
+        // 如果使用原登录路径
+        if ($config['is_origin']) {
+            // 放到第一个执行,只执行一次
+            Hook::add('view_filter', function (&$content) {
+                if (self::$isViewFilter) return true;
+                self::$isViewFilter = true;
+
+                $auth = new Auth();
+                if ($auth->isLogin()) return true;
+
+                $config = $this->getConfig();
+                $request = Request::instance();
+                $module = strtolower($request->module());
+                $controller = strtolower($request->controller());
+                $action = strtolower($request->action());
+
+                if ($module == 'admin' && $controller == 'index' && $action == 'login') {
+                    $templateTypeList = [];
+                    $type = $config['type'];
+
+
+                    if (true == $config['dev']) {
+                        $type = input('type') ? input('type') : $type;
+
+                        $templateTypeList = get_addon_fullconfig('adminlogin')[0]['content'];
+                        $i = 1;
+                        foreach ($templateTypeList as &$item) {
+                            $item = "{$i}、{$item}";
+                            $i++;
+                        }
+                    }
+
+                    $type = $type > 1 ? $type : '';
+                    $path = APP_PATH. 'admin/view/adminlogin/login'.$type.'.html';
+                    $temp = file_get_contents($path);
+
+                    $background = Config::get('fastadmin.login_background');
+                    $background = $background ? (stripos($background, 'http') === 0 ? $background : config('site.cdnurl') . $background) : '';
+
+                    $data = [
+                        'templateTypeList' => $templateTypeList,
+                        'hasCaptcha' => 1,
+                        'background' => $background
+                    ];
+                    $content =  (View::instance())->display($temp, $data);
+                }
+
+                return false;
+            }, true);
+
+
+            return true;
+        }
+
+
+        // 判断是否关闭fast自带登录
+        $addonConfig = get_addon_config('adminlogin');
+        $is = $addonConfig['close_fast'];
+        if (!$is) {
+            return true;
+        }
+        if (request()->module() != 'admin') {
+            return true;
+        }
+
+        $controllername = Loader::parseName(request()->controller());
+        $actionname = strtolower(request()->action());
+        $path = str_replace('.', '/', $controllername) . '/' . $actionname;
+        if ($path == 'index/login') {
+            Service::error();
+        }
+    }
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        
+        return true;
+    }
+
+}

+ 78 - 0
addons/adminlogin/config.php

@@ -0,0 +1,78 @@
+<?php
+
+return [
+    [
+        'name' => 'type',
+        'title' => '登录主题',
+        'type' => 'radio',
+        'content' => [
+            -1 => '默认主题',
+            3 => '数据中心主题',
+            2 => '酷炫科技主题',
+            5 => '商务主题',
+        ],
+        'value' => '3',
+        'rule' => '',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'is_origin',
+        'title' => 'URL保持不变',
+        'type' => 'radio',
+        'content' => [
+            1 => '开启',
+            0 => '关闭',
+        ],
+        'value' => '1',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '开启后最后两个配置将失效',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'dev',
+        'title' => '调试模式',
+        'type' => 'radio',
+        'content' => [
+            1 => '开启',
+            0 => '不开启',
+        ],
+        'value' => '0',
+        'rule' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+        'visible' => '',
+    ],
+    [
+        'name' => 'num',
+        'title' => '免验证码输入次数',
+        'type' => 'number',
+        'content' => [],
+        'value' => '3',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'close_fast',
+        'title' => '关闭自带登录',
+        'type' => 'radio',
+        'content' => [
+            1 => '关闭',
+            0 => '不关闭',
+        ],
+        'value' => '1',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

+ 6 - 0
addons/adminlogin/config/cache.php

@@ -0,0 +1,6 @@
+<?php 
+ return array (
+  'table_name' => '',
+  'self_path' => '',
+  'update_data' => '',
+);

+ 7 - 0
addons/adminlogin/config/menu.php

@@ -0,0 +1,7 @@
+<?php
+/**
+ * 菜单配置文件
+ */
+
+return array (
+);

+ 15 - 0
addons/adminlogin/controller/Index.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace addons\adminlogin\controller;
+
+use think\addons\Controller;
+
+class Index extends Controller
+{
+
+    public function index()
+    {
+        $this->error("当前插件暂无前台页面");
+    }
+
+}

+ 10 - 0
addons/adminlogin/info.ini

@@ -0,0 +1,10 @@
+name = adminlogin
+title = 后台登录页面主题
+intro = 后台登录页面样式切换,输验证码流程简化
+author = 风雨
+website = www.sdkj.cc
+version = 1.1.0
+state = 1
+url = /addons/adminlogin
+license = extended
+licenseto = 45835

+ 0 - 0
addons/adminlogin/install.sql


+ 84 - 0
addons/adminlogin/library/Service.php

@@ -0,0 +1,84 @@
+<?php
+
+namespace addons\adminlogin\library;
+
+
+use think\exception\HttpResponseException;
+use think\Request;
+use think\Response;
+use think\response\Redirect;
+use think\Session;
+
+class Service
+{
+
+    public static function getUrl()
+    {
+        $url = \request()->get('url', 'index/index', 'url_clean');
+        // 返回地址不能是 login/logout
+        $arr = [
+            'index/login',
+            'index/logout',
+            'adminlogin/index'
+        ];
+        foreach ($arr as $item) {
+            if ($url == url($item)) {
+                $url = 'index/index';
+                break;
+            }
+        }
+
+        return $url;
+    }
+
+    /**
+     * URL 重定向
+     * @access protected
+     * @param string    $url    跳转的 URL 表达式
+     * @param array|int $params 其它 URL 参数
+     * @param int       $code   http code
+     * @param array     $with   隐式传参
+     * @return void
+     * @throws HttpResponseException
+     */
+    public static function redirect($url='', $params = [], $code = 302, $with = [])
+    {
+        $url = 'adminlogin/index';
+        if (empty($params)) {
+            $goUrl = Session::get('referer');
+            $goUrl = $goUrl ? $goUrl : request()->url();
+            $params['url'] = $goUrl;
+        }
+
+        if (is_integer($params)) {
+            $code   = $params;
+            $params = [];
+        }
+
+        $response = new Redirect($url);
+        $response->code($code)->params($params)->with($with);
+
+        throw new HttpResponseException($response);
+        die;
+    }
+
+
+    /**
+     * 返回错误消息
+     */
+    public static function error()
+    {
+        if (request()->isAjax()) {
+            $data = [
+                'code' => 0,
+                'msg' => __('Please login first'),
+                'data' => [],
+                'time' => time(),
+            ];
+            $response = \think\Response::create($data, 'json', 200);
+            throw new HttpResponseException($response);
+        } else {
+            static::redirect();
+        }
+    }
+}

+ 1 - 0
addons/apilog/.addonrc

@@ -0,0 +1 @@
+{"files":["application\/admin\/controller\/apilog\/Index.php","application\/admin\/controller\/apilog\/Trend.php","application\/admin\/controller\/apilog\/Data.php","application\/admin\/lang\/zh-cn\/apilog\/index.php","application\/admin\/view\/apilog\/trend\/index.html","application\/admin\/view\/apilog\/index\/index.html","application\/admin\/view\/apilog\/index\/detail.html","application\/admin\/view\/apilog\/data\/index.html","public\/assets\/js\/backend\/apilog\/trend.js","public\/assets\/js\/backend\/apilog\/index.js","public\/assets\/js\/backend\/apilog\/data.js"],"license":"extended","licenseto":"9671","licensekey":"1JBetCMAlyFaSn4P DDsnq1KtEhBVISY1zbEr3w==","domains":["thinkphp_fastadmin_tailored.test"],"licensecodes":[],"validations":["849901681e419b30a3ed3e4629b8f4ac"],"menus":["apilog","apilog\/data","apilog\/data\/index","apilog\/trend","apilog\/trend\/index","apilog\/index","apilog\/index\/index","apilog\/index\/del","apilog\/index\/detail","apilog\/index\/banip","apilog\/index\/clear"]}

+ 272 - 0
addons/apilog/Apilog.php

@@ -0,0 +1,272 @@
+<?php
+
+namespace addons\apilog;
+
+use app\common\library\Menu;
+use think\Addons;
+use think\addons\Service;
+use think\Request;
+use app\common\library\Auth;
+use addons\apilog\model\Apilog as ModelApilog;
+use think\Cache;
+use app\common\library\Email;
+
+/**
+ * 插件
+ */
+class Apilog extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        $menu = [
+            [
+                'name'    => 'apilog',
+                'title'   => 'API访问监测分析',
+                'icon'    => 'fa fa-pie-chart',
+                'ismenu'  => 1,
+                'sublist' => [
+                    [
+                        "name"   => "apilog/data",
+                        "title"  => "基础数据",
+                        "ismenu" => 1,
+                        "icon"   => "fa fa-dashboard",
+                        'sublist' => [
+                            ['name' => 'apilog/data/index', 'title' => '查看'],
+                        ]
+                    ],
+                    [
+                        "name"   => "apilog/trend",
+                        "title"  => "趋势数据",
+                        "ismenu" => 1,
+                        "icon"   => "fa fa-area-chart",
+                        'sublist' => [
+                            ['name' => 'apilog/trend/index', 'title' => '查看'],
+                        ]
+                    ],
+                    [
+                        "name"   => "apilog/index",
+                        "title"  => "请求列表",
+                        "ismenu" => 1,
+                        "icon"   => "fa fa-list",
+                        'sublist' => [
+                            ['name' => 'apilog/index/index', 'title' => '查看'],
+                            ['name' => 'apilog/index/del', 'title' => '删除'],
+                            ['name' => 'apilog/index/detail', 'title' => '详情'],
+                            ['name' => 'apilog/index/banip', 'title' => '禁用IP'],
+                            ['name' => 'apilog/index/clear', 'title' => '清空数据'],
+                        ]
+                    ],
+                ]
+            ]
+        ];
+        Menu::create($menu);
+        Service::refresh();
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        Menu::delete("apilog");
+        Service::refresh();
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        Menu::enable('apilog');
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        Menu::disable("apilog");
+        return true;
+    }
+
+    public function responseSend(&$params)
+    {
+        try {
+            if (Request::instance()->module() == "api") {
+                $log['time'] = (microtime(true) - Request::instance()->time(true)) * 1000;
+                $auth = Auth::instance();
+                $user_id = $auth->isLogin() ? $auth->id : 0;
+                $username = $auth->isLogin() ? $auth->username : __('Unknown');
+                $log['url'] = substr(Request::instance()->baseUrl(), 0, 200);
+                $log['method'] = Request::instance()->method();
+                $log['param'] = json_encode(Request::instance()->param());
+                $log['ip'] = Request::instance()->ip();
+                $log['ua'] = substr(Request::instance()->header('user-agent'), 0, 200);
+                $log['controller'] = Request::instance()->controller();
+                $log['action'] = Request::instance()->action();
+                $log['code'] = $params->getCode();
+                $log['user_id'] = $user_id;
+                $log['username'] = $username;
+                $log['response'] = $params->getContent();
+                (new ModelApilog)->save($log);
+                $config = get_addon_config('apilog');
+
+                //状态码记录
+                if ($config['error']['open'] == 1) {
+                    $count_code = Cache::get('countcode', null);
+                    if (is_null($count_code)) {
+                        Cache::set('countcode', 0, $config['error']['pl']);
+                        $tagkey = Cache::get('tag_' . md5('code'));
+                        $keys = $tagkey  ?  array_filter(explode(',', $tagkey)) : [];
+                        foreach ($keys as $k => $v) {
+                            Cache::rm($v);
+                        }
+                        Cache::rm($tagkey);
+                    }
+                    $count_code = Cache::inc('countcode');
+                    $k_code = 'code:' . $params->getCode();
+                    $yj_code = Cache::get($k_code, null);
+                    if (is_null($yj_code)) {
+                        Cache::set($k_code, 0, 0);
+                        Cache::tag('code', $k_code);
+                    }
+                    Cache::inc($k_code);
+                    $codes = array_filter(explode(',', $config['error']['sj']));
+                    $now = 0;
+                    foreach ($codes as $k => $v) {
+                        $now += Cache::get('code:' . $v, 0);
+                    }
+                    if ($now / $count_code  >= $config['error']['zb'] / 100) {
+                        // echo '触发错误预警' . $now / $count_code;
+                        $this->emailnotify($config['base']['email'], '请求错误监控', '当前api请求错误率已达到【' . round($now / $count_code * 100, 2) . '%】,请及时关注!');
+                    }
+                }
+                //超时记录数
+                if ($config['time']['open'] == 1) {
+                    $count_time = Cache::get('counttime', null);
+                    if (is_null($count_time)) {
+                        Cache::set('counttime', 0, $config['time']['pl']);
+                        Cache::rm('time');
+                    }
+                    $tot_time = Cache::inc('counttime');
+                    if ($log['time'] > $config['time']['sj']) {
+                        $yj_time = Cache::get('time', null);
+                        if (is_null($yj_time)) {
+                            Cache::set('time', 0, 0);
+                        }
+                        $now_time = Cache::inc('time');
+                        if ($now_time / $tot_time >= $config['time']['zb'] / 100) {
+                            // echo '触发超时预警' . $now_time / $tot_time;
+                            $this->emailnotify($config['base']['email'], '响应超时监控', '当前api响应超时请求占比已达到【' . round($now_time / $tot_time * 100, 2) . '%】,请及时关注!');
+                        }
+                    }
+                }
+            }
+        } catch (\Exception $e) {
+            //宁可不记录也不能影响api的正常访问
+        }
+    }
+
+    public function moduleInit(&$params)
+    {
+        try {
+            if (Request::instance()->module() == "api") {
+                $ip = 'banip:' . Request::instance()->ip();
+                $cacheIp = Cache::get($ip);
+                if ($cacheIp !== false) {
+                    $this->respone(500, '抱歉,您的IP已被禁止访问');
+                }
+                $config = get_addon_config('apilog');
+                //总请求数
+                if ($config['count']['open'] == 1) {
+                    $yj_count = Cache::get('count', null);
+                    if (is_null($yj_count)) {
+                        Cache::set('count', 0, $config['count']['pl']);
+                    }
+                    Cache::inc('count');
+                    //预警
+                    if ($yj_count + 1 >= $config['count']['max']) {
+                        Cache::rm('count');
+                        // $this->respone(500, '触发请求量预警');
+                        $this->emailnotify($config['base']['email'], '请求量监控', '当前最大请求数量已达到【' . ++$yj_count . '次】,请及时关注!');
+                    }
+                }
+                //IP访问请求数
+                if ($config['ip']['open'] == 1) {
+                    $count_ip = Cache::get('countip', null);
+                    if (is_null($count_ip)) {
+                        Cache::set('countip', 0, $config['ip']['pl']);
+                        $tagkey = Cache::get('tag_' . md5('ip'));
+                        $keys = $tagkey  ?  array_filter(explode(',', $tagkey)) : [];
+                        foreach ($keys as $k => $v) {
+                            Cache::rm($v);
+                        }
+                        Cache::rm($tagkey);
+                    }
+                    $count_ip = Cache::inc('countip');
+                    $k_ip = 'ip:' . Request::instance()->ip();
+                    $yj_ip = Cache::get($k_ip, null);
+                    if (is_null($yj_ip)) {
+                        Cache::set($k_ip, 0, 0);
+                        Cache::tag('ip', $k_ip);
+                    }
+                    $this_ip = Cache::inc($k_ip);
+                    //白名单
+                    $white = array_filter(explode(',', $config['ip']['white']));
+                    //预警
+                    if (!in_array(Request::instance()->ip(), $white) && $this_ip / $count_ip >= $config['ip']['zb'] / 100) {
+                        //$this->respone(500, '触发IP预警');
+                        $this->emailnotify($config['base']['email'], 'IP异常监控', 'IP【' . Request::instance()->ip()
+                            . '】的访问请求占比已达到【' . round($this_ip / $count_ip * 100, 2) . '%】,请及时关注!');
+                    }
+                }
+            }
+        } catch (\Exception $e) {
+            //宁可不记录也不能影响api的正常访问
+        }
+    }
+
+    protected function respone($code, $msg)
+    {
+        $result = [
+            'code' => $code,
+            'msg'  => $msg,
+            'time' => Request::instance()->server('REQUEST_TIME'),
+            'data' => null,
+        ];
+        $type = Request::instance()->param(config('var_jsonp_handler')) ? 'jsonp' : 'json';
+        $response = \think\Response::create($result, $type, 500);
+        throw new \think\exception\HttpResponseException($response);
+    }
+
+    /**
+     * 发送邮件预警
+     * 同类型预警半小时最多发一次
+     */
+    protected function emailnotify($receiver, $subject, $content)
+    {
+        $cache = Cache::get('notify:' . $subject, null);
+        if (is_null($cache)) {
+            $email = new Email;
+            $result = $email
+                ->to($receiver)
+                ->subject('【API预警】' . $subject)
+                ->message('<div style="min-height:550px; padding: 100px 55px 200px;">' . $content . '</div>')
+                ->send();
+            if ($result) {
+                Cache::set('notify:' . $subject, 1, 1800);
+            }
+        }
+    }
+}

+ 412 - 0
addons/apilog/config.html

@@ -0,0 +1,412 @@
+<style>
+    .row .input-group{
+    float: none;
+    padding-left: 15px;
+    padding-right: 15px;
+    margin-right: 15px;
+}
+</style>
+<div class="panel panel-default panel-intro">
+    <div class="panel-heading">
+        <div class="panel-lead"><em>API预警配置</em>可在此对API的运行情况进行监控并发送通知</div>
+        <ul class="nav nav-tabs">
+            <li class="active"><a href="#tab-base" data-toggle="tab">基础配置</a></li>
+            <li><a href="#tab-time" data-toggle="tab">响应超时监控</a></li>
+            <li><a href="#tab-error" data-toggle="tab">请求错误监控</a></li>
+            <li><a href="#tab-ip" data-toggle="tab">IP异常监控</a></li>
+            <li><a href="#tab-count" data-toggle="tab">请求量监控</a></li>
+        </ul>
+    </div>
+    <div class="panel-body">
+        <form id="config-form" class="edit-form form-horizontal" role="form" data-toggle="validator" method="POST"
+            action="">
+            <div id="myTabContent" class="tab-content">
+                <!--基础配置-->
+                <div class="tab-pane fade active in" id="tab-base">
+                    <div class="widget-body no-padding">
+                        <table class="table table-striped">
+                            <thead>
+                                <tr>
+                                    <th width="15%">配置项</th>
+                                    <th width="85%">配置值</th>
+                                </tr>
+                            </thead>
+                            <tbody>
+                                <tr>
+                                    <td>预警邮箱</td>
+                                    <td>
+                                        <div class="row">
+                                            <div class="col-sm-8 col-xs-12">
+                                                <input type="text" name="row[base][email]" placeholder="请输入接收预警的邮箱,多个使用英文逗号分隔"
+                                                    value='{$addon.config[0]["value"]["email"]}' class="form-control"
+                                                    data-tip="">
+                                            </div>
+                                            <div class="col-sm-4"></div>
+                                        </div>
+                                    </td>
+                                </tr>
+                               
+                               
+                            </tbody>
+                        </table>
+
+                    </div>
+                    <div class="alert alert-info-light" style="margin-bottom:10px;">
+                        <b>基础配置:</b><br>
+                        1、预警邮箱:接收预警监控通知的邮箱;<br>
+                        请务必先在 [常规管理-系统配置-邮件配置] 中配置并测试邮件发送是否正常<br>
+                        2、相同预警通知在30分钟内仅会发送一次,请及时关注API运行情况<br>
+                        3、建议使用Redis作为系统缓存
+                    </div>                   
+                </div>
+                <!--响应超时-->
+                <div class="tab-pane fade" id="tab-time">
+                    <div class="widget-body no-padding">
+                        <div class="widget-body no-padding">
+                            <table class="table table-striped">
+                                <thead>
+                                    <tr>
+                                        <th width="15%">配置项</th>
+                                        <th width="85%">配置值</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <tr>
+                                        <td>监控频率</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-12 col-xs-12">
+                                                    <select name="row[time][pl]" class="selectpicker">
+                                                        <option value ="60" {if $addon.config[1]["value"]["pl"]==60} selected {/if}>1分钟</option>
+                                                        <option value ="180" {if $addon.config[1]["value"]["pl"]==180} selected {/if}>3分钟</option>
+                                                        <option value ="300" {if $addon.config[1]["value"]["pl"]==300} selected {/if}>5分钟</option>
+                                                        <option value ="600" {if $addon.config[1]["value"]["pl"]==600} selected {/if}>10分钟</option>
+                                                        <option value ="1800" {if $addon.config[1]["value"]["pl"]==1800} selected {/if}>30分钟</option>
+                                                        <option value="3600" {if $addon.config[1]["value"]["pl"]==3600} selected {/if}>1小时</option>
+                                                        <option value="7200" {if $addon.config[1]["value"]["pl"]==7200} selected {/if}>2小时</option>
+                                                        <option value="14400" {if $addon.config[1]["value"]["pl"]==14400} selected {/if}>4小时</option>
+                                                        <option value="21600" {if $addon.config[1]["value"]["pl"]==21600} selected {/if}>6小时</option>
+                                                        <option value="28800" {if $addon.config[1]["value"]["pl"]==28800} selected {/if}>8小时</option>
+                                                        <option value="43200" {if $addon.config[1]["value"]["pl"]==43200} selected {/if}>12小时</option>
+                                                        <option value="86400" {if $addon.config[1]["value"]["pl"]==86400} selected {/if}>1天</option>
+                                                      </select>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>超时时间</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12 input-group">
+                                                    <input type="text" name="row[time][sj]"
+                                                        value='{$addon.config[1]["value"]["sj"]}'
+                                                        class="form-control" data-tip="" aria-describedby="time_t">
+                                                    <span class="input-group-addon" id="time_t">毫秒</span>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>百分比</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12 input-group">
+                                                    <input type="text" name="row[time][zb]"
+                                                        value='{$addon.config[1]["value"]["zb"]}'
+                                                        class="form-control" data-tip="" aria-describedby="time_zb">
+                                                        <span class="input-group-addon" id="time_zb">%</span>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>是否开启预警</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="radio" name="row[time][open]" value="0" {if
+                                                        $addon.config[1]["value"]["open"]==0} checked {/if}> 关闭
+                                                    <input type="radio" name="row[time][open]" value="1" {if
+                                                        $addon.config[1]["value"]["open"]==1} checked {/if}> 开启
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                </tbody>
+                            </table>
+
+                        </div>
+                        <div class="alert alert-info-light" style="margin-bottom:10px;">
+                            <b>响应超时监控:</b><br>主要针对一段时间内接口调用超时率过高<br>
+                            1、设定接口超时时间(毫秒)<br>
+                            2、设置接口超时百分比。支持两位小数,超时率达到或者大于设定值时会触发预警。
+                        </div>            
+                    </div>
+                </div>
+                <!--请求错误-->
+                <div class="tab-pane fade" id="tab-error">
+                    <div class="widget-body no-padding">
+                        <div class="widget-body no-padding">
+                            <table class="table table-striped">
+                                <thead>
+                                    <tr>
+                                        <th width="15%">配置项</th>
+                                        <th width="85%">配置值</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <tr>
+                                        <td>监控频率</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                        <select name="row[error][pl]" class="selectpicker">
+                                                            <option value ="60" {if $addon.config[2]["value"]["pl"]==60} selected {/if}>1分钟</option>
+                                                            <option value ="180" {if $addon.config[2]["value"]["pl"]==180} selected {/if}>3分钟</option>
+                                                            <option value ="300" {if $addon.config[2]["value"]["pl"]==300} selected {/if}>5分钟</option>
+                                                            <option value ="600" {if $addon.config[2]["value"]["pl"]==600} selected {/if}>10分钟</option>
+                                                            <option value ="1800" {if $addon.config[2]["value"]["pl"]==1800} selected {/if}>30分钟</option>
+                                                            <option value="3600" {if $addon.config[2]["value"]["pl"]==3600} selected {/if}>1小时</option>
+                                                            <option value="7200" {if $addon.config[2]["value"]["pl"]==7200} selected {/if}>2小时</option>
+                                                            <option value="14400" {if $addon.config[2]["value"]["pl"]==14400} selected {/if}>4小时</option>
+                                                            <option value="21600" {if $addon.config[2]["value"]["pl"]==21600} selected {/if}>6小时</option>
+                                                            <option value="28800" {if $addon.config[2]["value"]["pl"]==28800} selected {/if}>8小时</option>
+                                                            <option value="43200" {if $addon.config[2]["value"]["pl"]==43200} selected {/if}>12小时</option>
+                                                            <option value="86400" {if $addon.config[2]["value"]["pl"]==86400} selected {/if}>1天</option>
+                                                          </select>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>HTTP状态码</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="text" name="row[error][sj]"
+                                                        value='{$addon.config[2]["value"]["sj"]}'
+                                                        class="form-control" placeholder="请输入需要监控的状态码,多个使用英文逗号分隔" data-tip="多个状态码用英文逗号隔开">
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>百分比</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12 input-group">
+                                                    <input type="text" name="row[error][zb]"
+                                                        value='{$addon.config[2]["value"]["zb"]}'
+                                                        class="form-control" data-tip="" aria-describedby="erro_zb">
+                                                        <span class="input-group-addon" id="erro_zb">%</span>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>是否开启预警</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="radio" name="row[error][open]" value="0" {if
+                                                        $addon.config[2]["value"]["open"]==0} checked {/if}> 关闭
+                                                    <input type="radio" name="row[error][open]" value="1" {if
+                                                        $addon.config[2]["value"]["open"]==1} checked {/if}> 开启
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                </tbody>
+                            </table>
+
+                        </div>
+                        <div class="alert alert-info-light" style="margin-bottom:10px;">
+                            <b>请求错误监控:</b><br>主要针对一段时间内接口调用错误率过高<br>
+                            1、设定Http状态码,500,503,404,多个状态码用英文逗号隔开<br>
+                            2、设置命中率,支持 两位小数点,命中率达到或大于设定值时会触发预警。
+                        </div>            
+                    </div>
+                </div>
+                <!--IP异常-->
+                <div class="tab-pane fade" id="tab-ip">
+                    <div class="widget-body no-padding">
+                        <div class="widget-body no-padding">
+                            <table class="table table-striped">
+                                <thead>
+                                    <tr>
+                                        <th width="15%">配置项</th>
+                                        <th width="85%">配置值</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <tr>
+                                        <td>监控频率</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                        <select name="row[ip][pl]" class="selectpicker">
+                                                            <option value ="60" {if $addon.config[3]["value"]["pl"]==60} selected {/if}>1分钟</option>
+                                                            <option value ="180" {if $addon.config[3]["value"]["pl"]==180} selected {/if}>3分钟</option>
+                                                            <option value ="300" {if $addon.config[3]["value"]["pl"]==300} selected {/if}>5分钟</option>
+                                                            <option value ="600" {if $addon.config[3]["value"]["pl"]==600} selected {/if}>10分钟</option>
+                                                            <option value ="1800" {if $addon.config[3]["value"]["pl"]==1800} selected {/if}>30分钟</option>
+                                                            <option value="3600" {if $addon.config[3]["value"]["pl"]==3600} selected {/if}>1小时</option>
+                                                            <option value="7200" {if $addon.config[3]["value"]["pl"]==7200} selected {/if}>2小时</option>
+                                                            <option value="14400" {if $addon.config[3]["value"]["pl"]==14400} selected {/if}>4小时</option>
+                                                            <option value="21600" {if $addon.config[3]["value"]["pl"]==21600} selected {/if}>6小时</option>
+                                                            <option value="28800" {if $addon.config[3]["value"]["pl"]==28800} selected {/if}>8小时</option>
+                                                            <option value="43200" {if $addon.config[3]["value"]["pl"]==43200} selected {/if}>12小时</option>
+                                                            <option value="86400" {if $addon.config[3]["value"]["pl"]==86400} selected {/if}>1天</option>
+                                                          </select>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>IP白名单</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="text" name="row[ip][white]"
+                                                        value='{$addon.config[3]["value"]["white"]}' placeholder="请输入IP白名单"
+                                                        class="form-control" data-tip="多个IP地址中间用英文逗号分开">
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>百分比</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12 input-group">
+                                                    <input type="text" name="row[ip][zb]"
+                                                        value='{$addon.config[3]["value"]["zb"]}'
+                                                        class="form-control" data-tip="" aria-describedby="ip_zb">
+                                                        <span class="input-group-addon" id="ip_zb">%</span>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>是否开启预警</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="radio" name="row[ip][open]" value="0" {if
+                                                        $addon.config[3]["value"]["open"]==0} checked {/if}> 关闭
+                                                    <input type="radio" name="row[ip][open]" value="1" {if
+                                                        $addon.config[3]["value"]["open"]==1} checked {/if}> 开启
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                </tbody>
+                            </table>
+
+                        </div>
+                        <div class="alert alert-info-light" style="margin-bottom:10px;">
+                            <b>IP异常监控:</b><br>主要针对一段时间内大量固定IP请求,类似机器人请求;<br>
+                            1、设置IP白名单(可不填),多个IP地址中间用英文逗号分开<br>
+                            2、设置重复率,支持两位小数点,当IP重复率达到或大于设定值时,触发预警。
+                        </div>  
+                    </div>
+                </div>
+                <!--请求量-->
+                <div class="tab-pane fade" id="tab-count">
+                    <div class="widget-body no-padding">
+                        <div class="widget-body no-padding">
+                            <table class="table table-striped">
+                                <thead>
+                                    <tr>
+                                        <th width="15%">配置项</th>
+                                        <th width="85%">配置值</th>
+                                    </tr>
+                                </thead>
+                                <tbody>
+                                    <tr>
+                                        <td>监控频率</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                        <select name="row[count][pl]" class="selectpicker">
+                                                            <option value ="60" {if $addon.config[4]["value"]["pl"]==60} selected {/if}>1分钟</option>
+                                                            <option value ="180" {if $addon.config[4]["value"]["pl"]==180} selected {/if}>3分钟</option>
+                                                            <option value ="300" {if $addon.config[4]["value"]["pl"]==300} selected {/if}>5分钟</option>
+                                                            <option value ="600" {if $addon.config[4]["value"]["pl"]==600} selected {/if}>10分钟</option>
+                                                            <option value ="1800" {if $addon.config[4]["value"]["pl"]==1800} selected {/if}>30分钟</option>
+                                                            <option value="3600" {if $addon.config[4]["value"]["pl"]==3600} selected {/if}>1小时</option>
+                                                            <option value="7200" {if $addon.config[4]["value"]["pl"]==7200} selected {/if}>2小时</option>
+                                                            <option value="14400" {if $addon.config[4]["value"]["pl"]==14400} selected {/if}>4小时</option>
+                                                            <option value="21600" {if $addon.config[4]["value"]["pl"]==21600} selected {/if}>6小时</option>
+                                                            <option value="28800" {if $addon.config[4]["value"]["pl"]==28800} selected {/if}>8小时</option>
+                                                            <option value="43200" {if $addon.config[4]["value"]["pl"]==43200} selected {/if}>12小时</option>
+                                                            <option value="86400" {if $addon.config[4]["value"]["pl"]==86400} selected {/if}>1天</option>
+                                                          </select>
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    <tr>
+                                        <td>最大请求数量</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="text" name="row[count][max]"
+                                                        value='{$addon.config[4]["value"]["max"]}'
+                                                        class="form-control" data-tip="">
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                    
+                                    <tr>
+                                        <td>是否开启预警</td>
+                                        <td>
+                                            <div class="row">
+                                                <div class="col-sm-8 col-xs-12">
+                                                    <input type="radio" name="row[count][open]" value="0" {if
+                                                        $addon.config[4]["value"]["open"]==0} checked {/if}> 关闭
+                                                    <input type="radio" name="row[count][open]" value="1" {if
+                                                        $addon.config[4]["value"]["open"]==1} checked {/if}> 开启
+                                                </div>
+                                                <div class="col-sm-4"></div>
+                                            </div>
+                                        </td>
+                                    </tr>
+                                </tbody>
+                            </table>
+
+                        </div>
+                        <div class="alert alert-info-light" style="margin-bottom:10px;">
+                            请求量监控:</b><br>主要针对一段时间内接口大量请求<br>
+                            1、设置单位时间内接口最大请求量,当请求量达到或者大于设定时触发预警
+                        </div>  
+                    </div>
+                </div>
+                <!--footer-->
+                <div class="form-group layer-footer">
+                    <label class="control-label col-xs-12 col-sm-2"></label>
+                    <div class="col-xs-12 col-sm-8">
+                        <button type="submit" class="btn btn-success btn-embossed disabled">{:__('OK')}</button>
+                        <button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
+                    </div>
+                </div>
+            </div>
+        </form>
+    </div>
+</div>

+ 105 - 0
addons/apilog/config.php

@@ -0,0 +1,105 @@
+<?php
+
+return array (
+  0 => 
+  array (
+    'name' => 'base',
+    'title' => '基础配置',
+    'type' => 'array',
+    'content' => 
+    array (
+    ),
+    'value' => 
+    array (
+      'email' => '',
+    ),
+    'rule' => 'required',
+    'msg' => '',
+    'tip' => '',
+    'ok' => '',
+    'extend' => '',
+  ),
+  1 => 
+  array (
+    'name' => 'time',
+    'title' => '响应时间',
+    'type' => 'array',
+    'content' => 
+    array (
+    ),
+    'value' => 
+    array (
+      'pl' => '1800',
+      'sj' => '5000',
+      'zb' => '10',
+      'open' => '0',
+    ),
+    'rule' => 'required',
+    'msg' => '',
+    'tip' => '',
+    'ok' => '',
+    'extend' => '',
+  ),
+  2 => 
+  array (
+    'name' => 'error',
+    'title' => '错误',
+    'type' => 'array',
+    'content' => 
+    array (
+    ),
+    'value' => 
+    array (
+      'pl' => '1800',
+      'sj' => '500',
+      'zb' => '10',
+      'open' => '0',
+    ),
+    'rule' => 'required',
+    'msg' => '',
+    'tip' => '',
+    'ok' => '',
+    'extend' => '',
+  ),
+  3 => 
+  array (
+    'name' => 'ip',
+    'title' => 'ip',
+    'type' => 'array',
+    'content' => 
+    array (
+    ),
+    'value' => 
+    array (
+      'pl' => '1800',
+      'white' => '127.0.0.1',
+      'zb' => '20',
+      'open' => '0',
+    ),
+    'rule' => 'required',
+    'msg' => '',
+    'tip' => '',
+    'ok' => '',
+    'extend' => '',
+  ),
+  4 => 
+  array (
+    'name' => 'count',
+    'title' => '最大请求',
+    'type' => 'array',
+    'content' => 
+    array (
+    ),
+    'value' => 
+    array (
+      'pl' => '300',
+      'max' => '500',
+      'open' => '0',
+    ),
+    'rule' => 'required',
+    'msg' => '',
+    'tip' => '',
+    'ok' => '',
+    'extend' => '',
+  ),
+);

+ 24 - 0
addons/apilog/controller/Index.php

@@ -0,0 +1,24 @@
+<?php
+/*
+ * @Descripttion: 
+ * @version: 
+ * @Author: xiaoyu5062
+ * @QQ/Email: xiaoyu5062@qq.com
+ * @Date: 2020-07-25 10:01:48
+ * @LastEditors: xiaoyu5062
+ * @LastEditTime: 2025-02-19 16:23:40
+ */
+
+namespace addons\apilog\controller;
+
+use addons\apilog\model\Apilog;
+use think\addons\Controller;
+
+class Index extends Controller
+{
+
+    public function index()
+    {
+        return $this->view->fetch();
+    }
+}

+ 10 - 0
addons/apilog/info.ini

@@ -0,0 +1,10 @@
+name = apilog
+title = API访问监测分析
+intro = API访问监测分析,快速了解接口运行情况
+author = xiaoyu5062
+website = https://www.fastadmin.net
+version = 1.0.7
+state = 1
+url = /addons/apilog
+license = extended
+licenseto = 9671

+ 28 - 0
addons/apilog/install.sql

@@ -0,0 +1,28 @@
+CREATE TABLE IF NOT EXISTS `__PREFIX__apilog` (
+  `id` int(11) NOT NULL AUTO_INCREMENT,
+  `ip` varchar(255) DEFAULT NULL COMMENT 'IP',
+  `url` varchar(255) DEFAULT NULL COMMENT '请求地址',
+  `method` enum('GET','POST','PUT','DELETE') DEFAULT NULL COMMENT '请求方法',
+  `param` text COMMENT '参数',
+  `ua` text COMMENT 'UA',
+  `controller` varchar(255) DEFAULT NULL COMMENT '控制器',
+  `action` varchar(255) DEFAULT NULL COMMENT '操作',
+  `time` float(11,6) DEFAULT '0.000000' COMMENT '耗时',
+  `code` int(11) DEFAULT '200' COMMENT '状态码',
+  `createtime` int(11) DEFAULT NULL COMMENT '请求时间',
+  `user_id` int(11) DEFAULT NULL,
+  `username` varchar(255) DEFAULT NULL,
+  `response` text COMMENT '响应内容',
+  PRIMARY KEY (`id`),
+  KEY `createtime` (`createtime`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+--
+--1.0.1版本 增加响应内容记录
+--
+ALTER TABLE `__PREFIX__apilog` ADD COLUMN `response` text COMMENT '响应内容'; 
+
+--
+--1.0.5版本 修改UA类型为text,防止长度不足造成异常
+--
+ALTER TABLE `__PREFIX__apilog` MODIFY `ua` text;

+ 278 - 0
addons/apilog/model/Apilog.php

@@ -0,0 +1,278 @@
+<?php
+
+namespace addons\apilog\model;
+
+use think\Model;
+
+
+class Apilog extends Model
+{
+
+    protected $name = 'apilog';
+
+    protected $autoWriteTimestamp = 'int';
+
+    protected $createTime = 'createtime';
+    protected $updateTime = false;
+    protected $deleteTime = false;
+
+    protected $append = [
+        'method_text',
+        'time_text'
+    ];
+
+    public function getMethodList()
+    {
+        return ['GET' => 'GET', 'POST' => 'POST', 'PUT' => 'PUT', 'DELETE' => 'DELETE'];
+    }
+
+
+    public function getMethodTextAttr($value, $data)
+    {
+        $value = $value ? $value : (isset($data['method']) ? $data['method'] : '');
+        $list = $this->getMethodList();
+        return isset($list[$value]) ? $list[$value] : '';
+    }
+
+
+    public function getTimeTextAttr($value, $data)
+    {
+        $value = $value ? $value : (isset($data['time']) ? $data['time'] : '');
+        return is_numeric($value) ? date("Y-m-d H:i:s", $value) : $value;
+    }
+
+    protected function setTimeAttr($value)
+    {
+        return $value === '' ? null : ($value && !is_numeric($value) ? strtotime($value) : $value);
+    }
+
+
+    /**
+     * 基本数据
+     *
+     * @param [type] $start
+     * @param [type] $end
+     * @return void
+     */
+    public static function getBaseInfo($start, $end)
+    {
+        //请求次数 
+        $count_request = Apilog::whereTime('createtime', 'between', [$start, $end])->count();
+        //平均处理时间
+        $avg_time = Apilog::whereTime('createtime', 'between', [$start, $end])->avg('time');
+        //404
+        $count_404 = Apilog::whereTime('createtime', 'between', [$start, $end])->where('code', 404)->count();
+        //500
+        $count_500 = Apilog::whereTime('createtime', 'between', [$start, $end])->where('code', 500)->count();
+        //错误率占比
+        $error_rank = $count_request > 0 ? $count_500 / $count_request : 0;
+        //接口总数(已请求)
+        $count_api = Apilog::whereTime('createtime', 'between', [$start, $end])->group('controller,action')->count();
+        //echo Apilog::getLastSql();
+        return [
+            'count_request' => $count_request,
+            'avg_time' => $avg_time,
+            'count_404' => $count_404,
+            'count_500' => $count_500,
+            'error_rank' => $error_rank,
+            'count_api' => $count_api
+        ];
+    }
+
+    /**
+     * 请求状态码 饼图
+     *
+     * @return void
+     */
+    public static function getHttpCodePie($start, $end)
+    {
+        $list = Apilog::whereTime('createtime', 'between', [$start, $end])->group('code')->field('count(1) num,code')->select();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['code'];
+            $data['y'][] = $v['num'];
+            $data['kv'][] = ['name' => $v['code'], 'value' => $v['num']];
+        }
+        return $data;
+    }
+
+    /**
+     * 请求处理时间(ms)饼图
+     * 按0-100 100-500,500-1000,1000-3000,3000-5000,5000以上划分
+     *
+     * @return void
+     */
+    public static function getResponseTimePie($start, $end)
+    {
+        $row = Apilog::whereTime('createtime', 'between', [$start, $end])
+            ->field("sum(CASE WHEN TIME<100 THEN 1 ELSE 0 END) AS '0-100' ,
+            sum(CASE WHEN TIME>=100 and TIME<500 THEN 1 ELSE 0 END) AS '100-500' ,
+            sum(CASE WHEN TIME>=500 and TIME<1000 THEN 1 ELSE 0 END) AS '500-1000' ,
+            sum(CASE WHEN TIME>=1000 and TIME<3000 THEN 1 ELSE 0 END) AS '1000-3000' ,
+            sum(CASE WHEN TIME>=3000 and TIME<5000 THEN 1 ELSE 0 END) AS '3000-5000' ,
+            sum(CASE WHEN TIME>=5000  THEN 1 ELSE 0 END) AS '5000以上' 
+            ")
+            ->find();
+        // echo Apilog::getLastSql();
+        $data['x'] = ['0-100', '100-500', '500-1000', '1000-3000', '3000-5000', '5000以上'];
+        $data['y'] = [$row['0-100'], $row['100-500'], $row['500-1000'], $row['1000-3000'], $row['3000-5000'], $row['5000以上']];
+        foreach ($data['x'] as $k => $v) {
+            $data['kv'][] = ['name' => $v, 'value' => $data['y'][$k]];
+        }
+        return $data;
+    }
+
+    /**
+     * 最多请求 Top n,展现接口名称
+     *
+     * @return void
+     */
+    public static function getMaxRequestTop($start, $end)
+    {
+        $list = Apilog::whereTime('createtime', 'between', [$start, $end])
+            ->group('url')->field('count(1) num, url')->order('num desc')->limit(0, 15)->select();
+        // echo Apilog::getLastSql();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['url'];
+            $data['y'][] = $v['num'];
+        }
+        return $data;
+    }
+
+    /**
+     * 请求错误 Top n
+     *
+     * @return void
+     */
+    public static function getMaxErrorTop($start, $end)
+    {
+        $list = Apilog::whereTime('createtime', 'between', [$start, $end])
+            ->where('code', 500)
+            ->group('url')->field('count(1) num, url')->order('num desc')->limit(0, 15)->select();
+        // echo Apilog::getLastSql();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['url'];
+            $data['y'][] = $v['num'];
+        }
+        return $data;
+    }
+
+    /**
+     * 平均处理时间最快  Top n
+     *
+     * @return void
+     */
+    public static function getDoFastTop($start, $end)
+    {
+        $list = Apilog::whereTime('createtime', 'between', [$start, $end])
+            ->group('url')->field('avg(time) num, url')->order('num')->limit(0, 15)->select();
+        // echo Apilog::getLastSql();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['url'];
+            $data['y'][] = $v['num'];
+        }
+        return $data;
+    }
+
+    /**
+     * 平均处理时间最慢 Top n
+     *
+     * @return void
+     */
+    public static function getDoSlowTop($start, $end)
+    {
+        $list = Apilog::whereTime('createtime', 'between', [$start, $end])
+            ->group('url')->field('avg(time) num, url')->order('num desc')->limit(0, 15)->select();
+        // echo Apilog::getLastSql();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['url'];
+            $data['y'][] = $v['num'];
+        }
+        return $data;
+    }
+
+
+
+    /**
+     * 请求次数 近一个小时,按分钟
+     *
+     * @param int $type 0:每分钟 1:每小时 2:每天
+     * @return void
+     */
+    public static function getRequestCountLine($type)
+    {
+        $now = time();
+        $where = $type == 0 ? [$now - 3600, $now] : ($type == 1 ? [$now - 3600 * 24, $now] : 'month');
+        $format = $type == 0 ? 'i' : ($type == 1 ? 'H' : 'd');
+        $group = "FROM_UNIXTIME(createtime,'%" . $format . "')";
+        $list = Apilog::whereTime('createtime', $where)->group($group)->field('count(1) num,' . $group . ' as time')->select();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['time'];
+            $data['y'][] = $v['num'];
+        }
+        if ($type == 2) {
+            return $data;
+        }
+        $max = $type == 0 ? 60 : ($type == 1 ? 24 : 0);
+        $s = $type == 0 ? getdate()['minutes'] + 1 : ($type == 1 ? getdate()['hours'] + 1 : 0);
+        $tmp = null;
+        for ($i = 0; $i < $max; $i++) {
+            $k = $s + $i >= $max ? $s + $i - $max : $s + $i;
+            $tmp['x'][] = $k;
+            if (($idx = array_search($k, $data['x'])) !== false) {
+                $tmp['y'][] = $data['y'][$idx];
+            } else {
+                $tmp['y'][] = 0;
+            }
+        }
+        return $tmp;
+    }
+
+    /**
+     * 平均处理时间 近一个小时,按分钟
+     *
+     * @param int $type 0:每分钟 1:每小时 2:每天
+     * @return void
+     */
+    public static function getDoTimeLine($type)
+    {
+        $now = time();
+        $where = $type == 0 ? [$now - 3600, $now] : ($type == 1 ? [$now - 3600 * 24, $now] : 'month');
+        $format = $type == 0 ? 'i' : ($type == 1 ? 'H' : 'd');
+        $group = "FROM_UNIXTIME(createtime,'%" . $format . "')";
+        $list = Apilog::whereTime('createtime', $where)->group($group)->field('avg(time) num,' . $group . ' as time')->select();
+        $data['x'] = [];
+        $data['y'] = [];
+        foreach ($list as $k => $v) {
+            $data['x'][] = $v['time'];
+            $data['y'][] = $v['num'];
+        }
+        if ($type == 2) {
+            return $data;
+        }
+        $max = $type == 0 ? 60 : ($type == 1 ? 24 : 0);
+        $s = $type == 0 ? getdate()['minutes'] + 1 : ($type == 1 ? getdate()['hours'] + 1 : 0);
+        $tmp = null;
+        for ($i = 0; $i < $max; $i++) {
+            $k = $s + $i >= $max ? $s + $i - $max : $s + $i;
+            $tmp['x'][] = $k;
+            if (($idx = array_search($k, $data['x'])) !== false) {
+                $tmp['y'][] = $data['y'][$idx];
+            } else {
+                $tmp['y'][] = 0;
+            }
+        }
+        return $tmp;
+    }
+}

+ 44 - 0
addons/apilog/view/index/index.html

@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+    <meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
+    <title>API访问监测分析</title>
+
+    <!-- Bootstrap Core CSS -->
+    <link href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
+
+    <!-- Custom CSS -->
+    <link href="__CDN__/assets/css/frontend.css" rel="stylesheet">
+
+    <!-- Plugin CSS -->
+    <link href="https://cdn.staticfile.org/font-awesome/4.7.0/css/font-awesome.min.css" rel="stylesheet">
+    <link href="https://cdn.staticfile.org/simple-line-icons/2.4.1/css/simple-line-icons.min.css" rel="stylesheet">
+
+    <!-- HTML5 Shim and Respond.js IE8 support of HTML5 elements and media queries -->
+    <!--[if lt IE 9]>
+    <script src="https://cdn.staticfile.org/html5shiv/3.7.3/html5shiv.min.js"></script>
+    <script src="https://cdn.staticfile.org/respond.js/1.4.2/respond.min.js"></script>
+    <![endif]-->
+</head>
+
+<body>
+    <div class="container">
+        <div class="well" style="margin-top:30px;">
+            <h3>欢迎使用【API访问监测分析】工具</h3>
+    <br>
+    <br><p>如您在使用过程有遇到问题可通过以下三种方式解决:</p>
+    <p>1、通过问答社区提问(推荐);</p>
+    <p>2、通过个人中心的售后工单提交工单;</p>
+    <p>3、联系插件作者(QQ:170515071,注明插件售后)。</p>
+        </div>
+    </div>
+    <!-- jQuery -->
+    <script src="https://cdn.staticfile.org/jquery/2.1.4/jquery.min.js"></script>
+
+    <!-- Bootstrap Core JavaScript -->
+    <script src="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/js/bootstrap.min.js"></script>
+
+</body>
+
+</html>

+ 1 - 0
addons/betterform/.addonrc

@@ -0,0 +1 @@
+{"files":["public\/assets\/addons\/betterform\/css\/common.css"],"license":"regular","licenseto":"9671","licensekey":"k10vTCcSms5YHoR7 YgQ1JUlXRRzh+HWVtsrusdIsmhTEbg2lR65W4KKE+bk=","domains":["thinkphp_fastadmin_tailored.test"],"licensecodes":[],"validations":["849901681e419b30a3ed3e4629b8f4ac"]}

+ 113 - 0
addons/betterform/Betterform.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace addons\betterform;
+
+use app\common\library\Menu;
+use think\Addons;
+use think\Loader;
+
+/**
+ * 插件
+ */
+class Betterform extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+
+        return true;
+    }
+
+    public function viewFilter(&$content)
+    {
+        $request = \think\Request::instance();
+        $dispatch = $request->dispatch();
+        if (!$dispatch) {
+            return;
+        }
+
+        if (!$request->module() || $request->module() !== 'admin') {
+            return;
+        }
+
+        $config = get_addon_config('betterform');
+
+        //在head前引入CSS
+        $content = preg_replace("/<\/head>/i", "<link href='/assets/addons/betterform/css/common.css' rel='stylesheet' />" . "\n\$0", $content);
+
+        //如果不存在表单
+        if (!preg_match('/<form (.*?)data-toggle="validator"/i', $content)) {
+            return;
+        }
+        // 避免栈空间不足
+        ini_set('pcre.jit', false);
+
+        // 匹配<div class="form-group">标签
+        $regex = '/<div[^>]*class\s*=\s*"[^"]*\bform-group\b[^"]*"[^>]*>(?:(?!<div[^>]*class\s*=\s*"[^"]*\bform-group\b[^"]*").)*?data-rule="[^"]*?(required|checked)[^"]*?"[^>]*>/si';
+        $result = preg_replace_callback($regex, function ($matches) use ($config) {
+            return str_replace("form-group", "form-group required-{$config['asteriskposition']}", $matches[0]);
+        }, $content);
+
+        $content = is_null($result) ? $content : $result;
+
+        // 匹配<tr>
+        $pattern = '/(<tr[^>]*>)\s*<td[^>]*>(.*?)<\/td>\s*<td[^>]*>.*?<input[^>]*data-rule="[^"]*required[^"]*"[^>]*>.*?<\/td>\s*<\/tr>/si';
+        $result = preg_replace_callback($pattern, function ($matches) use ($config) {
+            if (preg_match('/(<tr[^>]*)class\s*=\s*"[^"]*"/i', $matches[1])) {
+                return preg_replace('/(<tr[^>]*)class\s*=\s*"([^"]*)"/i', '$1class="$2 required-' . $config['asteriskposition'] . '"', $matches[0]);
+            } else {
+                return str_replace("<tr", "<tr class=\"required-{$config['asteriskposition']}\"", $matches[0]);
+            }
+        }, $content);
+
+        $content = is_null($result) ? $content : $result;
+    }
+
+    /**
+     * @param $params
+     */
+    public function configInit(&$params)
+    {
+        $config = $this->getConfig();
+
+        $config['area'] = preg_match("/\[(.*?)\]/i", $config['area']) ? array_slice(array_values((array)json_decode($config['area'], true)), 0, 2) : $config['area'];
+        $config['shade'] = floatval($config['shade']);
+        $config['shadeClose'] = boolval($config['shadeClose']);
+        $params['betterform'] = $config;
+    }
+
+}

+ 27 - 0
addons/betterform/bootstrap.js

@@ -0,0 +1,27 @@
+require(['fast', 'layer'], function (Fast, Layer) {
+    var _fastOpen = Fast.api.open;
+    Fast.api.open = function (url, title, options) {
+        options = options || {};
+        options.area = Config.betterform.area;
+        options.offset = Config.betterform.offset;
+        options.anim = Config.betterform.anim;
+        options.shadeClose = Config.betterform.shadeClose;
+        options.shade = Config.betterform.shade;
+        return _fastOpen(url, title, options);
+    };
+    if (isNaN(Config.betterform.anim)) {
+        var _layerOpen = Layer.open;
+        Layer.open = function (options) {
+            var classNameArr = {slideDown: "layer-anim-slide-down", slideLeft: "layer-anim-slide-left", slideUp: "layer-anim-slide-up", slideRight: "layer-anim-slide-right"};
+            var animClass = "layer-anim " + classNameArr[options.anim] || "layer-anim-fadein";
+            var index = _layerOpen(options);
+            var layero = $('#layui-layer' + index);
+
+            layero.addClass(classNameArr[options.anim] + "-custom");
+            layero.addClass(animClass).one('webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend', function () {
+                $(this).removeClass(animClass);
+            });
+            return index;
+        }
+    }
+});

+ 102 - 0
addons/betterform/config.php

@@ -0,0 +1,102 @@
+<?php
+
+return [
+    [
+        'name' => 'asteriskposition',
+        'title' => '*号位置',
+        'type' => 'radio',
+        'group' => '',
+        'visible' => '',
+        'content' => [
+            'before' => '位于文本前',
+            'after' => '位于文本后',
+        ],
+        'value' => 'before',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'offset',
+        'title' => '弹窗位置',
+        'type' => 'radio',
+        'content' => [
+            'auto' => '居中',
+            't' => '顶部',
+            'b' => '底部',
+            'l' => '左部',
+            'r' => '右部',
+        ],
+        'value' => 'r',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'anim',
+        'title' => '打开动画',
+        'type' => 'select',
+        'content' => [
+            '平滑放大',
+            '从上掉落',
+            '从最底部往上滑入',
+            '从左滑入',
+            '从左翻滚',
+            '渐显',
+            '抖动',
+            'slideDown' => '从上边缘往下',
+            'slideLeft' => '从右边缘往左',
+            'slideUp' => '从下边缘往上',
+            'slideRight' => '从左边缘往右',
+        ],
+        'value' => 'slideLeft',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'area',
+        'title' => '弹窗宽高',
+        'type' => 'string',
+        'content' => [],
+        'value' => '["60%", "100%"]',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'shade',
+        'title' => '阴影透明度',
+        'type' => 'number',
+        'content' => [],
+        'value' => '0.3',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'shadeClose',
+        'title' => '点击阴影关闭弹窗',
+        'type' => 'bool',
+        'content' => [
+            1 => '开启',
+            0 => '关闭',
+        ],
+        'value' => '1',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

+ 15 - 0
addons/betterform/controller/Index.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace addons\betterform\controller;
+
+use think\addons\Controller;
+
+class Index extends Controller
+{
+
+    public function index()
+    {
+        $this->error("当前插件暂无前台页面");
+    }
+
+}

+ 10 - 0
addons/betterform/info.ini

@@ -0,0 +1,10 @@
+name = betterform
+title = FastAdmin表单弹窗优化插件
+intro = 优化FastAdmin表单弹窗
+author = FastAdmin
+website = https://www.fastadmin.net
+version = 1.0.3
+state = 1
+url = /addons/betterform
+license = regular
+licenseto = 9671

+ 1 - 0
addons/command/.addonrc

@@ -0,0 +1 @@
+{"files":["application\/admin\/validate\/Command.php","application\/admin\/controller\/Command.php","application\/admin\/lang\/zh-cn\/command.php","application\/admin\/model\/Command.php","application\/admin\/view\/command\/index.html","application\/admin\/view\/command\/add.html","application\/admin\/view\/command\/detail.html","public\/assets\/js\/backend\/command.js"],"license":"regular","licenseto":"45835","licensekey":"RyIj4MWSwYXoOZfl rZABL+9Vqo+x47mshzQxng==","domains":["thinkphp_fastadmin_tailored.test"],"licensecodes":[],"validations":["849901681e419b30a3ed3e4629b8f4ac"],"menus":["command","command\/index","command\/add","command\/detail","command\/command","command\/execute","command\/del","command\/multi"]}

+ 70 - 0
addons/command/Command.php

@@ -0,0 +1,70 @@
+<?php
+
+namespace addons\command;
+
+use app\common\library\Menu;
+use think\Addons;
+
+/**
+ * 在线命令插件
+ */
+class Command extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        $menu = [
+            [
+                'name'    => 'command',
+                'title'   => '在线命令管理',
+                'icon'    => 'fa fa-terminal',
+                'sublist' => [
+                    ['name' => 'command/index', 'title' => '查看'],
+                    ['name' => 'command/add', 'title' => '添加'],
+                    ['name' => 'command/detail', 'title' => '详情'],
+                    ['name' => 'command/command', 'title' => '生成并执行命令'],
+                    ['name' => 'command/execute', 'title' => '再次执行命令'],
+                    ['name' => 'command/del', 'title' => '删除'],
+                    ['name' => 'command/multi', 'title' => '批量更新'],
+                ]
+            ]
+        ];
+        Menu::create($menu);
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        Menu::delete('command');
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        Menu::enable('command');
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        Menu::disable('command');
+        return true;
+    }
+
+}

+ 4 - 0
addons/command/config.php

@@ -0,0 +1,4 @@
+<?php
+
+return [
+];

+ 15 - 0
addons/command/controller/Index.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace addons\command\controller;
+
+use think\addons\Controller;
+
+class Index extends Controller
+{
+
+    public function index()
+    {
+        $this->error("当前插件暂无前台页面");
+    }
+
+}

+ 10 - 0
addons/command/info.ini

@@ -0,0 +1,10 @@
+name = command
+title = 在线命令
+intro = 可在线执行一键生成CRUD、一键生成菜单等相关命令
+author = FastAdmin
+website = https://www.fastadmin.net
+version = 1.1.2
+state = 1
+url = /addons/command
+license = regular
+licenseto = 45835

+ 12 - 0
addons/command/install.sql

@@ -0,0 +1,12 @@
+CREATE TABLE IF NOT EXISTS `__PREFIX__command`  (
+  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `type` varchar(30) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '类型',
+  `params` varchar(1500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '参数',
+  `command` varchar(1500) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT '' COMMENT '命令',
+  `content` text COMMENT '返回结果',
+  `executetime` bigint(16) UNSIGNED DEFAULT NULL COMMENT '执行时间',
+  `createtime` bigint(16) UNSIGNED DEFAULT NULL COMMENT '创建时间',
+  `updatetime` bigint(16) UNSIGNED DEFAULT NULL COMMENT '更新时间',
+  `status` enum('successed','failured') CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT 'failured' COMMENT '状态',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE = InnoDB AUTO_INCREMENT = 1 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '在线命令表';

+ 28 - 0
addons/command/library/Output.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace addons\command\library;
+
+/**
+ * Class Output
+ */
+class Output extends \think\console\Output
+{
+
+    protected $message = [];
+
+    public function __construct($driver = 'console')
+    {
+        parent::__construct($driver);
+    }
+
+    protected function block($style, $message)
+    {
+        $this->message[] = $message;
+    }
+
+    public function getMessage()
+    {
+        return $this->message;
+    }
+
+}

+ 1 - 0
addons/crontab/.gitignore

@@ -0,0 +1 @@
+.addonrc

+ 80 - 0
addons/crontab/Crontab.php

@@ -0,0 +1,80 @@
+<?php
+
+namespace addons\crontab;
+
+use app\common\library\Menu;
+use think\Addons;
+use think\Loader;
+
+/**
+ * 定时任务
+ */
+class Crontab extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        $menu = [
+            [
+                'name'    => 'general/crontab',
+                'title'   => '定时任务',
+                'icon'    => 'fa fa-tasks',
+                'remark'  => '按照设定的时间进行任务的执行,目前支持三种任务:请求URL、执行SQL、执行Shell。',
+                'sublist' => [
+                    ['name' => 'general/crontab/index', 'title' => '查看'],
+                    ['name' => 'general/crontab/add', 'title' => '添加'],
+                    ['name' => 'general/crontab/edit', 'title' => '编辑 '],
+                    ['name' => 'general/crontab/del', 'title' => '删除'],
+                    ['name' => 'general/crontab/multi', 'title' => '批量更新'],
+                ]
+            ]
+        ];
+        Menu::create($menu, 'general');
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        Menu::delete('general/crontab');
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     */
+    public function enable()
+    {
+        Menu::enable('general/crontab');
+    }
+
+    /**
+     * 插件禁用方法
+     */
+    public function disable()
+    {
+        Menu::disable('general/crontab');
+    }
+
+    /**
+     * 添加命名空间
+     */
+    public function appInit()
+    {
+        //添加命名空间
+        if (!class_exists('\Cron\CronExpression')) {
+            Loader::addNamespace('Cron', ADDON_PATH . 'crontab' . DS . 'library' . DS . 'Cron' . DS);
+        }
+        if (!class_exists('\Jenner\SimpleFork\Pool')) {
+            Loader::addNamespace('Jenner\SimpleFork', ADDON_PATH . 'crontab' . DS . 'library' . DS . 'SimpleFork' . DS);
+        }
+    }
+
+}

+ 19 - 0
addons/crontab/config.php

@@ -0,0 +1,19 @@
+<?php
+
+return [
+    [
+        'name'    => 'mode',
+        'title'   => '执行模式',
+        'type'    => 'select',
+        'content' => [
+            'single' => '单进程,阻塞',
+            'pcntl'  => '子进程,无阻塞,需支持pcntl,不支持时自动切换为单进程',
+        ],
+        'value'   => 'pcntl',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+];

+ 125 - 0
addons/crontab/controller/Autotask.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace addons\crontab\controller;
+
+use addons\crontab\model\Crontab;
+use Cron\CronExpression;
+use fast\Http;
+use think\Controller;
+use think\Db;
+use think\Exception;
+use think\Log;
+
+/**
+ * 定时任务接口
+ *
+ * 以Crontab方式每分钟定时执行,且只可以Cli方式运行
+ * @internal
+ */
+class Autotask extends Controller
+{
+
+    /**
+     * 初始化方法,最前且始终执行
+     */
+    public function _initialize()
+    {
+        // 只可以以cli方式执行
+        if (!$this->request->isCli()) {
+            $this->error('Autotask script only work at client!');
+        }
+
+        parent::_initialize();
+
+        // 清除错误
+        error_reporting(0);
+
+        // 设置永不超时
+        set_time_limit(0);
+    }
+
+    /**
+     * 执行定时任务
+     */
+    public function index()
+    {
+        $withPcntl = false;
+        $pool = null;
+
+        $config = get_addon_config('crontab');
+        $mode = $config['mode'] ?? 'pcntl';
+        if ($mode == 'pcntl' && function_exists('pcntl_fork')) {
+            $withPcntl = true;
+            $pool = new \Jenner\SimpleFork\Pool();
+        }
+
+        $time = time();
+        $logDir = LOG_PATH . 'crontab' . DS;
+        if (!is_dir($logDir)) {
+            mkdir($logDir, 0755);
+        }
+        //筛选未过期且未完成的任务
+        $crontabList = Crontab::where('status', '=', 'normal')->order('weigh DESC,id DESC')->select();
+        $execTime = time();
+        foreach ($crontabList as $crontab) {
+            $update = [];
+            $execute = false;
+            if ($time < $crontab['begintime']) {
+                //任务未开始
+                continue;
+            }
+            if ($crontab['maximums'] && $crontab['executes'] > $crontab['maximums']) {
+                //任务已超过最大执行次数
+                $update['status'] = 'completed';
+            } else {
+                if ($crontab['endtime'] > 0 && $time > $crontab['endtime']) {
+                    //任务已过期
+                    $update['status'] = 'expired';
+                } else {
+                    //重复执行
+                    //如果未到执行时间则继续循环
+                    $cron = CronExpression::factory($crontab['schedule']);
+                    if (!$cron->isDue() || ($crontab['executetime'] && date("YmdHi", $execTime) === date("YmdHi", $crontab['executetime']))) {
+                        continue;
+                    }
+                    $execute = true;
+                }
+            }
+
+            // 如果允许执行
+            if ($execute) {
+                $update['executetime'] = $time;
+                $update['executes'] = $crontab['executes'] + 1;
+                $update['status'] = ($crontab['maximums'] > 0 && $update['executes'] >= $crontab['maximums']) ? 'completed' : 'normal';
+            }
+
+            // 如果需要更新状态
+            if (!$update) {
+                continue;
+            }
+            // 更新状态
+            $crontab->save($update);
+            Db::connect()->close();
+            // 将执行放在后面是为了避免超时导致多次执行
+            if (!$execute) {
+                continue;
+            }
+
+            $runnable = new \addons\crontab\library\CommandRunnable($crontab);
+            if ($withPcntl) {
+                $process = new \Jenner\SimpleFork\Process($runnable);
+                $name = $crontab['title'];
+                $pool->execute($process);
+            } else {
+                $runnable->run();
+            }
+
+        }
+        if ($withPcntl && $pool) {
+            $pool->wait();
+        }
+        return "Execute completed!\n";
+    }
+
+
+}

+ 16 - 0
addons/crontab/controller/Index.php

@@ -0,0 +1,16 @@
+<?php
+
+namespace addons\crontab\controller;
+
+use think\addons\Controller;
+
+class Index extends Controller
+{
+
+    public function index()
+    {
+        $this->error("当前插件暂无前台页面");
+    }
+
+}
+

+ 10 - 0
addons/crontab/info.ini

@@ -0,0 +1,10 @@
+name = crontab
+title = 定时任务管理
+intro = 便捷的后台定时任务管理
+author = FastAdmin
+website = https://www.fastadmin.net
+version = 1.1.3
+state = 1
+url = /addons/crontab
+license = regular
+licenseto = 45835

+ 40 - 0
addons/crontab/install.sql

@@ -0,0 +1,40 @@
+CREATE TABLE IF NOT EXISTS `__PREFIX__crontab` (
+  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `type` varchar(10) NOT NULL DEFAULT '' COMMENT '事件类型',
+  `title` varchar(100) NOT NULL DEFAULT '' COMMENT '事件标题',
+  `content` text NOT NULL COMMENT '事件内容',
+  `schedule` varchar(100) NOT NULL DEFAULT '' COMMENT 'Crontab格式',
+  `sleep` tinyint(1) UNSIGNED NOT NULL DEFAULT '0' COMMENT '延迟秒数执行',
+  `maximums` int(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '最大执行次数 0为不限',
+  `executes` int(10) UNSIGNED NOT NULL DEFAULT '0' COMMENT '已经执行的次数',
+  `createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
+  `begintime` bigint(16) DEFAULT NULL COMMENT '开始时间',
+  `endtime` bigint(16) DEFAULT NULL COMMENT '结束时间',
+  `executetime` bigint(16) DEFAULT NULL COMMENT '最后执行时间',
+  `weigh` int(10) NOT NULL DEFAULT '0' COMMENT '权重',
+  `status` enum('completed','expired','hidden','normal') NOT NULL DEFAULT 'normal' COMMENT '状态',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='定时任务表';
+
+BEGIN;
+INSERT INTO `__PREFIX__crontab` (`id`, `type`, `title`, `content`, `schedule`, `sleep`, `maximums`, `executes`, `createtime`, `updatetime`, `begintime`, `endtime`, `executetime`, `weigh`, `status`) VALUES
+(1, 'url', '请求百度', 'https://www.baidu.com', '* * * * *', 0, 0, 0, 1497070825, 1501253101, 1483200000, 1830268800, 1501253101, 1, 'normal'),
+(2, 'sql', '查询一条SQL', 'SELECT 1;', '* * * * *', 0, 0, 0, 1497071095, 1501253101, 1483200000, 1830268800, 1501253101, 2, 'normal');
+COMMIT;
+
+CREATE TABLE IF NOT EXISTS `__PREFIX__crontab_log` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `crontab_id` int(10) DEFAULT NULL COMMENT '任务ID',
+  `executetime` bigint(16) DEFAULT NULL COMMENT '执行时间',
+  `completetime` bigint(16) DEFAULT NULL COMMENT '结束时间',
+  `content` text COMMENT '执行结果',
+  `processid` int(10) NULL DEFAULT 0 COMMENT '进程ID',
+  `status` enum('success','failure', 'inprogress') DEFAULT 'failure' COMMENT '状态',
+  PRIMARY KEY (`id`),
+  KEY `crontab_id` (`crontab_id`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='定时任务日志表';
+
+-- 1.1.0 --
+ALTER TABLE `__PREFIX__crontab_log` ADD `processid` INT(10)  NULL  DEFAULT 0  COMMENT '进程ID'  AFTER `content`, CHANGE `status` `status` ENUM('success','failure','inprogress')  CHARACTER SET utf8  COLLATE utf8_general_ci  NOT NULL  DEFAULT 'failure'  COMMENT '状态';
+

+ 130 - 0
addons/crontab/library/CommandRunnable.php

@@ -0,0 +1,130 @@
+<?php
+
+namespace addons\crontab\library;
+
+use fast\Http;
+use think\Config;
+use think\Db;
+
+class CommandRunnable implements \Jenner\SimpleFork\Runnable
+{
+    protected $connect = null;
+    protected $crontab = null;
+
+    public function __construct($crontab)
+    {
+        $this->crontab = $crontab;
+    }
+
+    public function run()
+    {
+        $processId = getmypid();
+
+        //这里需要强制重连数据库,使用已有的连接会报2014错误
+        $this->connect = Db::connect([], true);
+        $this->connect->execute("SELECT 1");
+
+        $message = '';
+        $result = false;
+        $this->crontabLog = null;
+        $log = [
+            'crontab_id'   => $this->crontab['id'],
+            'executetime'  => time(),
+            'completetime' => null,
+            'content'      => '',
+            'processid'    => $processId,
+            'status'       => 'inprogress',
+        ];
+        $this->connect->name("crontab_log")->insert($log);
+        $this->crontabLogId = $this->connect->getLastInsID();
+        try {
+            if ($this->crontab['type'] == 'url') {
+                if (substr($this->crontab['content'], 0, 1) == "/") {
+                    // 本地项目URL
+                    $message = shell_exec('php ' . ROOT_PATH . 'public/index.php ' . $this->crontab['content']);
+                    $result = (bool)$message;
+                } else {
+                    $arr = explode(" ", $this->crontab['content']);
+                    $url = $arr[0];
+                    $params = $arr[1] ?? '';
+                    $method = $arr[2] ?? 'POST';
+                    try {
+                        // 远程异步调用URL
+                        $ret = Http::sendRequest($url, $params, $method);
+                        $result = $ret['ret'];
+                        $message = $ret['msg'];
+                    } catch (\Exception $e) {
+                        $message = $e->getMessage();
+                    }
+                }
+
+            } elseif ($this->crontab['type'] == 'sql') {
+                $ret = $this->sql($this->crontab['content']);
+                $result = $ret['ret'];
+                $message = $ret['msg'];
+            } elseif ($this->crontab['type'] == 'shell') {
+                // 执行Shell
+                $message = shell_exec($this->crontab['content']);
+                $result = !is_null($message);
+            }
+        } catch (\Exception $e) {
+            $message = $e->getMessage();
+        }
+
+        //设定任务完成
+        $this->connect->name("crontab_log")->where('id', $this->crontabLogId)->update(['content' => $message, 'completetime' => time(), 'status' => $result ? 'success' : 'failure']);
+    }
+
+    /**
+     * 执行SQL语句
+     */
+    protected function sql($sql)
+    {
+
+        // 执行SQL
+        $sqlquery = str_replace('__PREFIX__', config('database.prefix'), $sql);
+        $sqls = preg_split("/;[ \t]{0,}\n/i", $sqlquery);
+
+        $result = false;
+        $message = '';
+        $this->connect->startTrans();
+        try {
+            foreach ($sqls as $key => $val) {
+                if (trim($val) == '' || substr($val, 0, 2) == '--' || substr($val, 0, 2) == '/*') {
+                    continue;
+                }
+                $message .= "\nSQL:{$val}\n";
+                $val = rtrim($val, ';');
+                if (preg_match("/^(select|explain)(.*)/i ", $val)) {
+                    $count = $this->connect->execute($val);
+                    if ($count > 0) {
+                        $resultlist = Db::query($val);
+                    } else {
+                        $resultlist = [];
+                    }
+
+                    $message .= "Total:{$count}\n";
+                    $j = 1;
+                    foreach ($resultlist as $m => $n) {
+                        $message .= "\n";
+                        $message .= "Row:{$j}\n";
+                        foreach ($n as $k => $v) {
+                            $message .= "{$k}:{$v}\n";
+                        }
+                        $j++;
+                    }
+                } else {
+                    $count = $this->connect->getPdo()->exec($val);
+                    $message = "Affected rows:{$count}";
+                }
+            }
+            $this->connect->commit();
+            $result = true;
+        } catch (\PDOException $e) {
+            $message = $e->getMessage();
+            $this->connect->rollback();
+            $result = false;
+        }
+        return ['ret' => $result, 'msg' => $message];
+    }
+}

+ 252 - 0
addons/crontab/library/Cron/AbstractField.php

@@ -0,0 +1,252 @@
+<?php
+
+namespace Cron;
+
+/**
+ * Abstract CRON expression field
+ */
+abstract class AbstractField implements FieldInterface
+{
+    /**
+     * Full range of values that are allowed for this field type
+     * @var array
+     */
+    protected $fullRange = [];
+
+    /**
+     * Literal values we need to convert to integers
+     * @var array
+     */
+    protected $literals = [];
+
+    /**
+     * Start value of the full range
+     * @var integer
+     */
+    protected $rangeStart;
+
+    /**
+     * End value of the full range
+     * @var integer
+     */
+    protected $rangeEnd;
+
+
+    public function __construct()
+    {
+        $this->fullRange = range($this->rangeStart, $this->rangeEnd);
+    }
+
+    /**
+     * Check to see if a field is satisfied by a value
+     *
+     * @param string $dateValue Date value to check
+     * @param string $value     Value to test
+     *
+     * @return bool
+     */
+    public function isSatisfied($dateValue, $value)
+    {
+        if ($this->isIncrementsOfRanges($value)) {
+            return $this->isInIncrementsOfRanges($dateValue, $value);
+        } elseif ($this->isRange($value)) {
+            return $this->isInRange($dateValue, $value);
+        }
+
+        return $value == '*' || $dateValue == $value;
+    }
+
+    /**
+     * Check if a value is a range
+     *
+     * @param string $value Value to test
+     *
+     * @return bool
+     */
+    public function isRange($value)
+    {
+        return strpos($value, '-') !== false;
+    }
+
+    /**
+     * Check if a value is an increments of ranges
+     *
+     * @param string $value Value to test
+     *
+     * @return bool
+     */
+    public function isIncrementsOfRanges($value)
+    {
+        return strpos($value, '/') !== false;
+    }
+
+    /**
+     * Test if a value is within a range
+     *
+     * @param string $dateValue Set date value
+     * @param string $value     Value to test
+     *
+     * @return bool
+     */
+    public function isInRange($dateValue, $value)
+    {
+        $parts = array_map('trim', explode('-', $value, 2));
+
+        return $dateValue >= $parts[0] && $dateValue <= $parts[1];
+    }
+
+    /**
+     * Test if a value is within an increments of ranges (offset[-to]/step size)
+     *
+     * @param string $dateValue Set date value
+     * @param string $value     Value to test
+     *
+     * @return bool
+     */
+    public function isInIncrementsOfRanges($dateValue, $value)
+    {
+        $chunks = array_map('trim', explode('/', $value, 2));
+        $range = $chunks[0];
+        $step = isset($chunks[1]) ? $chunks[1] : 0;
+
+        // No step or 0 steps aren't cool
+        if (is_null($step) || '0' === $step || 0 === $step) {
+            return false;
+        }
+
+        // Expand the * to a full range
+        if ('*' == $range) {
+            $range = $this->rangeStart . '-' . $this->rangeEnd;
+        }
+
+        // Generate the requested small range
+        $rangeChunks = explode('-', $range, 2);
+        $rangeStart = $rangeChunks[0];
+        $rangeEnd = isset($rangeChunks[1]) ? $rangeChunks[1] : $rangeStart;
+
+        if ($rangeStart < $this->rangeStart || $rangeStart > $this->rangeEnd || $rangeStart > $rangeEnd) {
+            throw new \OutOfRangeException('Invalid range start requested');
+        }
+
+        if ($rangeEnd < $this->rangeStart || $rangeEnd > $this->rangeEnd || $rangeEnd < $rangeStart) {
+            throw new \OutOfRangeException('Invalid range end requested');
+        }
+
+        if ($step > ($rangeEnd - $rangeStart) + 1) {
+            throw new \OutOfRangeException('Step cannot be greater than total range');
+        }
+
+        $thisRange = range($rangeStart, $rangeEnd, $step);
+
+        return in_array($dateValue, $thisRange);
+    }
+
+    /**
+     * Returns a range of values for the given cron expression
+     *
+     * @param string $expression The expression to evaluate
+     * @param int $max           Maximum offset for range
+     *
+     * @return array
+     */
+    public function getRangeForExpression($expression, $max)
+    {
+        $values = array();
+
+        if ($this->isRange($expression) || $this->isIncrementsOfRanges($expression)) {
+            if (!$this->isIncrementsOfRanges($expression)) {
+                list ($offset, $to) = explode('-', $expression);
+                $stepSize = 1;
+            }
+            else {
+                $range = array_map('trim', explode('/', $expression, 2));
+                $stepSize = isset($range[1]) ? $range[1] : 0;
+                $range = $range[0];
+                $range = explode('-', $range, 2);
+                $offset = $range[0];
+                $to = isset($range[1]) ? $range[1] : $max;
+            }
+            $offset = $offset == '*' ? 0 : $offset;
+            for ($i = $offset; $i <= $to; $i += $stepSize) {
+                $values[] = $i;
+            }
+            sort($values);
+        }
+        else {
+            $values = array($expression);
+        }
+
+        return $values;
+    }
+
+    protected function convertLiterals($value)
+    {
+        if (count($this->literals)) {
+            $key = array_search($value, $this->literals);
+            if ($key !== false) {
+                return $key;
+            }
+        }
+
+        return $value;
+    }
+
+    /**
+     * Checks to see if a value is valid for the field
+     *
+     * @param string $value
+     * @return bool
+     */
+    public function validate($value)
+    {
+        $value = $this->convertLiterals($value);
+
+        // All fields allow * as a valid value
+        if ('*' === $value) {
+            return true;
+        }
+
+        // You cannot have a range and a list at the same time
+        if (strpos($value, ',') !== false && strpos($value, '-') !== false) {
+            return false;
+        }
+
+        if (strpos($value, '/') !== false) {
+            list($range, $step) = explode('/', $value);
+            return $this->validate($range) && filter_var($step, FILTER_VALIDATE_INT);
+        }
+
+        if (strpos($value, '-') !== false) {
+            if (substr_count($value, '-') > 1) {
+                return false;
+            }
+
+            $chunks = explode('-', $value);
+            $chunks[0] = $this->convertLiterals($chunks[0]);
+            $chunks[1] = $this->convertLiterals($chunks[1]);
+
+            if ('*' == $chunks[0] || '*' == $chunks[1]) {
+                return false;
+            }
+
+            return $this->validate($chunks[0]) && $this->validate($chunks[1]);
+        }
+
+        // Validate each chunk of a list individually
+        if (strpos($value, ',') !== false) {
+            foreach (explode(',', $value) as $listItem) {
+                if (!$this->validate($listItem)) {
+                    return false;
+                }
+            }
+            return true;
+        }
+
+        // We should have a numeric by now, so coerce this into an integer
+        if (filter_var($value, FILTER_VALIDATE_INT) !== false) {
+            $value = (int) $value;
+        }
+
+        return in_array($value, $this->fullRange, true);
+    }
+}

+ 402 - 0
addons/crontab/library/Cron/CronExpression.php

@@ -0,0 +1,402 @@
+<?php
+
+namespace Cron;
+
+use DateTime;
+use DateTimeImmutable;
+use DateTimeZone;
+use Exception;
+use InvalidArgumentException;
+use RuntimeException;
+
+/**
+ * CRON expression parser that can determine whether or not a CRON expression is
+ * due to run, the next run date and previous run date of a CRON expression.
+ * The determinations made by this class are accurate if checked run once per
+ * minute (seconds are dropped from date time comparisons).
+ *
+ * Schedule parts must map to:
+ * minute [0-59], hour [0-23], day of month, month [1-12|JAN-DEC], day of week
+ * [1-7|MON-SUN], and an optional year.
+ *
+ * @link http://en.wikipedia.org/wiki/Cron
+ */
+class CronExpression
+{
+    const MINUTE = 0;
+    const HOUR = 1;
+    const DAY = 2;
+    const MONTH = 3;
+    const WEEKDAY = 4;
+    const YEAR = 5;
+
+    /**
+     * @var array CRON expression parts
+     */
+    private $cronParts;
+
+    /**
+     * @var FieldFactory CRON field factory
+     */
+    private $fieldFactory;
+
+    /**
+     * @var int Max iteration count when searching for next run date
+     */
+    private $maxIterationCount = 1000;
+
+    /**
+     * @var array Order in which to test of cron parts
+     */
+    private static $order = array(self::YEAR, self::MONTH, self::DAY, self::WEEKDAY, self::HOUR, self::MINUTE);
+
+    /**
+     * Factory method to create a new CronExpression.
+     *
+     * @param string $expression The CRON expression to create.  There are
+     *                           several special predefined values which can be used to substitute the
+     *                           CRON expression:
+     *
+     *      `@yearly`, `@annually` - Run once a year, midnight, Jan. 1 - 0 0 1 1 *
+     *      `@monthly` - Run once a month, midnight, first of month - 0 0 1 * *
+     *      `@weekly` - Run once a week, midnight on Sun - 0 0 * * 0
+     *      `@daily` - Run once a day, midnight - 0 0 * * *
+     *      `@hourly` - Run once an hour, first minute - 0 * * * *
+     * @param FieldFactory $fieldFactory Field factory to use
+     *
+     * @return CronExpression
+     */
+    public static function factory($expression, FieldFactory $fieldFactory = null)
+    {
+        $mappings = array(
+            '@yearly' => '0 0 1 1 *',
+            '@annually' => '0 0 1 1 *',
+            '@monthly' => '0 0 1 * *',
+            '@weekly' => '0 0 * * 0',
+            '@daily' => '0 0 * * *',
+            '@hourly' => '0 * * * *'
+        );
+
+        if (isset($mappings[$expression])) {
+            $expression = $mappings[$expression];
+        }
+
+        return new static($expression, $fieldFactory ?: new FieldFactory());
+    }
+
+    /**
+     * Validate a CronExpression.
+     *
+     * @param string $expression The CRON expression to validate.
+     *
+     * @return bool True if a valid CRON expression was passed. False if not.
+     * @see \Cron\CronExpression::factory
+     */
+    public static function isValidExpression($expression)
+    {
+        try {
+            self::factory($expression);
+        } catch (InvalidArgumentException $e) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * Parse a CRON expression
+     *
+     * @param string       $expression   CRON expression (e.g. '8 * * * *')
+     * @param FieldFactory $fieldFactory Factory to create cron fields
+     */
+    public function __construct($expression, FieldFactory $fieldFactory)
+    {
+        $this->fieldFactory = $fieldFactory;
+        $this->setExpression($expression);
+    }
+
+    /**
+     * Set or change the CRON expression
+     *
+     * @param string $value CRON expression (e.g. 8 * * * *)
+     *
+     * @return CronExpression
+     * @throws \InvalidArgumentException if not a valid CRON expression
+     */
+    public function setExpression($value)
+    {
+        $this->cronParts = preg_split('/\s/', $value, -1, PREG_SPLIT_NO_EMPTY);
+        if (count($this->cronParts) < 5) {
+            throw new InvalidArgumentException(
+                $value . ' is not a valid CRON expression'
+            );
+        }
+
+        foreach ($this->cronParts as $position => $part) {
+            $this->setPart($position, $part);
+        }
+
+        return $this;
+    }
+
+    /**
+     * Set part of the CRON expression
+     *
+     * @param int    $position The position of the CRON expression to set
+     * @param string $value    The value to set
+     *
+     * @return CronExpression
+     * @throws \InvalidArgumentException if the value is not valid for the part
+     */
+    public function setPart($position, $value)
+    {
+        if (!$this->fieldFactory->getField($position)->validate($value)) {
+            throw new InvalidArgumentException(
+                'Invalid CRON field value ' . $value . ' at position ' . $position
+            );
+        }
+
+        $this->cronParts[$position] = $value;
+
+        return $this;
+    }
+
+    /**
+     * Set max iteration count for searching next run dates
+     *
+     * @param int $maxIterationCount Max iteration count when searching for next run date
+     *
+     * @return CronExpression
+     */
+    public function setMaxIterationCount($maxIterationCount)
+    {
+        $this->maxIterationCount = $maxIterationCount;
+        
+        return $this;
+    }
+
+    /**
+     * Get a next run date relative to the current date or a specific date
+     *
+     * @param string|\DateTime $currentTime      Relative calculation date
+     * @param int              $nth              Number of matches to skip before returning a
+     *                                           matching next run date.  0, the default, will return the current
+     *                                           date and time if the next run date falls on the current date and
+     *                                           time.  Setting this value to 1 will skip the first match and go to
+     *                                           the second match.  Setting this value to 2 will skip the first 2
+     *                                           matches and so on.
+     * @param bool             $allowCurrentDate Set to TRUE to return the current date if
+     *                                           it matches the cron expression.
+     * @param null|string      $timeZone         Timezone to use instead of the system default
+     *
+     * @return \DateTime
+     * @throws \RuntimeException on too many iterations
+     */
+    public function getNextRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null)
+    {
+        return $this->getRunDate($currentTime, $nth, false, $allowCurrentDate, $timeZone);
+    }
+
+    /**
+     * Get a previous run date relative to the current date or a specific date
+     *
+     * @param string|\DateTime $currentTime      Relative calculation date
+     * @param int              $nth              Number of matches to skip before returning
+     * @param bool             $allowCurrentDate Set to TRUE to return the
+     *                                           current date if it matches the cron expression
+     * @param null|string      $timeZone         Timezone to use instead of the system default
+     *
+     * @return \DateTime
+     * @throws \RuntimeException on too many iterations
+     * @see \Cron\CronExpression::getNextRunDate
+     */
+    public function getPreviousRunDate($currentTime = 'now', $nth = 0, $allowCurrentDate = false, $timeZone = null)
+    {
+        return $this->getRunDate($currentTime, $nth, true, $allowCurrentDate, $timeZone);
+    }
+
+    /**
+     * Get multiple run dates starting at the current date or a specific date
+     *
+     * @param int              $total            Set the total number of dates to calculate
+     * @param string|\DateTime $currentTime      Relative calculation date
+     * @param bool             $invert           Set to TRUE to retrieve previous dates
+     * @param bool             $allowCurrentDate Set to TRUE to return the
+     *                                           current date if it matches the cron expression
+     * @param null|string      $timeZone         Timezone to use instead of the system default
+     *
+     * @return array Returns an array of run dates
+     */
+    public function getMultipleRunDates($total, $currentTime = 'now', $invert = false, $allowCurrentDate = false, $timeZone = null)
+    {
+        $matches = array();
+        for ($i = 0; $i < max(0, $total); $i++) {
+            try {
+                $matches[] = $this->getRunDate($currentTime, $i, $invert, $allowCurrentDate, $timeZone);
+            } catch (RuntimeException $e) {
+                break;
+            }
+        }
+
+        return $matches;
+    }
+
+    /**
+     * Get all or part of the CRON expression
+     *
+     * @param string $part Specify the part to retrieve or NULL to get the full
+     *                     cron schedule string.
+     *
+     * @return string|null Returns the CRON expression, a part of the
+     *                     CRON expression, or NULL if the part was specified but not found
+     */
+    public function getExpression($part = null)
+    {
+        if (null === $part) {
+            return implode(' ', $this->cronParts);
+        } elseif (array_key_exists($part, $this->cronParts)) {
+            return $this->cronParts[$part];
+        }
+
+        return null;
+    }
+
+    /**
+     * Helper method to output the full expression.
+     *
+     * @return string Full CRON expression
+     */
+    public function __toString()
+    {
+        return $this->getExpression();
+    }
+
+    /**
+     * Determine if the cron is due to run based on the current date or a
+     * specific date.  This method assumes that the current number of
+     * seconds are irrelevant, and should be called once per minute.
+     *
+     * @param string|\DateTime $currentTime Relative calculation date
+     * @param null|string      $timeZone    Timezone to use instead of the system default
+     *
+     * @return bool Returns TRUE if the cron is due to run or FALSE if not
+     */
+    public function isDue($currentTime = 'now', $timeZone = null)
+    {
+        if (is_null($timeZone)) {
+            $timeZone = date_default_timezone_get();
+        }
+        
+        if ('now' === $currentTime) {
+            $currentDate = date('Y-m-d H:i');
+            $currentTime = strtotime($currentDate);
+        } elseif ($currentTime instanceof DateTime) {
+            $currentDate = clone $currentTime;
+            // Ensure time in 'current' timezone is used
+            $currentDate->setTimezone(new DateTimeZone($timeZone));
+            $currentDate = $currentDate->format('Y-m-d H:i');
+            $currentTime = strtotime($currentDate);
+        } elseif ($currentTime instanceof DateTimeImmutable) {
+            $currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
+            $currentDate->setTimezone(new DateTimeZone($timeZone));
+            $currentDate = $currentDate->format('Y-m-d H:i');
+            $currentTime = strtotime($currentDate);
+        } else {
+            $currentTime = new DateTime($currentTime);
+            $currentTime->setTime($currentTime->format('H'), $currentTime->format('i'), 0);
+            $currentDate = $currentTime->format('Y-m-d H:i');
+            $currentTime = $currentTime->getTimeStamp();
+        }
+
+        try {
+            return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime;
+        } catch (Exception $e) {
+            return false;
+        }
+    }
+
+    /**
+     * Get the next or previous run date of the expression relative to a date
+     *
+     * @param string|\DateTime $currentTime      Relative calculation date
+     * @param int              $nth              Number of matches to skip before returning
+     * @param bool             $invert           Set to TRUE to go backwards in time
+     * @param bool             $allowCurrentDate Set to TRUE to return the
+     *                                           current date if it matches the cron expression
+     * @param string|null      $timeZone         Timezone to use instead of the system default
+     *
+     * @return \DateTime
+     * @throws \RuntimeException on too many iterations
+     */
+    protected function getRunDate($currentTime = null, $nth = 0, $invert = false, $allowCurrentDate = false, $timeZone = null)
+    {
+        if (is_null($timeZone)) {
+            $timeZone = date_default_timezone_get();
+        }
+        
+        if ($currentTime instanceof DateTime) {
+            $currentDate = clone $currentTime;
+        } elseif ($currentTime instanceof DateTimeImmutable) {
+            $currentDate = DateTime::createFromFormat('U', $currentTime->format('U'));
+            $currentDate->setTimezone($currentTime->getTimezone());
+        } else {
+            $currentDate = new DateTime($currentTime ?: 'now');
+            $currentDate->setTimezone(new DateTimeZone($timeZone));
+        }
+
+        $currentDate->setTime($currentDate->format('H'), $currentDate->format('i'), 0);
+        $nextRun = clone $currentDate;
+        $nth = (int) $nth;
+
+        // We don't have to satisfy * or null fields
+        $parts = array();
+        $fields = array();
+        foreach (self::$order as $position) {
+            $part = $this->getExpression($position);
+            if (null === $part || '*' === $part) {
+                continue;
+            }
+            $parts[$position] = $part;
+            $fields[$position] = $this->fieldFactory->getField($position);
+        }
+
+        // Set a hard limit to bail on an impossible date
+        for ($i = 0; $i < $this->maxIterationCount; $i++) {
+
+            foreach ($parts as $position => $part) {
+                $satisfied = false;
+                // Get the field object used to validate this part
+                $field = $fields[$position];
+                // Check if this is singular or a list
+                if (strpos($part, ',') === false) {
+                    $satisfied = $field->isSatisfiedBy($nextRun, $part);
+                } else {
+                    foreach (array_map('trim', explode(',', $part)) as $listPart) {
+                        if ($field->isSatisfiedBy($nextRun, $listPart)) {
+                            $satisfied = true;
+                            break;
+                        }
+                    }
+                }
+
+                // If the field is not satisfied, then start over
+                if (!$satisfied) {
+                    $field->increment($nextRun, $invert, $part);
+                    continue 2;
+                }
+            }
+
+            // Skip this match if needed
+            if ((!$allowCurrentDate && $nextRun == $currentDate) || --$nth > -1) {
+                $this->fieldFactory->getField(0)->increment($nextRun, $invert, isset($parts[0]) ? $parts[0] : null);
+                continue;
+            }
+
+            return $nextRun;
+        }
+
+        // @codeCoverageIgnoreStart
+        throw new RuntimeException('Impossible CRON expression');
+        // @codeCoverageIgnoreEnd
+    }
+}

+ 131 - 0
addons/crontab/library/Cron/DayOfMonthField.php

@@ -0,0 +1,131 @@
+<?php
+
+namespace Cron;
+
+use DateTime;
+
+/**
+ * Day of month field.  Allows: * , / - ? L W
+ *
+ * 'L' stands for "last" and specifies the last day of the month.
+ *
+ * The 'W' character is used to specify the weekday (Monday-Friday) nearest the
+ * given day. As an example, if you were to specify "15W" as the value for the
+ * day-of-month field, the meaning is: "the nearest weekday to the 15th of the
+ * month". So if the 15th is a Saturday, the trigger will fire on Friday the
+ * 14th. If the 15th is a Sunday, the trigger will fire on Monday the 16th. If
+ * the 15th is a Tuesday, then it will fire on Tuesday the 15th. However if you
+ * specify "1W" as the value for day-of-month, and the 1st is a Saturday, the
+ * trigger will fire on Monday the 3rd, as it will not 'jump' over the boundary
+ * of a month's days. The 'W' character can only be specified when the
+ * day-of-month is a single day, not a range or list of days.
+ *
+ * @author Michael Dowling <mtdowling@gmail.com>
+ */
+class DayOfMonthField extends AbstractField
+{
+    protected $rangeStart = 1;
+    protected $rangeEnd = 31;
+
+    /**
+     * Get the nearest day of the week for a given day in a month
+     *
+     * @param int $currentYear  Current year
+     * @param int $currentMonth Current month
+     * @param int $targetDay    Target day of the month
+     *
+     * @return \DateTime Returns the nearest date
+     */
+    private static function getNearestWeekday($currentYear, $currentMonth, $targetDay)
+    {
+        $tday = str_pad($targetDay, 2, '0', STR_PAD_LEFT);
+        $target = DateTime::createFromFormat('Y-m-d', "$currentYear-$currentMonth-$tday");
+        $currentWeekday = (int) $target->format('N');
+
+        if ($currentWeekday < 6) {
+            return $target;
+        }
+
+        $lastDayOfMonth = $target->format('t');
+
+        foreach (array(-1, 1, -2, 2) as $i) {
+            $adjusted = $targetDay + $i;
+            if ($adjusted > 0 && $adjusted <= $lastDayOfMonth) {
+                $target->setDate($currentYear, $currentMonth, $adjusted);
+                if ($target->format('N') < 6 && $target->format('m') == $currentMonth) {
+                    return $target;
+                }
+            }
+        }
+    }
+
+    public function isSatisfiedBy(DateTime $date, $value)
+    {
+        // ? states that the field value is to be skipped
+        if ($value == '?') {
+            return true;
+        }
+
+        $fieldValue = $date->format('d');
+
+        // Check to see if this is the last day of the month
+        if ($value == 'L') {
+            return $fieldValue == $date->format('t');
+        }
+
+        // Check to see if this is the nearest weekday to a particular value
+        if (strpos($value, 'W')) {
+            // Parse the target day
+            $targetDay = substr($value, 0, strpos($value, 'W'));
+            // Find out if the current day is the nearest day of the week
+            return $date->format('j') == self::getNearestWeekday(
+                $date->format('Y'),
+                $date->format('m'),
+                $targetDay
+            )->format('j');
+        }
+
+        return $this->isSatisfied($date->format('d'), $value);
+    }
+
+    public function increment(DateTime $date, $invert = false)
+    {
+        if ($invert) {
+            $date->modify('previous day');
+            $date->setTime(23, 59);
+        } else {
+            $date->modify('next day');
+            $date->setTime(0, 0);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function validate($value)
+    {
+        $basicChecks = parent::validate($value);
+
+        // Validate that a list don't have W or L
+        if (strpos($value, ',') !== false && (strpos($value, 'W') !== false || strpos($value, 'L') !== false)) {
+            return false;
+        }
+
+        if (!$basicChecks) {
+
+            if ($value === 'L') {
+                return true;
+            }
+
+            if (preg_match('/^(.*)W$/', $value, $matches)) {
+                return $this->validate($matches[1]);
+            }
+
+            return false;
+        }
+
+        return $basicChecks;
+    }
+}

+ 170 - 0
addons/crontab/library/Cron/DayOfWeekField.php

@@ -0,0 +1,170 @@
+<?php
+
+namespace Cron;
+
+use DateTime;
+use InvalidArgumentException;
+
+
+/**
+ * Day of week field.  Allows: * / , - ? L #
+ *
+ * Days of the week can be represented as a number 0-7 (0|7 = Sunday)
+ * or as a three letter string: SUN, MON, TUE, WED, THU, FRI, SAT.
+ *
+ * 'L' stands for "last". It allows you to specify constructs such as
+ * "the last Friday" of a given month.
+ *
+ * '#' is allowed for the day-of-week field, and must be followed by a
+ * number between one and five. It allows you to specify constructs such as
+ * "the second Friday" of a given month.
+ */
+class DayOfWeekField extends AbstractField
+{
+    protected $rangeStart = 0;
+    protected $rangeEnd = 7;
+
+    protected $nthRange;
+
+    protected $literals = [1 => 'MON', 2 => 'TUE', 3 => 'WED', 4 => 'THU', 5 => 'FRI', 6 => 'SAT', 7 => 'SUN'];
+
+    public function __construct()
+    {
+        $this->nthRange = range(1, 5);
+        parent::__construct();
+    }
+
+    public function isSatisfiedBy(DateTime $date, $value)
+    {
+        if ($value == '?') {
+            return true;
+        }
+
+        // Convert text day of the week values to integers
+        $value = $this->convertLiterals($value);
+
+        $currentYear = $date->format('Y');
+        $currentMonth = $date->format('m');
+        $lastDayOfMonth = $date->format('t');
+
+        // Find out if this is the last specific weekday of the month
+        if (strpos($value, 'L')) {
+            $weekday = str_replace('7', '0', substr($value, 0, strpos($value, 'L')));
+            $tdate = clone $date;
+            $tdate->setDate($currentYear, $currentMonth, $lastDayOfMonth);
+            while ($tdate->format('w') != $weekday) {
+                $tdateClone = new DateTime();
+                $tdate = $tdateClone
+                    ->setTimezone($tdate->getTimezone())
+                    ->setDate($currentYear, $currentMonth, --$lastDayOfMonth);
+            }
+
+            return $date->format('j') == $lastDayOfMonth;
+        }
+
+        // Handle # hash tokens
+        if (strpos($value, '#')) {
+            list($weekday, $nth) = explode('#', $value);
+
+            if (!is_numeric($nth)) {
+                throw new InvalidArgumentException("Hashed weekdays must be numeric, {$nth} given");
+            } else {
+                $nth = (int) $nth;
+            }
+
+            // 0 and 7 are both Sunday, however 7 matches date('N') format ISO-8601
+            if ($weekday === '0') {
+                $weekday = 7;
+            }
+
+            $weekday = $this->convertLiterals($weekday);
+
+            // Validate the hash fields
+            if ($weekday < 0 || $weekday > 7) {
+                throw new InvalidArgumentException("Weekday must be a value between 0 and 7. {$weekday} given");
+            }
+
+            if (!in_array($nth, $this->nthRange)) {
+                throw new InvalidArgumentException("There are never more than 5 or less than 1 of a given weekday in a month, {$nth} given");
+            }
+
+            // The current weekday must match the targeted weekday to proceed
+            if ($date->format('N') != $weekday) {
+                return false;
+            }
+
+            $tdate = clone $date;
+            $tdate->setDate($currentYear, $currentMonth, 1);
+            $dayCount = 0;
+            $currentDay = 1;
+            while ($currentDay < $lastDayOfMonth + 1) {
+                if ($tdate->format('N') == $weekday) {
+                    if (++$dayCount >= $nth) {
+                        break;
+                    }
+                }
+                $tdate->setDate($currentYear, $currentMonth, ++$currentDay);
+            }
+
+            return $date->format('j') == $currentDay;
+        }
+
+        // Handle day of the week values
+        if (strpos($value, '-')) {
+            $parts = explode('-', $value);
+            if ($parts[0] == '7') {
+                $parts[0] = '0';
+            } elseif ($parts[1] == '0') {
+                $parts[1] = '7';
+            }
+            $value = implode('-', $parts);
+        }
+
+        // Test to see which Sunday to use -- 0 == 7 == Sunday
+        $format = in_array(7, str_split($value)) ? 'N' : 'w';
+        $fieldValue = $date->format($format);
+
+        return $this->isSatisfied($fieldValue, $value);
+    }
+
+    public function increment(DateTime $date, $invert = false)
+    {
+        if ($invert) {
+            $date->modify('-1 day');
+            $date->setTime(23, 59, 0);
+        } else {
+            $date->modify('+1 day');
+            $date->setTime(0, 0, 0);
+        }
+
+        return $this;
+    }
+
+    /**
+     * @inheritDoc
+     */
+    public function validate($value)
+    {
+        $basicChecks = parent::validate($value);
+
+        if (!$basicChecks) {
+            // Handle the # value
+            if (strpos($value, '#') !== false) {
+                $chunks = explode('#', $value);
+                $chunks[0] = $this->convertLiterals($chunks[0]);
+
+                if (parent::validate($chunks[0]) && is_numeric($chunks[1]) && in_array($chunks[1], $this->nthRange)) {
+                    return true;
+                }
+            }
+
+            if (preg_match('/^(.*)L$/', $value, $matches)) {
+                return $this->validate($matches[1]);
+            }
+
+            return false;
+        }
+
+        return $basicChecks;
+    }
+}

+ 54 - 0
addons/crontab/library/Cron/FieldFactory.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace Cron;
+
+use InvalidArgumentException;
+
+/**
+ * CRON field factory implementing a flyweight factory
+ * @link http://en.wikipedia.org/wiki/Cron
+ */
+class FieldFactory
+{
+    /**
+     * @var array Cache of instantiated fields
+     */
+    private $fields = array();
+
+    /**
+     * Get an instance of a field object for a cron expression position
+     *
+     * @param int $position CRON expression position value to retrieve
+     *
+     * @return FieldInterface
+     * @throws InvalidArgumentException if a position is not valid
+     */
+    public function getField($position)
+    {
+        if (!isset($this->fields[$position])) {
+            switch ($position) {
+                case 0:
+                    $this->fields[$position] = new MinutesField();
+                    break;
+                case 1:
+                    $this->fields[$position] = new HoursField();
+                    break;
+                case 2:
+                    $this->fields[$position] = new DayOfMonthField();
+                    break;
+                case 3:
+                    $this->fields[$position] = new MonthField();
+                    break;
+                case 4:
+                    $this->fields[$position] = new DayOfWeekField();
+                    break;
+                default:
+                    throw new InvalidArgumentException(
+                        $position . ' is not a valid position'
+                    );
+            }
+        }
+
+        return $this->fields[$position];
+    }
+}

+ 40 - 0
addons/crontab/library/Cron/FieldInterface.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace Cron;
+use DateTime;
+
+/**
+ * CRON field interface
+ */
+interface FieldInterface
+{
+    /**
+     * Check if the respective value of a DateTime field satisfies a CRON exp
+     *
+     * @param DateTime $date  DateTime object to check
+     * @param string   $value CRON expression to test against
+     *
+     * @return bool Returns TRUE if satisfied, FALSE otherwise
+     */
+    public function isSatisfiedBy(DateTime $date, $value);
+
+    /**
+     * When a CRON expression is not satisfied, this method is used to increment
+     * or decrement a DateTime object by the unit of the cron field
+     *
+     * @param DateTime $date   DateTime object to change
+     * @param bool     $invert (optional) Set to TRUE to decrement
+     *
+     * @return FieldInterface
+     */
+    public function increment(DateTime $date, $invert = false);
+
+    /**
+     * Validates a CRON expression for a given field
+     *
+     * @param string $value CRON expression value to validate
+     *
+     * @return bool Returns TRUE if valid, FALSE otherwise
+     */
+    public function validate($value);
+}

+ 69 - 0
addons/crontab/library/Cron/HoursField.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace Cron;
+use DateTime;
+use DateTimeZone;
+
+
+/**
+ * Hours field.  Allows: * , / -
+ */
+class HoursField extends AbstractField
+{
+    protected $rangeStart = 0;
+    protected $rangeEnd = 23;
+
+    public function isSatisfiedBy(DateTime $date, $value)
+    {
+        return $this->isSatisfied($date->format('H'), $value);
+    }
+
+    public function increment(DateTime $date, $invert = false, $parts = null)
+    {
+        // Change timezone to UTC temporarily. This will
+        // allow us to go back or forwards and hour even
+        // if DST will be changed between the hours.
+        if (is_null($parts) || $parts == '*') {
+            $timezone = $date->getTimezone();
+            $date->setTimezone(new DateTimeZone('UTC'));
+            if ($invert) {
+                $date->modify('-1 hour');
+            } else {
+                $date->modify('+1 hour');
+            }
+            $date->setTimezone($timezone);
+
+            $date->setTime($date->format('H'), $invert ? 59 : 0);
+            return $this;
+        }
+
+        $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts);
+        $hours = array();
+        foreach ($parts as $part) {
+            $hours = array_merge($hours, $this->getRangeForExpression($part, 23));
+        }
+
+        $current_hour = $date->format('H');
+        $position = $invert ? count($hours) - 1 : 0;
+        if (count($hours) > 1) {
+            for ($i = 0; $i < count($hours) - 1; $i++) {
+                if ((!$invert && $current_hour >= $hours[$i] && $current_hour < $hours[$i + 1]) ||
+                    ($invert && $current_hour > $hours[$i] && $current_hour <= $hours[$i + 1])) {
+                    $position = $invert ? $i : $i + 1;
+                    break;
+                }
+            }
+        }
+
+        $hour = $hours[$position];
+        if ((!$invert && $date->format('H') >= $hour) || ($invert && $date->format('H') <= $hour)) {
+            $date->modify(($invert ? '-' : '+') . '1 day');
+            $date->setTime($invert ? 23 : 0, $invert ? 59 : 0);
+        }
+        else {
+            $date->setTime($hour, $invert ? 59 : 0);
+        }
+
+        return $this;
+    }
+}

+ 60 - 0
addons/crontab/library/Cron/MinutesField.php

@@ -0,0 +1,60 @@
+<?php
+
+namespace Cron;
+
+use DateTime;
+
+
+/**
+ * Minutes field.  Allows: * , / -
+ */
+class MinutesField extends AbstractField
+{
+    protected $rangeStart = 0;
+    protected $rangeEnd = 59;
+
+    public function isSatisfiedBy(DateTime $date, $value)
+    {
+        return $this->isSatisfied($date->format('i'), $value);
+    }
+
+    public function increment(DateTime $date, $invert = false, $parts = null)
+    {
+        if (is_null($parts)) {
+            if ($invert) {
+                $date->modify('-1 minute');
+            } else {
+                $date->modify('+1 minute');
+            }
+            return $this;
+        }
+
+        $parts = strpos($parts, ',') !== false ? explode(',', $parts) : array($parts);
+        $minutes = array();
+        foreach ($parts as $part) {
+            $minutes = array_merge($minutes, $this->getRangeForExpression($part, 59));
+        }
+
+        $current_minute = $date->format('i');
+        $position = $invert ? count($minutes) - 1 : 0;
+        if (count($minutes) > 1) {
+            for ($i = 0; $i < count($minutes) - 1; $i++) {
+                if ((!$invert && $current_minute >= $minutes[$i] && $current_minute < $minutes[$i + 1]) ||
+                    ($invert && $current_minute > $minutes[$i] && $current_minute <= $minutes[$i + 1])) {
+                    $position = $invert ? $i : $i + 1;
+                    break;
+                }
+            }
+        }
+
+        if ((!$invert && $current_minute >= $minutes[$position]) || ($invert && $current_minute <= $minutes[$position])) {
+            $date->modify(($invert ? '-' : '+') . '1 hour');
+            $date->setTime($date->format('H'), $invert ? 59 : 0);
+        }
+        else {
+            $date->setTime($date->format('H'), $minutes[$position]);
+        }
+
+        return $this;
+    }
+}

+ 38 - 0
addons/crontab/library/Cron/MonthField.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace Cron;
+
+use DateTime;
+
+/**
+ * Month field.  Allows: * , / -
+ */
+class MonthField extends AbstractField
+{
+    protected $rangeStart = 1;
+    protected $rangeEnd = 12;
+    protected $literals = [1 => 'JAN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAY', 6 => 'JUN', 7 => 'JUL',
+        8 => 'AUG', 9 => 'SEP', 10 => 'OCT', 11 => 'NOV', 12 => 'DEC'];
+
+    public function isSatisfiedBy(DateTime $date, $value)
+    {
+        $value = $this->convertLiterals($value);
+
+        return $this->isSatisfied($date->format('m'), $value);
+    }
+
+    public function increment(DateTime $date, $invert = false)
+    {
+        if ($invert) {
+            $date->modify('last day of previous month');
+            $date->setTime(23, 59);
+        } else {
+            $date->modify('first day of next month');
+            $date->setTime(0, 0);
+        }
+
+        return $this;
+    }
+
+
+}

+ 183 - 0
addons/crontab/library/SimpleFork/AbstractPool.php

@@ -0,0 +1,183 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/11/3
+ * Time: 14:37
+ */
+
+namespace Jenner\SimpleFork;
+
+
+/**
+ * processes' pool
+ *
+ * @package Jenner\SimpleFork
+ */
+abstract class AbstractPool
+{
+    /**
+     * process list
+     *
+     * @var Process[]
+     */
+    protected $processes = array();
+
+    /**
+     * get process by pid
+     *
+     * @param $pid
+     * @return null|Process
+     */
+    public function getProcessByPid($pid)
+    {
+        foreach ($this->processes as $process) {
+            if ($process->getPid() == $pid) {
+                return $process;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * shutdown sub process and no wait. it is dangerous,
+     * maybe the sub process is working.
+     */
+    public function shutdownForce()
+    {
+        $this->shutdown(SIGKILL);
+    }
+
+    /**
+     * shutdown all process
+     *
+     * @param int $signal
+     */
+    public function shutdown($signal = SIGTERM)
+    {
+        foreach ($this->processes as $process) {
+            if ($process->isRunning()) {
+                $process->shutdown(true, $signal);
+            }
+        }
+    }
+
+    /**
+     * if all processes are stopped
+     *
+     * @return bool
+     */
+    public function isFinished()
+    {
+        foreach ($this->processes as $process) {
+            if (!$process->isStopped()) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * waiting for the sub processes to exit
+     *
+     * @param bool|true $block if true the parent process will be blocked until all
+     * sub processes exit. else it will check if there are processes that had been exited once and return.
+     * @param int $sleep when $block is true, it will check sub processes every $sleep minute
+     */
+    public function wait($block = true, $sleep = 100)
+    {
+        do {
+            foreach ($this->processes as $process) {
+                if (!$process->isRunning()) {
+                    continue;
+                }
+            }
+            usleep($sleep);
+        } while ($block && $this->aliveCount() > 0);
+    }
+
+    /**
+     * get the count of running processes
+     *
+     * @return int
+     */
+    public function aliveCount()
+    {
+        $count = 0;
+        foreach ($this->processes as $process) {
+            if ($process->isRunning()) {
+                $count++;
+            }
+        }
+
+        return $count;
+    }
+
+    /**
+     * get process by name
+     *
+     * @param string $name process name
+     * @return Process|null
+     */
+    public function getProcessByName($name)
+    {
+        foreach ($this->processes as $process) {
+            if ($process->name() == $name) {
+                return $process;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * remove process by name
+     *
+     * @param string $name process name
+     * @throws \RuntimeException
+     */
+    public function removeProcessByName($name)
+    {
+        foreach ($this->processes as $key => $process) {
+            if ($process->name() == $name) {
+                if ($process->isRunning()) {
+                    throw new \RuntimeException("can not remove a running process");
+                }
+                unset($this->processes[$key]);
+            }
+        }
+    }
+
+    /**
+     * remove exited process
+     */
+    public function removeExitedProcess()
+    {
+        foreach ($this->processes as $key => $process) {
+            if ($process->isStopped()) {
+                unset($this->processes[$key]);
+            }
+        }
+    }
+
+    /**
+     * return process count
+     *
+     * @return int
+     */
+    public function count()
+    {
+        return count($this->processes);
+    }
+
+    /**
+     * get all processes
+     *
+     * @return Process[]
+     */
+    public function getProcesses()
+    {
+        return $this->processes;
+    }
+}

+ 53 - 0
addons/crontab/library/SimpleFork/Cache/CacheInterface.php

@@ -0,0 +1,53 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 14:59
+ */
+
+namespace Jenner\SimpleFork\Cache;
+
+/**
+ * cache for processes shared variables
+ *
+ * @package Jenner\SimpleFork\Cache
+ */
+interface CacheInterface
+{
+
+    /**
+     * get var
+     *
+     * @param $key
+     * @param null $default
+     * @return bool|mixed
+     */
+    public function get($key, $default = null);
+
+    /**
+     * set var
+     *
+     * @param $key
+     * @param null $value
+     * @return
+     */
+    public function set($key, $value);
+
+    /**
+     * has var ?
+     *
+     * @param $key
+     * @return bool
+     */
+    public function has($key);
+
+    /**
+     * delete var
+     *
+     * @param $key
+     * @return bool
+     */
+    public function delete($key);
+
+}

+ 263 - 0
addons/crontab/library/SimpleFork/Cache/FileCache.php

@@ -0,0 +1,263 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2016/6/22
+ * Time: 16:18
+ */
+
+namespace Jenner\SimpleFork\Cache;
+
+
+class FileCache implements CacheInterface
+{
+
+    /**
+     * 缓存目录
+     * @var
+     */
+    private $cache_dir;
+
+    /**
+     * @param string $cache_dir
+     * @throws \Exception
+     */
+    public function __construct($cache_dir)
+    {
+        $this->cache_dir = $cache_dir;
+        if (!is_dir($cache_dir)) {
+            $make_dir_result = mkdir($cache_dir, 0755, true);
+            if ($make_dir_result === false) throw new \Exception('Cannot create the cache directory');
+        }
+    }
+
+
+    /**
+     * get value by key, and check if it is expired
+     * @param string $key
+     * @param string $default
+     * @return mixed
+     */
+    public function get($key, $default = null)
+    {
+        $cache_data = $this->getItem($key);
+        if ($cache_data === false || !is_array($cache_data)) return $default;
+
+        return $cache_data['data'];
+    }
+
+    /**
+     * 添加或覆盖一个key
+     * @param string $key
+     * @param mixed $value
+     * @param int $expire expire time in seconds
+     * @return mixed
+     */
+    public function set($key, $value, $expire = 0)
+    {
+        return $this->setItem($key, $value, time(), $expire);
+    }
+
+    /**
+     * 设置包含元数据的信息
+     * @param $key
+     * @param $value
+     * @param $time
+     * @param $expire
+     * @return bool
+     */
+    private function setItem($key, $value, $time, $expire)
+    {
+        $cache_file = $this->createCacheFile($key);
+        if ($cache_file === false) return false;
+
+        $cache_data = array('data' => $value, 'time' => $time, 'expire' => $expire);
+        $cache_data = serialize($cache_data);
+
+        $put_result = file_put_contents($cache_file, $cache_data);
+        if ($put_result === false) return false;
+
+        return true;
+    }
+
+    /**
+     * 创建缓存文件
+     * @param $key
+     * @return bool|string
+     */
+    private function createCacheFile($key)
+    {
+        $cache_file = $this->path($key);
+        if (!file_exists($cache_file)) {
+            $directory = dirname($cache_file);
+            if (!is_dir($directory)) {
+                $make_dir_result = mkdir($directory, 0755, true);
+                if ($make_dir_result === false) return false;
+            }
+            $create_result = touch($cache_file);
+            if ($create_result === false) return false;
+        }
+
+        return $cache_file;
+    }
+
+    /**
+     * 判断Key是否存在
+     * @param $key
+     * @return mixed
+     */
+    public function has($key)
+    {
+        $value = $this->get($key);
+        if ($value === false) return false;
+
+        return true;
+    }
+
+    /**
+     * 加法递增
+     * @param $key
+     * @param int $value
+     * @return mixed
+     */
+    public function increment($key, $value = 1)
+    {
+        $item = $this->getItem($key);
+        if ($item === false) {
+            $set_result = $this->set($key, $value);
+            if ($set_result === false) return false;
+            return $value;
+        }
+
+        $check_expire = $this->checkExpire($item);
+        if ($check_expire === false) return false;
+
+        $item['data'] += $value;
+
+        $result = $this->setItem($key, $item['data'], $item['time'], $item['expire']);
+        if ($result === false) return false;
+
+        return $item['data'];
+    }
+
+    /**
+     * 减法递增
+     * @param $key
+     * @param int $value
+     * @return mixed
+     */
+    public function decrement($key, $value = 1)
+    {
+        $item = $this->getItem($key);
+        if ($item === false) {
+            $value = 0 - $value;
+            $set_result = $this->set($key, $value);
+            if ($set_result === false) return false;
+            return $value;
+        }
+
+        $check_expire = $this->checkExpire($item);
+        if ($check_expire === false) return false;
+
+        $item['data'] -= $value;
+
+        $result = $this->setItem($key, $item['data'], $item['time'], $item['expire']);
+        if ($result === false) return false;
+
+        return $item['data'];
+    }
+
+    /**
+     * 删除一个key,同事会删除缓存文件
+     * @param $key
+     * @return boolean
+     */
+    public function delete($key)
+    {
+        $cache_file = $this->path($key);
+        if (file_exists($cache_file)) {
+            $unlink_result = unlink($cache_file);
+            if ($unlink_result === false) return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * 清楚所有缓存
+     * @return mixed
+     */
+    public function flush()
+    {
+        return $this->delTree($this->cache_dir);
+    }
+
+    /**
+     * 递归删除目录
+     * @param $dir
+     * @return bool
+     */
+    function delTree($dir)
+    {
+        $files = array_diff(scandir($dir), array('.', '..'));
+        foreach ($files as $file) {
+            (is_dir("$dir/$file")) ? $this->delTree("$dir/$file") : unlink("$dir/$file");
+        }
+        return rmdir($dir);
+    }
+
+    /**
+     * 根据key获取缓存文件路径
+     *
+     * @param  string $key
+     * @return string
+     */
+    protected function path($key)
+    {
+        $parts = array_slice(str_split($hash = md5($key), 2), 0, 2);
+        return $this->cache_dir . '/' . implode('/', $parts) . '/' . $hash;
+    }
+
+    /**
+     * 获取含有元数据的信息
+     * @param $key
+     * @return bool|mixed|string
+     */
+    protected function getItem($key)
+    {
+        $cache_file = $this->path($key);
+        if (!file_exists($cache_file) || !is_readable($cache_file)) {
+            return false;
+        }
+
+        $data = file_get_contents($cache_file);
+        if (empty($data)) return false;
+        $cache_data = unserialize($data);
+
+        if ($cache_data === false) {
+            return false;
+        }
+
+        $check_expire = $this->checkExpire($cache_data);
+        if ($check_expire === false) {
+            $this->delete($key);
+            return false;
+        }
+
+        return $cache_data;
+    }
+
+    /**
+     * 检查key是否过期
+     * @param $cache_data
+     * @return bool
+     */
+    protected function checkExpire($cache_data)
+    {
+        $time = time();
+        $is_expire = intval($cache_data['expire']) !== 0 && (intval($cache_data['time']) + intval($cache_data['expire']) < $time);
+        if ($is_expire) return false;
+
+        return true;
+    }
+}

+ 126 - 0
addons/crontab/library/SimpleFork/Cache/RedisCache.php

@@ -0,0 +1,126 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/20
+ * Time: 15:14
+ */
+
+namespace Jenner\SimpleFork\Cache;
+
+
+/**
+ * redis cache
+ *
+ * @package Jenner\SimpleFork\Cache
+ */
+class RedisCache implements CacheInterface
+{
+
+    /**
+     * @var \Redis
+     */
+    protected $redis;
+
+    protected $prefix;
+
+    /**
+     * @param string $host
+     * @param int $port
+     * @param int $database
+     * @param string $prefix
+     */
+    public function __construct(
+        $host = '127.0.0.1',
+        $port = 6379,
+        $database = 0,
+        $prefix = 'simple-fork'
+    )
+    {
+        $this->redis = new \Redis();
+        $connection_result = $this->redis->connect($host, $port);
+        if (!$connection_result) {
+            throw new \RuntimeException('can not connect to the redis server');
+        }
+
+        if ($database != 0) {
+            $select_result = $this->redis->select($database);
+            if (!$select_result) {
+                throw new \RuntimeException('can not select the database');
+            }
+        }
+
+        if (empty($prefix)) {
+            throw new \InvalidArgumentException('prefix can not be empty');
+        }
+        $this->prefix = $prefix;
+    }
+
+    /**
+     * close redis connection
+     */
+    public function __destruct()
+    {
+        $this->close();
+    }
+
+    /**
+     * close the connection
+     */
+    public function close()
+    {
+        $this->redis->close();
+    }
+
+    /**
+     * get var
+     *
+     * @param $key
+     * @param null $default
+     * @return bool|string|null
+     */
+    public function get($key, $default = null)
+    {
+        $result = $this->redis->hGet($this->prefix, $key);
+        if ($result !== false) return $result;
+
+        return $default;
+    }
+
+    /**
+     * set var
+     *
+     * @param $key
+     * @param null $value
+     * @return boolean
+     */
+    public function set($key, $value)
+    {
+        return $this->redis->hSet($this->prefix, $key, $value);
+    }
+
+    /**
+     * has var ?
+     *
+     * @param $key
+     * @return bool
+     */
+    public function has($key)
+    {
+        return $this->redis->hExists($this->prefix, $key);
+    }
+
+    /**
+     * delete var
+     *
+     * @param $key
+     * @return bool
+     */
+    public function delete($key)
+    {
+        if ($this->redis->hDel($this->prefix, $key) > 0) {
+            return true;
+        }
+        return false;
+    }
+}

+ 176 - 0
addons/crontab/library/SimpleFork/Cache/SharedMemory.php

@@ -0,0 +1,176 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 15:00
+ */
+
+namespace Jenner\SimpleFork\Cache;
+
+
+/**
+ * shared memory cache
+ *
+ * @package Jenner\SimpleFork\Cache
+ */
+class SharedMemory implements CacheInterface
+{
+    /**
+     * holds shared memory resource
+     * @var resource
+     */
+    protected $shm;
+
+    /**
+     * shared memory ipc key
+     * @var string
+     */
+    protected $client_count_key = 'system_client_count';
+
+    /**
+     * memory size
+     * @var int
+     */
+    protected $size;
+
+    /**
+     * @param int $size memory size
+     * @param string $file
+     */
+    public function __construct($size = 33554432, $file = __FILE__)
+    {
+        $this->size = $size;
+        if (function_exists("shm_attach") === false) {
+            $message = "\nYour PHP configuration needs adjustment. " .
+                "See: http://us2.php.net/manual/en/shmop.setup.php. " .
+                "To enable the System V shared memory support compile " .
+                " PHP with the option --enable-sysvshm.";
+
+            throw new \RuntimeException($message);
+        }
+        $this->attach($file); //create resources (shared memory)
+    }
+
+    /**
+     * connect shared memory
+     *
+     * @param string $file
+     */
+    public function attach($file = __FILE__)
+    {
+        if (!file_exists($file)) {
+            $touch = touch($file);
+            if (!$touch) {
+                throw new \RuntimeException("file is not exists and it can not be created. file: {$file}");
+            }
+        }
+        $key = ftok($file, 'a');
+        $this->shm = shm_attach($key, $this->size); //allocate shared memory
+    }
+
+    /**
+     * remove shared memory.
+     * you should know that it maybe does not work.
+     *
+     * @return bool
+     */
+    public function remove()
+    {
+        //dallocate shared memory
+        if (!shm_remove($this->shm)) {
+            return false;
+        }
+        $this->dettach();
+        // shm_remove maybe not working. it likes a php bug.
+        unset($this->shm);
+
+        return true;
+    }
+
+    /**
+     * @return bool
+     */
+    public function dettach()
+    {
+        return shm_detach($this->shm); //allocate shared memory
+    }
+
+    /**
+     * set var
+     *
+     * @param $key
+     * @param $value
+     * @return bool
+     */
+    public function set($key, $value)
+    {
+        return shm_put_var($this->shm, $this->shm_key($key), $value); //store var
+    }
+
+    /**
+     * generate shm key
+     *
+     * @param $val
+     * @return mixed
+     */
+    public function shm_key($val)
+    {   // enable all world langs and chars !
+        // text to number system.
+        return preg_replace("/[^0-9]/", "", (preg_replace("/[^0-9]/", "", md5($val)) / 35676248) / 619876);
+    }
+
+    /**
+     * get var
+     *
+     * @param $key
+     * @param null $default
+     * @return bool|mixed
+     */
+    public function get($key, $default = null)
+    {
+        if ($this->has($key)) {
+            return shm_get_var($this->shm, $this->shm_key($key));
+        } else {
+            return $default;
+        }
+    }
+
+    /**
+     * has var ?
+     *
+     * @param $key
+     * @return bool
+     */
+    public function has($key)
+    {
+        if (shm_has_var($this->shm, $this->shm_key($key))) { // check is isset
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * delete var
+     *
+     * @param $key
+     * @return bool
+     */
+    public function delete($key)
+    {
+        if ($this->has($key)) {
+            return shm_remove_var($this->shm, $this->shm_key($key));
+        } else {
+            return false;
+        }
+    }
+
+    /**
+     * init when wakeup
+     */
+    public function __wakeup()
+    {
+        $this->attach();
+    }
+}

+ 67 - 0
addons/crontab/library/SimpleFork/FixedPool.php

@@ -0,0 +1,67 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/11/2
+ * Time: 17:45
+ */
+
+namespace Jenner\SimpleFork;
+
+
+/**
+ * fixed pool
+ *
+ * @package Jenner\SimpleFork
+ */
+class FixedPool extends AbstractPool
+{
+    /**
+     * @var int max process count
+     */
+    protected $max;
+
+    /**
+     * @param int $max
+     */
+    public function __construct($max = 4)
+    {
+        $this->max = $max;
+    }
+
+    public function execute(Process $process)
+    {
+        Utils::checkOverwriteRunMethod(get_class($process));
+
+        if ($this->aliveCount() < $this->max && !$process->isStarted()) {
+            $process->start();
+        }
+        array_push($this->processes, $process);
+    }
+
+    /**
+     * wait for all process done
+     *
+     * @param bool $block block the master process
+     * to keep the sub process count all the time
+     * @param int $interval check time interval
+     */
+    public function wait($block = false, $interval = 100)
+    {
+        do {
+            if ($this->isFinished()) {
+                return;
+            }
+            parent::wait(false);
+            if ($this->aliveCount() < $this->max) {
+                foreach ($this->processes as $process) {
+                    if ($process->isStarted()) continue;
+                    $process->start();
+                    if ($this->aliveCount() >= $this->max) break;
+                }
+            }
+            $block ? usleep($interval) : null;
+        } while ($block);
+    }
+
+}

+ 126 - 0
addons/crontab/library/SimpleFork/Lock/FileLock.php

@@ -0,0 +1,126 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/21
+ * Time: 14:30
+ */
+
+namespace Jenner\SimpleFork\Lock;
+
+
+/**
+ * file lock
+ *
+ * @package Jenner\SimpleFork\Lock
+ */
+class FileLock implements LockInterface
+{
+    /**
+     * @var string lock file
+     */
+    protected $file;
+
+    /**
+     * @var resource
+     */
+    protected $fp;
+
+    /**
+     * @var bool
+     */
+    protected $locked = false;
+
+    /**
+     * @param $file
+     */
+    private function __construct($file)
+    {
+        if (!file_exists($file) || !is_readable($file)) {
+            throw new \RuntimeException("{$file} is not exists or not readable");
+        }
+        $this->fp = fopen($file, "r+");
+        if (!is_resource($this->fp)) {
+            throw new \RuntimeException("open {$file} failed");
+        }
+    }
+
+    /**
+     * create a file lock instance
+     * if the file is not exists, it will be created
+     *
+     * @param string $file lock file
+     * @return FileLock
+     */
+    public static function create($file)
+    {
+        return new FileLock($file);
+    }
+
+    /**
+     * get a lock
+     *
+     * @param bool $blocking
+     * @return mixed
+     */
+    public function acquire($blocking = true)
+    {
+        if ($this->locked) {
+            throw new \RuntimeException('already lock by yourself');
+        }
+
+        if ($blocking) {
+            $locked = flock($this->fp, LOCK_EX);
+        } else {
+            $locked = flock($this->fp, LOCK_EX | LOCK_NB);
+        }
+
+        if ($locked !== true) {
+            return false;
+        }
+        $this->locked = true;
+
+        return true;
+    }
+
+    /**
+     * is locked
+     *
+     * @return mixed
+     */
+    public function isLocked()
+    {
+        return $this->locked === true ? true : false;
+    }
+
+    /**
+     *
+     */
+    public function __destory()
+    {
+        if ($this->locked) {
+            $this->release();
+        }
+    }
+
+    /**
+     * release lock
+     *
+     * @return mixed
+     */
+    public function release()
+    {
+        if (!$this->locked) {
+            throw new \RuntimeException('release a non lock');
+        }
+
+        $unlock = flock($this->fp, LOCK_UN);
+        fclose($this->fp);
+        if ($unlock !== true) {
+            return false;
+        }
+        $this->locked = false;
+
+        return true;
+    }
+}

+ 40 - 0
addons/crontab/library/SimpleFork/Lock/LockInterface.php

@@ -0,0 +1,40 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/21
+ * Time: 14:24
+ */
+
+namespace Jenner\SimpleFork\Lock;
+
+
+/**
+ * lock for processes to mutual exclusion
+ *
+ * @package Jenner\SimpleFork\Lock
+ */
+interface LockInterface
+{
+    /**
+     * get a lock
+     *
+     * @param bool $blocking
+     * @return bool
+     */
+    public function acquire($blocking = true);
+
+    /**
+     * release lock
+     *
+     * @return bool
+     */
+    public function release();
+
+    /**
+     * is locked
+     *
+     * @return bool
+     */
+    public function isLocked();
+}

+ 163 - 0
addons/crontab/library/SimpleFork/Lock/Semaphore.php

@@ -0,0 +1,163 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 20:52
+ */
+
+namespace Jenner\SimpleFork\Lock;
+
+
+/**
+ * sem lock
+ *
+ * @package Jenner\SimpleFork\Lock
+ */
+class Semaphore implements LockInterface
+{
+    /**
+     * @var
+     */
+    private $lock_id;
+    /**
+     * @var bool
+     */
+    private $locked = false;
+
+    /**
+     * init a lock
+     *
+     * @param $key
+     * @param $count
+     * @throws \RuntimeException
+     */
+    private function __construct($key, $count = 1)
+    {
+        if (($this->lock_id = sem_get($this->_stringToSemKey($key), $count)) === false) {
+            throw new \RuntimeException("Cannot create semaphore for key: {$key}");
+        }
+    }
+
+    /**
+     * Semaphore requires a numeric value as the key
+     *
+     * @param $identifier
+     * @return int
+     */
+    protected function _stringToSemKey($identifier)
+    {
+        $md5 = md5($identifier);
+        $key = 0;
+        for ($i = 0; $i < 32; $i++) {
+            $key += ord($md5{$i}) * $i;
+        }
+        return $key;
+    }
+
+    /**
+     * create a lock instance
+     *
+     * @param $key
+     * @return Semaphore
+     */
+    public static function create($key)
+    {
+        return new Semaphore($key);
+    }
+
+    /**
+     * release lock
+     *
+     * @throws \RuntimeException
+     */
+    public function __destruct()
+    {
+        if ($this->isLocked()) {
+            $this->release();
+        }
+    }
+
+    /**
+     * is locked
+     *
+     * @return bool
+     */
+    public function isLocked()
+    {
+        return $this->locked === true ? true : false;
+    }
+
+    /**
+     * release lock
+     *
+     * @return bool
+     * @throws \RuntimeException
+     */
+    public function release()
+    {
+        if (!$this->locked) {
+            throw new \RuntimeException("release a non lock");
+        }
+
+        if (!sem_release($this->lock_id)) {
+            return false;
+        }
+        $this->locked = false;
+
+        return true;
+    }
+
+    /**
+     * get a lock
+     *
+     * @param bool $blocking
+     * @return bool
+     */
+    public function acquire($blocking = true)
+    {
+        if ($this->locked) {
+            throw new \RuntimeException('already lock by yourself');
+        }
+
+        if ($blocking === false) {
+            if (version_compare(PHP_VERSION, '5.6.0') < 0) {
+                throw new \RuntimeException('php version is at least 5.6.0 for param blocking');
+            }
+            if (!sem_acquire($this->lock_id, true)) {
+                return false;
+            }
+            $this->locked = true;
+
+            return true;
+        }
+
+        if (!sem_acquire($this->lock_id)) {
+            return false;
+        }
+        $this->locked = true;
+
+        return true;
+    }
+
+    /**
+     * remove the semaphore resource
+     *
+     * @return bool
+     */
+    public function remove()
+    {
+        if ($this->locked) {
+            throw new \RuntimeException('can not remove a locked semaphore resource');
+        }
+        if (!is_resource($this->lock_id)) {
+            throw new \RuntimeException('can not remove a empty semaphore resource');
+        }
+
+        if (!sem_release($this->lock_id)) {
+            return false;
+        }
+
+        return true;
+    }
+}

+ 106 - 0
addons/crontab/library/SimpleFork/ParallelPool.php

@@ -0,0 +1,106 @@
+<?php
+/**
+ * @author Jenner <hypxm@qq.com>
+ * @blog http://www.huyanping.cn
+ * @license https://opensource.org/licenses/MIT MIT
+ * @datetime: 2015/11/19 20:49
+ */
+
+namespace Jenner\SimpleFork;
+
+/**
+ * parallel pool
+ *
+ * @package Jenner\SimpleFork
+ */
+class ParallelPool extends AbstractPool
+{
+
+    /**
+     * @var callable|Runnable sub process callback
+     */
+    protected $runnable;
+
+    /**
+     * @var int max process count
+     */
+    protected $max;
+
+    /**
+     * @param callable|Runnable $callback
+     * @param int $max
+     */
+    public function __construct($callback, $max = 4)
+    {
+        if (!is_callable($callback) && !($callback instanceof Runnable)) {
+            throw new \InvalidArgumentException('callback must be a callback function or a object of Runnalbe');
+        }
+
+        $this->runnable = $callback;
+        $this->max = $max;
+    }
+
+    /**
+     * start the same number processes and kill the old sub process
+     * just like nginx -s reload
+     * this method will block until all the old process exit;
+     *
+     * @param bool $block
+     */
+    public function reload($block = true)
+    {
+        $old_processes = $this->processes;
+        for ($i = 0; $i < $this->max; $i++) {
+            $process = new Process($this->runnable);
+            $process->start();
+            $this->processes[$process->getPid()] = $process;
+        }
+
+        foreach ($old_processes as $process) {
+            $process->shutdown();
+            $process->wait($block);
+            unset($this->processes[$process->getPid()]);
+        }
+    }
+
+    /**
+     * keep sub process count
+     *
+     * @param bool $block block the master process
+     * to keep the sub process count all the time
+     * @param int $interval check time interval
+     */
+    public function keep($block = false, $interval = 100)
+    {
+        do {
+            $this->start();
+
+            // recycle sub process and delete the processes
+            // which are not running from process list
+            foreach ($this->processes as $process) {
+                if (!$process->isRunning()) {
+                    unset($this->processes[$process->getPid()]);
+                }
+            }
+
+            $block ? usleep($interval) : null;
+        } while ($block);
+    }
+
+    /**
+     * start the pool
+     */
+    public function start()
+    {
+        $alive_count = $this->aliveCount();
+        // create sub process and run
+        if ($alive_count < $this->max) {
+            $need = $this->max - $alive_count;
+            for ($i = 0; $i < $need; $i++) {
+                $process = new Process($this->runnable);
+                $process->start();
+                $this->processes[$process->getPid()] = $process;
+            }
+        }
+    }
+}

+ 38 - 0
addons/crontab/library/SimpleFork/Pool.php

@@ -0,0 +1,38 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 17:54
+ */
+
+namespace Jenner\SimpleFork;
+
+
+/**
+ * pool
+ *
+ * @package Jenner\SimpleFork
+ */
+class Pool extends AbstractPool
+{
+
+    /**
+     * add a process
+     *
+     * @param Process $process
+     * @param null|string $name process name
+     * @return int
+     */
+    public function execute(Process $process, $name = null)
+    {
+        if (!is_null($name)) {
+            $process->name($name);
+        }
+        if (!$process->isStarted()) {
+            $process->start();
+        }
+
+        return array_push($this->processes, $process);
+    }
+}

+ 56 - 0
addons/crontab/library/SimpleFork/PoolFactory.php

@@ -0,0 +1,56 @@
+<?php
+/**
+ * @author Jenner <hypxm@qq.com>
+ * @blog http://www.huyanping.cn
+ * @license https://opensource.org/licenses/MIT MIT
+ * @datetime: 2015/11/19 21:14
+ */
+
+namespace Jenner\SimpleFork;
+
+
+class PoolFactory
+{
+    /**
+     * create a pool instance
+     *
+     * @return Pool
+     */
+    public static function newPool()
+    {
+        return new Pool();
+    }
+
+    /**
+     * create a fixed pool instance
+     *
+     * @param int $max
+     * @return FixedPool
+     */
+    public static function newFixedPool($max = 4)
+    {
+        return new FixedPool($max);
+    }
+
+    /**
+     * create a parallel pool instance
+     *
+     * @param $callback
+     * @param int $max
+     * @return ParallelPool
+     */
+    public static function newParallelPool($callback, $max = 4)
+    {
+        return new ParallelPool($callback, $max);
+    }
+
+    /**
+     * create a single pool
+     *
+     * @return SinglePool
+     */
+    public static function newSinglePool()
+    {
+        return new SinglePool();
+    }
+}

+ 373 - 0
addons/crontab/library/SimpleFork/Process.php

@@ -0,0 +1,373 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 15:25
+ */
+
+namespace Jenner\SimpleFork;
+
+class Process
+{
+    /**
+     * @var Runnable|callable
+     */
+    protected $runnable;
+
+    /**
+     * @var int
+     */
+    protected $pid = 0;
+
+    /**
+     * @var string custom process name
+     */
+    protected $name = null;
+
+    /**
+     * @var bool if the process is started
+     */
+    protected $started = false;
+
+    /**
+     * @var bool
+     */
+    protected $running = false;
+
+    /**
+     * @var int the signal which made the process terminate
+     */
+    protected $term_signal = null;
+
+    /**
+     * @var int the signal which made the process stop
+     */
+    protected $stop_signal = null;
+
+    /**
+     * @var int error code
+     */
+    protected $errno = null;
+
+    /**
+     * @var string error message
+     */
+    protected $errmsg = null;
+
+    /**
+     * @var bool
+     */
+    protected $if_signal = false;
+
+    /**
+     * @var array
+     */
+    protected $callbacks = array();
+
+    /**
+     * @var array signal handlers
+     */
+    protected $signal_handlers = array();
+
+
+    /**
+     * @param string $execution it can be a Runnable object, callback function or null
+     * @param null $name process name,you can manager the process by it's name.
+     */
+    public function __construct($execution = null, $name = null)
+    {
+        if (!is_null($execution) && $execution instanceof Runnable) {
+            $this->runnable = $execution;
+        } elseif (!is_null($execution) && is_callable($execution)) {
+            $this->runnable = $execution;
+        } elseif (!is_null($execution)) {
+            throw new \InvalidArgumentException('param execution is not a object of Runnable or callable');
+        } else {
+            Utils::checkOverwriteRunMethod(get_class($this));
+        }
+        if (!is_null($name)) {
+            $this->name = $name;
+        }
+
+        $this->initStatus();
+    }
+
+    /**
+     * init process status
+     */
+    protected function initStatus()
+    {
+        $this->pid = null;
+        $this->running = null;
+        $this->term_signal = null;
+        $this->stop_signal = null;
+        $this->errno = null;
+        $this->errmsg = null;
+    }
+
+    /**
+     * get pid
+     *
+     * @return int
+     */
+    public function getPid()
+    {
+        return $this->pid;
+    }
+
+    /**
+     * get or set name
+     *
+     * @param string|null $name
+     * @return mixed
+     */
+    public function name($name = null)
+    {
+        if (!is_null($name)) {
+            $this->name = $name;
+        } else {
+            return $this->name;
+        }
+    }
+
+    /**
+     * if the process is stopped
+     *
+     * @return bool
+     */
+    public function isStopped()
+    {
+        if (is_null($this->errno)) {
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * if the process is started
+     *
+     * @return bool
+     */
+    public function isStarted()
+    {
+        return $this->started;
+    }
+
+    /**
+     * get pcntl errno
+     *
+     * @return int
+     */
+    public function errno()
+    {
+        return $this->errno;
+    }
+
+    /**
+     * get pcntl errmsg
+     *
+     * @return string
+     */
+    public function errmsg()
+    {
+        return $this->errmsg;
+    }
+
+    public function ifSignal()
+    {
+        return $this->if_signal;
+    }
+
+    /**
+     * start the sub process
+     * and run the callback
+     *
+     * @return string pid
+     */
+    public function start()
+    {
+        if (!empty($this->pid) && $this->isRunning()) {
+            throw new \LogicException("the process is already running");
+        }
+
+        $callback = $this->getCallable();
+
+        $pid = pcntl_fork();
+        if ($pid < 0) {
+            throw new \RuntimeException("fork error");
+        } elseif ($pid > 0) {
+            $this->pid = $pid;
+            $this->running = true;
+            $this->started = true;
+        } else {
+            $this->pid = getmypid();
+            $this->signal();
+            foreach ($this->signal_handlers as $signal => $handler) {
+                pcntl_signal($signal, $handler);
+            }
+            call_user_func($callback);
+            exit(0);
+        }
+    }
+
+    /**
+     * if the process is running
+     *
+     * @return bool
+     */
+    public function isRunning()
+    {
+        $this->updateStatus();
+        return $this->running;
+    }
+
+    /**
+     * update the process status
+     *
+     * @param bool $block
+     */
+    protected function updateStatus($block = false)
+    {
+        if ($this->running !== true) {
+            return;
+        }
+
+        if ($block) {
+            $res = pcntl_waitpid($this->pid, $status);
+        } else {
+            $res = pcntl_waitpid($this->pid, $status, WNOHANG | WUNTRACED);
+        }
+
+        if ($res === -1) {
+            throw new \RuntimeException('pcntl_waitpid failed. the process maybe available');
+        } elseif ($res === 0) {
+            $this->running = true;
+        } else {
+            if (pcntl_wifsignaled($status)) {
+                $this->term_signal = pcntl_wtermsig($status);
+            }
+            if (pcntl_wifstopped($status)) {
+                $this->stop_signal = pcntl_wstopsig($status);
+            }
+            if (pcntl_wifexited($status)) {
+                $this->errno = pcntl_wexitstatus($status);
+                $this->errmsg = pcntl_strerror($this->errno);
+            } else {
+                $this->errno = pcntl_get_last_error();
+                $this->errmsg = pcntl_strerror($this->errno);
+            }
+            if (pcntl_wifsignaled($status)) {
+                $this->if_signal = true;
+            } else {
+                $this->if_signal = false;
+            }
+
+            $this->running = false;
+        }
+    }
+
+    /**
+     * get sub process callback
+     *
+     * @return array|callable|null
+     */
+    protected function getCallable()
+    {
+        $callback = null;
+        if (is_object($this->runnable) && $this->runnable instanceof Runnable) {
+            $callback = array($this->runnable, 'run');
+        } elseif (is_callable($this->runnable)) {
+            $callback = $this->runnable;
+        } else {
+            $callback = array($this, 'run');
+        }
+
+        return $callback;
+    }
+
+    /**
+     * register signal SIGTERM handler,
+     * when the parent process call shutdown and use the default signal,
+     * this handler will be triggered
+     */
+    protected function signal()
+    {
+        pcntl_signal(SIGTERM, function () {
+            exit(0);
+        });
+    }
+
+    /**
+     * kill self
+     *
+     * @param bool|true $block
+     * @param int $signal
+     */
+    public function shutdown($block = true, $signal = SIGTERM)
+    {
+        if (empty($this->pid)) {
+            throw new \LogicException('the process pid is null, so maybe the process is not started');
+        }
+        if (!$this->isRunning()) {
+            throw new \LogicException("the process is not running");
+        }
+        if (!posix_kill($this->pid, $signal)) {
+            throw new \RuntimeException("kill son process failed");
+        }
+
+        $this->updateStatus($block);
+    }
+
+    /**
+     * waiting for the sub process exit
+     *
+     * @param bool|true $block if block the process
+     * @param int $sleep default 0.1s check sub process status
+     * every $sleep milliseconds.
+     */
+    public function wait($block = true, $sleep = 100000)
+    {
+        while (true) {
+            if ($this->isRunning() === false) {
+                return;
+            }
+            if (!$block) {
+                break;
+            }
+            usleep($sleep);
+        }
+    }
+
+    /**
+     * register sub process signal handler,
+     * when the sub process start, the handlers will be registered
+     *
+     * @param $signal
+     * @param callable $handler
+     */
+    public function registerSignalHandler($signal, callable $handler)
+    {
+        $this->signal_handlers[$signal] = $handler;
+    }
+
+    /**
+     * after php-5.3.0, we can call pcntl_singal_dispatch to call signal handlers for pending signals
+     * which can save cpu resources than using declare(tick=n)
+     *
+     * @return bool
+     */
+    public function dispatchSignal()
+    {
+        return pcntl_signal_dispatch();
+    }
+
+    /**
+     * you should overwrite this function
+     * if you do not use the Runnable or callback.
+     */
+    public function run()
+    {
+    }
+}

+ 143 - 0
addons/crontab/library/SimpleFork/Queue/Pipe.php

@@ -0,0 +1,143 @@
+<?php
+/**
+ * @author Jenner <hypxm@qq.com>
+ * @blog http://www.huyanping.cn
+ * @license https://opensource.org/licenses/MIT MIT
+ * @datetime: 2015/11/24 16:29
+ */
+
+namespace Jenner\SimpleFork\Queue;
+
+
+class Pipe
+{
+    /**
+     * @var resource
+     */
+    protected $read;
+
+    /**
+     * @var resource
+     */
+    protected $write;
+
+    /**
+     * @var string
+     */
+    protected $filename;
+
+    /**
+     * @var bool
+     */
+    protected $block;
+
+    /**
+     * @param string $filename fifo filename
+     * @param int $mode
+     * @param bool $block if blocking
+     */
+    public function __construct($filename = '/tmp/simple-fork.pipe', $mode = 0666, $block = false)
+    {
+        if (!file_exists($filename) && !posix_mkfifo($filename, $mode)) {
+            throw new \RuntimeException('create pipe failed');
+        }
+        if (filetype($filename) != 'fifo') {
+            throw new \RuntimeException('file exists and it is not a fifo file');
+        }
+
+        $this->filename = $filename;
+        $this->block = $block;
+    }
+
+    public function setBlock($block = true)
+    {
+        if (is_resource($this->read)) {
+            $set = stream_set_blocking($this->read, $block);
+            if (!$set) {
+                throw new \RuntimeException('stream_set_blocking failed');
+            }
+        }
+
+        if (is_resource($this->write)) {
+            $set = stream_set_blocking($this->write, $block);
+            if (!$set) {
+                throw new \RuntimeException('stream_set_blocking failed');
+            }
+        }
+
+        $this->block = $block;
+    }
+
+    /**
+     * if the stream is blocking, you would better set the value of size,
+     * it will not return until the data size is equal to the value of param size
+     *
+     * @param int $size
+     * @return string
+     */
+    public function read($size = 1024)
+    {
+        if (!is_resource($this->read)) {
+            $this->read = fopen($this->filename, 'r+');
+            if (!is_resource($this->read)) {
+                throw new \RuntimeException('open file failed');
+            }
+            if (!$this->block) {
+                $set = stream_set_blocking($this->read, false);
+                if (!$set) {
+                    throw new \RuntimeException('stream_set_blocking failed');
+                }
+            }
+        }
+
+        return fread($this->read, $size);
+    }
+
+    /**
+     * @param $message
+     * @return int
+     */
+    public function write($message)
+    {
+        if (!is_resource($this->write)) {
+            $this->write = fopen($this->filename, 'w+');
+            if (!is_resource($this->write)) {
+                throw new \RuntimeException('open file failed');
+            }
+            if (!$this->block) {
+                $set = stream_set_blocking($this->write, false);
+                if (!$set) {
+                    throw new \RuntimeException('stream_set_blocking failed');
+                }
+            }
+        }
+
+        return fwrite($this->write, $message);
+    }
+
+    /**
+     *
+     */
+    public function __destruct()
+    {
+        $this->close();
+    }
+
+    /**
+     *
+     */
+    public function close()
+    {
+        if (is_resource($this->read)) {
+            fclose($this->read);
+        }
+        if (is_resource($this->write)) {
+            fclose($this->write);
+        }
+    }
+
+    public function remove()
+    {
+        return unlink($this->filename);
+    }
+}

+ 104 - 0
addons/crontab/library/SimpleFork/Queue/PipeQueue.php

@@ -0,0 +1,104 @@
+<?php
+/**
+ * @author Jenner <hypxm@qq.com>
+ * @blog http://www.huyanping.cn
+ * @license https://opensource.org/licenses/MIT MIT
+ * @datetime: 2015/11/24 18:38
+ */
+
+namespace Jenner\SimpleFork\Queue;
+
+
+class PipeQueue implements QueueInterface
+{
+    /**
+     * @var Pipe
+     */
+    protected $pipe;
+
+    /**
+     * @var bool
+     */
+    protected $block;
+
+    /**
+     * @param string $filename fifo filename
+     * @param int $mode
+     * @param bool $block if blocking
+     */
+    public function __construct($filename = '/tmp/simple-fork.pipe', $mode = 0666)
+    {
+        $this->pipe = new Pipe($filename, $mode);
+        $this->block = false;
+        $this->pipe->setBlock($this->block);
+    }
+
+    /**
+     * put value into the queue of channel
+     *
+     * @param $value
+     * @return bool
+     */
+    public function put($value)
+    {
+        $len = strlen($value);
+        if ($len > 2147483647) {
+            throw new \RuntimeException('value is too long');
+        }
+        $raw = pack('N', $len) . $value;
+        $write_len = $this->pipe->write($raw);
+
+        return $write_len == strlen($raw);
+    }
+
+    /**
+     * get value from the queue of channel
+     *
+     * @param bool $block if block when the queue is empty
+     * @return bool|string
+     */
+    public function get($block = false)
+    {
+        if ($this->block != $block) {
+            $this->pipe->setBlock($block);
+            $this->block = $block;
+        }
+        $len = $this->pipe->read(4);
+        if ($len === false) {
+            throw new \RuntimeException('read pipe failed');
+        }
+
+        if (strlen($len) === 0) {
+            return null;
+        }
+        $len = unpack('N', $len);
+        if (empty($len) || !array_key_exists(1, $len) || empty($len[1])) {
+            throw new \RuntimeException('data protocol error');
+        }
+        $len = intval($len[1]);
+
+        $value = '';
+        while (true) {
+            $temp = $this->pipe->read($len);
+            if (strlen($temp) == $len) {
+                return $temp;
+            }
+            $value .= $temp;
+            $len -= strlen($temp);
+            if ($len == 0) {
+                return $value;
+            }
+        }
+    }
+
+    /**
+     * remove the queue resource
+     *
+     * @return bool
+     */
+    public function remove()
+    {
+        $this->pipe->close();
+        $this->pipe->remove();
+    }
+}

+ 34 - 0
addons/crontab/library/SimpleFork/Queue/QueueInterface.php

@@ -0,0 +1,34 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 15:11
+ */
+
+namespace Jenner\SimpleFork\Queue;
+
+
+/**
+ * queue for processes to transfer data
+ *
+ * @package Jenner\SimpleFork\Queue
+ */
+interface QueueInterface
+{
+    /**
+     * put value into the queue of channel
+     *
+     * @param $value
+     * @return bool
+     */
+    public function put($value);
+
+    /**
+     * get value from the queue of channel
+     *
+     * @param bool $block if block when the queue is empty
+     * @return bool|string
+     */
+    public function get($block = false);
+}

+ 144 - 0
addons/crontab/library/SimpleFork/Queue/RedisQueue.php

@@ -0,0 +1,144 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/20
+ * Time: 15:03
+ */
+
+namespace Jenner\SimpleFork\Queue;
+
+/**
+ * redis queue
+ *
+ * @package Jenner\SimpleFork\Queue
+ */
+class RedisQueue implements QueueInterface
+{
+    /**
+     * @var \Redis
+     */
+    protected $redis;
+
+    /**
+     * @var string redis key of queue
+     */
+    protected $channel;
+
+    /**
+     * @param string $host redis server host
+     * @param int $port redis server port
+     * @param int $database redis server database num
+     * @param string $channel redis queue key
+     * @param string $prefix prefix of redis queue key
+     */
+    public function __construct(
+        $host = '127.0.0.1',
+        $port = 6379,
+        $database = 0,
+        $channel = 'cache',
+        $prefix = 'simple-fork-'
+    )
+    {
+        $this->redis = new \Redis();
+        $connection_result = $this->redis->connect($host, $port);
+        if (!$connection_result) {
+            throw new \RuntimeException('can not connect to the redis server');
+        }
+
+        if ($database != 0) {
+            $select_result = $this->redis->select($database);
+            if (!$select_result) {
+                throw new \RuntimeException('can not select the database');
+            }
+        }
+
+        if (empty($channel)) {
+            throw new \InvalidArgumentException('channel can not be empty');
+        }
+
+        $this->channel = $channel;
+
+        if (empty($prefix)) return;
+
+        $set_option_result = $this->redis->setOption(\Redis::OPT_PREFIX, $prefix);
+        if (!$set_option_result) {
+            throw new \RuntimeException('can not set the \Redis::OPT_PREFIX Option');
+        }
+    }
+
+    /**
+     * put value into the queue
+     *
+     * @param $value
+     * @return bool
+     */
+    public function put($value)
+    {
+
+        if ($this->redis->lPush($this->channel, $value) !== false) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * get value from the queue
+     *
+     * @param bool $block if block when the queue is empty
+     * @return bool|string
+     */
+    public function get($block = false)
+    {
+        if (!$block) {
+            return $this->redis->rPop($this->channel);
+        } else {
+            while (true) {
+                $record = $this->redis->rPop($this->channel);
+                if ($record === false) {
+                    usleep(1000);
+                    continue;
+                }
+
+                return $record;
+            }
+        }
+    }
+
+    /**
+     * get the size of the queue
+     *
+     * @return int
+     */
+    public function size()
+    {
+        return $this->redis->lSize($this->channel);
+    }
+
+    /**
+     * remove the queue resource
+     *
+     * @return mixed
+     */
+    public function remove()
+    {
+        return $this->redis->delete($this->channel);
+    }
+
+    /**
+     * close the connection
+     */
+    public function __destruct()
+    {
+        $this->close();
+    }
+
+    /**
+     * close the connection
+     */
+    public function close()
+    {
+        $this->redis->close();
+    }
+}

+ 293 - 0
addons/crontab/library/SimpleFork/Queue/SystemVMessageQueue.php

@@ -0,0 +1,293 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 15:15
+ */
+
+namespace Jenner\SimpleFork\Queue;
+
+
+/**
+ * system v message queue
+ *
+ * @package Jenner\SimpleFork\Queue
+ */
+class SystemVMessageQueue implements QueueInterface
+{
+    /**
+     * @var int channel
+     */
+    protected $msg_type;
+
+    /**
+     * @var
+     */
+    protected $queue;
+
+    /**
+     * @var bool
+     */
+    protected $serialize_needed;
+
+    /**
+     * @var bool
+     */
+    protected $block_send;
+
+    /**
+     * @var int
+     */
+    protected $option_receive;
+
+    /**
+     * @var int
+     */
+    protected $maxsize;
+
+    /**
+     * @var
+     */
+    protected $key_t;
+
+    /**
+     * @var string
+     */
+    protected $ipc_filename;
+
+    /**
+     * @param string $ipc_filename ipc file to make ipc key.
+     * if it does not exists, it will try to create the file.
+     * @param int $channel message type
+     * @param bool $serialize_needed serialize or not
+     * @param bool $block_send if block when the queue is full
+     * @param int $option_receive if the value is MSG_IPC_NOWAIT it will not
+     * going to wait a message coming. if the value is null,
+     * it will block and wait a message
+     * @param int $maxsize the max size of queue
+     */
+    public function __construct(
+        $ipc_filename = __FILE__,
+        $channel = 1,
+        $serialize_needed = true,
+        $block_send = true,
+        $option_receive = MSG_IPC_NOWAIT,
+        $maxsize = 100000
+    )
+    {
+        $this->ipc_filename = $ipc_filename;
+        $this->msg_type = $channel;
+        $this->serialize_needed = $serialize_needed;
+        $this->block_send = $block_send;
+        $this->option_receive = $option_receive;
+        $this->maxsize = $maxsize;
+        $this->initQueue($ipc_filename, $channel);
+    }
+
+    /**
+     * init queue
+     *
+     * @param $ipc_filename
+     * @param $msg_type
+     * @throws \Exception
+     */
+    protected function initQueue($ipc_filename, $msg_type)
+    {
+        $this->key_t = $this->getIpcKey($ipc_filename, $msg_type);
+        $this->queue = \msg_get_queue($this->key_t);
+        if (!$this->queue) throw new \RuntimeException('msg_get_queue failed');
+    }
+
+    /**
+     * @param $ipc_filename
+     * @param $msg_type
+     * @throws \Exception
+     * @return int
+     */
+    public function getIpcKey($ipc_filename, $msg_type)
+    {
+        if (!file_exists($ipc_filename)) {
+            $create_file = touch($ipc_filename);
+            if ($create_file === false) {
+                throw new \RuntimeException('ipc_file is not exists and create failed');
+            }
+        }
+
+        $key_t = \ftok($ipc_filename, $msg_type);
+        if ($key_t == 0) throw new \RuntimeException('ftok error');
+
+        return $key_t;
+    }
+
+    /**
+     * get message
+     *
+     * @param bool $block if block when the queue is empty
+     * @return bool|string
+     */
+    public function get($block = false)
+    {
+        $queue_status = $this->status();
+        if ($queue_status['msg_qnum'] > 0) {
+            $option_receive = $block ? 0 : $this->option_receive;
+            if (\msg_receive(
+                    $this->queue,
+                    $this->msg_type,
+                    $msgtype_erhalten,
+                    $this->maxsize,
+                    $data,
+                    $this->serialize_needed,
+                    $option_receive,
+                    $err
+                ) === true
+            ) {
+                return $data;
+            } else {
+                throw new \RuntimeException($err);
+            }
+        } else {
+            return false;
+        }
+    }
+
+    public function status()
+    {
+        $queue_status = \msg_stat_queue($this->queue);
+        return $queue_status;
+    }
+
+    /*
+     * return array's keys
+     * msg_perm.uid	 The uid of the owner of the queue.
+     * msg_perm.gid	 The gid of the owner of the queue.
+     * msg_perm.mode	 The file access mode of the queue.
+     * msg_stime	 The time that the last message was sent to the queue.
+     * msg_rtime	 The time that the last message was received from the queue.
+     * msg_ctime	 The time that the queue was last changed.
+     * msg_qnum	 The number of messages waiting to be read from the queue.
+     * msg_qbytes	 The maximum number of bytes allowed in one message queue.
+     *               On Linux, this value may be read and modified via /proc/sys/kernel/msgmnb.
+     * msg_lspid	 The pid of the process that sent the last message to the queue.
+     * msg_lrpid	 The pid of the process that received the last message from the queue.
+     *
+     * @return array
+     */
+
+    /**
+     * put message
+     *
+     * @param $message
+     * @return bool
+     * @throws \Exception
+     */
+    public function put($message)
+    {
+        if (!\msg_send($this->queue, $this->msg_type, $message, $this->serialize_needed, $this->block_send, $err) === true) {
+            throw new \RuntimeException($err);
+        }
+
+        return true;
+    }
+
+    /**
+     * get the size of queue
+     *
+     * @return mixed
+     */
+    public function size()
+    {
+        $status = $this->status();
+
+        return $status['msg_qnum'];
+    }
+
+    /**
+     * allows you to change the values of the msg_perm.uid,
+     * msg_perm.gid, msg_perm.mode and msg_qbytes fields of the underlying message queue data structure
+     *
+     * @param string $key status key
+     * @param int $value status value
+     * @return bool
+     */
+    public function setStatus($key, $value)
+    {
+        $this->checkSetPrivilege($key);
+        if ($key == 'msg_qbytes')
+            return $this->setMaxQueueSize($value);
+        $queue_status[$key] = $value;
+
+        return \msg_set_queue($this->queue, $queue_status);
+    }
+
+    /**
+     * check the privilege of update the queue's status
+     *
+     * @param $key
+     * @throws \Exception
+     */
+    private function checkSetPrivilege($key)
+    {
+        $privilege_field = array('msg_perm.uid', 'msg_perm.gid', 'msg_perm.mode');
+        if (!\in_array($key, $privilege_field)) {
+            $message = 'you can only change msg_perm.uid, msg_perm.gid, ' .
+                ' msg_perm.mode and msg_qbytes. And msg_qbytes needs root privileges';
+
+            throw new \RuntimeException($message);
+        }
+    }
+
+    /**
+     * update the max size of queue
+     * need root
+     *
+     * @param $size
+     * @throws \Exception
+     * @return bool
+     */
+    public function setMaxQueueSize($size)
+    {
+        $user = \get_current_user();
+        if ($user !== 'root')
+            throw new \Exception('changing msg_qbytes needs root privileges');
+
+        return $this->setStatus('msg_qbytes', $size);
+    }
+
+    /**
+     * remove queue
+     *
+     * @return bool
+     */
+    public function remove()
+    {
+        return \msg_remove_queue($this->queue);
+    }
+
+    /**
+     * check if the queue is exists or not
+     *
+     * @param $key
+     * @return bool
+     */
+    public function queueExists($key)
+    {
+        return \msg_queue_exists($key);
+    }
+
+    /**
+     * init when wakeup
+     */
+    public function __wakeup()
+    {
+        $this->initQueue($this->ipc_filename, $this->msg_type);
+    }
+
+    /**
+     *
+     */
+    public function __destruct()
+    {
+        unset($this);
+    }
+}

+ 20 - 0
addons/crontab/library/SimpleFork/Runnable.php

@@ -0,0 +1,20 @@
+<?php
+/**
+ * Created by PhpStorm.
+ * User: Jenner
+ * Date: 2015/8/12
+ * Time: 15:28
+ */
+
+namespace Jenner\SimpleFork;
+
+
+interface Runnable
+{
+    /**
+     * process entry
+     *
+     * @return mixed
+     */
+    public function run();
+}

+ 26 - 0
addons/crontab/library/SimpleFork/SinglePool.php

@@ -0,0 +1,26 @@
+<?php
+/**
+ * @author Jenner <hypxm@qq.com>
+ * @blog http://www.huyanping.cn
+ * @license https://opensource.org/licenses/MIT MIT
+ * @datetime: 2015/11/19 21:13
+ */
+
+namespace Jenner\SimpleFork;
+
+
+/**
+ * Only one process will be started at the same time
+ *
+ * @package Jenner\SimpleFork
+ */
+class SinglePool extends FixedPool
+{
+    /**
+     * SinglePool constructor.
+     */
+    public function __construct()
+    {
+        parent::__construct(1);
+    }
+}

+ 48 - 0
addons/crontab/library/SimpleFork/Utils.php

@@ -0,0 +1,48 @@
+<?php
+/**
+ * @author Jenner <hypxm@qq.com>
+ * @license https://opensource.org/licenses/MIT MIT
+ * @datetime: 2015/11/11 17:50
+ */
+
+namespace Jenner\SimpleFork;
+
+
+class Utils
+{
+    /**
+     * check if the sub class of Process has overwrite the run method
+     *
+     * @param $child_class
+     */
+    public static function checkOverwriteRunMethod($child_class)
+    {
+        $parent_class = '\\Jenner\\SimpleFork\\Process';
+        if ($child_class == $parent_class) {
+            $message = "you should extend the `{$parent_class}`" .
+                ' and overwrite the run method';
+            throw new \RuntimeException($message);
+        }
+
+        $child = new \ReflectionClass($child_class);
+        if ($child->getParentClass() === false) {
+            $message = "you should extend the `{$parent_class}`" .
+                ' and overwrite the run method';
+            throw new \RuntimeException($message);
+        }
+
+        $parent_methods = $child->getParentClass()->getMethods(\ReflectionMethod::IS_PUBLIC);
+
+        foreach ($parent_methods as $parent_method) {
+            if ($parent_method->getName() !== 'run') continue;
+
+            $declaring_class = $child->getMethod($parent_method->getName())
+                ->getDeclaringClass()
+                ->getName();
+
+            if ($declaring_class === $parent_class) {
+                throw new \RuntimeException('you must overwrite the run method');
+            }
+        }
+    }
+}

+ 19 - 0
addons/crontab/licenses/cron/LICENSE

@@ -0,0 +1,19 @@
+Copyright (c) 2011 Michael Dowling <mtdowling@gmail.com> and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 21 - 0
addons/crontab/licenses/simplefork/LICENSE

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Jenner
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.

+ 54 - 0
addons/crontab/model/Crontab.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace addons\crontab\model;
+
+use think\Model;
+
+class Crontab extends Model
+{
+
+    // 开启自动写入时间戳字段
+    protected $autoWriteTimestamp = 'integer';
+    // 定义时间戳字段名
+    protected $createTime = 'createtime';
+    protected $updateTime = 'updatetime';
+    // 定义字段类型
+    protected $type = [
+    ];
+    // 追加属性
+    protected $append = [
+        'type_text'
+    ];
+
+    public static function getTypeList()
+    {
+        return [
+            'url'   => __('Request Url'),
+            'sql'   => __('Execute Sql Script'),
+            'shell' => __('Execute Shell'),
+        ];
+    }
+
+    public function getTypeTextAttr($value, $data)
+    {
+        $typelist = self::getTypeList();
+        $value = $value ? $value : $data['type'];
+        return $value && isset($typelist[$value]) ? $typelist[$value] : $value;
+    }
+
+    protected function setBegintimeAttr($value)
+    {
+        return $value && !is_numeric($value) ? strtotime($value) : $value;
+    }
+
+    protected function setEndtimeAttr($value)
+    {
+        return $value && !is_numeric($value) ? strtotime($value) : $value;
+    }
+
+    protected function setExecutetimeAttr($value)
+    {
+        return $value && !is_numeric($value) ? strtotime($value) : $value;
+    }
+
+}

+ 1 - 0
addons/cropper/.addonrc

@@ -0,0 +1 @@
+{"files":["public\/assets\/addons\/cropper\/css\/cropper.css","public\/assets\/addons\/cropper\/css\/main.css","public\/assets\/addons\/cropper\/js\/cropper-license.txt","public\/assets\/addons\/cropper\/js\/cropper.js"],"license":"regular","licenseto":"45835","licensekey":"m9UHCcBAw0bvGegZ D57lxYnEQ8TzamPV9g9RvQ==","domains":["thinkphp_fastadmin_tailored.test"],"licensecodes":[],"validations":["849901681e419b30a3ed3e4629b8f4ac"]}

+ 40 - 0
addons/cropper/Cropper.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace addons\cropper;
+
+use think\Addons;
+
+/**
+ * 图片裁剪插件
+ */
+class Cropper extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        return true;
+    }
+
+
+    /**
+     * @param $params
+     */
+    public function configInit(&$params)
+    {
+        $config = $this->getConfig();
+        $params['cropper'] = ['dialogWidth' => $config['dialogWidth'] ?? 880, 'dialogHeight' => $config['dialogHeight'] ?? 600];
+    }
+}

+ 306 - 0
addons/cropper/assets/css/cropper.css

@@ -0,0 +1,306 @@
+/*!
+ * Cropper v4.0.0
+ * https://github.com/fengyuanchen/cropper
+ *
+ * Copyright (c) 2014-2018 Chen Fengyuan
+ * Released under the MIT license
+ *
+ * Date: 2018-04-01T06:26:32.417Z
+ */
+
+.cropper-container {
+    direction: ltr;
+    font-size: 0;
+    line-height: 0;
+    position: relative;
+    -ms-touch-action: none;
+    touch-action: none;
+    -webkit-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+}
+
+.cropper-container img {
+    /*Avoid margin top issue (Occur only when margin-top <= -height)
+     */
+    display: block;
+    height: 100%;
+    image-orientation: 0deg;
+    max-height: none !important;
+    max-width: none !important;
+    min-height: 0 !important;
+    min-width: 0 !important;
+    width: 100%;
+}
+
+.cropper-wrap-box,
+.cropper-canvas,
+.cropper-drag-box,
+.cropper-crop-box,
+.cropper-modal {
+    bottom: 0;
+    left: 0;
+    position: absolute;
+    right: 0;
+    top: 0;
+}
+
+.cropper-wrap-box,
+.cropper-canvas {
+    overflow: hidden;
+}
+
+.cropper-drag-box {
+    background-color: #fff;
+    opacity: 0;
+}
+
+.cropper-modal {
+    background-color: #000;
+    opacity: .5;
+}
+
+.cropper-view-box {
+    display: block;
+    height: 100%;
+    outline-color: rgba(51, 153, 255, 0.75);
+    outline: 1px solid #39f;
+    overflow: hidden;
+    width: 100%;
+}
+
+.cropper-dashed {
+    border: 0 dashed #eee;
+    display: block;
+    opacity: .5;
+    position: absolute;
+}
+
+.cropper-dashed.dashed-h {
+    border-bottom-width: 1px;
+    border-top-width: 1px;
+    height: 33.33333%;
+    left: 0;
+    top: 33.33333%;
+    width: 100%;
+}
+
+.cropper-dashed.dashed-v {
+    border-left-width: 1px;
+    border-right-width: 1px;
+    height: 100%;
+    left: 33.33333%;
+    top: 0;
+    width: 33.33333%;
+}
+
+.cropper-center {
+    display: block;
+    height: 0;
+    left: 50%;
+    opacity: .75;
+    position: absolute;
+    top: 50%;
+    width: 0;
+}
+
+.cropper-center:before,
+.cropper-center:after {
+    background-color: #eee;
+    content: ' ';
+    display: block;
+    position: absolute;
+}
+
+.cropper-center:before {
+    height: 1px;
+    left: -3px;
+    top: 0;
+    width: 7px;
+}
+
+.cropper-center:after {
+    height: 7px;
+    left: 0;
+    top: -3px;
+    width: 1px;
+}
+
+.cropper-face,
+.cropper-line,
+.cropper-point {
+    display: block;
+    height: 100%;
+    opacity: .1;
+    position: absolute;
+    width: 100%;
+}
+
+.cropper-face {
+    background-color: #fff;
+    left: 0;
+    top: 0;
+}
+
+.cropper-line {
+    background-color: #39f;
+}
+
+.cropper-line.line-e {
+    cursor: ew-resize;
+    right: -3px;
+    top: 0;
+    width: 5px;
+}
+
+.cropper-line.line-n {
+    cursor: ns-resize;
+    height: 5px;
+    left: 0;
+    top: -3px;
+}
+
+.cropper-line.line-w {
+    cursor: ew-resize;
+    left: -3px;
+    top: 0;
+    width: 5px;
+}
+
+.cropper-line.line-s {
+    bottom: -3px;
+    cursor: ns-resize;
+    height: 5px;
+    left: 0;
+}
+
+.cropper-point {
+    background-color: #39f;
+    height: 5px;
+    opacity: .75;
+    width: 5px;
+}
+
+.cropper-point.point-e {
+    cursor: ew-resize;
+    margin-top: -3px;
+    right: -3px;
+    top: 50%;
+}
+
+.cropper-point.point-n {
+    cursor: ns-resize;
+    left: 50%;
+    margin-left: -3px;
+    top: -3px;
+}
+
+.cropper-point.point-w {
+    cursor: ew-resize;
+    left: -3px;
+    margin-top: -3px;
+    top: 50%;
+}
+
+.cropper-point.point-s {
+    bottom: -3px;
+    cursor: s-resize;
+    left: 50%;
+    margin-left: -3px;
+}
+
+.cropper-point.point-ne {
+    cursor: nesw-resize;
+    right: -3px;
+    top: -3px;
+}
+
+.cropper-point.point-nw {
+    cursor: nwse-resize;
+    left: -3px;
+    top: -3px;
+}
+
+.cropper-point.point-sw {
+    bottom: -3px;
+    cursor: nesw-resize;
+    left: -3px;
+}
+
+.cropper-point.point-se {
+    bottom: -3px;
+    cursor: nwse-resize;
+    height: 20px;
+    opacity: 1;
+    right: -3px;
+    width: 20px;
+}
+
+@media (min-width: 768px) {
+    .cropper-point.point-se {
+        height: 15px;
+        width: 15px;
+    }
+}
+
+@media (min-width: 992px) {
+    .cropper-point.point-se {
+        height: 10px;
+        width: 10px;
+    }
+}
+
+@media (min-width: 1200px) {
+    .cropper-point.point-se {
+        height: 5px;
+        opacity: .75;
+        width: 5px;
+    }
+}
+
+.cropper-point.point-se:before {
+    background-color: #39f;
+    bottom: -50%;
+    content: ' ';
+    display: block;
+    height: 200%;
+    opacity: 0;
+    position: absolute;
+    right: -50%;
+    width: 200%;
+}
+
+.cropper-invisible {
+    opacity: 0;
+}
+
+.cropper-bg {
+    background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC');
+}
+
+.cropper-hide {
+    display: block;
+    height: 0;
+    position: absolute;
+    width: 0;
+}
+
+.cropper-hidden {
+    display: none !important;
+}
+
+.cropper-move {
+    cursor: move;
+}
+
+.cropper-crop {
+    cursor: crosshair;
+}
+
+.cropper-disabled .cropper-drag-box,
+.cropper-disabled .cropper-face,
+.cropper-disabled .cropper-line,
+.cropper-disabled .cropper-point {
+    cursor: not-allowed;
+}

+ 263 - 0
addons/cropper/assets/css/main.css

@@ -0,0 +1,263 @@
+.btn {
+    padding-left: .75rem;
+    padding-right: .75rem;
+}
+
+label.btn {
+    margin-bottom: 0;
+}
+
+.d-flex > .btn {
+    flex: 1;
+}
+
+.carbonads {
+    border-radius: .25rem;
+    border: 1px solid #ccc;
+    font-size: .875rem;
+    overflow: hidden;
+    padding: 1rem;
+}
+
+.carbon-wrap {
+    overflow: hidden;
+}
+
+.carbon-img {
+    clear: left;
+    display: block;
+    float: left;
+}
+
+.carbon-text,
+.carbon-poweredby {
+    display: block;
+    margin-left: 140px;
+}
+
+.carbon-text,
+.carbon-text:hover,
+.carbon-text:focus {
+    color: #fff;
+    text-decoration: none;
+}
+
+.carbon-poweredby,
+.carbon-poweredby:hover,
+.carbon-poweredby:focus {
+    color: #ddd;
+    text-decoration: none;
+}
+
+@media (min-width: 768px) {
+    .carbonads {
+        float: right;
+        margin-bottom: -1rem;
+        margin-top: -1rem;
+        max-width: 360px;
+    }
+}
+
+.footer {
+    font-size: .875rem;
+    overflow: hidden;
+}
+
+.heart {
+    color: #ddd;
+    display: block;
+    height: 2rem;
+    line-height: 2rem;
+    margin-bottom: 0;
+    margin-top: 1rem;
+    position: relative;
+    text-align: center;
+    width: 100%;
+}
+
+.heart:hover {
+    color: #ff4136;
+}
+
+.heart::before {
+    border-top: 1px solid #eee;
+    content: " ";
+    display: block;
+    height: 0;
+    left: 0;
+    position: absolute;
+    right: 0;
+    top: 50%;
+}
+
+.heart::after {
+    background-color: #fff;
+    content: "♥";
+    padding-left: .5rem;
+    padding-right: .5rem;
+    position: relative;
+    z-index: 1;
+}
+
+.img-container,
+.img-preview {
+    background-color: #f7f7f7;
+    text-align: center;
+    width: 100%;
+}
+
+.img-container {
+    margin-bottom: 1rem;
+    max-height: 400px;
+    min-height: 200px;
+}
+
+@media (min-width: 768px) {
+    .img-container {
+        min-height: 400px;
+    }
+}
+
+.img-container > img {
+    max-width: 100%;
+}
+
+.docs-preview {
+    margin-right: -1rem;
+}
+
+.img-preview {
+    float: left;
+    margin-bottom: .5rem;
+    margin-right: .5rem;
+    overflow: hidden;
+}
+
+.img-preview > img {
+    max-width: 100%;
+}
+
+.preview-lg {
+    height: 9rem;
+    width: 16rem;
+}
+
+.preview-md {
+    height: 4.5rem;
+    width: 8rem;
+}
+
+.preview-sm {
+    height: 2.25rem;
+    width: 4rem;
+}
+
+.preview-xs {
+    height: 1.125rem;
+    margin-right: 0;
+    width: 2rem;
+}
+
+.docs-data > .input-group {
+    margin-bottom: .5rem;
+}
+
+.docs-data .input-group-prepend .input-group-text {
+    min-width: 4rem;
+}
+
+.docs-data .input-group-append .input-group-text {
+    min-width: 3rem;
+}
+
+.docs-buttons > .btn,
+.docs-buttons > .btn-group,
+.docs-buttons > .form-control {
+    margin-bottom: .5rem;
+    margin-right: .25rem;
+}
+
+.docs-toggles > .btn,
+.docs-toggles > .btn-group,
+.docs-toggles > .dropdown {
+    margin-bottom: .5rem;
+}
+
+.docs-tooltip {
+    display: block;
+    margin: -.5rem -.75rem;
+    padding: .5rem .75rem;
+}
+
+.docs-tooltip > .icon {
+    margin: 0 -.25rem;
+    vertical-align: top;
+}
+
+.tooltip-inner {
+    white-space: normal;
+}
+
+.btn-upload .tooltip-inner,
+.btn-toggle .tooltip-inner {
+    white-space: nowrap;
+}
+
+.btn-toggle {
+    padding: .5rem;
+}
+
+.btn-toggle > .docs-tooltip {
+    margin: -.5rem;
+    padding: .5rem;
+}
+
+@media (max-width: 400px) {
+    .btn-group-crop {
+        margin-right: -1rem !important;
+    }
+
+    .btn-group-crop > .btn {
+        padding-left: .5rem;
+        padding-right: .5rem;
+    }
+
+    .btn-group-crop .docs-tooltip {
+        margin-left: -.5rem;
+        margin-right: -.5rem;
+        padding-left: .5rem;
+        padding-right: .5rem;
+    }
+}
+
+.docs-options .dropdown-menu {
+    width: 100%;
+}
+
+.docs-options .dropdown-menu > li {
+    font-size: .875rem;
+    padding: .125rem 1rem;
+}
+
+.docs-options .dropdown-menu .form-check-label {
+    display: block;
+}
+
+.docs-cropped .modal-body {
+    text-align: center;
+}
+
+.docs-cropped .modal-body > img,
+.docs-cropped .modal-body > canvas {
+    max-width: 100%;
+}
+
+.d-flex {
+    display:flex;
+}
+.input-group-text {
+    font-weight:normal;
+    width:30px;
+    text-align:center;
+    display:inline-block;
+}

+ 21 - 0
addons/cropper/assets/js/cropper-license.txt

@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright 2014-present Chen Fengyuan and contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.

+ 3763 - 0
addons/cropper/assets/js/cropper.js

@@ -0,0 +1,3763 @@
+/*!
+ * Cropper v4.0.0
+ * https://github.com/fengyuanchen/cropper
+ *
+ * Copyright (c) 2014-2018 Chen Fengyuan
+ * Released under the MIT license
+ *
+ * Date: 2018-04-01T06:27:27.267Z
+ */
+
+(function (global, factory) {
+    typeof exports === 'object' && typeof module !== 'undefined' ? factory(require('jquery')) :
+        typeof define === 'function' && define.amd ? define(['jquery'], factory) :
+            (factory(global.jQuery));
+}(this, (function ($) {
+    'use strict';
+
+    $ = $ && $.hasOwnProperty('default') ? $['default'] : $;
+
+    var IN_BROWSER = typeof window !== 'undefined';
+    var WINDOW = IN_BROWSER ? window : {};
+    var NAMESPACE = 'cropper';
+
+    // Actions
+    var ACTION_ALL = 'all';
+    var ACTION_CROP = 'crop';
+    var ACTION_MOVE = 'move';
+    var ACTION_ZOOM = 'zoom';
+    var ACTION_EAST = 'e';
+    var ACTION_WEST = 'w';
+    var ACTION_SOUTH = 's';
+    var ACTION_NORTH = 'n';
+    var ACTION_NORTH_EAST = 'ne';
+    var ACTION_NORTH_WEST = 'nw';
+    var ACTION_SOUTH_EAST = 'se';
+    var ACTION_SOUTH_WEST = 'sw';
+
+    // Classes
+    var CLASS_CROP = NAMESPACE + '-crop';
+    var CLASS_DISABLED = NAMESPACE + '-disabled';
+    var CLASS_HIDDEN = NAMESPACE + '-hidden';
+    var CLASS_HIDE = NAMESPACE + '-hide';
+    var CLASS_INVISIBLE = NAMESPACE + '-invisible';
+    var CLASS_MODAL = NAMESPACE + '-modal';
+    var CLASS_MOVE = NAMESPACE + '-move';
+
+    // Data keys
+    var DATA_ACTION = 'action';
+    var DATA_PREVIEW = 'preview';
+
+    // Drag modes
+    var DRAG_MODE_CROP = 'crop';
+    var DRAG_MODE_MOVE = 'move';
+    var DRAG_MODE_NONE = 'none';
+
+    // Events
+    var EVENT_CROP = 'crop';
+    var EVENT_CROP_END = 'cropend';
+    var EVENT_CROP_MOVE = 'cropmove';
+    var EVENT_CROP_START = 'cropstart';
+    var EVENT_DBLCLICK = 'dblclick';
+    var EVENT_LOAD = 'load';
+    var EVENT_POINTER_DOWN = WINDOW.PointerEvent ? 'pointerdown' : 'touchstart mousedown';
+    var EVENT_POINTER_MOVE = WINDOW.PointerEvent ? 'pointermove' : 'touchmove mousemove';
+    var EVENT_POINTER_UP = WINDOW.PointerEvent ? 'pointerup pointercancel' : 'touchend touchcancel mouseup';
+    var EVENT_READY = 'ready';
+    var EVENT_RESIZE = 'resize';
+    var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll';
+    var EVENT_ZOOM = 'zoom';
+
+    // RegExps
+    var REGEXP_ACTIONS = /^(?:e|w|s|n|se|sw|ne|nw|all|crop|move|zoom)$/;
+    var REGEXP_DATA_URL = /^data:/;
+    var REGEXP_DATA_URL_JPEG = /^data:image\/jpeg;base64,/;
+    var REGEXP_TAG_NAME = /^(?:img|canvas)$/i;
+
+    var DEFAULTS = {
+        // Define the view mode of the cropper
+        viewMode: 0, // 0, 1, 2, 3
+
+        // Define the dragging mode of the cropper
+        dragMode: DRAG_MODE_CROP, // 'crop', 'move' or 'none'
+
+        // Define the aspect ratio of the crop box
+        aspectRatio: NaN,
+
+        // An object with the previous cropping result data
+        data: null,
+
+        // A selector for adding extra containers to preview
+        preview: '',
+
+        // Re-render the cropper when resize the window
+        responsive: true,
+
+        // Restore the cropped area after resize the window
+        restore: true,
+
+        // Check if the current image is a cross-origin image
+        checkCrossOrigin: true,
+
+        // Check the current image's Exif Orientation information
+        checkOrientation: true,
+
+        // Show the black modal
+        modal: true,
+
+        // Show the dashed lines for guiding
+        guides: true,
+
+        // Show the center indicator for guiding
+        center: true,
+
+        // Show the white modal to highlight the crop box
+        highlight: true,
+
+        // Show the grid background
+        background: true,
+
+        // Enable to crop the image automatically when initialize
+        autoCrop: true,
+
+        // Define the percentage of automatic cropping area when initializes
+        autoCropArea: 0.8,
+
+        // Enable to move the image
+        movable: true,
+
+        // Enable to rotate the image
+        rotatable: true,
+
+        // Enable to scale the image
+        scalable: true,
+
+        // Enable to zoom the image
+        zoomable: true,
+
+        // Enable to zoom the image by dragging touch
+        zoomOnTouch: true,
+
+        // Enable to zoom the image by wheeling mouse
+        zoomOnWheel: true,
+
+        // Define zoom ratio when zoom the image by wheeling mouse
+        wheelZoomRatio: 0.1,
+
+        // Enable to move the crop box
+        cropBoxMovable: true,
+
+        // Enable to resize the crop box
+        cropBoxResizable: true,
+
+        // Toggle drag mode between "crop" and "move" when click twice on the cropper
+        toggleDragModeOnDblclick: true,
+
+        // Size limitation
+        minCanvasWidth: 0,
+        minCanvasHeight: 0,
+        minCropBoxWidth: 0,
+        minCropBoxHeight: 0,
+        minContainerWidth: 200,
+        minContainerHeight: 100,
+
+        // Shortcuts of events
+        ready: null,
+        cropstart: null,
+        cropmove: null,
+        cropend: null,
+        crop: null,
+        zoom: null
+    };
+
+    var TEMPLATE = '<div class="cropper-container" touch-action="none">' + '<div class="cropper-wrap-box">' + '<div class="cropper-canvas"></div>' + '</div>' + '<div class="cropper-drag-box"></div>' + '<div class="cropper-crop-box">' + '<span class="cropper-view-box"></span>' + '<span class="cropper-dashed dashed-h"></span>' + '<span class="cropper-dashed dashed-v"></span>' + '<span class="cropper-center"></span>' + '<span class="cropper-face"></span>' + '<span class="cropper-line line-e" data-action="e"></span>' + '<span class="cropper-line line-n" data-action="n"></span>' + '<span class="cropper-line line-w" data-action="w"></span>' + '<span class="cropper-line line-s" data-action="s"></span>' + '<span class="cropper-point point-e" data-action="e"></span>' + '<span class="cropper-point point-n" data-action="n"></span>' + '<span class="cropper-point point-w" data-action="w"></span>' + '<span class="cropper-point point-s" data-action="s"></span>' + '<span class="cropper-point point-ne" data-action="ne"></span>' + '<span class="cropper-point point-nw" data-action="nw"></span>' + '<span class="cropper-point point-sw" data-action="sw"></span>' + '<span class="cropper-point point-se" data-action="se"></span>' + '</div>' + '</div>';
+
+    var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) {
+        return typeof obj;
+    } : function (obj) {
+        return obj && typeof Symbol === "function" && obj.constructor === Symbol && obj !== Symbol.prototype ? "symbol" : typeof obj;
+    };
+
+    var classCallCheck = function (instance, Constructor) {
+        if (!(instance instanceof Constructor)) {
+            throw new TypeError("Cannot call a class as a function");
+        }
+    };
+
+    var createClass = function () {
+        function defineProperties(target, props) {
+            for (var i = 0; i < props.length; i++) {
+                var descriptor = props[i];
+                descriptor.enumerable = descriptor.enumerable || false;
+                descriptor.configurable = true;
+                if ("value" in descriptor) descriptor.writable = true;
+                Object.defineProperty(target, descriptor.key, descriptor);
+            }
+        }
+
+        return function (Constructor, protoProps, staticProps) {
+            if (protoProps) defineProperties(Constructor.prototype, protoProps);
+            if (staticProps) defineProperties(Constructor, staticProps);
+            return Constructor;
+        };
+    }();
+
+    var toConsumableArray = function (arr) {
+        if (Array.isArray(arr)) {
+            for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i];
+
+            return arr2;
+        } else {
+            return Array.from(arr);
+        }
+    };
+
+    /**
+     * Check if the given value is not a number.
+     */
+    var isNaN = Number.isNaN || WINDOW.isNaN;
+
+    /**
+     * Check if the given value is a number.
+     * @param {*} value - The value to check.
+     * @returns {boolean} Returns `true` if the given value is a number, else `false`.
+     */
+    function isNumber(value) {
+        return typeof value === 'number' && !isNaN(value);
+    }
+
+    /**
+     * Check if the given value is undefined.
+     * @param {*} value - The value to check.
+     * @returns {boolean} Returns `true` if the given value is undefined, else `false`.
+     */
+    function isUndefined(value) {
+        return typeof value === 'undefined';
+    }
+
+    /**
+     * Check if the given value is an object.
+     * @param {*} value - The value to check.
+     * @returns {boolean} Returns `true` if the given value is an object, else `false`.
+     */
+    function isObject(value) {
+        return (typeof value === 'undefined' ? 'undefined' : _typeof(value)) === 'object' && value !== null;
+    }
+
+    var hasOwnProperty = Object.prototype.hasOwnProperty;
+
+    /**
+     * Check if the given value is a plain object.
+     * @param {*} value - The value to check.
+     * @returns {boolean} Returns `true` if the given value is a plain object, else `false`.
+     */
+
+    function isPlainObject(value) {
+        if (!isObject(value)) {
+            return false;
+        }
+
+        try {
+            var _constructor = value.constructor;
+            var prototype = _constructor.prototype;
+
+
+            return _constructor && prototype && hasOwnProperty.call(prototype, 'isPrototypeOf');
+        } catch (e) {
+            return false;
+        }
+    }
+
+    /**
+     * Check if the given value is a function.
+     * @param {*} value - The value to check.
+     * @returns {boolean} Returns `true` if the given value is a function, else `false`.
+     */
+    function isFunction(value) {
+        return typeof value === 'function';
+    }
+
+    /**
+     * Iterate the given data.
+     * @param {*} data - The data to iterate.
+     * @param {Function} callback - The process function for each element.
+     * @returns {*} The original data.
+     */
+    function forEach(data, callback) {
+        if (data && isFunction(callback)) {
+            if (Array.isArray(data) || isNumber(data.length) /* array-like */) {
+                var length = data.length;
+
+                var i = void 0;
+
+                for (i = 0; i < length; i += 1) {
+                    if (callback.call(data, data[i], i, data) === false) {
+                        break;
+                    }
+                }
+            } else if (isObject(data)) {
+                Object.keys(data).forEach(function (key) {
+                    callback.call(data, data[key], key, data);
+                });
+            }
+        }
+
+        return data;
+    }
+
+    /**
+     * Extend the given object.
+     * @param {*} obj - The object to be extended.
+     * @param {*} args - The rest objects which will be merged to the first object.
+     * @returns {Object} The extended object.
+     */
+    var assign = Object.assign || function assign(obj) {
+        for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+            args[_key - 1] = arguments[_key];
+        }
+
+        if (isObject(obj) && args.length > 0) {
+            args.forEach(function (arg) {
+                if (isObject(arg)) {
+                    Object.keys(arg).forEach(function (key) {
+                        obj[key] = arg[key];
+                    });
+                }
+            });
+        }
+
+        return obj;
+    };
+
+    var REGEXP_DECIMALS = /\.\d*(?:0|9){12}\d*$/i;
+
+    /**
+     * Normalize decimal number.
+     * Check out {@link http://0.30000000000000004.com/}
+     * @param {number} value - The value to normalize.
+     * @param {number} [times=100000000000] - The times for normalizing.
+     * @returns {number} Returns the normalized number.
+     */
+    function normalizeDecimalNumber(value) {
+        var times = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100000000000;
+
+        return REGEXP_DECIMALS.test(value) ? Math.round(value * times) / times : value;
+    }
+
+    var REGEXP_SUFFIX = /^(?:width|height|left|top|marginLeft|marginTop)$/;
+
+    /**
+     * Apply styles to the given element.
+     * @param {Element} element - The target element.
+     * @param {Object} styles - The styles for applying.
+     */
+    function setStyle(element, styles) {
+        var style = element.style;
+
+
+        forEach(styles, function (value, property) {
+            if (REGEXP_SUFFIX.test(property) && isNumber(value)) {
+                value += 'px';
+            }
+
+            style[property] = value;
+        });
+    }
+
+    /**
+     * Check if the given element has a special class.
+     * @param {Element} element - The element to check.
+     * @param {string} value - The class to search.
+     * @returns {boolean} Returns `true` if the special class was found.
+     */
+    function hasClass(element, value) {
+        return element.classList ? element.classList.contains(value) : element.className.indexOf(value) > -1;
+    }
+
+    /**
+     * Add classes to the given element.
+     * @param {Element} element - The target element.
+     * @param {string} value - The classes to be added.
+     */
+    function addClass(element, value) {
+        if (!value) {
+            return;
+        }
+
+        if (isNumber(element.length)) {
+            forEach(element, function (elem) {
+                addClass(elem, value);
+            });
+            return;
+        }
+
+        if (element.classList) {
+            element.classList.add(value);
+            return;
+        }
+
+        var className = element.className.trim();
+
+        if (!className) {
+            element.className = value;
+        } else if (className.indexOf(value) < 0) {
+            element.className = className + ' ' + value;
+        }
+    }
+
+    /**
+     * Remove classes from the given element.
+     * @param {Element} element - The target element.
+     * @param {string} value - The classes to be removed.
+     */
+    function removeClass(element, value) {
+        if (!value) {
+            return;
+        }
+
+        if (isNumber(element.length)) {
+            forEach(element, function (elem) {
+                removeClass(elem, value);
+            });
+            return;
+        }
+
+        if (element.classList) {
+            element.classList.remove(value);
+            return;
+        }
+
+        if (element.className.indexOf(value) >= 0) {
+            element.className = element.className.replace(value, '');
+        }
+    }
+
+    /**
+     * Add or remove classes from the given element.
+     * @param {Element} element - The target element.
+     * @param {string} value - The classes to be toggled.
+     * @param {boolean} added - Add only.
+     */
+    function toggleClass(element, value, added) {
+        if (!value) {
+            return;
+        }
+
+        if (isNumber(element.length)) {
+            forEach(element, function (elem) {
+                toggleClass(elem, value, added);
+            });
+            return;
+        }
+
+        // IE10-11 doesn't support the second parameter of `classList.toggle`
+        if (added) {
+            addClass(element, value);
+        } else {
+            removeClass(element, value);
+        }
+    }
+
+    var REGEXP_HYPHENATE = /([a-z\d])([A-Z])/g;
+
+    /**
+     * Transform the given string from camelCase to kebab-case
+     * @param {string} value - The value to transform.
+     * @returns {string} The transformed value.
+     */
+    function hyphenate(value) {
+        return value.replace(REGEXP_HYPHENATE, '$1-$2').toLowerCase();
+    }
+
+    /**
+     * Get data from the given element.
+     * @param {Element} element - The target element.
+     * @param {string} name - The data key to get.
+     * @returns {string} The data value.
+     */
+    function getData(element, name) {
+        if (isObject(element[name])) {
+            return element[name];
+        } else if (element.dataset) {
+            return element.dataset[name];
+        }
+
+        return element.getAttribute('data-' + hyphenate(name));
+    }
+
+    /**
+     * Set data to the given element.
+     * @param {Element} element - The target element.
+     * @param {string} name - The data key to set.
+     * @param {string} data - The data value.
+     */
+    function setData(element, name, data) {
+        if (isObject(data)) {
+            element[name] = data;
+        } else if (element.dataset) {
+            element.dataset[name] = data;
+        } else {
+            element.setAttribute('data-' + hyphenate(name), data);
+        }
+    }
+
+    /**
+     * Remove data from the given element.
+     * @param {Element} element - The target element.
+     * @param {string} name - The data key to remove.
+     */
+    function removeData(element, name) {
+        if (isObject(element[name])) {
+            try {
+                delete element[name];
+            } catch (e) {
+                element[name] = undefined;
+            }
+        } else if (element.dataset) {
+            // #128 Safari not allows to delete dataset property
+            try {
+                delete element.dataset[name];
+            } catch (e) {
+                element.dataset[name] = undefined;
+            }
+        } else {
+            element.removeAttribute('data-' + hyphenate(name));
+        }
+    }
+
+    var REGEXP_SPACES = /\s\s*/;
+    var onceSupported = function () {
+        var supported = false;
+
+        if (IN_BROWSER) {
+            var once = false;
+            var listener = function listener() {
+            };
+            var options = Object.defineProperty({}, 'once', {
+                get: function get$$1() {
+                    supported = true;
+                    return once;
+                },
+
+
+                /**
+                 * This setter can fix a `TypeError` in strict mode
+                 * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Getter_only}
+                 * @param {boolean} value - The value to set
+                 */
+                set: function set$$1(value) {
+                    once = value;
+                }
+            });
+
+            WINDOW.addEventListener('test', listener, options);
+            WINDOW.removeEventListener('test', listener, options);
+        }
+
+        return supported;
+    }();
+
+    /**
+     * Remove event listener from the target element.
+     * @param {Element} element - The event target.
+     * @param {string} type - The event type(s).
+     * @param {Function} listener - The event listener.
+     * @param {Object} options - The event options.
+     */
+    function removeListener(element, type, listener) {
+        var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
+
+        var handler = listener;
+
+        type.trim().split(REGEXP_SPACES).forEach(function (event) {
+            if (!onceSupported) {
+                var listeners = element.listeners;
+
+
+                if (listeners && listeners[event] && listeners[event][listener]) {
+                    handler = listeners[event][listener];
+                    delete listeners[event][listener];
+
+                    if (Object.keys(listeners[event]).length === 0) {
+                        delete listeners[event];
+                    }
+
+                    if (Object.keys(listeners).length === 0) {
+                        delete element.listeners;
+                    }
+                }
+            }
+
+            element.removeEventListener(event, handler, options);
+        });
+    }
+
+    /**
+     * Add event listener to the target element.
+     * @param {Element} element - The event target.
+     * @param {string} type - The event type(s).
+     * @param {Function} listener - The event listener.
+     * @param {Object} options - The event options.
+     */
+    function addListener(element, type, listener) {
+        var options = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {};
+
+        var _handler = listener;
+
+        type.trim().split(REGEXP_SPACES).forEach(function (event) {
+            if (options.once && !onceSupported) {
+                var _element$listeners = element.listeners,
+                    listeners = _element$listeners === undefined ? {} : _element$listeners;
+
+
+                _handler = function handler() {
+                    for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) {
+                        args[_key2] = arguments[_key2];
+                    }
+
+                    delete listeners[event][listener];
+                    element.removeEventListener(event, _handler, options);
+                    listener.apply(element, args);
+                };
+
+                if (!listeners[event]) {
+                    listeners[event] = {};
+                }
+
+                if (listeners[event][listener]) {
+                    element.removeEventListener(event, listeners[event][listener], options);
+                }
+
+                listeners[event][listener] = _handler;
+                element.listeners = listeners;
+            }
+
+            element.addEventListener(event, _handler, options);
+        });
+    }
+
+    /**
+     * Dispatch event on the target element.
+     * @param {Element} element - The event target.
+     * @param {string} type - The event type(s).
+     * @param {Object} data - The additional event data.
+     * @returns {boolean} Indicate if the event is default prevented or not.
+     */
+    function dispatchEvent(element, type, data) {
+        var event = void 0;
+
+        // Event and CustomEvent on IE9-11 are global objects, not constructors
+        if (isFunction(Event) && isFunction(CustomEvent)) {
+            event = new CustomEvent(type, {
+                detail: data,
+                bubbles: true,
+                cancelable: true
+            });
+        } else {
+            event = document.createEvent('CustomEvent');
+            event.initCustomEvent(type, true, true, data);
+        }
+
+        return element.dispatchEvent(event);
+    }
+
+    /**
+     * Get the offset base on the document.
+     * @param {Element} element - The target element.
+     * @returns {Object} The offset data.
+     */
+    function getOffset(element) {
+        var box = element.getBoundingClientRect();
+
+        return {
+            left: box.left + (window.pageXOffset - document.documentElement.clientLeft),
+            top: box.top + (window.pageYOffset - document.documentElement.clientTop)
+        };
+    }
+
+    var location = WINDOW.location;
+
+    var REGEXP_ORIGINS = /^(https?:)\/\/([^:/?#]+):?(\d*)/i;
+
+    /**
+     * Check if the given URL is a cross origin URL.
+     * @param {string} url - The target URL.
+     * @returns {boolean} Returns `true` if the given URL is a cross origin URL, else `false`.
+     */
+    function isCrossOriginURL(url) {
+        var parts = url.match(REGEXP_ORIGINS);
+
+        return parts && (parts[1] !== location.protocol || parts[2] !== location.hostname || parts[3] !== location.port);
+    }
+
+    /**
+     * Add timestamp to the given URL.
+     * @param {string} url - The target URL.
+     * @returns {string} The result URL.
+     */
+    function addTimestamp(url) {
+        var timestamp = 'timestamp=' + new Date().getTime();
+
+        return url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp;
+    }
+
+    /**
+     * Get transforms base on the given object.
+     * @param {Object} obj - The target object.
+     * @returns {string} A string contains transform values.
+     */
+    function getTransforms(_ref) {
+        var rotate = _ref.rotate,
+            scaleX = _ref.scaleX,
+            scaleY = _ref.scaleY,
+            translateX = _ref.translateX,
+            translateY = _ref.translateY;
+
+        var values = [];
+
+        if (isNumber(translateX) && translateX !== 0) {
+            values.push('translateX(' + translateX + 'px)');
+        }
+
+        if (isNumber(translateY) && translateY !== 0) {
+            values.push('translateY(' + translateY + 'px)');
+        }
+
+        // Rotate should come first before scale to match orientation transform
+        if (isNumber(rotate) && rotate !== 0) {
+            values.push('rotate(' + rotate + 'deg)');
+        }
+
+        if (isNumber(scaleX) && scaleX !== 1) {
+            values.push('scaleX(' + scaleX + ')');
+        }
+
+        if (isNumber(scaleY) && scaleY !== 1) {
+            values.push('scaleY(' + scaleY + ')');
+        }
+
+        var transform = values.length ? values.join(' ') : 'none';
+
+        return {
+            WebkitTransform: transform,
+            msTransform: transform,
+            transform: transform
+        };
+    }
+
+    /**
+     * Get the max ratio of a group of pointers.
+     * @param {string} pointers - The target pointers.
+     * @returns {number} The result ratio.
+     */
+    function getMaxZoomRatio(pointers) {
+        var pointers2 = assign({}, pointers);
+        var ratios = [];
+
+        forEach(pointers, function (pointer, pointerId) {
+            delete pointers2[pointerId];
+
+            forEach(pointers2, function (pointer2) {
+                var x1 = Math.abs(pointer.startX - pointer2.startX);
+                var y1 = Math.abs(pointer.startY - pointer2.startY);
+                var x2 = Math.abs(pointer.endX - pointer2.endX);
+                var y2 = Math.abs(pointer.endY - pointer2.endY);
+                var z1 = Math.sqrt(x1 * x1 + y1 * y1);
+                var z2 = Math.sqrt(x2 * x2 + y2 * y2);
+                var ratio = (z2 - z1) / z1;
+
+                ratios.push(ratio);
+            });
+        });
+
+        ratios.sort(function (a, b) {
+            return Math.abs(a) < Math.abs(b);
+        });
+
+        return ratios[0];
+    }
+
+    /**
+     * Get a pointer from an event object.
+     * @param {Object} event - The target event object.
+     * @param {boolean} endOnly - Indicates if only returns the end point coordinate or not.
+     * @returns {Object} The result pointer contains start and/or end point coordinates.
+     */
+    function getPointer(_ref2, endOnly) {
+        var pageX = _ref2.pageX,
+            pageY = _ref2.pageY;
+
+        var end = {
+            endX: pageX,
+            endY: pageY
+        };
+
+        return endOnly ? end : assign({
+            startX: pageX,
+            startY: pageY
+        }, end);
+    }
+
+    /**
+     * Get the center point coordinate of a group of pointers.
+     * @param {Object} pointers - The target pointers.
+     * @returns {Object} The center point coordinate.
+     */
+    function getPointersCenter(pointers) {
+        var pageX = 0;
+        var pageY = 0;
+        var count = 0;
+
+        forEach(pointers, function (_ref3) {
+            var startX = _ref3.startX,
+                startY = _ref3.startY;
+
+            pageX += startX;
+            pageY += startY;
+            count += 1;
+        });
+
+        pageX /= count;
+        pageY /= count;
+
+        return {
+            pageX: pageX,
+            pageY: pageY
+        };
+    }
+
+    /**
+     * Check if the given value is a finite number.
+     */
+    var isFinite = Number.isFinite || WINDOW.isFinite;
+
+    /**
+     * Get the max sizes in a rectangle under the given aspect ratio.
+     * @param {Object} data - The original sizes.
+     * @param {string} [type='contain'] - The adjust type.
+     * @returns {Object} The result sizes.
+     */
+    function getAdjustedSizes(_ref4) // or 'cover'
+    {
+        var aspectRatio = _ref4.aspectRatio,
+            height = _ref4.height,
+            width = _ref4.width;
+        var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'contain';
+
+        var isValidNumber = function isValidNumber(value) {
+            return isFinite(value) && value > 0;
+        };
+
+        if (isValidNumber(width) && isValidNumber(height)) {
+            var adjustedWidth = height * aspectRatio;
+
+            if (type === 'contain' && adjustedWidth > width || type === 'cover' && adjustedWidth < width) {
+                height = width / aspectRatio;
+            } else {
+                width = height * aspectRatio;
+            }
+        } else if (isValidNumber(width)) {
+            height = width / aspectRatio;
+        } else if (isValidNumber(height)) {
+            width = height * aspectRatio;
+        }
+
+        return {
+            width: width,
+            height: height
+        };
+    }
+
+    /**
+     * Get the new sizes of a rectangle after rotated.
+     * @param {Object} data - The original sizes.
+     * @returns {Object} The result sizes.
+     */
+    function getRotatedSizes(_ref5) {
+        var width = _ref5.width,
+            height = _ref5.height,
+            degree = _ref5.degree;
+
+        degree = Math.abs(degree) % 180;
+
+        if (degree === 90) {
+            return {
+                width: height,
+                height: width
+            };
+        }
+
+        var arc = degree % 90 * Math.PI / 180;
+        var sinArc = Math.sin(arc);
+        var cosArc = Math.cos(arc);
+        var newWidth = width * cosArc + height * sinArc;
+        var newHeight = width * sinArc + height * cosArc;
+
+        return degree > 90 ? {
+            width: newHeight,
+            height: newWidth
+        } : {
+            width: newWidth,
+            height: newHeight
+        };
+    }
+
+    /**
+     * Get a canvas which drew the given image.
+     * @param {HTMLImageElement} image - The image for drawing.
+     * @param {Object} imageData - The image data.
+     * @param {Object} canvasData - The canvas data.
+     * @param {Object} options - The options.
+     * @returns {HTMLCanvasElement} The result canvas.
+     */
+    function getSourceCanvas(image, _ref6, _ref7, _ref8) {
+        var imageAspectRatio = _ref6.aspectRatio,
+            imageNaturalWidth = _ref6.naturalWidth,
+            imageNaturalHeight = _ref6.naturalHeight,
+            _ref6$rotate = _ref6.rotate,
+            rotate = _ref6$rotate === undefined ? 0 : _ref6$rotate,
+            _ref6$scaleX = _ref6.scaleX,
+            scaleX = _ref6$scaleX === undefined ? 1 : _ref6$scaleX,
+            _ref6$scaleY = _ref6.scaleY,
+            scaleY = _ref6$scaleY === undefined ? 1 : _ref6$scaleY;
+        var aspectRatio = _ref7.aspectRatio,
+            naturalWidth = _ref7.naturalWidth,
+            naturalHeight = _ref7.naturalHeight;
+        var _ref8$fillColor = _ref8.fillColor,
+            fillColor = _ref8$fillColor === undefined ? 'transparent' : _ref8$fillColor,
+            _ref8$imageSmoothingE = _ref8.imageSmoothingEnabled,
+            imageSmoothingEnabled = _ref8$imageSmoothingE === undefined ? true : _ref8$imageSmoothingE,
+            _ref8$imageSmoothingQ = _ref8.imageSmoothingQuality,
+            imageSmoothingQuality = _ref8$imageSmoothingQ === undefined ? 'low' : _ref8$imageSmoothingQ,
+            _ref8$maxWidth = _ref8.maxWidth,
+            maxWidth = _ref8$maxWidth === undefined ? Infinity : _ref8$maxWidth,
+            _ref8$maxHeight = _ref8.maxHeight,
+            maxHeight = _ref8$maxHeight === undefined ? Infinity : _ref8$maxHeight,
+            _ref8$minWidth = _ref8.minWidth,
+            minWidth = _ref8$minWidth === undefined ? 0 : _ref8$minWidth,
+            _ref8$minHeight = _ref8.minHeight,
+            minHeight = _ref8$minHeight === undefined ? 0 : _ref8$minHeight;
+
+        var canvas = document.createElement('canvas');
+        var context = canvas.getContext('2d');
+        var maxSizes = getAdjustedSizes({
+            aspectRatio: aspectRatio,
+            width: maxWidth,
+            height: maxHeight
+        });
+        var minSizes = getAdjustedSizes({
+            aspectRatio: aspectRatio,
+            width: minWidth,
+            height: minHeight
+        }, 'cover');
+        var width = Math.min(maxSizes.width, Math.max(minSizes.width, naturalWidth));
+        var height = Math.min(maxSizes.height, Math.max(minSizes.height, naturalHeight));
+
+        // Note: should always use image's natural sizes for drawing as
+        // imageData.naturalWidth === canvasData.naturalHeight when rotate % 180 === 90
+        var destMaxSizes = getAdjustedSizes({
+            aspectRatio: imageAspectRatio,
+            width: maxWidth,
+            height: maxHeight
+        });
+        var destMinSizes = getAdjustedSizes({
+            aspectRatio: imageAspectRatio,
+            width: minWidth,
+            height: minHeight
+        }, 'cover');
+        var destWidth = Math.min(destMaxSizes.width, Math.max(destMinSizes.width, imageNaturalWidth));
+        var destHeight = Math.min(destMaxSizes.height, Math.max(destMinSizes.height, imageNaturalHeight));
+        var params = [-destWidth / 2, -destHeight / 2, destWidth, destHeight];
+
+        canvas.width = normalizeDecimalNumber(width);
+        canvas.height = normalizeDecimalNumber(height);
+        context.fillStyle = fillColor;
+        context.fillRect(0, 0, width, height);
+        context.save();
+        context.translate(width / 2, height / 2);
+        context.rotate(rotate * Math.PI / 180);
+        context.scale(scaleX, scaleY);
+        context.imageSmoothingEnabled = imageSmoothingEnabled;
+        context.imageSmoothingQuality = imageSmoothingQuality;
+        context.drawImage.apply(context, [image].concat(toConsumableArray(params.map(function (param) {
+            return Math.floor(normalizeDecimalNumber(param));
+        }))));
+        context.restore();
+        return canvas;
+    }
+
+    var fromCharCode = String.fromCharCode;
+
+    /**
+     * Get string from char code in data view.
+     * @param {DataView} dataView - The data view for read.
+     * @param {number} start - The start index.
+     * @param {number} length - The read length.
+     * @returns {string} The read result.
+     */
+
+    function getStringFromCharCode(dataView, start, length) {
+        var str = '';
+        var i = void 0;
+
+        length += start;
+
+        for (i = start; i < length; i += 1) {
+            str += fromCharCode(dataView.getUint8(i));
+        }
+
+        return str;
+    }
+
+    var REGEXP_DATA_URL_HEAD = /^data:.*,/;
+
+    /**
+     * Transform Data URL to array buffer.
+     * @param {string} dataURL - The Data URL to transform.
+     * @returns {ArrayBuffer} The result array buffer.
+     */
+    function dataURLToArrayBuffer(dataURL) {
+        var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, '');
+        var binary = atob(base64);
+        var arrayBuffer = new ArrayBuffer(binary.length);
+        var uint8 = new Uint8Array(arrayBuffer);
+
+        forEach(uint8, function (value, i) {
+            uint8[i] = binary.charCodeAt(i);
+        });
+
+        return arrayBuffer;
+    }
+
+    /**
+     * Transform array buffer to Data URL.
+     * @param {ArrayBuffer} arrayBuffer - The array buffer to transform.
+     * @param {string} mimeType - The mime type of the Data URL.
+     * @returns {string} The result Data URL.
+     */
+    function arrayBufferToDataURL(arrayBuffer, mimeType) {
+        var uint8 = new Uint8Array(arrayBuffer);
+        var data = '';
+
+        // TypedArray.prototype.forEach is not supported in some browsers.
+        forEach(uint8, function (value) {
+            data += fromCharCode(value);
+        });
+
+        return 'data:' + mimeType + ';base64,' + btoa(data);
+    }
+
+    /**
+     * Get orientation value from given array buffer.
+     * @param {ArrayBuffer} arrayBuffer - The array buffer to read.
+     * @returns {number} The read orientation value.
+     */
+    function getOrientation(arrayBuffer) {
+        var dataView = new DataView(arrayBuffer);
+        var orientation = void 0;
+        var littleEndian = void 0;
+        var app1Start = void 0;
+        var ifdStart = void 0;
+
+        // Only handle JPEG image (start by 0xFFD8)
+        if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) {
+            var length = dataView.byteLength;
+            var offset = 2;
+
+            while (offset < length) {
+                if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) {
+                    app1Start = offset;
+                    break;
+                }
+
+                offset += 1;
+            }
+        }
+
+        if (app1Start) {
+            var exifIDCode = app1Start + 4;
+            var tiffOffset = app1Start + 10;
+
+            if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') {
+                var endianness = dataView.getUint16(tiffOffset);
+
+                littleEndian = endianness === 0x4949;
+
+                if (littleEndian || endianness === 0x4D4D /* bigEndian */) {
+                    if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) {
+                        var firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian);
+
+                        if (firstIFDOffset >= 0x00000008) {
+                            ifdStart = tiffOffset + firstIFDOffset;
+                        }
+                    }
+                }
+            }
+        }
+
+        if (ifdStart) {
+            var _length = dataView.getUint16(ifdStart, littleEndian);
+            var _offset = void 0;
+            var i = void 0;
+
+            for (i = 0; i < _length; i += 1) {
+                _offset = ifdStart + i * 12 + 2;
+
+                if (dataView.getUint16(_offset, littleEndian) === 0x0112 /* Orientation */) {
+                    // 8 is the offset of the current tag's value
+                    _offset += 8;
+
+                    // Get the original orientation value
+                    orientation = dataView.getUint16(_offset, littleEndian);
+
+                    // Override the orientation with its default value
+                    dataView.setUint16(_offset, 1, littleEndian);
+                    break;
+                }
+            }
+        }
+
+        return orientation;
+    }
+
+    /**
+     * Parse Exif Orientation value.
+     * @param {number} orientation - The orientation to parse.
+     * @returns {Object} The parsed result.
+     */
+    function parseOrientation(orientation) {
+        var rotate = 0;
+        var scaleX = 1;
+        var scaleY = 1;
+
+        switch (orientation) {
+            // Flip horizontal
+            case 2:
+                scaleX = -1;
+                break;
+
+            // Rotate left 180°
+            case 3:
+                rotate = -180;
+                break;
+
+            // Flip vertical
+            case 4:
+                scaleY = -1;
+                break;
+
+            // Flip vertical and rotate right 90°
+            case 5:
+                rotate = 90;
+                scaleY = -1;
+                break;
+
+            // Rotate right 90°
+            case 6:
+                rotate = 90;
+                break;
+
+            // Flip horizontal and rotate right 90°
+            case 7:
+                rotate = 90;
+                scaleX = -1;
+                break;
+
+            // Rotate left 90°
+            case 8:
+                rotate = -90;
+                break;
+
+            default:
+        }
+
+        return {
+            rotate: rotate,
+            scaleX: scaleX,
+            scaleY: scaleY
+        };
+    }
+
+    var render = {
+        render: function render() {
+            this.initContainer();
+            this.initCanvas();
+            this.initCropBox();
+            this.renderCanvas();
+
+            if (this.cropped) {
+                this.renderCropBox();
+            }
+        },
+        initContainer: function initContainer() {
+            var element = this.element,
+                options = this.options,
+                container = this.container,
+                cropper = this.cropper;
+
+
+            addClass(cropper, CLASS_HIDDEN);
+            removeClass(element, CLASS_HIDDEN);
+
+            var containerData = {
+                width: Math.max(container.offsetWidth, Number(options.minContainerWidth) || 200),
+                height: Math.max(container.offsetHeight, Number(options.minContainerHeight) || 100)
+            };
+
+            this.containerData = containerData;
+
+            setStyle(cropper, {
+                width: containerData.width,
+                height: containerData.height
+            });
+
+            addClass(element, CLASS_HIDDEN);
+            removeClass(cropper, CLASS_HIDDEN);
+        },
+
+
+        // Canvas (image wrapper)
+        initCanvas: function initCanvas() {
+            var containerData = this.containerData,
+                imageData = this.imageData;
+            var viewMode = this.options.viewMode;
+
+            var rotated = Math.abs(imageData.rotate) % 180 === 90;
+            var naturalWidth = rotated ? imageData.naturalHeight : imageData.naturalWidth;
+            var naturalHeight = rotated ? imageData.naturalWidth : imageData.naturalHeight;
+            var aspectRatio = naturalWidth / naturalHeight;
+            var canvasWidth = containerData.width;
+            var canvasHeight = containerData.height;
+
+            if (containerData.height * aspectRatio > containerData.width) {
+                if (viewMode === 3) {
+                    canvasWidth = containerData.height * aspectRatio;
+                } else {
+                    canvasHeight = containerData.width / aspectRatio;
+                }
+            } else if (viewMode === 3) {
+                canvasHeight = containerData.width / aspectRatio;
+            } else {
+                canvasWidth = containerData.height * aspectRatio;
+            }
+
+            var canvasData = {
+                aspectRatio: aspectRatio,
+                naturalWidth: naturalWidth,
+                naturalHeight: naturalHeight,
+                width: canvasWidth,
+                height: canvasHeight
+            };
+
+            canvasData.left = (containerData.width - canvasWidth) / 2;
+            canvasData.top = (containerData.height - canvasHeight) / 2;
+            canvasData.oldLeft = canvasData.left;
+            canvasData.oldTop = canvasData.top;
+
+            this.canvasData = canvasData;
+            this.limited = viewMode === 1 || viewMode === 2;
+            this.limitCanvas(true, true);
+            this.initialImageData = assign({}, imageData);
+            this.initialCanvasData = assign({}, canvasData);
+        },
+        limitCanvas: function limitCanvas(sizeLimited, positionLimited) {
+            var options = this.options,
+                containerData = this.containerData,
+                canvasData = this.canvasData,
+                cropBoxData = this.cropBoxData;
+            var viewMode = options.viewMode;
+            var aspectRatio = canvasData.aspectRatio;
+
+            var cropped = this.cropped && cropBoxData;
+
+            if (sizeLimited) {
+                var minCanvasWidth = Number(options.minCanvasWidth) || 0;
+                var minCanvasHeight = Number(options.minCanvasHeight) || 0;
+
+                if (viewMode > 1) {
+                    minCanvasWidth = Math.max(minCanvasWidth, containerData.width);
+                    minCanvasHeight = Math.max(minCanvasHeight, containerData.height);
+
+                    if (viewMode === 3) {
+                        if (minCanvasHeight * aspectRatio > minCanvasWidth) {
+                            minCanvasWidth = minCanvasHeight * aspectRatio;
+                        } else {
+                            minCanvasHeight = minCanvasWidth / aspectRatio;
+                        }
+                    }
+                } else if (viewMode > 0) {
+                    if (minCanvasWidth) {
+                        minCanvasWidth = Math.max(minCanvasWidth, cropped ? cropBoxData.width : 0);
+                    } else if (minCanvasHeight) {
+                        minCanvasHeight = Math.max(minCanvasHeight, cropped ? cropBoxData.height : 0);
+                    } else if (cropped) {
+                        minCanvasWidth = cropBoxData.width;
+                        minCanvasHeight = cropBoxData.height;
+
+                        if (minCanvasHeight * aspectRatio > minCanvasWidth) {
+                            minCanvasWidth = minCanvasHeight * aspectRatio;
+                        } else {
+                            minCanvasHeight = minCanvasWidth / aspectRatio;
+                        }
+                    }
+                }
+
+                var _getAdjustedSizes = getAdjustedSizes({
+                    aspectRatio: aspectRatio,
+                    width: minCanvasWidth,
+                    height: minCanvasHeight
+                });
+
+                minCanvasWidth = _getAdjustedSizes.width;
+                minCanvasHeight = _getAdjustedSizes.height;
+
+
+                canvasData.minWidth = minCanvasWidth;
+                canvasData.minHeight = minCanvasHeight;
+                canvasData.maxWidth = Infinity;
+                canvasData.maxHeight = Infinity;
+            }
+
+            if (positionLimited) {
+                if (viewMode) {
+                    var newCanvasLeft = containerData.width - canvasData.width;
+                    var newCanvasTop = containerData.height - canvasData.height;
+
+                    canvasData.minLeft = Math.min(0, newCanvasLeft);
+                    canvasData.minTop = Math.min(0, newCanvasTop);
+                    canvasData.maxLeft = Math.max(0, newCanvasLeft);
+                    canvasData.maxTop = Math.max(0, newCanvasTop);
+
+                    if (cropped && this.limited) {
+                        canvasData.minLeft = Math.min(cropBoxData.left, cropBoxData.left + (cropBoxData.width - canvasData.width));
+                        canvasData.minTop = Math.min(cropBoxData.top, cropBoxData.top + (cropBoxData.height - canvasData.height));
+                        canvasData.maxLeft = cropBoxData.left;
+                        canvasData.maxTop = cropBoxData.top;
+
+                        if (viewMode === 2) {
+                            if (canvasData.width >= containerData.width) {
+                                canvasData.minLeft = Math.min(0, newCanvasLeft);
+                                canvasData.maxLeft = Math.max(0, newCanvasLeft);
+                            }
+
+                            if (canvasData.height >= containerData.height) {
+                                canvasData.minTop = Math.min(0, newCanvasTop);
+                                canvasData.maxTop = Math.max(0, newCanvasTop);
+                            }
+                        }
+                    }
+                } else {
+                    canvasData.minLeft = -canvasData.width;
+                    canvasData.minTop = -canvasData.height;
+                    canvasData.maxLeft = containerData.width;
+                    canvasData.maxTop = containerData.height;
+                }
+            }
+        },
+        renderCanvas: function renderCanvas(changed, transformed) {
+            var canvasData = this.canvasData,
+                imageData = this.imageData;
+
+
+            if (transformed) {
+                var _getRotatedSizes = getRotatedSizes({
+                        width: imageData.naturalWidth * Math.abs(imageData.scaleX || 1),
+                        height: imageData.naturalHeight * Math.abs(imageData.scaleY || 1),
+                        degree: imageData.rotate || 0
+                    }),
+                    naturalWidth = _getRotatedSizes.width,
+                    naturalHeight = _getRotatedSizes.height;
+
+                var width = canvasData.width * (naturalWidth / canvasData.naturalWidth);
+                var height = canvasData.height * (naturalHeight / canvasData.naturalHeight);
+
+                canvasData.left -= (width - canvasData.width) / 2;
+                canvasData.top -= (height - canvasData.height) / 2;
+                canvasData.width = width;
+                canvasData.height = height;
+                canvasData.aspectRatio = naturalWidth / naturalHeight;
+                canvasData.naturalWidth = naturalWidth;
+                canvasData.naturalHeight = naturalHeight;
+                this.limitCanvas(true, false);
+            }
+
+            if (canvasData.width > canvasData.maxWidth || canvasData.width < canvasData.minWidth) {
+                canvasData.left = canvasData.oldLeft;
+            }
+
+            if (canvasData.height > canvasData.maxHeight || canvasData.height < canvasData.minHeight) {
+                canvasData.top = canvasData.oldTop;
+            }
+
+            canvasData.width = Math.min(Math.max(canvasData.width, canvasData.minWidth), canvasData.maxWidth);
+            canvasData.height = Math.min(Math.max(canvasData.height, canvasData.minHeight), canvasData.maxHeight);
+
+            this.limitCanvas(false, true);
+
+            canvasData.left = Math.min(Math.max(canvasData.left, canvasData.minLeft), canvasData.maxLeft);
+            canvasData.top = Math.min(Math.max(canvasData.top, canvasData.minTop), canvasData.maxTop);
+            canvasData.oldLeft = canvasData.left;
+            canvasData.oldTop = canvasData.top;
+
+            setStyle(this.canvas, assign({
+                width: canvasData.width,
+                height: canvasData.height
+            }, getTransforms({
+                translateX: canvasData.left,
+                translateY: canvasData.top
+            })));
+
+            this.renderImage(changed);
+
+            if (this.cropped && this.limited) {
+                this.limitCropBox(true, true);
+            }
+        },
+        renderImage: function renderImage(changed) {
+            var canvasData = this.canvasData,
+                imageData = this.imageData;
+
+            var width = imageData.naturalWidth * (canvasData.width / canvasData.naturalWidth);
+            var height = imageData.naturalHeight * (canvasData.height / canvasData.naturalHeight);
+
+            assign(imageData, {
+                width: width,
+                height: height,
+                left: (canvasData.width - width) / 2,
+                top: (canvasData.height - height) / 2
+            });
+            setStyle(this.image, assign({
+                width: imageData.width,
+                height: imageData.height
+            }, getTransforms(assign({
+                translateX: imageData.left,
+                translateY: imageData.top
+            }, imageData))));
+
+            if (changed) {
+                this.output();
+            }
+        },
+        initCropBox: function initCropBox() {
+            var options = this.options,
+                canvasData = this.canvasData;
+            var aspectRatio = options.aspectRatio;
+
+            var autoCropArea = Number(options.autoCropArea) || 0.8;
+            var cropBoxData = {
+                width: canvasData.width,
+                height: canvasData.height
+            };
+
+            if (aspectRatio) {
+                if (canvasData.height * aspectRatio > canvasData.width) {
+                    cropBoxData.height = cropBoxData.width / aspectRatio;
+                } else {
+                    cropBoxData.width = cropBoxData.height * aspectRatio;
+                }
+            }
+
+            this.cropBoxData = cropBoxData;
+            this.limitCropBox(true, true);
+
+            // Initialize auto crop area
+            cropBoxData.width = Math.min(Math.max(cropBoxData.width, cropBoxData.minWidth), cropBoxData.maxWidth);
+            cropBoxData.height = Math.min(Math.max(cropBoxData.height, cropBoxData.minHeight), cropBoxData.maxHeight);
+
+            // The width/height of auto crop area must large than "minWidth/Height"
+            cropBoxData.width = Math.max(cropBoxData.minWidth, cropBoxData.width * autoCropArea);
+            cropBoxData.height = Math.max(cropBoxData.minHeight, cropBoxData.height * autoCropArea);
+            cropBoxData.left = canvasData.left + (canvasData.width - cropBoxData.width) / 2;
+            cropBoxData.top = canvasData.top + (canvasData.height - cropBoxData.height) / 2;
+            cropBoxData.oldLeft = cropBoxData.left;
+            cropBoxData.oldTop = cropBoxData.top;
+
+            this.initialCropBoxData = assign({}, cropBoxData);
+        },
+        limitCropBox: function limitCropBox(sizeLimited, positionLimited) {
+            var options = this.options,
+                containerData = this.containerData,
+                canvasData = this.canvasData,
+                cropBoxData = this.cropBoxData,
+                limited = this.limited;
+            var aspectRatio = options.aspectRatio;
+
+
+            if (sizeLimited) {
+                var minCropBoxWidth = Number(options.minCropBoxWidth) || 0;
+                var minCropBoxHeight = Number(options.minCropBoxHeight) || 0;
+                var maxCropBoxWidth = Math.min(containerData.width, limited ? canvasData.width : containerData.width);
+                var maxCropBoxHeight = Math.min(containerData.height, limited ? canvasData.height : containerData.height);
+
+                // The min/maxCropBoxWidth/Height must be less than container's width/height
+                minCropBoxWidth = Math.min(minCropBoxWidth, containerData.width);
+                minCropBoxHeight = Math.min(minCropBoxHeight, containerData.height);
+
+                if (aspectRatio) {
+                    if (minCropBoxWidth && minCropBoxHeight) {
+                        if (minCropBoxHeight * aspectRatio > minCropBoxWidth) {
+                            minCropBoxHeight = minCropBoxWidth / aspectRatio;
+                        } else {
+                            minCropBoxWidth = minCropBoxHeight * aspectRatio;
+                        }
+                    } else if (minCropBoxWidth) {
+                        minCropBoxHeight = minCropBoxWidth / aspectRatio;
+                    } else if (minCropBoxHeight) {
+                        minCropBoxWidth = minCropBoxHeight * aspectRatio;
+                    }
+
+                    if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) {
+                        maxCropBoxHeight = maxCropBoxWidth / aspectRatio;
+                    } else {
+                        maxCropBoxWidth = maxCropBoxHeight * aspectRatio;
+                    }
+                }
+
+                // The minWidth/Height must be less than maxWidth/Height
+                cropBoxData.minWidth = Math.min(minCropBoxWidth, maxCropBoxWidth);
+                cropBoxData.minHeight = Math.min(minCropBoxHeight, maxCropBoxHeight);
+                cropBoxData.maxWidth = maxCropBoxWidth;
+                cropBoxData.maxHeight = maxCropBoxHeight;
+            }
+
+            if (positionLimited) {
+                if (limited) {
+                    cropBoxData.minLeft = Math.max(0, canvasData.left);
+                    cropBoxData.minTop = Math.max(0, canvasData.top);
+                    cropBoxData.maxLeft = Math.min(containerData.width, canvasData.left + canvasData.width) - cropBoxData.width;
+                    cropBoxData.maxTop = Math.min(containerData.height, canvasData.top + canvasData.height) - cropBoxData.height;
+                } else {
+                    cropBoxData.minLeft = 0;
+                    cropBoxData.minTop = 0;
+                    cropBoxData.maxLeft = containerData.width - cropBoxData.width;
+                    cropBoxData.maxTop = containerData.height - cropBoxData.height;
+                }
+            }
+        },
+        renderCropBox: function renderCropBox() {
+            var options = this.options,
+                containerData = this.containerData,
+                cropBoxData = this.cropBoxData;
+
+
+            if (cropBoxData.width > cropBoxData.maxWidth || cropBoxData.width < cropBoxData.minWidth) {
+                cropBoxData.left = cropBoxData.oldLeft;
+            }
+
+            if (cropBoxData.height > cropBoxData.maxHeight || cropBoxData.height < cropBoxData.minHeight) {
+                cropBoxData.top = cropBoxData.oldTop;
+            }
+
+            cropBoxData.width = Math.min(Math.max(cropBoxData.width, cropBoxData.minWidth), cropBoxData.maxWidth);
+            cropBoxData.height = Math.min(Math.max(cropBoxData.height, cropBoxData.minHeight), cropBoxData.maxHeight);
+
+            this.limitCropBox(false, true);
+
+            cropBoxData.left = Math.min(Math.max(cropBoxData.left, cropBoxData.minLeft), cropBoxData.maxLeft);
+            cropBoxData.top = Math.min(Math.max(cropBoxData.top, cropBoxData.minTop), cropBoxData.maxTop);
+            cropBoxData.oldLeft = cropBoxData.left;
+            cropBoxData.oldTop = cropBoxData.top;
+
+            if (options.movable && options.cropBoxMovable) {
+                // Turn to move the canvas when the crop box is equal to the container
+                setData(this.face, DATA_ACTION, cropBoxData.width >= containerData.width && cropBoxData.height >= containerData.height ? ACTION_MOVE : ACTION_ALL);
+            }
+
+            setStyle(this.cropBox, assign({
+                width: cropBoxData.width,
+                height: cropBoxData.height
+            }, getTransforms({
+                translateX: cropBoxData.left,
+                translateY: cropBoxData.top
+            })));
+
+            if (this.cropped && this.limited) {
+                this.limitCanvas(true, true);
+            }
+
+            if (!this.disabled) {
+                this.output();
+            }
+        },
+        output: function output() {
+            this.preview();
+            dispatchEvent(this.element, EVENT_CROP, this.getData());
+        }
+    };
+
+    var preview = {
+        initPreview: function initPreview() {
+            var crossOrigin = this.crossOrigin;
+            var preview = this.options.preview;
+
+            var url = crossOrigin ? this.crossOriginUrl : this.url;
+            var image = document.createElement('img');
+
+            if (crossOrigin) {
+                image.crossOrigin = crossOrigin;
+            }
+
+            image.src = url;
+            this.viewBox.appendChild(image);
+            this.viewBoxImage = image;
+
+            if (!preview) {
+                return;
+            }
+
+            var previews = preview;
+
+            if (typeof preview === 'string') {
+                previews = this.element.ownerDocument.querySelectorAll(preview);
+            } else if (preview.querySelector) {
+                previews = [preview];
+            }
+
+            this.previews = previews;
+
+            forEach(previews, function (el) {
+                var img = document.createElement('img');
+
+                // Save the original size for recover
+                setData(el, DATA_PREVIEW, {
+                    width: el.offsetWidth,
+                    height: el.offsetHeight,
+                    html: el.innerHTML
+                });
+
+                if (crossOrigin) {
+                    img.crossOrigin = crossOrigin;
+                }
+
+                img.src = url;
+
+                /**
+                 * Override img element styles
+                 * Add `display:block` to avoid margin top issue
+                 * Add `height:auto` to override `height` attribute on IE8
+                 * (Occur only when margin-top <= -height)
+                 */
+                img.style.cssText = 'display:block;' + 'width:100%;' + 'height:auto;' + 'min-width:0!important;' + 'min-height:0!important;' + 'max-width:none!important;' + 'max-height:none!important;' + 'image-orientation:0deg!important;"';
+
+                el.innerHTML = '';
+                el.appendChild(img);
+            });
+        },
+        resetPreview: function resetPreview() {
+            forEach(this.previews, function (element) {
+                var data = getData(element, DATA_PREVIEW);
+
+                setStyle(element, {
+                    width: data.width,
+                    height: data.height
+                });
+
+                element.innerHTML = data.html;
+                removeData(element, DATA_PREVIEW);
+            });
+        },
+        preview: function preview() {
+            var imageData = this.imageData,
+                canvasData = this.canvasData,
+                cropBoxData = this.cropBoxData;
+            var cropBoxWidth = cropBoxData.width,
+                cropBoxHeight = cropBoxData.height;
+            var width = imageData.width,
+                height = imageData.height;
+
+            var left = cropBoxData.left - canvasData.left - imageData.left;
+            var top = cropBoxData.top - canvasData.top - imageData.top;
+
+            if (!this.cropped || this.disabled) {
+                return;
+            }
+
+            setStyle(this.viewBoxImage, assign({
+                width: width,
+                height: height
+            }, getTransforms(assign({
+                translateX: -left,
+                translateY: -top
+            }, imageData))));
+
+            forEach(this.previews, function (element) {
+                var data = getData(element, DATA_PREVIEW);
+                var originalWidth = data.width;
+                var originalHeight = data.height;
+                var newWidth = originalWidth;
+                var newHeight = originalHeight;
+                var ratio = 1;
+
+                if (cropBoxWidth) {
+                    ratio = originalWidth / cropBoxWidth;
+                    newHeight = cropBoxHeight * ratio;
+                }
+
+                if (cropBoxHeight && newHeight > originalHeight) {
+                    ratio = originalHeight / cropBoxHeight;
+                    newWidth = cropBoxWidth * ratio;
+                    newHeight = originalHeight;
+                }
+
+                setStyle(element, {
+                    width: newWidth,
+                    height: newHeight
+                });
+
+                setStyle(element.getElementsByTagName('img')[0], assign({
+                    width: width * ratio,
+                    height: height * ratio
+                }, getTransforms(assign({
+                    translateX: -left * ratio,
+                    translateY: -top * ratio
+                }, imageData))));
+            });
+        }
+    };
+
+    var events = {
+        bind: function bind() {
+            var element = this.element,
+                options = this.options,
+                cropper = this.cropper;
+
+
+            if (isFunction(options.cropstart)) {
+                addListener(element, EVENT_CROP_START, options.cropstart);
+            }
+
+            if (isFunction(options.cropmove)) {
+                addListener(element, EVENT_CROP_MOVE, options.cropmove);
+            }
+
+            if (isFunction(options.cropend)) {
+                addListener(element, EVENT_CROP_END, options.cropend);
+            }
+
+            if (isFunction(options.crop)) {
+                addListener(element, EVENT_CROP, options.crop);
+            }
+
+            if (isFunction(options.zoom)) {
+                addListener(element, EVENT_ZOOM, options.zoom);
+            }
+
+            addListener(cropper, EVENT_POINTER_DOWN, this.onCropStart = this.cropStart.bind(this));
+
+            if (options.zoomable && options.zoomOnWheel) {
+                addListener(cropper, EVENT_WHEEL, this.onWheel = this.wheel.bind(this));
+            }
+
+            if (options.toggleDragModeOnDblclick) {
+                addListener(cropper, EVENT_DBLCLICK, this.onDblclick = this.dblclick.bind(this));
+            }
+
+            addListener(element.ownerDocument, EVENT_POINTER_MOVE, this.onCropMove = this.cropMove.bind(this));
+            addListener(element.ownerDocument, EVENT_POINTER_UP, this.onCropEnd = this.cropEnd.bind(this));
+
+            if (options.responsive) {
+                addListener(window, EVENT_RESIZE, this.onResize = this.resize.bind(this));
+            }
+        },
+        unbind: function unbind() {
+            var element = this.element,
+                options = this.options,
+                cropper = this.cropper;
+
+
+            if (isFunction(options.cropstart)) {
+                removeListener(element, EVENT_CROP_START, options.cropstart);
+            }
+
+            if (isFunction(options.cropmove)) {
+                removeListener(element, EVENT_CROP_MOVE, options.cropmove);
+            }
+
+            if (isFunction(options.cropend)) {
+                removeListener(element, EVENT_CROP_END, options.cropend);
+            }
+
+            if (isFunction(options.crop)) {
+                removeListener(element, EVENT_CROP, options.crop);
+            }
+
+            if (isFunction(options.zoom)) {
+                removeListener(element, EVENT_ZOOM, options.zoom);
+            }
+
+            removeListener(cropper, EVENT_POINTER_DOWN, this.onCropStart);
+
+            if (options.zoomable && options.zoomOnWheel) {
+                removeListener(cropper, EVENT_WHEEL, this.onWheel);
+            }
+
+            if (options.toggleDragModeOnDblclick) {
+                removeListener(cropper, EVENT_DBLCLICK, this.onDblclick);
+            }
+
+            removeListener(element.ownerDocument, EVENT_POINTER_MOVE, this.onCropMove);
+            removeListener(element.ownerDocument, EVENT_POINTER_UP, this.onCropEnd);
+
+            if (options.responsive) {
+                removeListener(window, EVENT_RESIZE, this.onResize);
+            }
+        }
+    };
+
+    var handlers = {
+        resize: function resize() {
+            var options = this.options,
+                container = this.container,
+                containerData = this.containerData;
+
+            var minContainerWidth = Number(options.minContainerWidth) || 200;
+            var minContainerHeight = Number(options.minContainerHeight) || 100;
+
+            if (this.disabled || containerData.width <= minContainerWidth || containerData.height <= minContainerHeight) {
+                return;
+            }
+
+            var ratio = container.offsetWidth / containerData.width;
+
+            // Resize when width changed or height changed
+            if (ratio !== 1 || container.offsetHeight !== containerData.height) {
+                var canvasData = void 0;
+                var cropBoxData = void 0;
+
+                if (options.restore) {
+                    canvasData = this.getCanvasData();
+                    cropBoxData = this.getCropBoxData();
+                }
+
+                this.render();
+
+                if (options.restore) {
+                    this.setCanvasData(forEach(canvasData, function (n, i) {
+                        canvasData[i] = n * ratio;
+                    }));
+                    this.setCropBoxData(forEach(cropBoxData, function (n, i) {
+                        cropBoxData[i] = n * ratio;
+                    }));
+                }
+            }
+        },
+        dblclick: function dblclick() {
+            if (this.disabled || this.options.dragMode === DRAG_MODE_NONE) {
+                return;
+            }
+
+            this.setDragMode(hasClass(this.dragBox, CLASS_CROP) ? DRAG_MODE_MOVE : DRAG_MODE_CROP);
+        },
+        wheel: function wheel(e) {
+            var _this = this;
+
+            var ratio = Number(this.options.wheelZoomRatio) || 0.1;
+            var delta = 1;
+
+            if (this.disabled) {
+                return;
+            }
+
+            e.preventDefault();
+
+            // Limit wheel speed to prevent zoom too fast (#21)
+            if (this.wheeling) {
+                return;
+            }
+
+            this.wheeling = true;
+
+            setTimeout(function () {
+                _this.wheeling = false;
+            }, 50);
+
+            if (e.deltaY) {
+                delta = e.deltaY > 0 ? 1 : -1;
+            } else if (e.wheelDelta) {
+                delta = -e.wheelDelta / 120;
+            } else if (e.detail) {
+                delta = e.detail > 0 ? 1 : -1;
+            }
+
+            this.zoom(-delta * ratio, e);
+        },
+        cropStart: function cropStart(e) {
+            if (this.disabled) {
+                return;
+            }
+
+            var options = this.options,
+                pointers = this.pointers;
+
+            var action = void 0;
+
+            if (e.changedTouches) {
+                // Handle touch event
+                forEach(e.changedTouches, function (touch) {
+                    pointers[touch.identifier] = getPointer(touch);
+                });
+            } else {
+                // Handle mouse event and pointer event
+                pointers[e.pointerId || 0] = getPointer(e);
+            }
+
+            if (Object.keys(pointers).length > 1 && options.zoomable && options.zoomOnTouch) {
+                action = ACTION_ZOOM;
+            } else {
+                action = getData(e.target, DATA_ACTION);
+            }
+
+            if (!REGEXP_ACTIONS.test(action)) {
+                return;
+            }
+
+            if (dispatchEvent(this.element, EVENT_CROP_START, {
+                    originalEvent: e,
+                    action: action
+                }) === false) {
+                return;
+            }
+
+            e.preventDefault();
+
+            this.action = action;
+            this.cropping = false;
+
+            if (action === ACTION_CROP) {
+                this.cropping = true;
+                addClass(this.dragBox, CLASS_MODAL);
+            }
+        },
+        cropMove: function cropMove(e) {
+            var action = this.action;
+
+
+            if (this.disabled || !action) {
+                return;
+            }
+
+            var pointers = this.pointers;
+
+
+            e.preventDefault();
+
+            if (dispatchEvent(this.element, EVENT_CROP_MOVE, {
+                    originalEvent: e,
+                    action: action
+                }) === false) {
+                return;
+            }
+
+            if (e.changedTouches) {
+                forEach(e.changedTouches, function (touch) {
+                    assign(pointers[touch.identifier], getPointer(touch, true));
+                });
+            } else {
+                assign(pointers[e.pointerId || 0], getPointer(e, true));
+            }
+
+            this.change(e);
+        },
+        cropEnd: function cropEnd(e) {
+            if (this.disabled) {
+                return;
+            }
+
+            var action = this.action,
+                pointers = this.pointers;
+
+
+            if (e.changedTouches) {
+                forEach(e.changedTouches, function (touch) {
+                    delete pointers[touch.identifier];
+                });
+            } else {
+                delete pointers[e.pointerId || 0];
+            }
+
+            if (!action) {
+                return;
+            }
+
+            e.preventDefault();
+
+            if (!Object.keys(pointers).length) {
+                this.action = '';
+            }
+
+            if (this.cropping) {
+                this.cropping = false;
+                toggleClass(this.dragBox, CLASS_MODAL, this.cropped && this.options.modal);
+            }
+
+            dispatchEvent(this.element, EVENT_CROP_END, {
+                originalEvent: e,
+                action: action
+            });
+        }
+    };
+
+    var change = {
+        change: function change(e) {
+            var options = this.options,
+                canvasData = this.canvasData,
+                containerData = this.containerData,
+                cropBoxData = this.cropBoxData,
+                pointers = this.pointers;
+            var action = this.action;
+            var aspectRatio = options.aspectRatio;
+            var left = cropBoxData.left,
+                top = cropBoxData.top,
+                width = cropBoxData.width,
+                height = cropBoxData.height;
+
+            var right = left + width;
+            var bottom = top + height;
+            var minLeft = 0;
+            var minTop = 0;
+            var maxWidth = containerData.width;
+            var maxHeight = containerData.height;
+            var renderable = true;
+            var offset = void 0;
+
+            // Locking aspect ratio in "free mode" by holding shift key
+            if (!aspectRatio && e.shiftKey) {
+                aspectRatio = width && height ? width / height : 1;
+            }
+
+            if (this.limited) {
+                minLeft = cropBoxData.minLeft;
+                minTop = cropBoxData.minTop;
+
+                maxWidth = minLeft + Math.min(containerData.width, canvasData.width, canvasData.left + canvasData.width);
+                maxHeight = minTop + Math.min(containerData.height, canvasData.height, canvasData.top + canvasData.height);
+            }
+
+            var pointer = pointers[Object.keys(pointers)[0]];
+            var range = {
+                x: pointer.endX - pointer.startX,
+                y: pointer.endY - pointer.startY
+            };
+            var check = function check(side) {
+                switch (side) {
+                    case ACTION_EAST:
+                        if (right + range.x > maxWidth) {
+                            range.x = maxWidth - right;
+                        }
+
+                        break;
+
+                    case ACTION_WEST:
+                        if (left + range.x < minLeft) {
+                            range.x = minLeft - left;
+                        }
+
+                        break;
+
+                    case ACTION_NORTH:
+                        if (top + range.y < minTop) {
+                            range.y = minTop - top;
+                        }
+
+                        break;
+
+                    case ACTION_SOUTH:
+                        if (bottom + range.y > maxHeight) {
+                            range.y = maxHeight - bottom;
+                        }
+
+                        break;
+
+                    default:
+                }
+            };
+
+            switch (action) {
+                // Move crop box
+                case ACTION_ALL:
+                    left += range.x;
+                    top += range.y;
+                    break;
+
+                // Resize crop box
+                case ACTION_EAST:
+                    if (range.x >= 0 && (right >= maxWidth || aspectRatio && (top <= minTop || bottom >= maxHeight))) {
+                        renderable = false;
+                        break;
+                    }
+
+                    check(ACTION_EAST);
+                    width += range.x;
+
+                    if (aspectRatio) {
+                        height = width / aspectRatio;
+                        top -= range.x / aspectRatio / 2;
+                    }
+
+                    if (width < 0) {
+                        action = ACTION_WEST;
+                        width = 0;
+                    }
+
+                    break;
+
+                case ACTION_NORTH:
+                    if (range.y <= 0 && (top <= minTop || aspectRatio && (left <= minLeft || right >= maxWidth))) {
+                        renderable = false;
+                        break;
+                    }
+
+                    check(ACTION_NORTH);
+                    height -= range.y;
+                    top += range.y;
+
+                    if (aspectRatio) {
+                        width = height * aspectRatio;
+                        left += range.y * aspectRatio / 2;
+                    }
+
+                    if (height < 0) {
+                        action = ACTION_SOUTH;
+                        height = 0;
+                    }
+
+                    break;
+
+                case ACTION_WEST:
+                    if (range.x <= 0 && (left <= minLeft || aspectRatio && (top <= minTop || bottom >= maxHeight))) {
+                        renderable = false;
+                        break;
+                    }
+
+                    check(ACTION_WEST);
+                    width -= range.x;
+                    left += range.x;
+
+                    if (aspectRatio) {
+                        height = width / aspectRatio;
+                        top += range.x / aspectRatio / 2;
+                    }
+
+                    if (width < 0) {
+                        action = ACTION_EAST;
+                        width = 0;
+                    }
+
+                    break;
+
+                case ACTION_SOUTH:
+                    if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && (left <= minLeft || right >= maxWidth))) {
+                        renderable = false;
+                        break;
+                    }
+
+                    check(ACTION_SOUTH);
+                    height += range.y;
+
+                    if (aspectRatio) {
+                        width = height * aspectRatio;
+                        left -= range.y * aspectRatio / 2;
+                    }
+
+                    if (height < 0) {
+                        action = ACTION_NORTH;
+                        height = 0;
+                    }
+
+                    break;
+
+                case ACTION_NORTH_EAST:
+                    if (aspectRatio) {
+                        if (range.y <= 0 && (top <= minTop || right >= maxWidth)) {
+                            renderable = false;
+                            break;
+                        }
+
+                        check(ACTION_NORTH);
+                        height -= range.y;
+                        top += range.y;
+                        width = height * aspectRatio;
+                    } else {
+                        check(ACTION_NORTH);
+                        check(ACTION_EAST);
+
+                        if (range.x >= 0) {
+                            if (right < maxWidth) {
+                                width += range.x;
+                            } else if (range.y <= 0 && top <= minTop) {
+                                renderable = false;
+                            }
+                        } else {
+                            width += range.x;
+                        }
+
+                        if (range.y <= 0) {
+                            if (top > minTop) {
+                                height -= range.y;
+                                top += range.y;
+                            }
+                        } else {
+                            height -= range.y;
+                            top += range.y;
+                        }
+                    }
+
+                    if (width < 0 && height < 0) {
+                        action = ACTION_SOUTH_WEST;
+                        height = 0;
+                        width = 0;
+                    } else if (width < 0) {
+                        action = ACTION_NORTH_WEST;
+                        width = 0;
+                    } else if (height < 0) {
+                        action = ACTION_SOUTH_EAST;
+                        height = 0;
+                    }
+
+                    break;
+
+                case ACTION_NORTH_WEST:
+                    if (aspectRatio) {
+                        if (range.y <= 0 && (top <= minTop || left <= minLeft)) {
+                            renderable = false;
+                            break;
+                        }
+
+                        check(ACTION_NORTH);
+                        height -= range.y;
+                        top += range.y;
+                        width = height * aspectRatio;
+                        left += range.y * aspectRatio;
+                    } else {
+                        check(ACTION_NORTH);
+                        check(ACTION_WEST);
+
+                        if (range.x <= 0) {
+                            if (left > minLeft) {
+                                width -= range.x;
+                                left += range.x;
+                            } else if (range.y <= 0 && top <= minTop) {
+                                renderable = false;
+                            }
+                        } else {
+                            width -= range.x;
+                            left += range.x;
+                        }
+
+                        if (range.y <= 0) {
+                            if (top > minTop) {
+                                height -= range.y;
+                                top += range.y;
+                            }
+                        } else {
+                            height -= range.y;
+                            top += range.y;
+                        }
+                    }
+
+                    if (width < 0 && height < 0) {
+                        action = ACTION_SOUTH_EAST;
+                        height = 0;
+                        width = 0;
+                    } else if (width < 0) {
+                        action = ACTION_NORTH_EAST;
+                        width = 0;
+                    } else if (height < 0) {
+                        action = ACTION_SOUTH_WEST;
+                        height = 0;
+                    }
+
+                    break;
+
+                case ACTION_SOUTH_WEST:
+                    if (aspectRatio) {
+                        if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) {
+                            renderable = false;
+                            break;
+                        }
+
+                        check(ACTION_WEST);
+                        width -= range.x;
+                        left += range.x;
+                        height = width / aspectRatio;
+                    } else {
+                        check(ACTION_SOUTH);
+                        check(ACTION_WEST);
+
+                        if (range.x <= 0) {
+                            if (left > minLeft) {
+                                width -= range.x;
+                                left += range.x;
+                            } else if (range.y >= 0 && bottom >= maxHeight) {
+                                renderable = false;
+                            }
+                        } else {
+                            width -= range.x;
+                            left += range.x;
+                        }
+
+                        if (range.y >= 0) {
+                            if (bottom < maxHeight) {
+                                height += range.y;
+                            }
+                        } else {
+                            height += range.y;
+                        }
+                    }
+
+                    if (width < 0 && height < 0) {
+                        action = ACTION_NORTH_EAST;
+                        height = 0;
+                        width = 0;
+                    } else if (width < 0) {
+                        action = ACTION_SOUTH_EAST;
+                        width = 0;
+                    } else if (height < 0) {
+                        action = ACTION_NORTH_WEST;
+                        height = 0;
+                    }
+
+                    break;
+
+                case ACTION_SOUTH_EAST:
+                    if (aspectRatio) {
+                        if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) {
+                            renderable = false;
+                            break;
+                        }
+
+                        check(ACTION_EAST);
+                        width += range.x;
+                        height = width / aspectRatio;
+                    } else {
+                        check(ACTION_SOUTH);
+                        check(ACTION_EAST);
+
+                        if (range.x >= 0) {
+                            if (right < maxWidth) {
+                                width += range.x;
+                            } else if (range.y >= 0 && bottom >= maxHeight) {
+                                renderable = false;
+                            }
+                        } else {
+                            width += range.x;
+                        }
+
+                        if (range.y >= 0) {
+                            if (bottom < maxHeight) {
+                                height += range.y;
+                            }
+                        } else {
+                            height += range.y;
+                        }
+                    }
+
+                    if (width < 0 && height < 0) {
+                        action = ACTION_NORTH_WEST;
+                        height = 0;
+                        width = 0;
+                    } else if (width < 0) {
+                        action = ACTION_SOUTH_WEST;
+                        width = 0;
+                    } else if (height < 0) {
+                        action = ACTION_NORTH_EAST;
+                        height = 0;
+                    }
+
+                    break;
+
+                // Move canvas
+                case ACTION_MOVE:
+                    this.move(range.x, range.y);
+                    renderable = false;
+                    break;
+
+                // Zoom canvas
+                case ACTION_ZOOM:
+                    this.zoom(getMaxZoomRatio(pointers), e);
+                    renderable = false;
+                    break;
+
+                // Create crop box
+                case ACTION_CROP:
+                    if (!range.x || !range.y) {
+                        renderable = false;
+                        break;
+                    }
+
+                    offset = getOffset(this.cropper);
+                    left = pointer.startX - offset.left;
+                    top = pointer.startY - offset.top;
+                    width = cropBoxData.minWidth;
+                    height = cropBoxData.minHeight;
+
+                    if (range.x > 0) {
+                        action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST;
+                    } else if (range.x < 0) {
+                        left -= width;
+                        action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST;
+                    }
+
+                    if (range.y < 0) {
+                        top -= height;
+                    }
+
+                    // Show the crop box if is hidden
+                    if (!this.cropped) {
+                        removeClass(this.cropBox, CLASS_HIDDEN);
+                        this.cropped = true;
+
+                        if (this.limited) {
+                            this.limitCropBox(true, true);
+                        }
+                    }
+
+                    break;
+
+                default:
+            }
+
+            if (renderable) {
+                cropBoxData.width = width;
+                cropBoxData.height = height;
+                cropBoxData.left = left;
+                cropBoxData.top = top;
+                this.action = action;
+                this.renderCropBox();
+            }
+
+            // Override
+            forEach(pointers, function (p) {
+                p.startX = p.endX;
+                p.startY = p.endY;
+            });
+        }
+    };
+
+    var methods = {
+        // Show the crop box manually
+        crop: function crop() {
+            if (this.ready && !this.cropped && !this.disabled) {
+                this.cropped = true;
+                this.limitCropBox(true, true);
+
+                if (this.options.modal) {
+                    addClass(this.dragBox, CLASS_MODAL);
+                }
+
+                removeClass(this.cropBox, CLASS_HIDDEN);
+                this.setCropBoxData(this.initialCropBoxData);
+            }
+
+            return this;
+        },
+
+
+        // Reset the image and crop box to their initial states
+        reset: function reset() {
+            if (this.ready && !this.disabled) {
+                this.imageData = assign({}, this.initialImageData);
+                this.canvasData = assign({}, this.initialCanvasData);
+                this.cropBoxData = assign({}, this.initialCropBoxData);
+                this.renderCanvas();
+
+                if (this.cropped) {
+                    this.renderCropBox();
+                }
+            }
+
+            return this;
+        },
+
+
+        // Clear the crop box
+        clear: function clear() {
+            if (this.cropped && !this.disabled) {
+                assign(this.cropBoxData, {
+                    left: 0,
+                    top: 0,
+                    width: 0,
+                    height: 0
+                });
+
+                this.cropped = false;
+                this.renderCropBox();
+                this.limitCanvas(true, true);
+
+                // Render canvas after crop box rendered
+                this.renderCanvas();
+                removeClass(this.dragBox, CLASS_MODAL);
+                addClass(this.cropBox, CLASS_HIDDEN);
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Replace the image's src and rebuild the cropper
+         * @param {string} url - The new URL.
+         * @param {boolean} [hasSameSize] - Indicate if the new image has the same size as the old one.
+         * @returns {Cropper} this
+         */
+        replace: function replace(url) {
+            var hasSameSize = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
+
+            if (!this.disabled && url) {
+                if (this.isImg) {
+                    this.element.src = url;
+                }
+
+                if (hasSameSize) {
+                    this.url = url;
+                    this.image.src = url;
+
+                    if (this.ready) {
+                        this.viewBoxImage.src = url;
+
+                        forEach(this.previews, function (element) {
+                            element.getElementsByTagName('img')[0].src = url;
+                        });
+                    }
+                } else {
+                    if (this.isImg) {
+                        this.replaced = true;
+                    }
+
+                    this.options.data = null;
+                    this.uncreate();
+                    this.load(url);
+                }
+            }
+
+            return this;
+        },
+
+
+        // Enable (unfreeze) the cropper
+        enable: function enable() {
+            if (this.ready && this.disabled) {
+                this.disabled = false;
+                removeClass(this.cropper, CLASS_DISABLED);
+            }
+
+            return this;
+        },
+
+
+        // Disable (freeze) the cropper
+        disable: function disable() {
+            if (this.ready && !this.disabled) {
+                this.disabled = true;
+                addClass(this.cropper, CLASS_DISABLED);
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Destroy the cropper and remove the instance from the image
+         * @returns {Cropper} this
+         */
+        destroy: function destroy() {
+            var element = this.element;
+
+
+            if (!getData(element, NAMESPACE)) {
+                return this;
+            }
+
+            if (this.isImg && this.replaced) {
+                element.src = this.originalUrl;
+            }
+
+            this.uncreate();
+            removeData(element, NAMESPACE);
+
+            return this;
+        },
+
+
+        /**
+         * Move the canvas with relative offsets
+         * @param {number} offsetX - The relative offset distance on the x-axis.
+         * @param {number} [offsetY=offsetX] - The relative offset distance on the y-axis.
+         * @returns {Cropper} this
+         */
+        move: function move(offsetX) {
+            var offsetY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : offsetX;
+            var _canvasData = this.canvasData,
+                left = _canvasData.left,
+                top = _canvasData.top;
+
+
+            return this.moveTo(isUndefined(offsetX) ? offsetX : left + Number(offsetX), isUndefined(offsetY) ? offsetY : top + Number(offsetY));
+        },
+
+
+        /**
+         * Move the canvas to an absolute point
+         * @param {number} x - The x-axis coordinate.
+         * @param {number} [y=x] - The y-axis coordinate.
+         * @returns {Cropper} this
+         */
+        moveTo: function moveTo(x) {
+            var y = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : x;
+            var canvasData = this.canvasData;
+
+            var changed = false;
+
+            x = Number(x);
+            y = Number(y);
+
+            if (this.ready && !this.disabled && this.options.movable) {
+                if (isNumber(x)) {
+                    canvasData.left = x;
+                    changed = true;
+                }
+
+                if (isNumber(y)) {
+                    canvasData.top = y;
+                    changed = true;
+                }
+
+                if (changed) {
+                    this.renderCanvas(true);
+                }
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Zoom the canvas with a relative ratio
+         * @param {number} ratio - The target ratio.
+         * @param {Event} _originalEvent - The original event if any.
+         * @returns {Cropper} this
+         */
+        zoom: function zoom(ratio, _originalEvent) {
+            var canvasData = this.canvasData;
+
+
+            ratio = Number(ratio);
+
+            if (ratio < 0) {
+                ratio = 1 / (1 - ratio);
+            } else {
+                ratio = 1 + ratio;
+            }
+
+            return this.zoomTo(canvasData.width * ratio / canvasData.naturalWidth, null, _originalEvent);
+        },
+
+
+        /**
+         * Zoom the canvas to an absolute ratio
+         * @param {number} ratio - The target ratio.
+         * @param {Object} pivot - The zoom pivot point coordinate.
+         * @param {Event} _originalEvent - The original event if any.
+         * @returns {Cropper} this
+         */
+        zoomTo: function zoomTo(ratio, pivot, _originalEvent) {
+            var options = this.options,
+                canvasData = this.canvasData;
+            var width = canvasData.width,
+                height = canvasData.height,
+                naturalWidth = canvasData.naturalWidth,
+                naturalHeight = canvasData.naturalHeight;
+
+
+            ratio = Number(ratio);
+
+            if (ratio >= 0 && this.ready && !this.disabled && options.zoomable) {
+                var newWidth = naturalWidth * ratio;
+                var newHeight = naturalHeight * ratio;
+
+                if (dispatchEvent(this.element, EVENT_ZOOM, {
+                        originalEvent: _originalEvent,
+                        oldRatio: width / naturalWidth,
+                        ratio: newWidth / naturalWidth
+                    }) === false) {
+                    return this;
+                }
+
+                if (_originalEvent) {
+                    var pointers = this.pointers;
+
+                    var offset = getOffset(this.cropper);
+                    var center = pointers && Object.keys(pointers).length ? getPointersCenter(pointers) : {
+                        pageX: _originalEvent.pageX,
+                        pageY: _originalEvent.pageY
+                    };
+
+                    // Zoom from the triggering point of the event
+                    canvasData.left -= (newWidth - width) * ((center.pageX - offset.left - canvasData.left) / width);
+                    canvasData.top -= (newHeight - height) * ((center.pageY - offset.top - canvasData.top) / height);
+                } else if (isPlainObject(pivot) && isNumber(pivot.x) && isNumber(pivot.y)) {
+                    canvasData.left -= (newWidth - width) * ((pivot.x - canvasData.left) / width);
+                    canvasData.top -= (newHeight - height) * ((pivot.y - canvasData.top) / height);
+                } else {
+                    // Zoom from the center of the canvas
+                    canvasData.left -= (newWidth - width) / 2;
+                    canvasData.top -= (newHeight - height) / 2;
+                }
+
+                canvasData.width = newWidth;
+                canvasData.height = newHeight;
+                this.renderCanvas(true);
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Rotate the canvas with a relative degree
+         * @param {number} degree - The rotate degree.
+         * @returns {Cropper} this
+         */
+        rotate: function rotate(degree) {
+            return this.rotateTo((this.imageData.rotate || 0) + Number(degree));
+        },
+
+
+        /**
+         * Rotate the canvas to an absolute degree
+         * @param {number} degree - The rotate degree.
+         * @returns {Cropper} this
+         */
+        rotateTo: function rotateTo(degree) {
+            degree = Number(degree);
+
+            if (isNumber(degree) && this.ready && !this.disabled && this.options.rotatable) {
+                this.imageData.rotate = degree % 360;
+                this.renderCanvas(true, true);
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Scale the image on the x-axis.
+         * @param {number} scaleX - The scale ratio on the x-axis.
+         * @returns {Cropper} this
+         */
+        scaleX: function scaleX(_scaleX) {
+            var scaleY = this.imageData.scaleY;
+
+
+            return this.scale(_scaleX, isNumber(scaleY) ? scaleY : 1);
+        },
+
+
+        /**
+         * Scale the image on the y-axis.
+         * @param {number} scaleY - The scale ratio on the y-axis.
+         * @returns {Cropper} this
+         */
+        scaleY: function scaleY(_scaleY) {
+            var scaleX = this.imageData.scaleX;
+
+
+            return this.scale(isNumber(scaleX) ? scaleX : 1, _scaleY);
+        },
+
+
+        /**
+         * Scale the image
+         * @param {number} scaleX - The scale ratio on the x-axis.
+         * @param {number} [scaleY=scaleX] - The scale ratio on the y-axis.
+         * @returns {Cropper} this
+         */
+        scale: function scale(scaleX) {
+            var scaleY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : scaleX;
+            var imageData = this.imageData;
+
+            var transformed = false;
+
+            scaleX = Number(scaleX);
+            scaleY = Number(scaleY);
+
+            if (this.ready && !this.disabled && this.options.scalable) {
+                if (isNumber(scaleX)) {
+                    imageData.scaleX = scaleX;
+                    transformed = true;
+                }
+
+                if (isNumber(scaleY)) {
+                    imageData.scaleY = scaleY;
+                    transformed = true;
+                }
+
+                if (transformed) {
+                    this.renderCanvas(true, true);
+                }
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Get the cropped area position and size data (base on the original image)
+         * @param {boolean} [rounded=false] - Indicate if round the data values or not.
+         * @returns {Object} The result cropped data.
+         */
+        getData: function getData$$1() {
+            var rounded = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : false;
+            var options = this.options,
+                imageData = this.imageData,
+                canvasData = this.canvasData,
+                cropBoxData = this.cropBoxData;
+
+            var data = void 0;
+
+            if (this.ready && this.cropped) {
+                data = {
+                    x: cropBoxData.left - canvasData.left,
+                    y: cropBoxData.top - canvasData.top,
+                    width: cropBoxData.width,
+                    height: cropBoxData.height
+                };
+
+                var ratio = imageData.width / imageData.naturalWidth;
+
+                forEach(data, function (n, i) {
+                    n /= ratio;
+                    data[i] = rounded ? Math.round(n) : n;
+                });
+            } else {
+                data = {
+                    x: 0,
+                    y: 0,
+                    width: 0,
+                    height: 0
+                };
+            }
+
+            if (options.rotatable) {
+                data.rotate = imageData.rotate || 0;
+            }
+
+            if (options.scalable) {
+                data.scaleX = imageData.scaleX || 1;
+                data.scaleY = imageData.scaleY || 1;
+            }
+
+            return data;
+        },
+
+
+        /**
+         * Set the cropped area position and size with new data
+         * @param {Object} data - The new data.
+         * @returns {Cropper} this
+         */
+        setData: function setData$$1(data) {
+            var options = this.options,
+                imageData = this.imageData,
+                canvasData = this.canvasData;
+
+            var cropBoxData = {};
+
+            if (this.ready && !this.disabled && isPlainObject(data)) {
+                var transformed = false;
+
+                if (options.rotatable) {
+                    if (isNumber(data.rotate) && data.rotate !== imageData.rotate) {
+                        imageData.rotate = data.rotate;
+                        transformed = true;
+                    }
+                }
+
+                if (options.scalable) {
+                    if (isNumber(data.scaleX) && data.scaleX !== imageData.scaleX) {
+                        imageData.scaleX = data.scaleX;
+                        transformed = true;
+                    }
+
+                    if (isNumber(data.scaleY) && data.scaleY !== imageData.scaleY) {
+                        imageData.scaleY = data.scaleY;
+                        transformed = true;
+                    }
+                }
+
+                if (transformed) {
+                    this.renderCanvas(true, true);
+                }
+
+                var ratio = imageData.width / imageData.naturalWidth;
+
+                if (isNumber(data.x)) {
+                    cropBoxData.left = data.x * ratio + canvasData.left;
+                }
+
+                if (isNumber(data.y)) {
+                    cropBoxData.top = data.y * ratio + canvasData.top;
+                }
+
+                if (isNumber(data.width)) {
+                    cropBoxData.width = data.width * ratio;
+                }
+
+                if (isNumber(data.height)) {
+                    cropBoxData.height = data.height * ratio;
+                }
+
+                this.setCropBoxData(cropBoxData);
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Get the container size data.
+         * @returns {Object} The result container data.
+         */
+        getContainerData: function getContainerData() {
+            return this.ready ? assign({}, this.containerData) : {};
+        },
+
+
+        /**
+         * Get the image position and size data.
+         * @returns {Object} The result image data.
+         */
+        getImageData: function getImageData() {
+            return this.sized ? assign({}, this.imageData) : {};
+        },
+
+
+        /**
+         * Get the canvas position and size data.
+         * @returns {Object} The result canvas data.
+         */
+        getCanvasData: function getCanvasData() {
+            var canvasData = this.canvasData;
+
+            var data = {};
+
+            if (this.ready) {
+                forEach(['left', 'top', 'width', 'height', 'naturalWidth', 'naturalHeight'], function (n) {
+                    data[n] = canvasData[n];
+                });
+            }
+
+            return data;
+        },
+
+
+        /**
+         * Set the canvas position and size with new data.
+         * @param {Object} data - The new canvas data.
+         * @returns {Cropper} this
+         */
+        setCanvasData: function setCanvasData(data) {
+            var canvasData = this.canvasData;
+            var aspectRatio = canvasData.aspectRatio;
+
+
+            if (this.ready && !this.disabled && isPlainObject(data)) {
+                if (isNumber(data.left)) {
+                    canvasData.left = data.left;
+                }
+
+                if (isNumber(data.top)) {
+                    canvasData.top = data.top;
+                }
+
+                if (isNumber(data.width)) {
+                    canvasData.width = data.width;
+                    canvasData.height = data.width / aspectRatio;
+                } else if (isNumber(data.height)) {
+                    canvasData.height = data.height;
+                    canvasData.width = data.height * aspectRatio;
+                }
+
+                this.renderCanvas(true);
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Get the crop box position and size data.
+         * @returns {Object} The result crop box data.
+         */
+        getCropBoxData: function getCropBoxData() {
+            var cropBoxData = this.cropBoxData;
+
+            var data = void 0;
+
+            if (this.ready && this.cropped) {
+                data = {
+                    left: cropBoxData.left,
+                    top: cropBoxData.top,
+                    width: cropBoxData.width,
+                    height: cropBoxData.height
+                };
+            }
+
+            return data || {};
+        },
+
+
+        /**
+         * Set the crop box position and size with new data.
+         * @param {Object} data - The new crop box data.
+         * @returns {Cropper} this
+         */
+        setCropBoxData: function setCropBoxData(data) {
+            var cropBoxData = this.cropBoxData;
+            var aspectRatio = this.options.aspectRatio;
+
+            var widthChanged = void 0;
+            var heightChanged = void 0;
+
+            if (this.ready && this.cropped && !this.disabled && isPlainObject(data)) {
+                if (isNumber(data.left)) {
+                    cropBoxData.left = data.left;
+                }
+
+                if (isNumber(data.top)) {
+                    cropBoxData.top = data.top;
+                }
+
+                if (isNumber(data.width) && data.width !== cropBoxData.width) {
+                    widthChanged = true;
+                    cropBoxData.width = data.width;
+                }
+
+                if (isNumber(data.height) && data.height !== cropBoxData.height) {
+                    heightChanged = true;
+                    cropBoxData.height = data.height;
+                }
+
+                if (aspectRatio) {
+                    if (widthChanged) {
+                        cropBoxData.height = cropBoxData.width / aspectRatio;
+                    } else if (heightChanged) {
+                        cropBoxData.width = cropBoxData.height * aspectRatio;
+                    }
+                }
+
+                this.renderCropBox();
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Get a canvas drawn the cropped image.
+         * @param {Object} [options={}] - The config options.
+         * @returns {HTMLCanvasElement} - The result canvas.
+         */
+        getCroppedCanvas: function getCroppedCanvas() {
+            var options = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {};
+
+            if (!this.ready || !window.HTMLCanvasElement) {
+                return null;
+            }
+
+            var canvasData = this.canvasData;
+
+            var source = getSourceCanvas(this.image, this.imageData, canvasData, options);
+
+            // Returns the source canvas if it is not cropped.
+            if (!this.cropped) {
+                return source;
+            }
+
+            var _getData = this.getData(),
+                initialX = _getData.x,
+                initialY = _getData.y,
+                initialWidth = _getData.width,
+                initialHeight = _getData.height;
+
+            var ratio = source.width / Math.floor(canvasData.naturalWidth);
+
+            if (ratio !== 1) {
+                initialX *= ratio;
+                initialY *= ratio;
+                initialWidth *= ratio;
+                initialHeight *= ratio;
+            }
+
+            var aspectRatio = initialWidth / initialHeight;
+            var maxSizes = getAdjustedSizes({
+                aspectRatio: aspectRatio,
+                width: options.maxWidth || Infinity,
+                height: options.maxHeight || Infinity
+            });
+            var minSizes = getAdjustedSizes({
+                aspectRatio: aspectRatio,
+                width: options.minWidth || 0,
+                height: options.minHeight || 0
+            }, 'cover');
+
+            var _getAdjustedSizes = getAdjustedSizes({
+                    aspectRatio: aspectRatio,
+                    width: options.width || (ratio !== 1 ? source.width : initialWidth),
+                    height: options.height || (ratio !== 1 ? source.height : initialHeight)
+                }),
+                width = _getAdjustedSizes.width,
+                height = _getAdjustedSizes.height;
+
+            width = Math.min(maxSizes.width, Math.max(minSizes.width, width));
+            height = Math.min(maxSizes.height, Math.max(minSizes.height, height));
+
+            var canvas = document.createElement('canvas');
+            var context = canvas.getContext('2d');
+
+            canvas.width = normalizeDecimalNumber(width);
+            canvas.height = normalizeDecimalNumber(height);
+
+            context.fillStyle = options.fillColor || 'transparent';
+            context.fillRect(0, 0, width, height);
+
+            var _options$imageSmoothi = options.imageSmoothingEnabled,
+                imageSmoothingEnabled = _options$imageSmoothi === undefined ? true : _options$imageSmoothi,
+                imageSmoothingQuality = options.imageSmoothingQuality;
+
+
+            context.imageSmoothingEnabled = imageSmoothingEnabled;
+
+            if (imageSmoothingQuality) {
+                context.imageSmoothingQuality = imageSmoothingQuality;
+            }
+
+            // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage
+            var sourceWidth = source.width;
+            var sourceHeight = source.height;
+
+            // Source canvas parameters
+            var srcX = initialX;
+            var srcY = initialY;
+            var srcWidth = void 0;
+            var srcHeight = void 0;
+
+            // Destination canvas parameters
+            var dstX = void 0;
+            var dstY = void 0;
+            var dstWidth = void 0;
+            var dstHeight = void 0;
+
+            if (srcX <= -initialWidth || srcX > sourceWidth) {
+                srcX = 0;
+                srcWidth = 0;
+                dstX = 0;
+                dstWidth = 0;
+            } else if (srcX <= 0) {
+                dstX = -srcX;
+                srcX = 0;
+                srcWidth = Math.min(sourceWidth, initialWidth + srcX);
+                dstWidth = srcWidth;
+            } else if (srcX <= sourceWidth) {
+                dstX = 0;
+                srcWidth = Math.min(initialWidth, sourceWidth - srcX);
+                dstWidth = srcWidth;
+            }
+
+            if (srcWidth <= 0 || srcY <= -initialHeight || srcY > sourceHeight) {
+                srcY = 0;
+                srcHeight = 0;
+                dstY = 0;
+                dstHeight = 0;
+            } else if (srcY <= 0) {
+                dstY = -srcY;
+                srcY = 0;
+                srcHeight = Math.min(sourceHeight, initialHeight + srcY);
+                dstHeight = srcHeight;
+            } else if (srcY <= sourceHeight) {
+                dstY = 0;
+                srcHeight = Math.min(initialHeight, sourceHeight - srcY);
+                dstHeight = srcHeight;
+            }
+
+            var params = [srcX, srcY, srcWidth, srcHeight];
+
+            // Avoid "IndexSizeError"
+            if (dstWidth > 0 && dstHeight > 0) {
+                var scale = width / initialWidth;
+
+                params.push(dstX * scale, dstY * scale, dstWidth * scale, dstHeight * scale);
+            }
+
+            // All the numerical parameters should be integer for `drawImage`
+            // https://github.com/fengyuanchen/cropper/issues/476
+            context.drawImage.apply(context, [source].concat(toConsumableArray(params.map(function (param) {
+                return Math.floor(normalizeDecimalNumber(param));
+            }))));
+
+            return canvas;
+        },
+
+
+        /**
+         * Change the aspect ratio of the crop box.
+         * @param {number} aspectRatio - The new aspect ratio.
+         * @returns {Cropper} this
+         */
+        setAspectRatio: function setAspectRatio(aspectRatio) {
+            var options = this.options;
+
+
+            if (!this.disabled && !isUndefined(aspectRatio)) {
+                // 0 -> NaN
+                options.aspectRatio = Math.max(0, aspectRatio) || NaN;
+
+                if (this.ready) {
+                    this.initCropBox();
+
+                    if (this.cropped) {
+                        this.renderCropBox();
+                    }
+                }
+            }
+
+            return this;
+        },
+
+
+        /**
+         * Change the drag mode.
+         * @param {string} mode - The new drag mode.
+         * @returns {Cropper} this
+         */
+        setDragMode: function setDragMode(mode) {
+            var options = this.options,
+                dragBox = this.dragBox,
+                face = this.face;
+
+
+            if (this.ready && !this.disabled) {
+                var croppable = mode === DRAG_MODE_CROP;
+                var movable = options.movable && mode === DRAG_MODE_MOVE;
+
+                mode = croppable || movable ? mode : DRAG_MODE_NONE;
+
+                options.dragMode = mode;
+                setData(dragBox, DATA_ACTION, mode);
+                toggleClass(dragBox, CLASS_CROP, croppable);
+                toggleClass(dragBox, CLASS_MOVE, movable);
+
+                if (!options.cropBoxMovable) {
+                    // Sync drag mode to crop box when it is not movable
+                    setData(face, DATA_ACTION, mode);
+                    toggleClass(face, CLASS_CROP, croppable);
+                    toggleClass(face, CLASS_MOVE, movable);
+                }
+            }
+
+            return this;
+        }
+    };
+
+    var AnotherCropper = WINDOW.Cropper;
+
+    var Cropper = function () {
+        /**
+         * Create a new Cropper.
+         * @param {Element} element - The target element for cropping.
+         * @param {Object} [options={}] - The configuration options.
+         */
+        function Cropper(element) {
+            var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
+            classCallCheck(this, Cropper);
+
+            if (!element || !REGEXP_TAG_NAME.test(element.tagName)) {
+                throw new Error('The first argument is required and must be an <img> or <canvas> element.');
+            }
+
+            this.element = element;
+            this.options = assign({}, DEFAULTS, isPlainObject(options) && options);
+            this.cropped = false;
+            this.disabled = false;
+            this.pointers = {};
+            this.ready = false;
+            this.reloading = false;
+            this.replaced = false;
+            this.sized = false;
+            this.sizing = false;
+            this.init();
+        }
+
+        createClass(Cropper, [{
+            key: 'init',
+            value: function init() {
+                var element = this.element;
+
+                var tagName = element.tagName.toLowerCase();
+                var url = void 0;
+
+                if (getData(element, NAMESPACE)) {
+                    return;
+                }
+
+                setData(element, NAMESPACE, this);
+
+                if (tagName === 'img') {
+                    this.isImg = true;
+
+                    // e.g.: "img/picture.jpg"
+                    url = element.getAttribute('src') || '';
+                    this.originalUrl = url;
+
+                    // Stop when it's a blank image
+                    if (!url) {
+                        return;
+                    }
+
+                    // e.g.: "http://example.com/img/picture.jpg"
+                    url = element.src;
+                } else if (tagName === 'canvas' && window.HTMLCanvasElement) {
+                    url = element.toDataURL();
+                }
+
+                this.load(url);
+            }
+        }, {
+            key: 'load',
+            value: function load(url) {
+                var _this = this;
+
+                if (!url) {
+                    return;
+                }
+
+                this.url = url;
+                this.imageData = {};
+
+                var element = this.element,
+                    options = this.options;
+
+
+                if (!options.checkOrientation || !window.ArrayBuffer) {
+                    this.clone();
+                    return;
+                }
+
+                // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari
+                if (REGEXP_DATA_URL.test(url)) {
+                    if (REGEXP_DATA_URL_JPEG.test(url)) {
+                        this.read(dataURLToArrayBuffer(url));
+                    } else {
+                        this.clone();
+                    }
+
+                    return;
+                }
+
+                var xhr = new XMLHttpRequest();
+
+                this.reloading = true;
+                this.xhr = xhr;
+
+                var done = function done() {
+                    _this.reloading = false;
+                    _this.xhr = null;
+                };
+
+                xhr.ontimeout = done;
+                xhr.onabort = done;
+                xhr.onerror = function () {
+                    done();
+                    _this.clone();
+                };
+
+                xhr.onload = function () {
+                    done();
+                    _this.read(xhr.response);
+                };
+
+                // Bust cache when there is a "crossOrigin" property
+                if (options.checkCrossOrigin && isCrossOriginURL(url) && element.crossOrigin) {
+                    url = addTimestamp(url);
+                }
+
+                xhr.open('get', url);
+                xhr.responseType = 'arraybuffer';
+                xhr.withCredentials = element.crossOrigin === 'use-credentials';
+                xhr.send();
+            }
+        }, {
+            key: 'read',
+            value: function read(arrayBuffer) {
+                var options = this.options,
+                    imageData = this.imageData;
+
+                var orientation = getOrientation(arrayBuffer);
+                var rotate = 0;
+                var scaleX = 1;
+                var scaleY = 1;
+
+                if (orientation > 1) {
+                    this.url = arrayBufferToDataURL(arrayBuffer, 'image/jpeg');
+
+                    var _parseOrientation = parseOrientation(orientation);
+
+                    rotate = _parseOrientation.rotate;
+                    scaleX = _parseOrientation.scaleX;
+                    scaleY = _parseOrientation.scaleY;
+                }
+
+                if (options.rotatable) {
+                    imageData.rotate = rotate;
+                }
+
+                if (options.scalable) {
+                    imageData.scaleX = scaleX;
+                    imageData.scaleY = scaleY;
+                }
+
+                this.clone();
+            }
+        }, {
+            key: 'clone',
+            value: function clone() {
+                var element = this.element,
+                    url = this.url;
+
+                var crossOrigin = void 0;
+                var crossOriginUrl = void 0;
+
+                if (this.options.checkCrossOrigin && isCrossOriginURL(url)) {
+                    crossOrigin = element.crossOrigin;
+
+
+                    if (crossOrigin) {
+                        crossOriginUrl = url;
+                    } else {
+                        crossOrigin = 'anonymous';
+
+                        // Bust cache when there is not a "crossOrigin" property
+                        crossOriginUrl = addTimestamp(url);
+                    }
+                }
+
+                this.crossOrigin = crossOrigin;
+                this.crossOriginUrl = crossOriginUrl;
+
+                var image = document.createElement('img');
+
+                if (crossOrigin) {
+                    image.crossOrigin = crossOrigin;
+                }
+
+                image.src = crossOriginUrl || url;
+
+                var start = this.start.bind(this);
+                var stop = this.stop.bind(this);
+
+                this.image = image;
+                this.onStart = start;
+                this.onStop = stop;
+
+                if (this.isImg) {
+                    if (element.complete) {
+                        // start asynchronously to keep `this.cropper` is accessible in `ready` event handler.
+                        this.timeout = setTimeout(start, 0);
+                    } else {
+                        addListener(element, EVENT_LOAD, start, {
+                            once: true
+                        });
+                    }
+                } else {
+                    image.onload = start;
+                    image.onerror = stop;
+                    addClass(image, CLASS_HIDE);
+                    element.parentNode.insertBefore(image, element.nextSibling);
+                }
+            }
+        }, {
+            key: 'start',
+            value: function start(event) {
+                var _this2 = this;
+
+                var image = this.isImg ? this.element : this.image;
+
+                if (event) {
+                    image.onload = null;
+                    image.onerror = null;
+                }
+
+                this.sizing = true;
+
+                var IS_SAFARI = WINDOW.navigator && /(Macintosh|iPhone|iPod|iPad).*AppleWebKit/i.test(WINDOW.navigator.userAgent);
+                var done = function done(naturalWidth, naturalHeight) {
+                    assign(_this2.imageData, {
+                        naturalWidth: naturalWidth,
+                        naturalHeight: naturalHeight,
+                        aspectRatio: naturalWidth / naturalHeight
+                    });
+                    _this2.sizing = false;
+                    _this2.sized = true;
+                    _this2.build();
+                };
+
+                // Modern browsers (except Safari)
+                if (image.naturalWidth && !IS_SAFARI) {
+                    done(image.naturalWidth, image.naturalHeight);
+                    return;
+                }
+
+                var sizingImage = document.createElement('img');
+                var body = document.body || document.documentElement;
+
+                this.sizingImage = sizingImage;
+
+                sizingImage.onload = function () {
+                    done(sizingImage.width, sizingImage.height);
+
+                    if (!IS_SAFARI) {
+                        body.removeChild(sizingImage);
+                    }
+                };
+
+                sizingImage.src = image.src;
+
+                // iOS Safari will convert the image automatically
+                // with its orientation once append it into DOM (#279)
+                if (!IS_SAFARI) {
+                    sizingImage.style.cssText = 'left:0;' + 'max-height:none!important;' + 'max-width:none!important;' + 'min-height:0!important;' + 'min-width:0!important;' + 'opacity:0;' + 'position:absolute;' + 'top:0;' + 'z-index:-1;';
+                    body.appendChild(sizingImage);
+                }
+            }
+        }, {
+            key: 'stop',
+            value: function stop() {
+                var image = this.image;
+
+
+                image.onload = null;
+                image.onerror = null;
+                image.parentNode.removeChild(image);
+                this.image = null;
+            }
+        }, {
+            key: 'build',
+            value: function build() {
+                if (!this.sized || this.ready) {
+                    return;
+                }
+
+                var element = this.element,
+                    options = this.options,
+                    image = this.image;
+
+                // Create cropper elements
+
+                var container = element.parentNode;
+                var template = document.createElement('div');
+
+                template.innerHTML = TEMPLATE;
+
+                var cropper = template.querySelector('.' + NAMESPACE + '-container');
+                var canvas = cropper.querySelector('.' + NAMESPACE + '-canvas');
+                var dragBox = cropper.querySelector('.' + NAMESPACE + '-drag-box');
+                var cropBox = cropper.querySelector('.' + NAMESPACE + '-crop-box');
+                var face = cropBox.querySelector('.' + NAMESPACE + '-face');
+
+                this.container = container;
+                this.cropper = cropper;
+                this.canvas = canvas;
+                this.dragBox = dragBox;
+                this.cropBox = cropBox;
+                this.viewBox = cropper.querySelector('.' + NAMESPACE + '-view-box');
+                this.face = face;
+
+                canvas.appendChild(image);
+
+                // Hide the original image
+                addClass(element, CLASS_HIDDEN);
+
+                // Inserts the cropper after to the current image
+                container.insertBefore(cropper, element.nextSibling);
+
+                // Show the image if is hidden
+                if (!this.isImg) {
+                    removeClass(image, CLASS_HIDE);
+                }
+
+                this.initPreview();
+                this.bind();
+
+                options.aspectRatio = Math.max(0, options.aspectRatio) || NaN;
+                options.viewMode = Math.max(0, Math.min(3, Math.round(options.viewMode))) || 0;
+
+                addClass(cropBox, CLASS_HIDDEN);
+
+                if (!options.guides) {
+                    addClass(cropBox.getElementsByClassName(NAMESPACE + '-dashed'), CLASS_HIDDEN);
+                }
+
+                if (!options.center) {
+                    addClass(cropBox.getElementsByClassName(NAMESPACE + '-center'), CLASS_HIDDEN);
+                }
+
+                if (options.background) {
+                    addClass(cropper, NAMESPACE + '-bg');
+                }
+
+                if (!options.highlight) {
+                    addClass(face, CLASS_INVISIBLE);
+                }
+
+                if (options.cropBoxMovable) {
+                    addClass(face, CLASS_MOVE);
+                    setData(face, DATA_ACTION, ACTION_ALL);
+                }
+
+                if (!options.cropBoxResizable) {
+                    addClass(cropBox.getElementsByClassName(NAMESPACE + '-line'), CLASS_HIDDEN);
+                    addClass(cropBox.getElementsByClassName(NAMESPACE + '-point'), CLASS_HIDDEN);
+                }
+
+                this.render();
+                this.ready = true;
+                this.setDragMode(options.dragMode);
+
+                if (options.autoCrop) {
+                    this.crop();
+                }
+
+                this.setData(options.data);
+
+                if (isFunction(options.ready)) {
+                    addListener(element, EVENT_READY, options.ready, {
+                        once: true
+                    });
+                }
+
+                dispatchEvent(element, EVENT_READY);
+            }
+        }, {
+            key: 'unbuild',
+            value: function unbuild() {
+                if (!this.ready) {
+                    return;
+                }
+
+                this.ready = false;
+                this.unbind();
+                this.resetPreview();
+                this.cropper.parentNode.removeChild(this.cropper);
+                removeClass(this.element, CLASS_HIDDEN);
+            }
+        }, {
+            key: 'uncreate',
+            value: function uncreate() {
+                var element = this.element;
+
+
+                if (this.ready) {
+                    this.unbuild();
+                    this.ready = false;
+                    this.cropped = false;
+                } else if (this.sizing) {
+                    this.sizingImage.onload = null;
+                    this.sizing = false;
+                    this.sized = false;
+                } else if (this.reloading) {
+                    this.xhr.abort();
+                } else if (this.isImg) {
+                    if (element.complete) {
+                        clearTimeout(this.timeout);
+                    } else {
+                        removeListener(element, EVENT_LOAD, this.onStart);
+                    }
+                } else if (this.image) {
+                    this.stop();
+                }
+            }
+
+            /**
+             * Get the no conflict cropper class.
+             * @returns {Cropper} The cropper class.
+             */
+
+        }], [{
+            key: 'noConflict',
+            value: function noConflict() {
+                window.Cropper = AnotherCropper;
+                return Cropper;
+            }
+
+            /**
+             * Change the default options.
+             * @param {Object} options - The new default options.
+             */
+
+        }, {
+            key: 'setDefaults',
+            value: function setDefaults(options) {
+                assign(DEFAULTS, isPlainObject(options) && options);
+            }
+        }]);
+        return Cropper;
+    }();
+
+    assign(Cropper.prototype, render, preview, events, handlers, change, methods);
+
+    if ($.fn) {
+        var AnotherCropper$1 = $.fn.cropper;
+        var NAMESPACE$1 = 'cropper';
+
+        $.fn.cropper = function jQueryCropper(option) {
+            for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) {
+                args[_key - 1] = arguments[_key];
+            }
+
+            var result = void 0;
+
+            this.each(function (i, element) {
+                var $element = $(element);
+                var isDestroy = option === 'destroy';
+                var cropper = $element.data(NAMESPACE$1);
+
+                if (!cropper) {
+                    if (isDestroy) {
+                        return;
+                    }
+
+                    var options = $.extend({}, $element.data(), $.isPlainObject(option) && option);
+
+                    cropper = new Cropper(element, options);
+                    $element.data(NAMESPACE$1, cropper);
+                }
+
+                if (typeof option === 'string') {
+                    var fn = cropper[option];
+
+                    if ($.isFunction(fn)) {
+                        result = fn.apply(cropper, args);
+
+                        if (result === cropper) {
+                            result = undefined;
+                        }
+
+                        if (isDestroy) {
+                            $element.removeData(NAMESPACE$1);
+                        }
+                    }
+                }
+            });
+
+            return result !== undefined ? result : this;
+        };
+
+        $.fn.cropper.Constructor = Cropper;
+        $.fn.cropper.setDefaults = Cropper.setDefaults;
+        $.fn.cropper.noConflict = function noConflict() {
+            $.fn.cropper = AnotherCropper$1;
+            return this;
+        };
+    }
+
+})));

+ 86 - 0
addons/cropper/bootstrap.js

@@ -0,0 +1,86 @@
+require(['form', 'upload'], function (Form, Upload) {
+    var _bindevent = Form.events.bindevent;
+    Form.events.bindevent = function (form) {
+        _bindevent.apply(this, [form]);
+
+        if ($("#croppertpl").length == 0) {
+            var allowAttr = [
+                'aspectRatio', 'autoCropArea', 'cropBoxMovable', 'cropBoxResizable', 'minCropBoxWidth', 'minCropBoxHeight', 'minContainerWidth', 'minContainerHeight',
+                'minCanvasHeight', 'minCanvasWidth', 'croppedWidth', 'croppedHeight', 'croppedMinWidth', 'croppedMinHeight', 'croppedMaxWidth', 'croppedMaxHeight', 'fillColor',
+                'containerMinHeight', 'containerMaxHeight', 'customWidthHeight', 'customAspectRatio'
+            ];
+            String.prototype.toLineCase = function () {
+                return this.replace(/[A-Z]/g, function (match) {
+                    return "-" + match.toLowerCase();
+                });
+            };
+
+            var btnAttr = [];
+            $.each(allowAttr, function (i, j) {
+                btnAttr.push('data-' + j.toLineCase() + '="<%=data.' + j + '%>"');
+            });
+
+            var btn = '<button class="btn btn-success btn-cropper btn-xs" data-input-id="<%=data.inputId%>" ' + btnAttr.join(" ") + ' style="position:absolute;top:10px;right:15px;">裁剪</button>';
+
+            var insertBtn = function () {
+                return arguments[0].replace(arguments[2], btn + arguments[2]);
+            };
+            $("<script type='text/html' id='croppertpl'>" + Upload.config.previewtpl.replace(/<li(.*?)>(.*?)<\/li>/, insertBtn) + "</script>").appendTo("body");
+        }
+
+        $(".plupload[data-preview-id],.faupload[data-preview-id]").each(function () {
+            var preview_id = $(this).data("preview-id");
+            var previewObj = $("#" + preview_id);
+            var tpl = previewObj.length > 0 ? previewObj.data("template") : '';
+            if (!tpl) {
+                if (!$(this).hasClass("cropper")) {
+                    $(this).addClass("cropper");
+                }
+                previewObj.data("template", "croppertpl");
+            }
+        });
+
+        //图片裁剪
+        $(document).off('click', '.btn-cropper').on('click', '.btn-cropper', function () {
+            var image = $(this).closest("li").find('.thumbnail').data('url');
+            var input = $("#" + $(this).data("input-id"));
+            var url = image;
+            var data = $(this).data();
+            var params = [];
+            $.each(allowAttr, function (i, j) {
+                if (typeof data[j] !== 'undefined' && data[j] !== '') {
+                    params.push(j + '=' + data[j]);
+                }
+            });
+            try {
+                var parentWin = (parent ? parent : window);
+                parentWin.Fast.api.open('/addons/cropper/index/cropper?url=' + image + (params.length > 0 ? '&' + params.join('&') : ''), '裁剪', {
+                    callback: function (data) {
+                        if (typeof data !== 'undefined') {
+                            var arr = data.dataURI.split(','), mime = arr[0].match(/:(.*?);/)[1],
+                                bstr = atob(arr[1]), n = bstr.length, u8arr = new Uint8Array(n);
+                            while (n--) {
+                                u8arr[n] = bstr.charCodeAt(n);
+                            }
+                            var urlArr = url.split('.');
+                            var suffix = 'png';
+                            url = urlArr.join('');
+                            var filename = url.substr(url.lastIndexOf('/') + 1);
+                            var exp = new RegExp("\\." + suffix + "$", "i");
+                            filename = exp.test(filename) ? filename : filename + "." + suffix;
+                            var file = new File([u8arr], filename, {type: mime});
+                            Upload.api.send(file, function (data) {
+                                input.val(input.val().replace(image, data.url)).trigger("change");
+                            }, function (data) {
+                            });
+                        }
+                    },
+                    area: [Math.min(parentWin.$(parentWin.window).width(), Config.cropper.dialogWidth) + "px", Math.min(parentWin.$(parentWin.window).height(), Config.cropper.dialogHeight) + "px"],
+                });
+            } catch (e) {
+                console.error(e);
+            }
+            return false;
+        });
+    }
+});

+ 296 - 0
addons/cropper/config.php

@@ -0,0 +1,296 @@
+<?php
+
+return [
+    [
+        'name'    => 'dialogWidth',
+        'title'   => '弹窗宽度',
+        'type'    => 'number',
+        'content' => [
+        ],
+        'value'   => '800',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '单位为px,可视窗口高度小于该值时自适应',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'dialogHeight',
+        'title'   => '弹窗高度',
+        'type'    => 'number',
+        'content' => [
+        ],
+        'value'   => '600',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '单位为px,可视窗口宽度小于该值时自适应',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'containerMinHeight',
+        'title'   => '容器最小高度',
+        'type'    => 'number',
+        'content' => [
+        ],
+        'value'   => '200',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'containerMaxHeight',
+        'title'   => '容器最大高度',
+        'type'    => 'number',
+        'content' => [
+        ],
+        'value'   => '800',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'customWidthHeight',
+        'title'   => '自定义宽度和高度',
+        'type'    => 'radio',
+        'content' => [
+            1 => '开',
+            0 => '关',
+        ],
+        'value'   => '1',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '是否开启手动设置宽度和高度,需关闭固定剪裁比例',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'customAspectRatio',
+        'title'   => '自定义剪裁比例',
+        'type'    => 'radio',
+        'content' => [
+            1 => '开',
+            0 => '关',
+        ],
+        'value'   => '1',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '是否开启自定义剪裁比例选项,需关闭固定剪裁比例',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'aspectRatio',
+        'title'   => '固定剪裁比例',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '宽:高=比例值,16:9=1.777777 4:3=1.333333',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'cropBoxMovable',
+        'title'   => '是否可移动图像',
+        'type'    => 'radio',
+        'content' => [
+            1 => '是',
+            0 => '否',
+        ],
+        'value'   => '1',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'cropBoxResizable',
+        'title'   => '是否允许调整裁剪框的大小',
+        'type'    => 'radio',
+        'content' => [
+            1 => '是',
+            0 => '否',
+        ],
+        'value'   => '1',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'minCropBoxWidth',
+        'title'   => '剪切框宽度最小值',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'minCropBoxHeight',
+        'title'   => '剪切框高度最小值',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'minContainerWidth',
+        'title'   => '容器宽度最小值',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '200',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'minContainerHeight',
+        'title'   => '容器高度最小值',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '100',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'minCanvasWidth',
+        'title'   => '画布宽度最小值',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '单位为px',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'minCanvasHeight',
+        'title'   => '画布高度最小值',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'autoCropArea',
+        'title'   => '自动剪裁区域的大小比例',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0.8',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'croppedWidth',
+        'title'   => '默认宽度',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '单位为px',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'croppedHeight',
+        'title'   => '默认高度',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '单位为px',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'croppedMinWidth',
+        'title'   => '默认输出最小宽度',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '单位为px',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'croppedMinHeight',
+        'title'   => '默认输出最小高度',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '0',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'croppedMaxWidth',
+        'title'   => '默认输出最大宽度',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '默认为无限制,单位为px',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'croppedMaxHeight',
+        'title'   => '默认输出最大高度',
+        'type'    => 'number',
+        'content' => [],
+        'value'   => '',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '默认为无限制,单位为px',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+    [
+        'name'    => 'fillColor',
+        'title'   => '填充颜色',
+        'type'    => 'string',
+        'content' => [],
+        'value'   => '',
+        'rule'    => '',
+        'msg'     => '',
+        'tip'     => '默认为透明',
+        'ok'      => '',
+        'extend'  => '',
+    ],
+];

+ 95 - 0
addons/cropper/controller/Index.php

@@ -0,0 +1,95 @@
+<?php
+
+namespace addons\cropper\controller;
+
+use think\addons\Controller;
+use think\Config;
+use think\Hook;
+
+/**
+ * 图片裁剪
+ *
+ */
+class Index extends Controller
+{
+
+    protected $model = null;
+
+    public function _initialize()
+    {
+        // 语言检测
+        $lang = $this->request->langset();
+        $lang = preg_match("/^([a-zA-Z0-9\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
+        $site = Config::get("site");
+        $upload = \app\common\model\Config::upload();
+        // 上传信息配置后
+        Hook::listen("upload_config_init", $upload);
+        // 配置信息
+        $config = [
+            'site'           => array_intersect_key($site, array_flip(['name', 'cdnurl', 'version', 'timezone', 'languages'])),
+            'upload'         => $upload,
+            'modulename'     => 'addons',
+            'controllername' => 'index',
+            'actionname'     => $this->request->action(),
+            'jsname'         => 'cropper',
+            'moduleurl'      => rtrim(url("/index", '', false), '/'),
+            'language'       => $lang
+        ];
+        $config = array_merge($config, Config::get("view_replace_str"));
+
+        Config::set('upload', array_merge(Config::get('upload'), $upload));
+        // 配置信息后
+        Hook::listen("config_init", $config);
+
+        $this->view->assign('jsconfig', $config);
+        $this->view->assign('site', $site);
+
+        parent::_initialize();
+    }
+
+    public function index()
+    {
+        return $this->view->fetch();
+    }
+
+    /**
+     * 图片剪裁
+     */
+    public function cropper()
+    {
+        $config = get_addon_config('cropper');
+        $get = $this->request->get();
+        $allowAttr = [
+            'aspectRatio',
+            'autoCropArea',
+            'cropBoxMovable',
+            'cropBoxResizable',
+            'minCropBoxWidth',
+            'minCropBoxHeight',
+            'minContainerWidth',
+            'minContainerHeight',
+            'minCanvasHeight',
+            'minCanvasWidth',
+            'croppedWidth',
+            'croppedHeight',
+            'croppedMinWidth',
+            'croppedMinHeight',
+            'croppedMaxWidth',
+            'croppedMaxHeight',
+            'containerMinHeight',
+            'containerMaxHeight',
+            'customWidthHeight',
+            'customAspectRatio'
+        ];
+
+        $attr = array_intersect_key($get, array_flip($allowAttr));
+        foreach ($attr as $index => &$item) {
+            $item = floatval($item);
+        }
+        $config = array_merge($config, $attr, ['url' => $get['url'] ?? '', 'fillColor' => $get['fillColor'] ?? '']);
+        $config['fillColor'] = $config['fillColor'] && $config['fillColor'] !== 'transparent' ? '#' . ltrim($config['fillColor'], '#') : 'transparent';
+        $this->view->assign("cropper", $config);
+        return $this->view->fetch();
+    }
+
+}

+ 10 - 0
addons/cropper/info.ini

@@ -0,0 +1,10 @@
+name = cropper
+title = 图片裁剪插件
+intro = 基于Cropper.js实现的图片裁剪
+author = FastAdmin
+website = https://www.fastadmin.net
+version = 1.1.2
+state = 0
+url = /addons/cropper
+license = regular
+licenseto = 45835

+ 630 - 0
addons/cropper/view/index/cropper.html

@@ -0,0 +1,630 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+    <meta http-equiv="X-UA-Compatible" content="ie=edge">
+    <title>图片剪裁</title>
+    <link href="__CDN__/assets/css/frontend{$Think.config.app_debug?'':'.min'}.css?v={$Think.config.site.version}" rel="stylesheet">
+
+    <!--[if lt IE 9]>
+    <script src="__CDN__/assets/js/html5shiv.js"></script>
+    <script src="__CDN__/assets/js/respond.min.js"></script>
+    <![endif]-->
+
+    <link rel="stylesheet" href="__ADDON__/css/cropper.css">
+    <link rel="stylesheet" href="__ADDON__/css/main.css">
+    <!--@formatter:off-->
+    <style>
+        .img-container {
+            min-height: {$cropper.containerMinHeight|default=200}px;
+            max-height: {$cropper.containerMaxHeight|default=400}px;
+        }
+    </style>
+    <style data-render="darktheme">
+        body.darktheme {
+            background-color: #262626;
+        }
+    </style>
+    <!--@formatter:on-->
+</head>
+<body style="padding:15px;">
+<script>if (parent.document.body.classList.contains("darktheme")) {document.body.classList.add("darktheme");}</script>
+<!--@formatter:off-->
+<script type="text/javascript">
+    var require = {
+        config: {$jsconfig|json_encode}
+    };
+</script>
+<!--@formatter:on-->
+
+<!-- Content -->
+<div class="">
+    <div class="row">
+        <div class="col-md-9 col-sm-9 col-xs-9">
+            <!-- <h3>Demo:</h3> -->
+            <div class="img-container">
+                <img id="image" src="{$cropper.url|cdnurl|htmlentities}"/>
+            </div>
+        </div>
+        <div class="col-md-3 col-sm-3 col-xs-3" style="padding-left:0;">
+            <!-- <h3>Preview:</h3> -->
+            <div class="docs-preview clearfix">
+                <div class="img-preview preview-lg"></div>
+                <div class="img-preview preview-md"></div>
+                <div class="img-preview preview-sm"></div>
+                <div class="img-preview preview-xs"></div>
+            </div>
+
+            <!-- <h3>Data:</h3> -->
+            <div class="docs-data">
+                <div class="input-group">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataX">X</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataX" placeholder="x" readonly>
+                    <span class="input-group-addon">
+                      <span class="input-group-text">px</span>
+                    </span>
+                </div>
+                <div class="input-group">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataY">Y</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataY" placeholder="y" readonly>
+                    <span class="input-group-addon">
+                      <span class="input-group-text">px</span>
+                    </span>
+                </div>
+
+                {if $cropper.customWidthHeight && !$cropper.aspectRatio}
+                <div class="input-group">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataWidth">宽度</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataWidth" placeholder="width">
+                    <span class="input-group-addon">
+                      <span class="input-group-text">px</span>
+                    </span>
+                </div>
+
+                <div class="input-group">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataHeight">高度</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataHeight" placeholder="height">
+                    <span class="input-group-addon">
+                      <span class="input-group-text">px</span>
+                    </span>
+                </div>
+                {/if}
+
+                <div class="input-group">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataRotate">旋转</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataRotate" placeholder="rotate" readonly>
+                    <span class="input-group-addon">
+                      <span class="input-group-text">deg</span>
+                    </span>
+                </div>
+                <div class="input-group hidden">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataScaleX">水平方向翻转</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataScaleX" placeholder="scaleX">
+                </div>
+                <div class="input-group hidden">
+                    <span class="input-group-addon">
+                      <label class="input-group-text" for="dataScaleY">垂直方向翻转</label>
+                    </span>
+                    <input type="text" class="form-control" id="dataScaleY" placeholder="scaleY">
+                </div>
+            </div>
+
+            <div class="docs-toggles">
+                {if $cropper.customAspectRatio && !$cropper.aspectRatio}
+                <div class="btn-group d-flex flex-nowrap" data-toggle="buttons" style="margin-top:0px;">
+                    <label class="btn btn-primary active">
+                        <input type="radio" class="sr-only" id="aspectRatio0" name="aspectRatio" value="1.7777777777777777">
+                        <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="比例: 16 / 9">
+                        16:9
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="aspectRatio1" name="aspectRatio" value="1.3333333333333333">
+                        <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="比例: 4 / 3">
+                      4:3
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="aspectRatio2" name="aspectRatio" value="1">
+                        <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="比例: 1 / 1">
+                      1:1
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="aspectRatio3" name="aspectRatio" value="0.6666666666666666">
+                        <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="比例: 2 / 3">
+                      2:3
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="aspectRatio4" name="aspectRatio" value="NaN">
+                        <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="比例: NaN">
+                      Free
+                    </span>
+                    </label>
+                </div>
+                {/if}
+
+                <div class="btn-group d-flex flex-nowrap" data-toggle="buttons">
+                    <label class="btn btn-primary active">
+                        <input type="radio" class="sr-only" id="viewMode0" name="viewMode" value="0" checked="">
+                        <span class="docs-tooltip" data-toggle="tooltip" title="" data-original-title="显示模示 0">
+                      VM0
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="viewMode1" name="viewMode" value="1">
+                        <span class="docs-tooltip" data-toggle="tooltip" title="" data-original-title="显示模示 1">
+                      VM1
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="viewMode2" name="viewMode" value="2">
+                        <span class="docs-tooltip" data-toggle="tooltip" title="" data-original-title="显示模示 2">
+                      VM2
+                    </span>
+                    </label>
+                    <label class="btn btn-primary">
+                        <input type="radio" class="sr-only" id="viewMode3" name="viewMode" value="3">
+                        <span class="docs-tooltip" data-toggle="tooltip" title="" data-original-title="显示模示 3">
+                      VM3
+                    </span>
+                    </label>
+                </div>
+            </div>
+
+        </div>
+    </div>
+    <div class="row">
+        <div class="col-md-9 col-sm-9 col-xs-9 docs-buttons">
+            <!-- <h3>Toolbar:</h3> -->
+            <div class="btn-group">
+                <button type="button" class="btn btn-primary" data-method="setDragMode" data-option="move" title="移动">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="移动">
+              <span class="fa fa-arrows"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="setDragMode" data-option="crop" title="剪裁">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="剪裁">
+              <span class="fa fa-crop"></span>
+            </span>
+                </button>
+            </div>
+
+            <div class="btn-group">
+                <button type="button" class="btn btn-primary" data-method="zoom" data-option="0.1" title="缩小">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="缩小">
+              <span class="fa fa-search-plus"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="zoom" data-option="-0.1" title="放大">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="放大">
+              <span class="fa fa-search-minus"></span>
+            </span>
+                </button>
+            </div>
+
+            <div class="btn-group">
+                <button type="button" class="btn btn-primary" data-method="move" data-option="-10" data-second-option="0" title="左移">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="左移">
+              <span class="fa fa-arrow-left"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="move" data-option="10" data-second-option="0" title="右移">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="右移">
+              <span class="fa fa-arrow-right"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="move" data-option="0" data-second-option="-10" title="上移">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="上移">
+              <span class="fa fa-arrow-up"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="move" data-option="0" data-second-option="10" title="下移">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="下移">
+              <span class="fa fa-arrow-down"></span>
+            </span>
+                </button>
+            </div>
+
+            <div class="btn-group">
+                <button type="button" class="btn btn-primary" data-method="rotate" data-option="-90" title="向左翻转">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="向左翻转">
+              <span class="fa fa-rotate-left"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="rotate" data-option="90" title="向右翻转">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="向右翻转">
+              <span class="fa fa-rotate-right"></span>
+            </span>
+                </button>
+            </div>
+
+            <div class="btn-group">
+                <button type="button" class="btn btn-primary" data-method="scaleX" data-option="-1" title="水平翻转">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="水平翻转">
+              <span class="fa fa-arrows-h"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="scaleY" data-option="-1" title="垂直翻转">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="垂直翻转">
+              <span class="fa fa-arrows-v"></span>
+            </span>
+                </button>
+            </div>
+
+
+            <div class="btn-group hidden">
+                <button type="button" class="btn btn-primary" data-method="disable" title="禁用">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="禁用">
+              <span class="fa fa-lock"></span>
+            </span>
+                </button>
+                <button type="button" class="btn btn-primary" data-method="enable" title="启用">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="启用">
+              <span class="fa fa-unlock"></span>
+            </span>
+                </button>
+            </div>
+
+            <div class="btn-group">
+                <button type="button" class="btn btn-primary" data-method="reset" title="重置">
+            <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="重置">
+              <span class="fa fa-refresh"></span>
+            </span>
+                </button>
+                <label class="btn btn-primary btn-upload" for="inputImage" title="上传图片">
+                    <input type="file" class="sr-only" id="inputImage" name="file" accept=".jpg,.jpeg,.png,.gif,.bmp,.tiff">
+                    <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="重新上传图片">
+              <span class="fa fa-upload"></span>
+            </span>
+                </label>
+
+            </div>
+
+            <div class="btn-group btn-group-crop">
+                <button type="button" class="btn btn-primary" data-method="getCroppedCanvas">
+                    <span class="docs-tooltip" data-toggle="tooltip" data-animation="false" title="预览&下载">
+                      预览&下载
+                    </span>
+                </button>
+            </div>
+
+            <!-- Show the cropped image in modal -->
+            <div class="modal fade docs-cropped" id="getCroppedCanvasModal" aria-hidden="true" aria-labelledby="getCroppedCanvasTitle" role="dialog" tabindex="-1">
+                <div class="modal-dialog">
+                    <div class="modal-content">
+                        <div class="modal-header">
+                            <h5 class="modal-title" id="getCroppedCanvasTitle">已剪裁</h5>
+                            <button type="button" class="close" data-dismiss="modal" aria-label="Close">
+                                <span aria-hidden="true">&times;</span>
+                            </button>
+                        </div>
+                        <div class="modal-body"></div>
+                        <div class="modal-footer">
+                            <button type="button" class="btn btn-secondary" data-dismiss="modal">关闭</button>
+                            <a class="btn btn-primary" id="download" href="javascript:void(0);" download="cropped.jpg">下载图片</a>
+                        </div>
+                    </div>
+                </div>
+            </div><!-- /.modal -->
+        </div><!-- /.docs-buttons -->
+
+        <div class="col-md-3 col-sm-3 col-xs-3" style="padding-left:0;">
+            <div style="margin:0;">
+                <div class="d-flex">
+                    <button type="button" class="btn btn-success btn-submit btn-embossed mr-1">确定</button>
+                    <button type="button" class="btn btn-default btn-cancel btn-embossed ml-1">取消</button>
+                </div>
+            </div>
+        </div><!-- /.docs-toggles -->
+    </div>
+</div>
+
+<script>
+    require.callback = function () {
+        define('cropper', ['jquery', 'bootstrap', 'frontend', 'template', '../addons/cropper/js/cropper'], function ($, undefined, Frontend, Template, undefined, Cropper) {
+            var Controller = {
+                cropper: function () {
+                    $("[data-toggle='tooltip']").data("container", "body");
+                    var URL = window.URL || window.webkitURL;
+                    var $image = $('#image');
+                    window.$image = $image;
+                    var console = window.console || {
+                        log: function () {
+                        }
+                    };
+
+                    var $download = $('#download');
+                    var $dataX = $('#dataX');
+                    var $dataY = $('#dataY');
+                    var $dataHeight = $('#dataHeight');
+                    var $dataWidth = $('#dataWidth');
+                    var $dataRotate = $('#dataRotate');
+                    var $dataScaleX = $('#dataScaleX');
+                    var $dataScaleY = $('#dataScaleY');
+                    var options = {
+                        aspectRatio: parseFloat("{$cropper.aspectRatio|default='NaN'}"),
+                        preview: '.img-preview',
+                        autoCropArea: parseFloat("{$cropper.autoCropArea|default='.8'}"),
+                        cropBoxMovable: !!parseInt("{$cropper.cropBoxMovable|default='1'}"),
+                        cropBoxResizable: !!parseInt("{$cropper.cropBoxResizable|default='1'}"),
+                        minCropBoxWidth: parseInt("{$cropper.minCropBoxWidth|default='0'}"),
+                        minCropBoxHeight: parseInt("{$cropper.minCropBoxHeight|default='0'}"),
+                        minContainerWidth: parseInt("{$cropper.minContainerWidth|default='0'}"),
+                        minContainerHeight: parseInt("{$cropper.minContainerHeight|default='0'}"),
+                        minCanvasWidth: parseInt("{$cropper.minCanvasWidth|default='0'}"),
+                        minCanvasHeight: parseInt("{$cropper.minCanvasHeight|default='0'}"),
+                        crop: function (e) {
+                            $dataX.val(Math.round(e.detail.x));
+                            $dataY.val(Math.round(e.detail.y));
+                            $dataHeight.val(Math.round(e.detail.height));
+                            $dataWidth.val(Math.round(e.detail.width));
+                            $dataRotate.val(e.detail.rotate);
+                            $dataScaleX.val(e.detail.scaleX);
+                            $dataScaleY.val(e.detail.scaleY);
+                        }
+                    };
+                    var croppedOptions = {
+                        minWidth: parseInt("{$cropper.croppedMinWidth|default='0'}"),
+                        minHeight: parseInt("{$cropper.croppedMinHeight|default='0'}"),
+                        maxWidth: parseInt("{$cropper.croppedMaxWidth|default='2048'}"),
+                        maxHeight: parseInt("{$cropper.croppedMaxHeight|default='2048'}"),
+                        fillColor: "{:$cropper.fillColor ? '#' . $cropper.fillColor : 'transparent'}",
+                    };
+                    if (parseInt("{$cropper.croppedMinWidth|default='0'}") > 0) {
+                        croppedOptions.width = parseInt("{$cropper.croppedWidth}");
+                    }
+                    if (parseInt("{$cropper.croppedMinHeight|default='0'}") > 0) {
+                        croppedOptions.height = parseInt("{$cropper.croppedHeight}");
+                    }
+                    var originalImageURL = $image.attr('src');
+                    var uploadedImageName = 'cropped.jpg';
+                    var uploadedImageType = 'image/jpeg';
+                    var uploadedImageURL;
+
+                    // 实例化
+                    $image.cropper(options);
+
+                    //确认事件
+                    $(document).on("click", ".btn-submit", function () {
+                        var data = $image.cropper('getData');
+                        var dataURI = $image.cropper('getCroppedCanvas', croppedOptions).toDataURL('image/png');
+                        data.dataURI = dataURI;
+                        Fast.api.close(data);
+                    });
+
+                    //取消事件
+                    $(document).on("click", ".btn-cancel", function () {
+                        Fast.api.close();
+                    });
+
+                    // Buttons
+                    if (!$.isFunction(document.createElement('canvas').getContext)) {
+                        $('button[data-method="getCroppedCanvas"]').prop('disabled', true);
+                    }
+
+                    if (typeof document.createElement('cropper').style.transition === 'undefined') {
+                        $('button[data-method="rotate"]').prop('disabled', true);
+                        $('button[data-method="scale"]').prop('disabled', true);
+                    }
+
+                    // $dataWidth,$dataHeight点击事件
+                    $('#dataWidth,#dataHeight').change(function () {
+                        const cropBoxData = $image.cropper('getCropBoxData');
+                        const imageData = $image.cropper('getImageData');
+                        const newHeight = imageData.height / imageData.naturalHeight * parseFloat($dataHeight.val());
+                        const newWidth = imageData.width / imageData.naturalWidth * parseFloat($dataWidth.val());
+                        const newCropBoxData = {
+                            left: cropBoxData.left,
+                            top: cropBoxData.top,
+                            height: newHeight,
+                            width: newWidth,
+                        };
+
+                        $image.cropper('setCropBoxData', newCropBoxData);
+                    });
+
+                    // Download
+                    if (typeof $download[0].download === 'undefined') {
+                        $download.addClass('disabled');
+                    }
+
+                    // Options
+                    $('.docs-toggles').on('change', 'input', function () {
+                        var $this = $(this);
+                        var name = $this.attr('name');
+                        var type = $this.prop('type');
+                        var cropBoxData;
+                        var canvasData;
+
+                        if (!$image.data('cropper')) {
+                            return;
+                        }
+
+                        if (type === 'checkbox') {
+                            options[name] = $this.prop('checked');
+                            cropBoxData = $image.cropper('getCropBoxData');
+                            canvasData = $image.cropper('getCanvasData');
+
+                            options.ready = function () {
+                                $image.cropper('setCropBoxData', cropBoxData);
+                                $image.cropper('setCanvasData', canvasData);
+                            };
+                        } else if (type === 'radio') {
+                            options[name] = $this.val();
+                        }
+                        $image.cropper('destroy').cropper(options);
+                    });
+
+                    // Methods
+                    $('.docs-buttons').on('click', '[data-method]', function () {
+                        var $this = $(this);
+                        var data = $this.data();
+                        var cropper = $image.data('cropper');
+                        var cropped;
+                        var $target;
+                        var result;
+
+                        if ($this.prop('disabled') || $this.hasClass('disabled')) {
+                            return;
+                        }
+
+                        if (cropper && data.method) {
+                            data = $.extend({}, data); // Clone a new one
+                            if (typeof data.target !== 'undefined') {
+                                $target = $(data.target);
+                                if (typeof data.option === 'undefined') {
+                                    try {
+                                        data.option = JSON.parse($target.val());
+                                    } catch (e) {
+                                        console.log(e.message);
+                                    }
+                                }
+                            }
+
+                            cropped = cropper.cropped;
+                            switch (data.method) {
+                                case 'rotate':
+                                    if (cropped && options.viewMode > 0) {
+                                        $image.cropper('clear');
+                                    }
+                                    break;
+
+                                case 'getCroppedCanvas':
+                                    if (uploadedImageType === 'image/jpeg') {
+                                        if (!data.option) {
+                                            data.option = {};
+                                        }
+                                        $.extend(data.option, croppedOptions);
+                                        data.option.fillColor = '#fff';
+                                    }
+                                    break;
+                            }
+                            result = $image.cropper(data.method, data.option);
+                            switch (data.method) {
+                                case 'rotate':
+                                    if (cropped && options.viewMode > 0) {
+                                        $image.cropper('crop');
+                                    }
+                                    break;
+                                case 'scaleX':
+                                case 'scaleY':
+                                    $(this).data('option', -data.option);
+                                    break;
+
+                                case 'getCroppedCanvas':
+                                    if (result) {
+                                        // Bootstrap's Modal
+                                        $('#getCroppedCanvasModal').modal().find('.modal-body').html(result);
+
+                                        if (!$download.hasClass('disabled')) {
+                                            download.download = uploadedImageName;
+                                            $download.attr('href', result.toDataURL(uploadedImageType));
+                                        }
+                                    }
+                                    break;
+
+                                case 'destroy':
+                                    if (uploadedImageURL) {
+                                        URL.revokeObjectURL(uploadedImageURL);
+                                        uploadedImageURL = '';
+                                        $image.attr('src', originalImageURL);
+                                    }
+
+                                    break;
+                            }
+
+                            if ($.isPlainObject(result) && $target) {
+                                try {
+                                    $target.val(JSON.stringify(result));
+                                } catch (e) {
+                                    console.log(e.message);
+                                }
+                            }
+                        }
+                    });
+
+                    // 键盘支持
+                    $(document.body).on('keydown', function (e) {
+                        if (e.target !== this || !$image.data('cropper') || this.scrollTop > 300) {
+                            return;
+                        }
+                        switch (e.which) {
+                            case 37:
+                                e.preventDefault();
+                                $image.cropper('move', -1, 0);
+                                break;
+                            case 38:
+                                e.preventDefault();
+                                $image.cropper('move', 0, -1);
+                                break;
+                            case 39:
+                                e.preventDefault();
+                                $image.cropper('move', 1, 0);
+                                break;
+                            case 40:
+                                e.preventDefault();
+                                $image.cropper('move', 0, 1);
+                                break;
+                        }
+                    });
+
+                    // 上传图片
+                    var $inputImage = $('#inputImage');
+
+                    if (URL) {
+                        $inputImage.change(function () {
+                            var files = this.files;
+                            var file;
+
+                            if (!$image.data('cropper')) {
+                                return;
+                            }
+
+                            if (files && files.length) {
+                                file = files[0];
+
+                                if (/^image\/\w+$/.test(file.type)) {
+                                    uploadedImageName = file.name;
+                                    uploadedImageType = file.type;
+
+                                    if (uploadedImageURL) {
+                                        URL.revokeObjectURL(uploadedImageURL);
+                                    }
+
+                                    uploadedImageURL = URL.createObjectURL(file);
+                                    $image.cropper('destroy').attr('src', uploadedImageURL).cropper(options);
+                                    $inputImage.val('');
+                                } else {
+                                    window.alert('请选择一张图片');
+                                }
+                            }
+                        });
+                    } else {
+                        $inputImage.prop('disabled', true).parent().addClass('disabled');
+                    }
+                }
+            };
+            return Controller;
+        });
+    };
+</script>
+
+<script src="__CDN__/assets/js/require{$Think.config.app_debug?'':'.min'}.js" data-main="__CDN__/assets/js/require-frontend{$Think.config.app_debug?'':'.min'}.js?v={$site.version}"></script>
+
+</body>
+</html>

+ 169 - 0
addons/cropper/view/index/index.html

@@ -0,0 +1,169 @@
+<!doctype html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
+    <meta http-equiv="X-UA-Compatible" content="ie=edge">
+    <title>图片剪裁示例</title>
+    <link href="__CDN__/assets/css/frontend{$Think.config.app_debug?'':'.min'}.css?v={$Think.config.site.version}" rel="stylesheet">
+
+    <!-- HTML5 shim, for IE6-8 support of HTML5 elements. All other JS at the end of file. -->
+    <!--[if lt IE 9]>
+    <script src="__CDN__/assets/js/html5shiv.js"></script>
+    <script src="__CDN__/assets/js/respond.min.js"></script>
+    <![endif]-->
+
+    <link rel="stylesheet" href="__ADDON__/css/cropper.css">
+    <link rel="stylesheet" href="__ADDON__/css/main.css">
+</head>
+<body>
+
+<!-- Content -->
+<div class="container">
+    <div class="clearfix">
+        <div class="row">
+            <div class="col-lg-12">
+                <div class="page-header">
+                    <h2 id="options">剪裁参数</h2>
+                </div>
+                <div class="alert alert-warning-light">
+                    <textarea class="form-control" style="height:220px;">
+<div class="input-group">
+    <input id="c-image" class="form-control" size="50" name="row[image]" type="text" value="">
+    <div class="input-group-addon no-border no-padding">
+        <span><button type="button" id="faupload-image" class="btn btn-danger faupload" data-aspect-ratio="0.75" data-auto-crop-area="0.5" data-cropped-width="300" data-cropped-height="300" data-input-id="c-image" data-mimetype="image/gif,image/jpeg,image/png,image/jpg,image/bmp,image/webp" data-multiple="false" data-preview-id="p-image"><i class="fa fa-upload"></i> 上传</button></span>
+    </div>
+    <span class="msg-box n-right" for="c-image"></span>
+</div>
+<ul class="row list-inline faupload-preview" id="p-image"></ul>
+                    </textarea>
+                </div>
+                <table class="table table-condensed table-hover">
+                    <thead>
+                    <tr>
+                        <th>参数</th>
+                        <th>示例</th>
+                        <th>说明</th>
+                        <th>默认</th>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    <tr class="text-danger">
+                        <td>aspectRatio</td>
+                        <td>data-aspect-ratio="0.8"</td>
+                        <td>比例</td>
+                        <td>0.8</td>
+                    </tr>
+                    <tr>
+                        <td>autoCropArea</td>
+                        <td>data-auto-crop-area="0.8"</td>
+                        <td>默认自动剪裁的区域大小</td>
+                        <td>0.8</td>
+                    </tr>
+                    <tr>
+                        <td>cropBoxMovable</td>
+                        <td>data-crop-box-movable="1"</td>
+                        <td>剪裁框是否可移动</td>
+                        <td>1</td>
+                    </tr>
+                    <tr>
+                        <td>cropBoxResizable</td>
+                        <td>data-crop-box-resizable="1"</td>
+                        <td>剪裁框是否可变大小</td>
+                        <td>1</td>
+                    </tr>
+                    <tr>
+                        <td>minCropBoxWidth</td>
+                        <td>data-min-crop-box-width="0"</td>
+                        <td>最小剪裁框宽度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>minCropBoxHeight</td>
+                        <td>data-min-crop-box-height="0"</td>
+                        <td>最小剪裁框高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>minContainerWidth</td>
+                        <td>data-min-container-width="0"</td>
+                        <td>最小窗口宽度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>minContainerHeight</td>
+                        <td>data-min-container-height="0"</td>
+                        <td>最小窗口高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>minCanvasHeight</td>
+                        <td>data-min-canvas-height="0"</td>
+                        <td>最小画布宽度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>minCanvasWidth</td>
+                        <td>data-min-canvas-width="0"</td>
+                        <td>最小画布高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr class="text-danger">
+                        <td>croppedWidth</td>
+                        <td>data-cropped-width="300"</td>
+                        <td>剪裁输出宽度</td>
+                        <td>实际宽度</td>
+                    </tr>
+                    <tr class="text-danger">
+                        <td>croppedHeight</td>
+                        <td>data-cropped-height="300"</td>
+                        <td>剪裁输出宽度</td>
+                        <td>实际高度</td>
+                    </tr>
+                    <tr>
+                        <td>croppedMinWidth</td>
+                        <td>data-cropped-min-width="400"</td>
+                        <td>最小画布高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>croppedMinHeight</td>
+                        <td>data-cropped-min-height="400"</td>
+                        <td>最小画布高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>croppedMaxWidth</td>
+                        <td>data-cropped-max-width="500"</td>
+                        <td>最大画布高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>croppedMaxHeight</td>
+                        <td>data-cropped-max-height="300"</td>
+                        <td>最大画布高度</td>
+                        <td>0</td>
+                    </tr>
+                    <tr>
+                        <td>fillColor</td>
+                        <td>data-fill-color="ffffff"</td>
+                        <td>背景填充色,默认为透明</td>
+                        <td>transparent</td>
+                    </tr>
+                    </tbody>
+                </table>
+
+                <div class="page-header">
+                    <h2 id="thanks">特别感谢</h2>
+                </div>
+                <div class="alert alert-danger-light">
+                    Cropper.js:<a href="https://github.com/fengyuanchen/cropper" target="_blank">https://github.com/fengyuanchen/cropper</a><br>
+                    QQ小伙伴:CARPE DIEM
+                </div>
+            </div>
+        </div>
+    </div>
+
+</div>
+</body>
+</html>

+ 1 - 0
addons/epay/.addonrc

@@ -0,0 +1 @@
+{"files":["application\/admin\/controller\/Epay.php","public\/assets\/addons\/epay\/css\/common.css","public\/assets\/addons\/epay\/less\/common.less","public\/assets\/addons\/epay\/images\/paid.png","public\/assets\/addons\/epay\/images\/logo-wechat.png","public\/assets\/addons\/epay\/images\/logo-alipay.png","public\/assets\/addons\/epay\/images\/screenshot-alipay.png","public\/assets\/addons\/epay\/images\/alipay.png","public\/assets\/addons\/epay\/images\/screenshot-wechat.png","public\/assets\/addons\/epay\/images\/wechat.png","public\/assets\/addons\/epay\/images\/scan.png","public\/assets\/addons\/epay\/images\/expired.png","public\/assets\/addons\/epay\/js\/jquery.qrcode.min.js","public\/assets\/addons\/epay\/js\/common.js"],"license":"regular","licenseto":"9671","licensekey":"AMGWSLFhxsvpKy9E j+5KbEzsYwu\/qtJpUU2lFA==","domains":["thinkphp_fastadmin_tailored.test"],"licensecodes":[],"validations":["849901681e419b30a3ed3e4629b8f4ac"]}

+ 100 - 0
addons/epay/Epay.php

@@ -0,0 +1,100 @@
+<?php
+
+namespace addons\epay;
+
+use addons\epay\library\Service;
+use think\Addons;
+use think\Config;
+use think\Loader;
+
+/**
+ * 微信支付宝整合插件
+ */
+class Epay extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        return true;
+    }
+
+    // 支持自定义加载
+    public function epayConfigInit()
+    {
+        $this->actionBegin();
+    }
+
+    // 插件方法加载开始
+    public function addonActionBegin()
+    {
+        $this->actionBegin();
+    }
+
+    // 模块控制器方法加载开始
+    public function actionBegin()
+    {
+        //添加命名空间
+        if (!class_exists('\Yansongda\Pay\Pay')) {
+
+            //SDK版本
+            $version = Service::getSdkVersion();
+
+            $libraryDir = ADDON_PATH . 'epay' . DS . 'library' . DS;
+            Loader::addNamespace('Yansongda\Pay', $libraryDir . $version . DS . 'Yansongda' . DS . 'Pay' . DS);
+
+            $checkArr = [
+                '\Hyperf\Context\Context'     => 'context',
+                '\Hyperf\Contract\Castable'   => 'contract',
+                '\Hyperf\Engine\Constant'     => 'engine',
+                '\Hyperf\Macroable\Macroable' => 'macroable',
+                '\Hyperf\Pimple\Container'    => 'pimple',
+                '\Hyperf\Utils\Arr'           => 'utils',
+            ];
+            foreach ($checkArr as $index => $item) {
+                if (!class_exists($index)) {
+                    Loader::addNamespace(substr($index, 1, strrpos($index, '\\') - 1), $libraryDir . 'hyperf' . DS . $item . DS . 'src' . DS);
+                }
+            }
+
+            if (!class_exists('\Yansongda\Supports\Logger')) {
+                Loader::addNamespace('Yansongda\Supports', $libraryDir . $version . DS . 'Yansongda' . DS . 'Supports' . DS);
+            }
+
+            // V3需载入辅助函数
+            if ($version == Service::SDK_VERSION_V3) {
+                require_once $libraryDir . $version . DS . 'Yansongda' . DS . 'Pay' . DS . 'Functions.php';
+            }
+        }
+    }
+}

+ 0 - 0
addons/epay/certs/alipayCertPublicKey.crt


+ 0 - 0
addons/epay/certs/alipayRootCert.crt


+ 0 - 0
addons/epay/certs/apiclient_cert.pem


+ 0 - 0
addons/epay/certs/apiclient_key.pem


+ 0 - 0
addons/epay/certs/appCertPublicKey.crt


Some files were not shown because too many files changed in this diff