xxxrrrdddd há 3 anos atrás
commit
b7a478d549
100 ficheiros alterados com 19113 adições e 0 exclusões
  1. 10 0
      .bowerrc
  2. 15 0
      .env.sample
  3. 15 0
      .gitignore
  4. 191 0
      LICENSE
  5. 1 0
      addons/.gitkeep
  6. 1 0
      addons/.htaccess
  7. 1 0
      addons/alioss/.addonrc
  8. 144 0
      addons/alioss/Alioss.php
  9. 0 0
      addons/alioss/assets/js/spark.js
  10. 82 0
      addons/alioss/bootstrap.js
  11. 151 0
      addons/alioss/config.php
  12. 69 0
      addons/alioss/controller/Index.php
  13. 8 0
      addons/alioss/info.ini
  14. 1 0
      addons/alioss/install.sql
  15. 78 0
      addons/alioss/library/Auth.php
  16. 91 0
      addons/aliyunsms/Aliyunsms.php
  17. 64 0
      addons/aliyunsms/config.php
  18. 15 0
      addons/aliyunsms/controller/Index.php
  19. 8 0
      addons/aliyunsms/info.ini
  20. 136 0
      addons/aliyunsms/library/Aliyunsms.php
  21. 1 0
      addons/command/.addonrc
  22. 69 0
      addons/command/Command.php
  23. 4 0
      addons/command/config.php
  24. 15 0
      addons/command/controller/Index.php
  25. 10 0
      addons/command/info.ini
  26. 12 0
      addons/command/install.sql
  27. 28 0
      addons/command/library/Output.php
  28. 1 0
      addons/qcloudsms/.addonrc
  29. 197 0
      addons/qcloudsms/Qcloudsms.php
  30. 128 0
      addons/qcloudsms/config.php
  31. 15 0
      addons/qcloudsms/controller/Index.php
  32. 10 0
      addons/qcloudsms/info.ini
  33. 69 0
      addons/qcloudsms/library/FileVoiceSender.php
  34. 91 0
      addons/qcloudsms/library/SmsMobileStatusPuller.php
  35. 99 0
      addons/qcloudsms/library/SmsMultiSender.php
  36. 211 0
      addons/qcloudsms/library/SmsSenderUtil.php
  37. 107 0
      addons/qcloudsms/library/SmsSingleSender.php
  38. 75 0
      addons/qcloudsms/library/SmsStatusPuller.php
  39. 71 0
      addons/qcloudsms/library/SmsVoicePromptSender.php
  40. 67 0
      addons/qcloudsms/library/SmsVoiceVerifyCodeSender.php
  41. 77 0
      addons/qcloudsms/library/TtsVoiceSender.php
  42. 1 0
      addons/simditor/.addonrc
  43. 31 0
      addons/simditor/Simditor.php
  44. 48 0
      addons/simditor/bootstrap.js
  45. 4 0
      addons/simditor/build/build.sh
  46. 4 0
      addons/simditor/build/css.js
  47. 10 0
      addons/simditor/build/js.js
  48. 4699 0
      addons/simditor/build/r.js
  49. 4 0
      addons/simditor/config.php
  50. 10 0
      addons/simditor/info.ini
  51. 460 0
      addons/simditor/src/css/mobile.css
  52. 2 0
      addons/simditor/src/css/simditor.css
  53. BIN
      addons/simditor/src/images/image.png
  54. 241 0
      addons/simditor/src/js/hotkeys.js
  55. 172 0
      addons/simditor/src/js/module.js
  56. 5641 0
      addons/simditor/src/js/simditor.js
  57. 261 0
      addons/simditor/src/js/uploader.js
  58. 1 0
      application/.htaccess
  59. 4 0
      application/UserException.php
  60. 14 0
      application/admin/behavior/AdminLog.php
  61. 383 0
      application/admin/command/Addon.php
  62. 68 0
      application/admin/command/Addon/stubs/addon.stub
  63. 40 0
      application/admin/command/Addon/stubs/config.stub
  64. 15 0
      application/admin/command/Addon/stubs/controller.stub
  65. 7 0
      application/admin/command/Addon/stubs/info.stub
  66. 195 0
      application/admin/command/Api.php
  67. 25 0
      application/admin/command/Api/lang/zh-cn.php
  68. 253 0
      application/admin/command/Api/library/Builder.php
  69. 544 0
      application/admin/command/Api/library/Extractor.php
  70. 663 0
      application/admin/command/Api/template/index.html
  71. 1520 0
      application/admin/command/Crud.php
  72. 11 0
      application/admin/command/Crud/stubs/add.stub
  73. 40 0
      application/admin/command/Crud/stubs/controller.stub
  74. 34 0
      application/admin/command/Crud/stubs/controllerindex.stub
  75. 11 0
      application/admin/command/Crud/stubs/edit.stub
  76. 6 0
      application/admin/command/Crud/stubs/html/checkbox.stub
  77. 10 0
      application/admin/command/Crud/stubs/html/fieldlist.stub
  78. 10 0
      application/admin/command/Crud/stubs/html/heading-html.stub
  79. 6 0
      application/admin/command/Crud/stubs/html/radio.stub
  80. 1 0
      application/admin/command/Crud/stubs/html/recyclebin-html.stub
  81. 6 0
      application/admin/command/Crud/stubs/html/select.stub
  82. 5 0
      application/admin/command/Crud/stubs/html/switch.stub
  83. 35 0
      application/admin/command/Crud/stubs/index.stub
  84. 48 0
      application/admin/command/Crud/stubs/javascript.stub
  85. 5 0
      application/admin/command/Crud/stubs/lang.stub
  86. 8 0
      application/admin/command/Crud/stubs/mixins/checkbox.stub
  87. 6 0
      application/admin/command/Crud/stubs/mixins/datetime.stub
  88. 1 0
      application/admin/command/Crud/stubs/mixins/enum.stub
  89. 8 0
      application/admin/command/Crud/stubs/mixins/modelinit.stub
  90. 5 0
      application/admin/command/Crud/stubs/mixins/modelrelationmethod.stub
  91. 8 0
      application/admin/command/Crud/stubs/mixins/multiple.stub
  92. 7 0
      application/admin/command/Crud/stubs/mixins/radio.stub
  93. 60 0
      application/admin/command/Crud/stubs/mixins/recyclebinjs.stub
  94. 7 0
      application/admin/command/Crud/stubs/mixins/select.stub
  95. 40 0
      application/admin/command/Crud/stubs/model.stub
  96. 25 0
      application/admin/command/Crud/stubs/recyclebin.stub
  97. 12 0
      application/admin/command/Crud/stubs/relationmodel.stub
  98. 27 0
      application/admin/command/Crud/stubs/validate.stub
  99. 314 0
      application/admin/command/Install.php
  100. 599 0
      application/admin/command/Install/fastadmin.sql

+ 10 - 0
.bowerrc

@@ -0,0 +1,10 @@
+{
+  "directory": "public/assets/libs",
+  "ignoredDependencies": [
+    "es6-promise",
+    "file-saver",
+    "html2canvas",
+    "jspdf",
+    "jspdf-autotable"
+  ]
+}

+ 15 - 0
.env.sample

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

+ 15 - 0
.gitignore

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

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

+ 1 - 0
addons/.gitkeep

@@ -0,0 +1 @@
+

+ 1 - 0
addons/.htaccess

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

+ 1 - 0
addons/alioss/.addonrc

@@ -0,0 +1 @@
+{"files":["public\\assets\\addons\\alioss\\js\\spark.js"]}

+ 144 - 0
addons/alioss/Alioss.php

@@ -0,0 +1,144 @@
+<?php
+
+namespace addons\alioss;
+
+use app\common\model\Attachment;
+use think\Addons;
+
+/**
+ * 阿里云OSS上传插件
+ */
+class Alioss extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        return true;
+    }
+
+    /**
+     * 加载配置
+     */
+    public function uploadConfigInit(&$upload)
+    {
+        $config = $this->getConfig();
+        if ($config['uploadmode'] === 'client')
+        {
+            $upload = [
+                'cdnurl'    => $config['cdnurl'],
+                'uploadurl' => 'http://' . $config['bucket'] . '.' . $config['endpoint'],
+                'bucket'    => $config['bucket'],
+                'maxsize'   => $config['maxsize'],
+                'mimetype'  => $config['mimetype'],
+                'multipart' => [],
+                'multiple'  => $config['multiple'] ? true : false,
+                'storage'   => 'alioss',
+                'chunking'  => (bool)($config['chunking']??false)
+            ];
+        }
+        else
+        {
+            $upload = array_merge($upload, [
+                'maxsize'  => $config['maxsize'],
+                'mimetype' => $config['mimetype'],
+                'multiple' => $config['multiple'] ? true : false,
+            ]);
+        }
+    }
+
+    /**
+     * 上传成功后
+     */
+    public function uploadAfter(Attachment $attachment)
+    {
+        $config = $this->getConfig();
+        if ($config['uploadmode']/* === 'server'*/)
+        {
+            $file = ROOT_PATH . 'public' . str_replace('/', DIRECTORY_SEPARATOR, parse_url($attachment->url,PHP_URL_PATH));
+
+            $name = basename($file);
+            $md5 = md5_file($file);
+
+            $auth = new \addons\alioss\library\Auth();
+            $params = $auth->params($name, $md5, false);
+            $multipart = [
+                [
+                    'name'     => 'key',
+                    'contents' => $params['key'],
+                ],
+                [
+                    'name'     => 'success_action_status',
+                    'contents' => 200,
+                ],
+                [
+                    'name'     => 'OSSAccessKeyId',
+                    'contents' => $params['id'],
+                ],
+                [
+                    'name'     => 'policy',
+                    'contents' => $params['policy'],
+                ],
+                [
+                    'name'     => 'Signature',
+                    'contents' => $params['signature'],
+                ],
+                [
+                    'name'     => 'file',
+                    'contents' => fopen($file, 'r'),
+                ],
+            ];
+            try
+            {
+                $uploadurl = 'http://' . $config['bucket'] . '.' . $config['endpoint'];
+
+                $client = new \GuzzleHttp\Client();
+//                $res = $client->request('POST', $uploadurl, [
+//                    'multipart' => $multipart,
+//                    'headers'   => ['Accept-Encoding' => 'gzip'],
+//                ]);
+                
+                $multipartStream = new \GuzzleHttp\Psr7\MultipartStream($multipart);
+                $boundary = $multipartStream->getBoundary();
+                $body = (string) $multipartStream;
+                //默认的request方法会添加Content-Length字段,但Alioss不识别,所以需要移除
+                $body = preg_replace('/Content\-Length:\s(\d+)[\r\n]+Content\-Type/i', "Content-Type", $body);
+                $params = [
+                    'headers' => [
+                        'Connection'   => 'close',
+                        'Content-Type' => 'multipart/form-data; boundary=' . $boundary,
+                    ],
+                    'body'    => $body,
+                ];
+
+                $res = $client->request('POST', $uploadurl, $params);
+                $code = $res->getStatusCode();
+                //成功不做任何操作
+                if($config['uploadmode']=='client'){
+                    if($attachment->id) {
+                        $attachment->delete();
+                    }
+                    unlink($file);
+                }
+            }
+            catch (\GuzzleHttp\Exception\ClientException $e)
+            {
+                echo json_encode(['code' => 0, 'msg' => '无法上传到远程服务器,错误:' . $e->getMessage()]);
+                exit;
+            }
+        }
+    }
+
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 0 - 0
addons/alioss/assets/js/spark.js


+ 82 - 0
addons/alioss/bootstrap.js

@@ -0,0 +1,82 @@
+//如果开启了alioss客户端上传模式
+if (typeof Config.upload.storage !== 'undefined' && Config.upload.storage === 'alioss') {
+    require(['upload', '../addons/alioss/js/spark'], function (Upload, SparkMD5) {
+        var _onFileAdded = Upload.events.onFileAdded;
+        var _onUploadResponse = Upload.events.onUploadResponse;
+        var _process = function (up, file) {
+            (function (up, file) {
+                var blob = file.getNative();
+                var loadedBytes = file.loaded;
+                var chunkSize = 2097152;
+                var chunkBlob = blob.slice(loadedBytes, loadedBytes + chunkSize);
+                var reader = new FileReader();
+                reader.addEventListener('loadend', function (e) {
+                    var spark = new SparkMD5.ArrayBuffer();
+                    spark.append(e.target.result);
+                    var md5 = spark.end();
+                    Fast.api.ajax({
+                        url: "/addons/alioss/index/params",
+                        data: {method: 'POST', md5: md5, name: file.name, type: file.type, size: file.size},
+                    }, function (data) {
+                        file.md5 = md5;
+                        file.status = 1;
+                        file.key = data.key;
+                        file.OSSAccessKeyId = data.id;
+                        file.policy = data.policy;
+                        file.signature = data.signature;
+                        up.start();
+                        return false;
+                    });
+                    return;
+                });
+                reader.readAsArrayBuffer(chunkBlob);
+            })(up, file);
+        };
+        Upload.events.onFileAdded = function (up, files) {
+            return _onFileAdded.call(this, up, files);
+        };
+        Upload.events.onBeforeUpload = function (up, file) {
+            if (typeof file.md5 === 'undefined') {
+                up.stop();
+                _process(up, file);
+            } else {
+                up.settings.headers = up.settings.headers || {};
+                up.settings.multipart_params.key = file.key;
+                up.settings.multipart_params.OSSAccessKeyId = file.OSSAccessKeyId;
+                up.settings.multipart_params.success_action_status = 200;
+                if (typeof file.callback !== 'undefined') {
+                    up.settings.multipart_params.callback = file.callback;
+                }
+                up.settings.multipart_params.policy = file.policy;
+                up.settings.multipart_params.signature = file.signature;
+                //up.settings.send_file_name = false;
+            }
+        };
+        Upload.events.onUploadResponse = function (response, info, up, file) {
+            try {
+                var ret = {};
+                if (info.status === 200) {
+                    var url = '/' + file.key;
+                    Fast.api.ajax({
+                        url: "/addons/alioss/index/notify",
+                        data: {method: 'POST', name: file.name, url: url, md5: file.md5, size: file.size, type: file.type, policy: file.policy, signature: file.signature}
+                    }, function () {
+                        return false;
+                    });
+                    ret.code = 1;
+                    ret.data = {
+                        url: url
+                    };
+                } else {
+                    ret.code = 0;
+                    ret.msg = info.response;
+                }
+                return _onUploadResponse.call(this, JSON.stringify(ret));
+
+            } catch (e) {
+            }
+            return _onUploadResponse.call(this, response);
+
+        };
+    });
+}

+ 151 - 0
addons/alioss/config.php

@@ -0,0 +1,151 @@
+<?php
+
+return [
+    [
+        'name' => 'app_id',
+        'title' => 'app_id',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasd',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'app_key',
+        'title' => 'app_key',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasda',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'bucket',
+        'title' => 'Bucket',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'dasdasda',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '阿里云OSS的空间名',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'endpoint',
+        'title' => 'EndPoint',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'oss-cn-shenzhen.aliyuncs.com',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '如果是服务器中转模式,可填写内网域名',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'cdnurl',
+        'title' => 'CDN地址',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'http://yourbucket.oss-cn-shenzhen.aliyuncs.com',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '如果你启用了CDN,请填写CDN地址',
+        'ok' => '',
+        'extend' => '',
+    ],
+    6 => [
+        'name' => 'uploadmode',
+        'title' => '上传模式',
+        'type' => 'select',
+        'content' => [
+            'client' => '客户端直传(速度快,无备份)',
+            'server' => '服务器中转(占用服务器带宽,有备份)',
+        ],
+        'value' => 'client',
+        'rule' => '',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'savekey',
+        'title' => '保存文件名',
+        'type' => 'string',
+        'content' => [],
+        'value' => '/uploads/{year}{mon}{day}/{filemd5}{.suffix}',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'expire',
+        'title' => '上传有效时长',
+        'type' => 'string',
+        'content' => [],
+        'value' => '600',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'maxsize',
+        'title' => '最大可上传',
+        'type' => 'string',
+        'content' => [],
+        'value' => '10M',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'mimetype',
+        'title' => '可上传后缀格式',
+        'type' => 'string',
+        'content' => [],
+        'value' => '*',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'multiple',
+        'title' => '多文件上传',
+        'type' => 'bool',
+        'content' => [],
+        'value' => '0',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'chunking',
+        'title' => '分片上传',
+        'type' => 'bool',
+        'content' => [],
+        'value' => '0',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

+ 69 - 0
addons/alioss/controller/Index.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace addons\alioss\controller;
+
+use app\common\model\Attachment;
+use think\addons\Controller;
+
+/**
+ * Ucloud
+ *
+ */
+class Index extends Controller
+{
+
+    public function index()
+    {
+        $this->error("当前插件暂无前台页面");
+    }
+
+    public function params()
+    {
+        $name = $this->request->post('name');
+        $md5 = $this->request->post('md5');
+        $auth = new \addons\alioss\library\Auth();
+        $params = $auth->params($name, $md5);
+        $this->success('', null, $params);
+        return;
+    }
+
+    public function notify()
+    {
+        $size = $this->request->post('size');
+        $name = $this->request->post('name');
+        $md5 = $this->request->post('md5');
+        $type = $this->request->post('type');
+        $signature = $this->request->post('signature');
+        $policy = $this->request->post('policy');
+        $url = $this->request->post('url');
+        $suffix = substr($name, stripos($name, '.') + 1);
+        $auth = new \addons\alioss\library\Auth();
+        if ($auth->check($signature, $policy))
+        {
+            $attachment = Attachment::getBySha1($md5);
+            if (!$attachment)
+            {
+                $params = array(
+                    'filesize'    => $size,
+                    'imagewidth'  => 0,
+                    'imageheight' => 0,
+                    'imagetype'   => $suffix,
+                    'imageframes' => 0,
+                    'mimetype'    => $type,
+                    'url'         => $url,
+                    'uploadtime'  => time(),
+                    'storage'     => 'alioss',
+                    'sha1'        => $md5,
+                );
+                Attachment::create($params);
+            }
+            $this->success();
+        }
+        else
+        {
+            $this->error(__('You have no permission'));
+        }
+        return;
+    }
+
+}

+ 8 - 0
addons/alioss/info.ini

@@ -0,0 +1,8 @@
+name = alioss
+title = 阿里OSS上传
+intro = 使用阿里OSS存储,上传时直传阿里云OSS
+author = Karson
+website = http://www.fastadmin.net
+version = 1.1.7
+state = 0
+url = /addons/alioss

+ 1 - 0
addons/alioss/install.sql

@@ -0,0 +1 @@
+ALTER TABLE `__PREFIX__attachment` MODIFY COLUMN `storage` enum('local','upyun','qiniu','ucloud','alioss') CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL DEFAULT 'local' COMMENT '存储位置' AFTER `uploadtime`;

+ 78 - 0
addons/alioss/library/Auth.php

@@ -0,0 +1,78 @@
+<?php
+
+namespace addons\alioss\library;
+
+class Auth
+{
+
+    public function __construct()
+    {
+        
+    }
+
+    public function params($name, $md5, $callback = true)
+    {
+        $config = get_addon_config('alioss');
+        $callback_param = array(
+            'callbackUrl'      => isset($config['notifyurl']) ? $config['notifyurl'] : '',
+            'callbackBody'     => 'filename=${object}&size=${size}&mimeType=${mimeType}&height=${imageInfo.height}&width=${imageInfo.width}',
+            'callbackBodyType' => "application/x-www-form-urlencoded"
+        );
+
+        $base64_callback_body = base64_encode(json_encode($callback_param));
+
+        $now = time();
+        $end = $now + $config['expire']; //设置该policy超时时间是10s. 即这个policy过了这个有效时间,将不能访问
+        $expiration = $this->gmt_iso8601($end);
+
+        preg_match('/(\d+)(\w+)/', $config['maxsize'], $matches);
+        $type = strtolower($matches[2]);
+        $typeDict = ['b' => 0, 'k' => 1, 'kb' => 1, 'm' => 2, 'mb' => 2, 'gb' => 3, 'g' => 3];
+        $size = (int) $config['maxsize'] * pow(1024, isset($typeDict[$type]) ? $typeDict[$type] : 0);
+
+        //最大文件大小.用户可以自己设置
+        $condition = array(0 => 'content-length-range', 1 => 0, 2 => $size);
+        $conditions[] = $condition;
+
+        //表示用户上传的数据,必须是以$dir开始, 不然上传会失败,这一步不是必须项,只是为了安全起见,防止用户通过policy上传到别人的目录
+        //$start = array(0 => 'starts-with', 1 => '$key', 2 => $dir);
+        //$conditions[] = $start;
+
+        $arr = array('expiration' => $expiration, 'conditions' => $conditions);
+
+        $policy = base64_encode(json_encode($arr));
+        $signature = base64_encode(hash_hmac('sha1', $policy, $config['app_key'], true));
+
+        $suffix = substr($name, stripos($name, '.') + 1);
+        $search = ['{year}', '{mon}', '{month}', '{day}', '{filemd5}', '{suffix}', '{.suffix}'];
+        $replace = [date("Y"), date("m"), date("m"), date("d"), $md5, $suffix, '.' . $suffix];
+        $key = ltrim(str_replace($search, $replace, $config['savekey']), '/');
+
+        $response = array();
+        $response['id'] = $config['app_id'];
+        $response['key'] = $key;
+        $response['policy'] = $policy;
+        $response['signature'] = $signature;
+        $response['expire'] = $end;
+        $response['callback'] = '';
+        return $response;
+    }
+
+    public function check($signature, $policy)
+    {
+        $config = get_addon_config('alioss');
+        $sign = base64_encode(hash_hmac('sha1', $policy, $config['app_key'], true));
+        return $signature == $sign;
+    }
+
+    private function gmt_iso8601($time)
+    {
+        $dtStr = date("c", $time);
+        $mydatetime = new \DateTime($dtStr);
+        $expiration = $mydatetime->format(\DateTime::ISO8601);
+        $pos = strpos($expiration, '+');
+        $expiration = substr($expiration, 0, $pos);
+        return $expiration . "Z";
+    }
+
+}

+ 91 - 0
addons/aliyunsms/Aliyunsms.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace addons\aliyunsms;
+
+use app\common\library\Menu;
+use app\common\model\Sms;
+use think\Addons;
+
+/**
+ * Aliyunsms插件
+ */
+class Aliyunsms 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;
+    }
+
+    /**
+     * 短信发送
+     * @param Sms $params
+     * @return mixed
+     * @throws \AlibabaCloud\Client\Exception\ClientException
+     * @throws \AlibabaCloud\Client\Exception\ServerException
+     */
+    public function smsSend(&$params)
+    {
+        $smsbao = new library\Aliyunsms();
+        return $smsbao->mobile($params['mobile'])->msg($params['code'])->send();
+    }
+
+    /**
+     * 短信发送通知(msg参数直接构建实际短信内容即可)
+     * @param array $params
+     * @return  boolean
+     * @throws \AlibabaCloud\Client\Exception\ClientException
+     * @throws \AlibabaCloud\Client\Exception\ServerException
+     */
+    public function smsNotice(&$params)
+    {
+        $smsbao = new library\Aliyunsms();
+        $result = $smsbao->mobile($params['mobile'])->msg($params['msg'])->send();
+        return $result;
+    }
+
+    /**
+     * 检测验证是否正确
+     * @param Sms $params
+     * @return  boolean
+     */
+    public function smsCheck(&$params)
+    {
+        return TRUE;
+    }
+}

+ 64 - 0
addons/aliyunsms/config.php

@@ -0,0 +1,64 @@
+<?php
+
+return [
+    [
+        'name' => 'accessKeyId',
+        'title' => 'accessKeyId',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasdasd',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'accessSecret',
+        'title' => 'accessSecret',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasdasda',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'regionId',
+        'title' => 'regionId',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'cn-hangzhou',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => 'cn-hangzhou',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'signName',
+        'title' => 'signName',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasdasd',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => 'signName',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'templateCode',
+        'title' => 'templateCode',
+        'type' => 'string',
+        'content' => [],
+        'value' => '123123',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => 'templateCode',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

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

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

+ 8 - 0
addons/aliyunsms/info.ini

@@ -0,0 +1,8 @@
+name = aliyunsms
+title = 阿里云短信
+intro = 快速接入、使用方便、价格低廉的短信服务
+author = xianghua_we
+website = https://www.fastadmin.net
+version = 1.0.0
+state = 1
+url = /addons/aliyunsms.html

+ 136 - 0
addons/aliyunsms/library/Aliyunsms.php

@@ -0,0 +1,136 @@
+<?php
+
+namespace addons\aliyunsms\library;
+
+use AlibabaCloud\Client\AlibabaCloud;
+
+class Aliyunsms
+{
+    private $_params = [];
+    protected $error = '';
+    protected $config = [];
+    protected static $instance = null;
+    protected $statusStr = array(
+        "0" => "短信发送成功",
+        "-1" => "参数不全",
+        "-2" => "服务器空间不支持,请确认支持curl或者fsocket,联系您的空间商解决或者更换空间!",
+        "30" => "密码错误",
+        "40" => "账号不存在",
+        "41" => "余额不足",
+        "42" => "帐户已过期",
+        "43" => "IP地址限制",
+        "50" => "内容含有敏感词"
+    );
+
+
+    /**
+     * Aliyunsms constructor.
+     * @param array $options
+     * @throws \AlibabaCloud\Client\Exception\ClientException
+     */
+    public function __construct($options = [])
+    {
+        if ($config = get_addon_config('aliyunsms')) {
+            $this->config = array_merge($this->config, $config);
+        }
+        $this->config = array_merge($this->config, is_array($options) ? $options : []);
+        AlibabaCloud::accessKeyClient($this->config['accessKeyId'], $this->config['accessSecret'])
+            ->regionId($this->config['regionId'])
+            ->asDefaultClient();
+    }
+
+    /**
+     * 单例
+     * @param array $options 参数
+     * @return Aliyunsms
+     */
+    public static function instance($options = [])
+    {
+        if (is_null(self::$instance)) {
+            self::$instance = new static($options);
+        }
+        return self::$instance;
+    }
+
+    /**
+     * 立即发送短信
+     *
+     * @return boolean
+     * @throws \AlibabaCloud\Client\Exception\ClientException
+     * @throws \AlibabaCloud\Client\Exception\ServerException
+     */
+    public function send()
+    {
+        $this->error = '';
+        $params = $this->_params();
+
+
+        $params_post = [
+            'code' => $params['msg']
+        ];
+
+
+        $result = AlibabaCloud::rpc()
+            ->product('Dysmsapi')
+            // ->scheme('https') // https | http
+            ->version('2017-05-25')
+            ->action('SendSms')
+            ->method('POST')
+            ->host('dysmsapi.aliyuncs.com')
+            ->options([
+                'query' => [
+                    'RegionId' => $this->config['regionId'],
+                    'PhoneNumbers' => $this->_params['mobile'],
+                    'SignName' => $this->config['signName'],
+                    'TemplateCode' => $this->config['templateCode'],
+                    'TemplateParam' => json_encode($params_post),
+                ],
+            ])
+            ->request();
+
+        $result = $result->toArray();
+
+        if ($result['Code'] == "OK") {
+            return true;
+        } else {
+            $this->error = $result['Message'];
+        }
+        return false;
+    }
+
+    private function _params()
+    {
+        return $this->_params;
+    }
+
+    /**
+     * 获取错误信息
+     * @return string
+     */
+    public function getError()
+    {
+        return $this->error;
+    }
+
+    /**
+     * 接收手机
+     * @param string $mobile 手机号码
+     * @return Aliyunsms
+     */
+    public function mobile($mobile = '')
+    {
+        $this->_params['mobile'] = $mobile;
+        return $this;
+    }
+
+    /**
+     * 短信内容
+     * @param string $msg 短信内容
+     * @return Aliyunsms
+     */
+    public function msg($msg = '')
+    {
+        $this->_params['msg'] = $msg;
+        return $this;
+    }
+}

+ 1 - 0
addons/command/.addonrc

@@ -0,0 +1 @@
+{"license":"regular","licenseto":"39487","licensekey":"YNMEsQF8LKjIvDAt sEV\/cCS3B6Y8jnDDnvhNww==","menus":["command","command\/index","command\/add","command\/detail","command\/execute","command\/del","command\/multi"],"files":["application\\admin\\controller\\Command.php","application\\admin\\lang\\zh-cn\\command.php","application\\admin\\model\\Command.php","application\\admin\\validate\\Command.php","application\\admin\\view\\command\\add.html","application\\admin\\view\\command\\detail.html","application\\admin\\view\\command\\index.html","public\\assets\\js\\backend\\command.js"]}

+ 69 - 0
addons/command/Command.php

@@ -0,0 +1,69 @@
+<?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/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 = 可在线执行FastAdmin的命令行相关命令
+author = Karson
+website = https://www.fastadmin.net
+version = 1.0.6
+state = 1
+url = /addons/command
+license = regular
+licenseto = 39487

+ 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` int(10) UNSIGNED DEFAULT NULL COMMENT '执行时间',
+  `createtime` int(10) UNSIGNED DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) 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/qcloudsms/.addonrc

@@ -0,0 +1 @@
+{"license":"regular","licenseto":"39487","licensekey":"5WZLFIqPHk1nj4eV JH\/3+YtThUj0D3m9ql7miJOU5jsrrPnvHW0bIVnEydE="}

+ 197 - 0
addons/qcloudsms/Qcloudsms.php

@@ -0,0 +1,197 @@
+<?php
+
+namespace addons\qcloudsms;
+
+use addons\qcloudsms\library\SmsSingleSender;
+use addons\qcloudsms\library\SmsVoicePromptSender;
+use addons\qcloudsms\library\SmsVoiceverifyCodeSender;
+use addons\qcloudsms\library\TtsVoiceSender;
+use think\Addons;
+use think\Config;
+
+/**
+ * 插件
+ */
+class Qcloudsms extends Addons
+{
+    private $appid = null;
+    private $appkey = null;
+    private $config = null;
+    private $sender = null;
+    private $sendError = '';
+
+    public function ConfigInit()
+    {
+        $this->config = $this->getConfig();
+        //如果使用语音短信  更换成语音短信模板
+        if ($this->config['isVoice'] == 1) {
+            $this->config['template'] = $this->config['voiceTemplate'];
+            //语音短信 需要另行设置Aappid 与Appkey
+            $this->appid = $this->config['voiceAppid'];
+            $this->appkey = $this->config['voiceAppkey'];
+        } else {
+            $this->appid = $this->config['appid'];
+            $this->appkey = $this->config['appkey'];
+        }
+    }
+
+    /**
+     * 短信发送行为
+     * @param Sms $params
+     * @return  boolean
+     */
+    public function smsSend(&$params)
+    {
+        $this->ConfigInit();
+        try {
+            if ($this->config['isTemplateSender'] == 1) {
+                $templateID = $this->config['template'][$params->event];
+                if ($this->config['isVoice'] != 1) {
+                    //普通短信发送
+                    $this->sender = new SmsSingleSender($this->appid, $this->appkey);
+                    $result = $this->sender->sendWithParam("86", $params['mobile'], $templateID, ["{$params->code}"], $this->config['sign'], "", "");
+                } else {
+                    //语音短信发送
+                    $this->sender = new TtsVoiceSender($this->appid, $this->appkey);
+                    //参数: 国家码,手机号、模板ID、模板参数、播放次数(可选字段)、用户的session内容,服务器端原样返回(可选字段)
+                    $result = $this->sender->send("86", $params['mobile'], $templateID, [$params->code]);
+                }
+            } else {
+                //判断是否是语音短信
+                if ($this->config['isVoice'] != 1) {
+                    $this->sender = new SmsSingleSender($this->appid, $this->appkey);
+                    //参数:短信类型{1营销短信,0普通短信 }、国家码、手机号、短信内容、扩展码(可留空)、服务的原样返回的参数
+                    $result = $this->sender->send($params['type'], '86', $params['mobile'], $params['msg'], "", "");
+                } else {
+                    $this->sender = new SmsVoiceVerifyCodeSender($this->appid, $this->appkey);
+                    //参数:国家码、手机号、短信内容、播放次数(默认2次)、服务的原样返回的参数
+                    $result = $this->sender->send('86', $params['mobile'], $params['msg']);
+                }
+            }
+
+            $rsp = json_decode($result, true);
+            if ($rsp['result'] == 0 && $rsp['errmsg'] == 'OK') {
+                return true;
+            } else {
+                //记录错误信息
+                $this->setError($rsp);
+                return false;
+            }
+        } catch (\Exception $e) {
+            $this->setError($e->getMessage());
+        }
+        return false;
+    }
+
+    /**
+     * 短信发送通知
+     * @param array $params
+     * @return  boolean
+     */
+    public function smsNotice(&$params)
+    {
+        $this->ConfigInit();
+        try {
+            if ($this->config['isTemplateSender'] == 1) {
+                $templateID = $this->config['template'][$params['template']];
+
+                if ($this->config['isVoice'] != 1) {
+                    //普通短信发送
+                    $this->sender = new SmsSingleSender($this->appid, $this->appkey);
+                    $result = $this->sender->sendWithParam("86", $params['mobile'], $templateID, ["{$params['msg']}"], $this->config['sign'], "", "");
+                } else {
+                    //语音短信发送
+                    $this->sender = new TtsVoiceSender($this->appid, $this->appkey);
+                    //参数: 国家码,手机号、模板ID、模板参数、播放次数(可选字段)、用户的session内容,服务器端原样返回(可选字段)
+                    $result = $this->sender->send("86", $params['mobile'], $templateID, [$params['msg']]);
+                }
+            } else {
+                //判断是否是语音短信
+                if ($this->config['isVoice'] != 1) {
+                    $this->sender = new SmsSingleSender($this->appid, $this->appkey);
+                    //参数:短信类型{1营销短信,0普通短信 }、国家码、手机号、短信内容、扩展码(可留空)、服务的原样返回的参数
+                    $result = $this->sender->send($params['type'], '86', $params['mobile'], $params['msg'], "", "");
+                } else {
+                    $this->sender = new SmsVoicePromptSender($this->appid, $this->appkey);
+                    //参数:国家码、手机号、语音类型(目前固定为2)、短信内容、播放次数(默认2次)、服务的原样返回的参数
+                    $result = $this->sender->send('86', $params['mobile'], 2, $params['msg']);
+                }
+            }
+            $rsp = (array)json_decode($result, true);
+            if ($rsp['result'] == 0 && $rsp['errmsg'] == 'OK') {
+                return true;
+            } else {
+                //记录错误信息
+                $this->setError($rsp);
+                return false;
+            }
+        } catch (\Exception $e) {
+            var_dump($e);
+            exit();
+        }
+    }
+
+    /**
+     * 记录失败信息
+     * @param [type] $err [description]
+     */
+    private function setError($err)
+    {
+        $this->sendError = $err;
+    }
+
+    /**
+     * 获取失败信息
+     * @return [type] [description]
+     */
+    public function getError()
+    {
+        return $this->sendError;
+    }
+
+    /**
+     * 检测验证是否正确
+     * @param Sms $params
+     * @return  boolean
+     */
+    public function smsCheck(&$params)
+    {
+        return true;
+    }
+
+    /**
+     * 插件安装方法
+     * @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;
+    }
+}

+ 128 - 0
addons/qcloudsms/config.php

@@ -0,0 +1,128 @@
+<?php
+
+return [
+    [
+        'name' => 'appid',
+        'title' => '应用AppID',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdas',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'appkey',
+        'title' => '应用AppKEY',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'dasdasd',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'voiceAppid',
+        'title' => '语音短信AppID',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasd',
+        'rule' => 'required',
+        'msg' => '使用语音短信必须设置',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'voiceAppkey',
+        'title' => '语音短信AppKEY',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'asdasd',
+        'rule' => 'required',
+        'msg' => '使用语音短信必须设置',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'sign',
+        'title' => '签名',
+        'type' => 'string',
+        'content' => [],
+        'value' => 'your sign',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'isVoice',
+        'title' => '是否使用语音短信',
+        'type' => 'radio',
+        'content' => [
+            '否',
+            '是',
+        ],
+        'value' => '0',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'isTemplateSender',
+        'title' => '是否使用短信模板发送',
+        'type' => 'radio',
+        'content' => [
+            '否',
+            '是',
+        ],
+        'value' => '1',
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'template',
+        'title' => '短信模板',
+        'type' => 'array',
+        'content' => [],
+        'value' => [
+            'register' => '',
+            'resetpwd' => '',
+            'changepwd' => '',
+            'profile' => '',
+        ],
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+    [
+        'name' => 'voiceTemplate',
+        'title' => '语音短信模板',
+        'type' => 'array',
+        'content' => [],
+        'value' => [
+            'register' => '',
+            'resetpwd' => '',
+            'changepwd' => '',
+            'profile' => '',
+        ],
+        'rule' => 'required',
+        'msg' => '',
+        'tip' => '',
+        'ok' => '',
+        'extend' => '',
+    ],
+];

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

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

+ 10 - 0
addons/qcloudsms/info.ini

@@ -0,0 +1,10 @@
+name = qcloudsms
+title = 腾讯云短信发送插件
+intro = 腾讯云短信发送插件
+author = Seacent
+website = https://www.seacent.com
+version = 1.0.3
+state = 0
+url = /addons/qcloudsms
+license = regular
+licenseto = 39487

+ 69 - 0
addons/qcloudsms/library/FileVoiceSender.php

@@ -0,0 +1,69 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+
+/**
+ * 按语音文件fid发送语音通知类
+ *
+ */
+class FileVoiceSender
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://cloud.tim.qq.com/v5/tlsvoicesvr/sendfvoice";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     *
+     * 按语音文件fid发送语音通知
+     *
+     * @param string $nationCode  国家码,如 86 为中国
+     * @param string $phoneNumber 不带国家码的手机号
+     * @param string $fid         语音文件fid
+     * @param string $playtimes   播放次数,可选,最多3次,默认2次
+     * @param string $ext         用户的session内容,服务端原样返回,可选字段,不需要可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function send($nationCode, $phoneNumber, $fid, $playtimes = 2, $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        // 按照协议组织 post 包体
+        $data = new \stdClass();
+        $tel = new \stdClass();
+        $tel->nationcode = "".$nationCode;
+        $tel->mobile = "".$phoneNumber;
+        $data->tel = $tel;
+        $data->fid = $fid;
+        $data->playtimes = $playtimes;
+
+        // app凭证
+        $data->sig = $this->util->calculateSig($this->appkey, $random,
+            $curTime, array($phoneNumber));
+
+        // unix时间戳,请求发起时间,如果和系统时间相差超过10分钟则会返回失败
+        $data->time = $curTime;
+        $data->ext = $ext;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+}

+ 91 - 0
addons/qcloudsms/library/SmsMobileStatusPuller.php

@@ -0,0 +1,91 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+/**
+ * 拉取单个手机短信状态类
+ *
+ */
+class SmsMobileStatusPuller
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://yun.tim.qq.com/v5/tlssmssvr/pullstatus4mobile";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     * 拉取回执结果
+     *
+     * @param int    $type         拉取类型,0表示回执结果,1表示回复信息
+     * @param string $nationCode   国家码,如 86 为中国
+     * @param string $mobile       不带国家码的手机号
+     * @param int    $beginTime    开始时间(unix timestamp)
+     * @param int    $endTime      结束时间(unix timestamp)
+     * @param int    $max          拉取最大条数,最多100
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    private function pull($type, $nationCode, $mobile, $beginTime, $endTime, $max)
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        $data = new \stdClass();
+        $data->sig = $this->util->calculateSigForPuller($this->appkey, $random, $curTime);
+        $data->time = $curTime;
+        $data->type = $type;
+        $data->max = $max;
+        $data->begin_time = $beginTime;
+        $data->end_time = $endTime;
+        $data->nationcode = $nationCode;
+        $data->mobile = $mobile;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+
+    /**
+     * 拉取回执结果
+     *
+     * @param string $nationCode   国家码,如 86 为中国
+     * @param string $mobile       不带国家码的手机号
+     * @param int    $beginTime    开始时间(unix timestamp)
+     * @param int    $endTime      结束时间(unix timestamp)
+     * @param int    $max          拉取最大条数,最多100
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function pullCallback($nationCode, $mobile, $beginTime, $endTime, $max)
+    {
+        return $this->pull(0, $nationCode, $mobile, $beginTime, $endTime, $max);
+    }
+
+    /**
+     * 拉取回复信息
+     *
+     * @param string $nationCode   国家码,如 86 为中国
+     * @param string $mobile       不带国家码的手机号
+     * @param int    $beginTime    开始时间(unix timestamp)
+     * @param int    $endTime      结束时间(unix timestamp)
+     * @param int    $max          拉取最大条数,最多100
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function pullReply($nationCode, $mobile, $beginTime, $endTime, $max)
+    {
+        return $this->pull(1, $nationCode, $mobile, $beginTime, $endTime, $max);
+    }
+}

+ 99 - 0
addons/qcloudsms/library/SmsMultiSender.php

@@ -0,0 +1,99 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+/**
+ * 群发短信类
+ *
+ */
+class SmsMultiSender
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://yun.tim.qq.com/v5/tlssmssvr/sendmultisms2";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     * 普通群发
+     *
+     * 普通群发需明确指定内容,如果有多个签名,请在内容中以【】的方式添加到信息内容中,
+     * 否则系统将使用默认签名。
+     *
+     *
+     * @param int    $type         短信类型,0 为普通短信,1 营销短信
+     * @param string $nationCode   国家码,如 86 为中国
+     * @param array  $phoneNumbers 不带国家码的手机号列表
+     * @param string $msg          信息内容,必须与申请的模板格式一致,否则将返回错误
+     * @param string $extend       扩展码,可填空串
+     * @param string $ext          服务端原样返回的参数,可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function send($type, $nationCode, $phoneNumbers, $msg, $extend = "", $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        $data = new \stdClass();
+        $data->tel = $this->util->phoneNumbersToArray($nationCode, $phoneNumbers);
+        $data->type = $type;
+        $data->msg = $msg;
+        $data->sig = $this->util->calculateSig($this->appkey, $random,
+            $curTime, $phoneNumbers);
+        $data->time = $curTime;
+        $data->extend = $extend;
+        $data->ext = $ext;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+
+    /**
+     * 指定模板群发
+     *
+     *
+     * @param  string $nationCode   国家码,如 86 为中国
+     * @param  array  $phoneNumbers 不带国家码的手机号列表
+     * @param  int    $templId      模板id
+     * @param  array  $params       模板参数列表,如模板 {1}...{2}...{3},那么需要带三个参数
+     * @param  string $sign         签名,如果填空串,系统会使用默认签名
+     * @param  string $extend       扩展码,可填空串
+     * @param  string $ext          服务端原样返回的参数,可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function sendWithParam($nationCode, $phoneNumbers, $templId, $params,
+        $sign = "", $extend = "", $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        $data = new \stdClass();
+        $data->tel = $this->util->phoneNumbersToArray($nationCode, $phoneNumbers);
+        $data->sign = $sign;
+        $data->tpl_id = $templId;
+        $data->params = $params;
+        $data->sig = $this->util->calculateSigForTemplAndPhoneNumbers(
+            $this->appkey, $random, $curTime, $phoneNumbers);
+        $data->time = $curTime;
+        $data->extend = $extend;
+        $data->ext = $ext;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+}

+ 211 - 0
addons/qcloudsms/library/SmsSenderUtil.php

@@ -0,0 +1,211 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+/**
+ * 发送Util类
+ *
+ */
+class SmsSenderUtil
+{
+    /**
+     * 生成随机数
+     *
+     * @return int 随机数结果
+     */
+    public function getRandom()
+    {
+        return rand(100000, 999999);
+    }
+
+    /**
+     * 生成签名
+     *
+     * @param string $appkey        sdkappid对应的appkey
+     * @param string $random        随机正整数
+     * @param string $curTime       当前时间
+     * @param array  $phoneNumbers  手机号码
+     * @return string  签名结果
+     */
+    public function calculateSig($appkey, $random, $curTime, $phoneNumbers)
+    {
+        $phoneNumbersString = $phoneNumbers[0];
+        for ($i = 1; $i < count($phoneNumbers); $i++) {
+            $phoneNumbersString .= ("," . $phoneNumbers[$i]);
+        }
+
+        return hash("sha256", "appkey=".$appkey."&random=".$random
+            ."&time=".$curTime."&mobile=".$phoneNumbersString);
+    }
+
+    /**
+     * 生成签名
+     *
+     * @param string $appkey        sdkappid对应的appkey
+     * @param string $random        随机正整数
+     * @param string $curTime       当前时间
+     * @param array  $phoneNumbers  手机号码
+     * @return string  签名结果
+     */
+    public function calculateSigForTemplAndPhoneNumbers($appkey, $random,
+        $curTime, $phoneNumbers)
+    {
+        $phoneNumbersString = $phoneNumbers[0];
+        for ($i = 1; $i < count($phoneNumbers); $i++) {
+            $phoneNumbersString .= ("," . $phoneNumbers[$i]);
+        }
+
+        return hash("sha256", "appkey=".$appkey."&random=".$random
+            ."&time=".$curTime."&mobile=".$phoneNumbersString);
+    }
+
+    public function phoneNumbersToArray($nationCode, $phoneNumbers)
+    {
+        $i = 0;
+        $tel = array();
+        do {
+            $telElement = new \stdClass();
+            $telElement->nationcode = $nationCode;
+            $telElement->mobile = $phoneNumbers[$i];
+            array_push($tel, $telElement);
+        } while (++$i < count($phoneNumbers));
+
+        return $tel;
+    }
+
+    /**
+     * 生成签名
+     *
+     * @param string $appkey        sdkappid对应的appkey
+     * @param string $random        随机正整数
+     * @param string $curTime       当前时间
+     * @param array  $phoneNumber   手机号码
+     * @return string  签名结果
+     */
+    public function calculateSigForTempl($appkey, $random, $curTime, $phoneNumber)
+    {
+        $phoneNumbers = array($phoneNumber);
+
+        return $this->calculateSigForTemplAndPhoneNumbers($appkey, $random,
+            $curTime, $phoneNumbers);
+    }
+
+    /**
+     * 生成签名
+     *
+     * @param string $appkey        sdkappid对应的appkey
+     * @param string $random        随机正整数
+     * @param string $curTime       当前时间
+     * @return string 签名结果
+     */
+    public function calculateSigForPuller($appkey, $random, $curTime)
+    {
+        return hash("sha256", "appkey=".$appkey."&random=".$random
+            ."&time=".$curTime);
+    }
+
+    /**
+     * 生成上传文件授权
+     *
+     * @param string $appkey        sdkappid对应的appkey
+     * @param string $random        随机正整数
+     * @param string $curTime       当前时间
+     * @param array  $fileSha1Sum   文件sha1sum
+     * @return string  授权结果
+     */
+    public function calculateAuth($appkey, $random, $curTime, $fileSha1Sum)
+    {
+        return hash("sha256", "appkey=".$appkey."&random=".$random
+            ."&time=".$curTime."&content-sha1=".$fileSha1Sum);
+    }
+
+    /**
+     * 生成sha1sum
+     *
+     * @param string $content  内容
+     * @return string  内容sha1散列值
+     */
+    public function sha1sum($content)
+    {
+        return hash("sha1", $content);
+    }
+
+    /**
+     * 发送请求
+     *
+     * @param string $url      请求地址
+     * @param array  $dataObj  请求内容
+     * @return string 应答json字符串
+     */
+    public function sendCurlPost($url, $dataObj)
+    {
+        $curl = curl_init();
+        curl_setopt($curl, CURLOPT_URL, $url);
+        curl_setopt($curl, CURLOPT_HEADER, 0);
+        curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
+        curl_setopt($curl, CURLOPT_POST, 1);
+        curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 60);
+        curl_setopt($curl, CURLOPT_POSTFIELDS, json_encode($dataObj));
+        curl_setopt($curl, CURLOPT_HTTPHEADER, array(
+        'Content-Type: application/json; charset=utf-8',
+        'Content-Length: ' . strlen(json_encode($dataObj)))
+    );
+        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
+
+        $ret = curl_exec($curl);
+        if (false == $ret) {
+            // curl_exec failed
+            $result = "{ \"result\":" . -2 . ",\"errmsg\":\"" . curl_error($curl) . "\"}";
+        } else {
+            $rsp = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+            if (200 != $rsp) {
+                $result = "{ \"result\":" . -1 . ",\"errmsg\":\"". $rsp
+                        . " " . curl_error($curl) ."\"}";
+            } else {
+                $result = $ret;
+            }
+        }
+
+        curl_close($curl);
+
+        return $result;
+    }
+
+    /**
+     * 发送请求
+     *
+     * @param string $req  请求对象
+     * @return string 应答json字符串
+     */
+    public function fetch($req)
+    {
+        $curl = curl_init();
+
+        curl_setopt($curl, CURLOPT_URL, $req->url);
+        curl_setopt($curl, CURLOPT_HTTPHEADER, $req->headers);
+        curl_setopt($curl, CURLOPT_POSTFIELDS, $req->body);
+        curl_setopt($curl, CURLOPT_HEADER, 0);
+        curl_setopt($curl, CURLOPT_RETURNTRANSFER, 1);
+        curl_setopt($curl, CURLOPT_POST, 1);
+        curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 60);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 0);
+        curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 0);
+
+        $result = curl_exec($curl);
+
+        if (false == $result) {
+            // curl_exec failed
+            $result = "{ \"result\":" . -2 . ",\"errmsg\":\"" . curl_error($curl) . "\"}";
+        } else {
+            $code = curl_getinfo($curl, CURLINFO_HTTP_CODE);
+            if (200 != $code) {
+                $result = "{ \"result\":" . -1 . ",\"errmsg\":\"". $rsp
+                    . " " . curl_error($curl) ."\"}";
+            }
+        }
+        curl_close($curl);
+
+        return $result;
+    }
+}

+ 107 - 0
addons/qcloudsms/library/SmsSingleSender.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+/**
+ * 单发短信类
+ *
+ */
+class SmsSingleSender
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://yun.tim.qq.com/v5/tlssmssvr/sendsms";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     * 普通单发
+     *
+     * 普通单发需明确指定内容,如果有多个签名,请在内容中以【】的方式添加到信息内容中,否则系统将使用默认签名。
+     *
+     * @param int    $type        短信类型,0 为普通短信,1 营销短信
+     * @param string $nationCode  国家码,如 86 为中国
+     * @param string $phoneNumber 不带国家码的手机号
+     * @param string $msg         信息内容,必须与申请的模板格式一致,否则将返回错误
+     * @param string $extend      扩展码,可填空串
+     * @param string $ext         服务端原样返回的参数,可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function send($type, $nationCode, $phoneNumber, $msg, $extend = "", $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        // 按照协议组织 post 包体
+        $data = new \stdClass();
+        $tel = new \stdClass();
+        $tel->nationcode = "".$nationCode;
+        $tel->mobile = "".$phoneNumber;
+
+        $data->tel = $tel;
+        $data->type = (int)$type;
+        $data->msg = $msg;
+        $data->sig = hash("sha256",
+            "appkey=".$this->appkey."&random=".$random."&time="
+            .$curTime."&mobile=".$phoneNumber, FALSE);
+        $data->time = $curTime;
+        $data->extend = $extend;
+        $data->ext = $ext;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+
+    /**
+     * 指定模板单发
+     *
+     * @param string $nationCode  国家码,如 86 为中国
+     * @param string $phoneNumber 不带国家码的手机号
+     * @param int    $templId     模板 id
+     * @param array  $params      模板参数列表,如模板 {1}...{2}...{3},那么需要带三个参数
+     * @param string $sign        签名,如果填空串,系统会使用默认签名
+     * @param string $extend      扩展码,可填空串
+     * @param string $ext         服务端原样返回的参数,可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function sendWithParam($nationCode, $phoneNumber, $templId = 0, $params,
+        $sign = "", $extend = "", $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        // 按照协议组织 post 包体
+        $data = new \stdClass();
+        $tel = new \stdClass();
+        $tel->nationcode = "".$nationCode;
+        $tel->mobile = "".$phoneNumber;
+
+        $data->tel = $tel;
+        $data->sig = $this->util->calculateSigForTempl($this->appkey, $random,
+            $curTime, $phoneNumber);
+        $data->tpl_id = $templId;
+        $data->params = $params;
+        $data->sign = $sign;
+        $data->time = $curTime;
+        $data->extend = $extend;
+        $data->ext = $ext;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+}

+ 75 - 0
addons/qcloudsms/library/SmsStatusPuller.php

@@ -0,0 +1,75 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+/**
+ * 拉取短信状态类
+ *
+ */
+class SmsStatusPuller
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://yun.tim.qq.com/v5/tlssmssvr/pullstatus";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     * 拉取回执结果
+     *
+     * @param int $type 拉取类型,0表示回执结果,1表示回复信息
+     * @param int $max  最大条数,最多100
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    private function pull($type, $max)
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        $data = new \stdClass();
+        $data->sig = $this->util->calculateSigForPuller($this->appkey, $random, $curTime);
+        $data->time = $curTime;
+        $data->type = $type;
+        $data->max = $max;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+
+    /**
+     * 拉取回执结果
+     *
+     * @param int $max 拉取最大条数,最多100
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function pullCallback($max)
+    {
+        return $this->pull(0, $max);
+    }
+
+    /**
+     * 拉取回复信息
+     *
+     * @param int $max 拉取最大条数,最多100
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function pullReply($max)
+    {
+        return $this->pull(1, $max);
+    }
+}

+ 71 - 0
addons/qcloudsms/library/SmsVoicePromptSender.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+/**
+ * 发送语音通知类
+ *
+ */
+class SmsVoicePromptSender
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://yun.tim.qq.com/v5/tlsvoicesvr/sendvoiceprompt";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     *
+     * 发送语音通知
+     *
+     * @param string $nationCode  国家码,如 86 为中国
+     * @param string $phoneNumber 不带国家码的手机号
+     * @param string $prompttype  语音类型,目前固定为2
+     * @param string $msg         信息内容,必须与申请的模板格式一致,否则将返回错误
+     * @param string $playtimes   播放次数,可选,最多3次,默认2次
+     * @param string $ext         用户的session内容,服务端原样返回,可选字段,不需要可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function send($nationCode, $phoneNumber, $prompttype, $msg, $playtimes = 2, $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        // 按照协议组织 post 包体
+        $data = new \stdClass();
+        $tel = new \stdClass();
+        $tel->nationcode = "".$nationCode;
+        $tel->mobile = "".$phoneNumber;
+
+        $data->tel = $tel;
+        // 通知内容,utf8编码,支持中文英文、数字及组合,需要和语音内容模版相匹配
+        $data->promptfile = $msg;
+        // 固定值 2
+        $data->prompttype = $prompttype;
+        $data->playtimes = $playtimes;
+        // app凭证
+        $data->sig = hash("sha256",
+            "appkey=".$this->appkey."&random=".$random."&time="
+            .$curTime."&mobile=".$phoneNumber, FALSE);
+        // unix时间戳,请求发起时间,如果和系统时间相差超过10分钟则会返回失败
+        $data->time = $curTime;
+        $data->ext = $ext;
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+}

+ 67 - 0
addons/qcloudsms/library/SmsVoiceVerifyCodeSender.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+/**
+ * 发送语音验证码类
+ *
+ */
+class SmsVoiceVerifyCodeSender
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://yun.tim.qq.com/v5/tlsvoicesvr/sendvoice";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     * 发送语音验证码
+     *
+     * @param string $nationCode  国家码,如 86 为中国
+     * @param string $phoneNumber 不带国家码的手机号
+     * @param string $msg         信息内容,必须与申请的模板格式一致,否则将返回错误
+     * @param int    $playtimes   播放次数,可选,最多3次,默认2次
+     * @param string $ext         用户的session内容,服务端原样返回,可选字段,不需要可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function send($nationCode, $phoneNumber, $msg, $playtimes = 2, $ext = "")
+    {
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        // 按照协议组织 post 包体
+        $data = new \stdClass();
+        $tel = new \stdClass();
+        $tel->nationcode = "".$nationCode;
+        $tel->mobile = "".$phoneNumber;
+
+        $data->tel = $tel;
+        $data->msg = $msg;
+        $data->playtimes = $playtimes;
+        // app凭证
+        $data->sig = hash("sha256",
+            "appkey=".$this->appkey."&random=".$random."&time="
+            .$curTime."&mobile=".$phoneNumber, FALSE);
+        // unix时间戳,请求发起时间,如果和系统时间相差超过10分钟则会返回失败
+        $data->time = $curTime;
+        $data->ext = $ext;
+
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+}

+ 77 - 0
addons/qcloudsms/library/TtsVoiceSender.php

@@ -0,0 +1,77 @@
+<?php
+
+namespace addons\qcloudsms\library;
+
+use addons\qcloudsms\library\SmsSenderUtil;
+
+
+/**
+ * 指定模板发送语音通知类
+ *
+ */
+class TtsVoiceSender
+{
+    private $url;
+    private $appid;
+    private $appkey;
+    private $util;
+
+    /**
+     * 构造函数
+     *
+     * @param string $appid  sdkappid
+     * @param string $appkey sdkappid对应的appkey
+     */
+    public function __construct($appid, $appkey)
+    {
+        $this->url = "https://cloud.tim.qq.com/v5/tlsvoicesvr/sendtvoice";
+        $this->appid =  $appid;
+        $this->appkey = $appkey;
+        $this->util = new SmsSenderUtil();
+    }
+
+    /**
+     *
+     * 指定模板发送语音短信
+     *
+     * @param string $nationCode  国家码,如 86 为中国
+     * @param string $phoneNumber 不带国家码的手机号
+     * @param int    $templId     模板 id
+     * @param array  $params      模板参数列表,如模板 {1}...{2}...{3},需要带三个参数
+     * @param string $playtimes   播放次数,可选,最多3次,默认2次
+     * @param string $ext         用户的session内容,服务端原样返回,可选字段,不需要可填空串
+     * @return string 应答json字符串,详细内容参见腾讯云协议文档
+     */
+    public function send($nationCode, $phoneNumber, $templId, $params, $playtimes = 2, $ext = "")
+    {
+        /*var_dump($nationCode);
+        var_dump($phoneNumber);
+        var_dump($templId);
+        var_dump($params);
+        var_dump($playtimes);
+        exit();*/
+        $random = $this->util->getRandom();
+        $curTime = time();
+        $wholeUrl = $this->url . "?sdkappid=" . $this->appid . "&random=" . $random;
+
+        // 按照协议组织 post 包体
+        $data = new \stdClass();
+        $tel = new \stdClass();
+        $tel->nationcode = "".$nationCode;
+        $tel->mobile = "".$phoneNumber;
+        $data->tel = $tel;
+        $data->tpl_id = $templId;
+        $data->params = $params;
+        $data->playtimes = $playtimes;
+
+        // app凭证
+        $data->sig = $this->util->calculateSig($this->appkey, $random,
+            $curTime, array($phoneNumber));
+
+        // unix时间戳,请求发起时间,如果和系统时间相差超过10分钟则会返回失败
+        $data->time = $curTime;
+        $data->ext = $ext;
+        //var_dump($data);exit();
+        return $this->util->sendCurlPost($wholeUrl, $data);
+    }
+}

+ 1 - 0
addons/simditor/.addonrc

@@ -0,0 +1 @@
+{"license":"regular","licenseto":"39487","licensekey":"sNyrSchJ0XT49QMa dk0+rvAuiKzRZP8jTxi7Nw==","files":["public\\assets\\addons\\simditor\\css\\simditor.min.css","public\\assets\\addons\\simditor\\images\\image.png","public\\assets\\addons\\simditor\\js\\simditor.min.js"]}

+ 31 - 0
addons/simditor/Simditor.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace addons\simditor;
+
+use think\Addons;
+
+/**
+ * 插件
+ */
+class Simditor extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        return true;
+    }
+
+}

+ 48 - 0
addons/simditor/bootstrap.js

@@ -0,0 +1,48 @@
+require.config({
+    paths: {
+        'simditor': '../addons/simditor/js/simditor.min',
+    },
+    shim: {
+        'simditor': [
+            'css!../addons/simditor/css/simditor.min.css'
+        ]
+    }
+});
+require(['form'], function (Form) {
+    var _bindevent = Form.events.bindevent;
+    Form.events.bindevent = function (form) {
+        _bindevent.apply(this, [form]);
+        if ($(".editor", form).size() > 0) {
+            //修改上传的接口调用
+            require(['upload', 'simditor'], function (Upload, Simditor) {
+                var editor, mobileToolbar, toolbar;
+                Simditor.locale = 'zh-CN';
+                Simditor.list = {};
+                toolbar = ['title', 'bold', 'italic', 'underline', 'strikethrough', 'fontScale', 'color', '|', 'ol', 'ul', 'blockquote', 'code', 'table', '|', 'link', 'image', 'hr', '|', 'indent', 'outdent', 'alignment'];
+                mobileToolbar = ["bold", "underline", "strikethrough", "color", "ul", "ol"];
+                $(".editor", form).each(function () {
+                    var id = $(this).attr("id");
+                    editor = new Simditor({
+                        textarea: this,
+                        toolbarFloat: false,
+                        toolbar: toolbar,
+                        pasteImage: true,
+                        defaultImage: Config.__CDN__ + '/assets/addons/simditor/images/image.png',
+                        upload: {url: '/'}
+                    });
+                    editor.uploader.on('beforeupload', function (e, file) {
+                        Upload.api.send(file.obj, function (data) {
+                            var url = Fast.api.cdnurl(data.url);
+                            editor.uploader.trigger("uploadsuccess", [file, {success: true, file_path: url}]);
+                        });
+                        return false;
+                    });
+                    editor.on("blur", function () {
+                        this.textarea.trigger("blur");
+                    });
+                    Simditor.list[id] = editor;
+                });
+            });
+        }
+    }
+});

+ 4 - 0
addons/simditor/build/build.sh

@@ -0,0 +1,4 @@
+#!/bin/bash
+
+/usr/local/bin/node r.js -o ./js.js name=simditor baseUrl=../src/js out=../assets/js/simditor.min.js
+/usr/local/bin/node r.js -o ./css.js cssIn=../src/css/simditor.css out=../assets/css/simditor.min.css

+ 4 - 0
addons/simditor/build/css.js

@@ -0,0 +1,4 @@
+({
+  optimizeCss: "default",
+  optimize: "uglify"
+})

+ 10 - 0
addons/simditor/build/js.js

@@ -0,0 +1,10 @@
+({
+    name: "simditor",
+    paths: {
+        'jquery': 'empty:',
+        'simditor': 'simditor',
+        'simple-module': 'module',
+        'simple-uploader': 'uploader',
+        'simple-hotkeys': 'hotkeys',
+    },
+});

Diff do ficheiro suprimidas por serem muito extensas
+ 4699 - 0
addons/simditor/build/r.js


+ 4 - 0
addons/simditor/config.php

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

+ 10 - 0
addons/simditor/info.ini

@@ -0,0 +1,10 @@
+name = simditor
+title = Simditor
+intro = 简洁清晰的富文本插件
+author = Karson
+website = http://www.fastadmin.net
+version = 1.0.5
+state = 1
+url = /addons/simditor
+license = regular
+licenseto = 39487

+ 460 - 0
addons/simditor/src/css/mobile.css

@@ -0,0 +1,460 @@
+@media screen and (max-device-width: 240px) and (min-device-width: 220px) {
+  body {
+    width: 240px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 80px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 80px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 320px) and (min-device-width: 300px) {
+  body {
+    width: 320px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 80px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 80px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 360px) and (min-device-width: 340px) {
+  body {
+    width: 360px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 80px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 80px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 480px) and (min-device-width: 460px) {
+  body {
+    width: 480px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 80px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 80px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 640px) and (min-device-width: 620px) {
+  body {
+    width: 320px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 80px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 80px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 720px) and (min-device-width: 700px) {
+  body {
+    width: 360px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 80px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 80px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 800px) and (min-device-width: 780px) {
+  body {
+    width: 400px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 88.8888888889px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 88.8888888889px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 960px) and (min-device-width: 940px) {
+  body {
+    width: 480px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 100px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 100px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 1024px) and (min-device-width: 1004px) {
+  body {
+    width: 512px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 100px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 100px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (max-device-width: 1280px) and (min-device-width: 1260px) {
+  body {
+    width: 640px;
+    margin: 0 auto;
+  }
+  body .wrapper {
+    width: 100%;
+  }
+  body .wrapper header {
+    padding: 30px 0 20px;
+  }
+  body .wrapper header h1 {
+    background-size: 200px auto;
+    background-position: 50px 0;
+    padding-top: 90px;
+    height: 45px;
+  }
+  body .wrapper header h1 a {
+    background-size: 160px auto;
+    background-position: 10px 0;
+  }
+  body .wrapper header p.desc {
+    font-size: 16px;
+  }
+  body .wrapper footer {
+    margin: 20px 0;
+  }
+  body .wrapper #page-demo {
+    width: 96%;
+    margin: 0 2%;
+  }
+  body .wrapper #link-fork {
+    z-index: -1;
+    width: 100px;
+    height: auto;
+  }
+  body .wrapper #link-fork img {
+    max-width: 100px;
+    height: auto;
+  }
+
+  nav {
+    display: none;
+  }
+}
+@media screen and (device-aspect-ratio: 40 / 71) and (orientation: landscape) {
+  body {
+    width: 568px;
+  }
+}
+@media screen and (device-aspect-ratio: 2 / 3) and (orientation: landscape) {
+  body {
+    width: 480px;
+  }
+}

Diff do ficheiro suprimidas por serem muito extensas
+ 2 - 0
addons/simditor/src/css/simditor.css


BIN
addons/simditor/src/images/image.png


+ 241 - 0
addons/simditor/src/js/hotkeys.js

@@ -0,0 +1,241 @@
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define('simple-hotkeys', ["jquery","simple-module"], function ($, SimpleModule) {
+      return (root['hotkeys'] = factory($, SimpleModule));
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory(require("jquery"),require("simple-module"));
+  } else {
+    root.simple = root.simple || {};
+    root.simple['hotkeys'] = factory(jQuery,SimpleModule);
+  }
+}(this, function ($, SimpleModule) {
+
+var Hotkeys, hotkeys,
+  extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
+  hasProp = {}.hasOwnProperty;
+
+Hotkeys = (function(superClass) {
+  extend(Hotkeys, superClass);
+
+  function Hotkeys() {
+    return Hotkeys.__super__.constructor.apply(this, arguments);
+  }
+
+  Hotkeys.count = 0;
+
+  Hotkeys.keyNameMap = {
+    8: "Backspace",
+    9: "Tab",
+    13: "Enter",
+    16: "Shift",
+    17: "Control",
+    18: "Alt",
+    19: "Pause",
+    20: "CapsLock",
+    27: "Esc",
+    32: "Spacebar",
+    33: "PageUp",
+    34: "PageDown",
+    35: "End",
+    36: "Home",
+    37: "Left",
+    38: "Up",
+    39: "Right",
+    40: "Down",
+    45: "Insert",
+    46: "Del",
+    91: "Meta",
+    93: "Meta",
+    48: "0",
+    49: "1",
+    50: "2",
+    51: "3",
+    52: "4",
+    53: "5",
+    54: "6",
+    55: "7",
+    56: "8",
+    57: "9",
+    65: "A",
+    66: "B",
+    67: "C",
+    68: "D",
+    69: "E",
+    70: "F",
+    71: "G",
+    72: "H",
+    73: "I",
+    74: "J",
+    75: "K",
+    76: "L",
+    77: "M",
+    78: "N",
+    79: "O",
+    80: "P",
+    81: "Q",
+    82: "R",
+    83: "S",
+    84: "T",
+    85: "U",
+    86: "V",
+    87: "W",
+    88: "X",
+    89: "Y",
+    90: "Z",
+    96: "0",
+    97: "1",
+    98: "2",
+    99: "3",
+    100: "4",
+    101: "5",
+    102: "6",
+    103: "7",
+    104: "8",
+    105: "9",
+    106: "Multiply",
+    107: "Add",
+    109: "Subtract",
+    110: "Decimal",
+    111: "Divide",
+    112: "F1",
+    113: "F2",
+    114: "F3",
+    115: "F4",
+    116: "F5",
+    117: "F6",
+    118: "F7",
+    119: "F8",
+    120: "F9",
+    121: "F10",
+    122: "F11",
+    123: "F12",
+    124: "F13",
+    125: "F14",
+    126: "F15",
+    127: "F16",
+    128: "F17",
+    129: "F18",
+    130: "F19",
+    131: "F20",
+    132: "F21",
+    133: "F22",
+    134: "F23",
+    135: "F24",
+    59: ";",
+    61: "=",
+    186: ";",
+    187: "=",
+    188: ",",
+    190: ".",
+    191: "/",
+    192: "`",
+    219: "[",
+    220: "\\",
+    221: "]",
+    222: "'"
+  };
+
+  Hotkeys.aliases = {
+    "escape": "esc",
+    "delete": "del",
+    "return": "enter",
+    "ctrl": "control",
+    "space": "spacebar",
+    "ins": "insert",
+    "cmd": "meta",
+    "command": "meta",
+    "wins": "meta",
+    "windows": "meta"
+  };
+
+  Hotkeys.normalize = function(shortcut) {
+    var i, j, key, keyname, keys, len;
+    keys = shortcut.toLowerCase().replace(/\s+/gi, "").split("+");
+    for (i = j = 0, len = keys.length; j < len; i = ++j) {
+      key = keys[i];
+      keys[i] = this.aliases[key] || key;
+    }
+    keyname = keys.pop();
+    keys.sort().push(keyname);
+    return keys.join("_");
+  };
+
+  Hotkeys.prototype.opts = {
+    el: document
+  };
+
+  Hotkeys.prototype._init = function() {
+    this.id = ++this.constructor.count;
+    this._map = {};
+    this._delegate = typeof this.opts.el === "string" ? document : this.opts.el;
+    return $(this._delegate).on("keydown.simple-hotkeys-" + this.id, this.opts.el, (function(_this) {
+      return function(e) {
+        var ref;
+        return (ref = _this._getHander(e)) != null ? ref.call(_this, e) : void 0;
+      };
+    })(this));
+  };
+
+  Hotkeys.prototype._getHander = function(e) {
+    var keyname, shortcut;
+    if (!(keyname = this.constructor.keyNameMap[e.which])) {
+      return;
+    }
+    shortcut = "";
+    if (e.altKey) {
+      shortcut += "alt_";
+    }
+    if (e.ctrlKey) {
+      shortcut += "control_";
+    }
+    if (e.metaKey) {
+      shortcut += "meta_";
+    }
+    if (e.shiftKey) {
+      shortcut += "shift_";
+    }
+    shortcut += keyname.toLowerCase();
+    return this._map[shortcut];
+  };
+
+  Hotkeys.prototype.respondTo = function(subject) {
+    if (typeof subject === 'string') {
+      return this._map[this.constructor.normalize(subject)] != null;
+    } else {
+      return this._getHander(subject) != null;
+    }
+  };
+
+  Hotkeys.prototype.add = function(shortcut, handler) {
+    this._map[this.constructor.normalize(shortcut)] = handler;
+    return this;
+  };
+
+  Hotkeys.prototype.remove = function(shortcut) {
+    delete this._map[this.constructor.normalize(shortcut)];
+    return this;
+  };
+
+  Hotkeys.prototype.destroy = function() {
+    $(this._delegate).off(".simple-hotkeys-" + this.id);
+    this._map = {};
+    return this;
+  };
+
+  return Hotkeys;
+
+})(SimpleModule);
+
+hotkeys = function(opts) {
+  return new Hotkeys(opts);
+};
+
+return hotkeys;
+
+}));
+

+ 172 - 0
addons/simditor/src/js/module.js

@@ -0,0 +1,172 @@
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define('simple-module', ["jquery"], function (a0) {
+      return (root['Module'] = factory(a0));
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory(require("jquery"));
+  } else {
+    root['SimpleModule'] = factory(jQuery);
+  }
+}(this, function ($) {
+
+var Module,
+  slice = [].slice;
+
+Module = (function() {
+  Module.extend = function(obj) {
+    var key, ref, val;
+    if (!((obj != null) && typeof obj === 'object')) {
+      return;
+    }
+    for (key in obj) {
+      val = obj[key];
+      if (key !== 'included' && key !== 'extended') {
+        this[key] = val;
+      }
+    }
+    return (ref = obj.extended) != null ? ref.call(this) : void 0;
+  };
+
+  Module.include = function(obj) {
+    var key, ref, val;
+    if (!((obj != null) && typeof obj === 'object')) {
+      return;
+    }
+    for (key in obj) {
+      val = obj[key];
+      if (key !== 'included' && key !== 'extended') {
+        this.prototype[key] = val;
+      }
+    }
+    return (ref = obj.included) != null ? ref.call(this) : void 0;
+  };
+
+  Module.connect = function(cls) {
+    if (typeof cls !== 'function') {
+      return;
+    }
+    if (!cls.pluginName) {
+      throw new Error('Module.connect: cannot connect plugin without pluginName');
+      return;
+    }
+    cls.prototype._connected = true;
+    if (!this._connectedClasses) {
+      this._connectedClasses = [];
+    }
+    this._connectedClasses.push(cls);
+    if (cls.pluginName) {
+      return this[cls.pluginName] = cls;
+    }
+  };
+
+  Module.prototype.opts = {};
+
+  function Module(opts) {
+    var base, cls, i, instance, instances, len, name;
+    this.opts = $.extend({}, this.opts, opts);
+    (base = this.constructor)._connectedClasses || (base._connectedClasses = []);
+    instances = (function() {
+      var i, len, ref, results;
+      ref = this.constructor._connectedClasses;
+      results = [];
+      for (i = 0, len = ref.length; i < len; i++) {
+        cls = ref[i];
+        name = cls.pluginName.charAt(0).toLowerCase() + cls.pluginName.slice(1);
+        if (cls.prototype._connected) {
+          cls.prototype._module = this;
+        }
+        results.push(this[name] = new cls());
+      }
+      return results;
+    }).call(this);
+    if (this._connected) {
+      this.opts = $.extend({}, this.opts, this._module.opts);
+    } else {
+      this._init();
+      for (i = 0, len = instances.length; i < len; i++) {
+        instance = instances[i];
+        if (typeof instance._init === "function") {
+          instance._init();
+        }
+      }
+    }
+    this.trigger('initialized');
+  }
+
+  Module.prototype._init = function() {};
+
+  Module.prototype.on = function() {
+    var args, ref;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    (ref = $(this)).on.apply(ref, args);
+    return this;
+  };
+
+  Module.prototype.one = function() {
+    var args, ref;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    (ref = $(this)).one.apply(ref, args);
+    return this;
+  };
+
+  Module.prototype.off = function() {
+    var args, ref;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    (ref = $(this)).off.apply(ref, args);
+    return this;
+  };
+
+  Module.prototype.trigger = function() {
+    var args, ref;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    (ref = $(this)).trigger.apply(ref, args);
+    return this;
+  };
+
+  Module.prototype.triggerHandler = function() {
+    var args, ref;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    return (ref = $(this)).triggerHandler.apply(ref, args);
+  };
+
+  Module.prototype._t = function() {
+    var args, ref;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    return (ref = this.constructor)._t.apply(ref, args);
+  };
+
+  Module._t = function() {
+    var args, key, ref, result;
+    key = arguments[0], args = 2 <= arguments.length ? slice.call(arguments, 1) : [];
+    result = ((ref = this.i18n[this.locale]) != null ? ref[key] : void 0) || '';
+    if (!(args.length > 0)) {
+      return result;
+    }
+    result = result.replace(/([^%]|^)%(?:(\d+)\$)?s/g, function(p0, p, position) {
+      if (position) {
+        return p + args[parseInt(position) - 1];
+      } else {
+        return p + args.shift();
+      }
+    });
+    return result.replace(/%%s/g, '%s');
+  };
+
+  Module.i18n = {
+    'zh-CN': {}
+  };
+
+  Module.locale = 'zh-CN';
+
+  return Module;
+
+})();
+
+return Module;
+
+}));

+ 5641 - 0
addons/simditor/src/js/simditor.js

@@ -0,0 +1,5641 @@
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define('simditor', ["jquery","simple-module","simple-hotkeys","simple-uploader"], function ($, SimpleModule, simpleHotkeys, simpleUploader) {
+      return (root['Simditor'] = factory($, SimpleModule, simpleHotkeys, simpleUploader));
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory(require("jquery"),require("simple-module"),require("simple-hotkeys"),require("simple-uploader"));
+  } else {
+    root['Simditor'] = factory(jQuery,SimpleModule,simple.hotkeys,simple.uploader);
+  }
+}(this, function ($, SimpleModule, simpleHotkeys, simpleUploader) {
+
+var AlignmentButton, BlockquoteButton, BoldButton, Button, Clipboard, CodeButton, CodePopover, ColorButton, FontScaleButton, Formatter, HrButton, ImageButton, ImagePopover, IndentButton, Indentation, InputManager, ItalicButton, Keystroke, LinkButton, LinkPopover, ListButton, OrderListButton, OutdentButton, Popover, Selection, Simditor, StrikethroughButton, TableButton, TitleButton, Toolbar, UnderlineButton, UndoManager, UnorderListButton, Util,
+  extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
+  hasProp = {}.hasOwnProperty,
+  indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; },
+  slice = [].slice;
+
+Selection = (function(superClass) {
+  extend(Selection, superClass);
+
+  function Selection() {
+    return Selection.__super__.constructor.apply(this, arguments);
+  }
+
+  Selection.pluginName = 'Selection';
+
+  Selection.prototype._range = null;
+
+  Selection.prototype._startNodes = null;
+
+  Selection.prototype._endNodes = null;
+
+  Selection.prototype._containerNode = null;
+
+  Selection.prototype._nodes = null;
+
+  Selection.prototype._blockNodes = null;
+
+  Selection.prototype._rootNodes = null;
+
+  Selection.prototype._init = function() {
+    this.editor = this._module;
+    this._selection = document.getSelection();
+    this.editor.on('selectionchanged', (function(_this) {
+      return function(e) {
+        _this.reset();
+        return _this._range = _this._selection.getRangeAt(0);
+      };
+    })(this));
+    this.editor.on('blur', (function(_this) {
+      return function(e) {
+        return _this.reset();
+      };
+    })(this));
+    return this.editor.on('focus', (function(_this) {
+      return function(e) {
+        _this.reset();
+        return _this._range = _this._selection.getRangeAt(0);
+      };
+    })(this));
+  };
+
+  Selection.prototype.reset = function() {
+    this._range = null;
+    this._startNodes = null;
+    this._endNodes = null;
+    this._containerNode = null;
+    this._nodes = null;
+    this._blockNodes = null;
+    return this._rootNodes = null;
+  };
+
+  Selection.prototype.clear = function() {
+    var e;
+    try {
+      this._selection.removeAllRanges();
+    } catch (_error) {
+      e = _error;
+    }
+    return this.reset();
+  };
+
+  Selection.prototype.range = function(range) {
+    var ffOrIE;
+    if (range) {
+      this.clear();
+      this._selection.addRange(range);
+      this._range = range;
+      ffOrIE = this.editor.util.browser.firefox || this.editor.util.browser.msie;
+      if (!this.editor.inputManager.focused && ffOrIE) {
+        this.editor.body.focus();
+      }
+    } else if (!this._range && this.editor.inputManager.focused && this._selection.rangeCount) {
+      this._range = this._selection.getRangeAt(0);
+    }
+    return this._range;
+  };
+
+  Selection.prototype.startNodes = function() {
+    if (this._range) {
+      this._startNodes || (this._startNodes = (function(_this) {
+        return function() {
+          var startNodes;
+          startNodes = $(_this._range.startContainer).parentsUntil(_this.editor.body).get();
+          startNodes.unshift(_this._range.startContainer);
+          return $(startNodes);
+        };
+      })(this)());
+    }
+    return this._startNodes;
+  };
+
+  Selection.prototype.endNodes = function() {
+    var endNodes;
+    if (this._range) {
+      this._endNodes || (this._endNodes = this._range.collapsed ? this.startNodes() : (endNodes = $(this._range.endContainer).parentsUntil(this.editor.body).get(), endNodes.unshift(this._range.endContainer), $(endNodes)));
+    }
+    return this._endNodes;
+  };
+
+  Selection.prototype.containerNode = function() {
+    if (this._range) {
+      this._containerNode || (this._containerNode = $(this._range.commonAncestorContainer));
+    }
+    return this._containerNode;
+  };
+
+  Selection.prototype.nodes = function() {
+    if (this._range) {
+      this._nodes || (this._nodes = (function(_this) {
+        return function() {
+          var nodes;
+          nodes = [];
+          if (_this.startNodes().first().is(_this.endNodes().first())) {
+            nodes = _this.startNodes().get();
+          } else {
+            _this.startNodes().each(function(i, node) {
+              var $endNode, $node, $nodes, endIndex, index, sharedIndex, startIndex;
+              $node = $(node);
+              if (_this.endNodes().index($node) > -1) {
+                return nodes.push(node);
+              } else if ($node.parent().is(_this.editor.body) || (sharedIndex = _this.endNodes().index($node.parent())) > -1) {
+                if (sharedIndex && sharedIndex > -1) {
+                  $endNode = _this.endNodes().eq(sharedIndex - 1);
+                } else {
+                  $endNode = _this.endNodes().last();
+                }
+                $nodes = $node.parent().contents();
+                startIndex = $nodes.index($node);
+                endIndex = $nodes.index($endNode);
+                return $.merge(nodes, $nodes.slice(startIndex, endIndex).get());
+              } else {
+                $nodes = $node.parent().contents();
+                index = $nodes.index($node);
+                return $.merge(nodes, $nodes.slice(index).get());
+              }
+            });
+            _this.endNodes().each(function(i, node) {
+              var $node, $nodes, index;
+              $node = $(node);
+              if ($node.parent().is(_this.editor.body) || _this.startNodes().index($node.parent()) > -1) {
+                nodes.push(node);
+                return false;
+              } else {
+                $nodes = $node.parent().contents();
+                index = $nodes.index($node);
+                return $.merge(nodes, $nodes.slice(0, index + 1));
+              }
+            });
+          }
+          return $($.unique(nodes));
+        };
+      })(this)());
+    }
+    return this._nodes;
+  };
+
+  Selection.prototype.blockNodes = function() {
+    if (!this._range) {
+      return;
+    }
+    this._blockNodes || (this._blockNodes = (function(_this) {
+      return function() {
+        return _this.nodes().filter(function(i, node) {
+          return _this.editor.util.isBlockNode(node);
+        });
+      };
+    })(this)());
+    return this._blockNodes;
+  };
+
+  Selection.prototype.rootNodes = function() {
+    if (!this._range) {
+      return;
+    }
+    this._rootNodes || (this._rootNodes = (function(_this) {
+      return function() {
+        return _this.nodes().filter(function(i, node) {
+          var $parent;
+          $parent = $(node).parent();
+          return $parent.is(_this.editor.body) || $parent.is('blockquote');
+        });
+      };
+    })(this)());
+    return this._rootNodes;
+  };
+
+  Selection.prototype.rangeAtEndOf = function(node, range) {
+    var afterLastNode, beforeLastNode, endNode, endNodeLength, lastNodeIsBr, result;
+    if (range == null) {
+      range = this.range();
+    }
+    if (!(range && range.collapsed)) {
+      return;
+    }
+    node = $(node)[0];
+    endNode = range.endContainer;
+    endNodeLength = this.editor.util.getNodeLength(endNode);
+    beforeLastNode = range.endOffset === endNodeLength - 1;
+    lastNodeIsBr = $(endNode).contents().last().is('br');
+    afterLastNode = range.endOffset === endNodeLength;
+    if (!((beforeLastNode && lastNodeIsBr) || afterLastNode)) {
+      return false;
+    }
+    if (node === endNode) {
+      return true;
+    } else if (!$.contains(node, endNode)) {
+      return false;
+    }
+    result = true;
+    $(endNode).parentsUntil(node).addBack().each(function(i, n) {
+      var $lastChild, beforeLastbr, isLastNode, nodes;
+      nodes = $(n).parent().contents().filter(function() {
+        return !(this !== n && this.nodeType === 3 && !this.nodeValue);
+      });
+      $lastChild = nodes.last();
+      isLastNode = $lastChild.get(0) === n;
+      beforeLastbr = $lastChild.is('br') && $lastChild.prev().get(0) === n;
+      if (!(isLastNode || beforeLastbr)) {
+        result = false;
+        return false;
+      }
+    });
+    return result;
+  };
+
+  Selection.prototype.rangeAtStartOf = function(node, range) {
+    var result, startNode;
+    if (range == null) {
+      range = this.range();
+    }
+    if (!(range && range.collapsed)) {
+      return;
+    }
+    node = $(node)[0];
+    startNode = range.startContainer;
+    if (range.startOffset !== 0) {
+      return false;
+    }
+    if (node === startNode) {
+      return true;
+    } else if (!$.contains(node, startNode)) {
+      return false;
+    }
+    result = true;
+    $(startNode).parentsUntil(node).addBack().each(function(i, n) {
+      var nodes;
+      nodes = $(n).parent().contents().filter(function() {
+        return !(this !== n && this.nodeType === 3 && !this.nodeValue);
+      });
+      if (nodes.first().get(0) !== n) {
+        return result = false;
+      }
+    });
+    return result;
+  };
+
+  Selection.prototype.insertNode = function(node, range) {
+    if (range == null) {
+      range = this.range();
+    }
+    if (!range) {
+      return;
+    }
+    node = $(node)[0];
+    range.insertNode(node);
+    return this.setRangeAfter(node, range);
+  };
+
+  Selection.prototype.setRangeAfter = function(node, range) {
+    if (range == null) {
+      range = this.range();
+    }
+    if (range == null) {
+      return;
+    }
+    node = $(node)[0];
+    range.setEndAfter(node);
+    range.collapse(false);
+    return this.range(range);
+  };
+
+  Selection.prototype.setRangeBefore = function(node, range) {
+    if (range == null) {
+      range = this.range();
+    }
+    if (range == null) {
+      return;
+    }
+    node = $(node)[0];
+    range.setEndBefore(node);
+    range.collapse(false);
+    return this.range(range);
+  };
+
+  Selection.prototype.setRangeAtStartOf = function(node, range) {
+    if (range == null) {
+      range = this.range();
+    }
+    node = $(node).get(0);
+    range.setEnd(node, 0);
+    range.collapse(false);
+    return this.range(range);
+  };
+
+  Selection.prototype.setRangeAtEndOf = function(node, range) {
+    var $lastNode, $node, contents, lastChild, lastChildLength, lastText, nodeLength;
+    if (range == null) {
+      range = this.range();
+    }
+    $node = $(node);
+    node = $node[0];
+    if ($node.is('pre')) {
+      contents = $node.contents();
+      if (contents.length > 0) {
+        lastChild = contents.last();
+        lastText = lastChild.text();
+        lastChildLength = this.editor.util.getNodeLength(lastChild[0]);
+        if (lastText.charAt(lastText.length - 1) === '\n') {
+          range.setEnd(lastChild[0], lastChildLength - 1);
+        } else {
+          range.setEnd(lastChild[0], lastChildLength);
+        }
+      } else {
+        range.setEnd(node, 0);
+      }
+    } else {
+      nodeLength = this.editor.util.getNodeLength(node);
+      if (node.nodeType !== 3 && nodeLength > 0) {
+        $lastNode = $(node).contents().last();
+        if ($lastNode.is('br')) {
+          nodeLength -= 1;
+        } else if ($lastNode[0].nodeType !== 3 && this.editor.util.isEmptyNode($lastNode)) {
+          $lastNode.append(this.editor.util.phBr);
+          node = $lastNode[0];
+          nodeLength = 0;
+        }
+      }
+      range.setEnd(node, nodeLength);
+    }
+    range.collapse(false);
+    return this.range(range);
+  };
+
+  Selection.prototype.deleteRangeContents = function(range) {
+    var atEndOfBody, atStartOfBody, endRange, startRange;
+    if (range == null) {
+      range = this.range();
+    }
+    startRange = range.cloneRange();
+    endRange = range.cloneRange();
+    startRange.collapse(true);
+    endRange.collapse(false);
+    atStartOfBody = this.rangeAtStartOf(this.editor.body, startRange);
+    atEndOfBody = this.rangeAtEndOf(this.editor.body, endRange);
+    if (!range.collapsed && atStartOfBody && atEndOfBody) {
+      this.editor.body.empty();
+      range.setStart(this.editor.body[0], 0);
+      range.collapse(true);
+      this.range(range);
+    } else {
+      range.deleteContents();
+    }
+    return range;
+  };
+
+  Selection.prototype.breakBlockEl = function(el, range) {
+    var $el;
+    if (range == null) {
+      range = this.range();
+    }
+    $el = $(el);
+    if (!range.collapsed) {
+      return $el;
+    }
+    range.setStartBefore($el.get(0));
+    if (range.collapsed) {
+      return $el;
+    }
+    return $el.before(range.extractContents());
+  };
+
+  Selection.prototype.save = function(range) {
+    var endCaret, endRange, startCaret;
+    if (range == null) {
+      range = this.range();
+    }
+    if (this._selectionSaved) {
+      return;
+    }
+    endRange = range.cloneRange();
+    endRange.collapse(false);
+    startCaret = $('<span/>').addClass('simditor-caret-start');
+    endCaret = $('<span/>').addClass('simditor-caret-end');
+    endRange.insertNode(endCaret[0]);
+    range.insertNode(startCaret[0]);
+    this.clear();
+    return this._selectionSaved = true;
+  };
+
+  Selection.prototype.restore = function() {
+    var endCaret, endContainer, endOffset, range, startCaret, startContainer, startOffset;
+    if (!this._selectionSaved) {
+      return false;
+    }
+    startCaret = this.editor.body.find('.simditor-caret-start');
+    endCaret = this.editor.body.find('.simditor-caret-end');
+    if (startCaret.length && endCaret.length) {
+      startContainer = startCaret.parent();
+      startOffset = startContainer.contents().index(startCaret);
+      endContainer = endCaret.parent();
+      endOffset = endContainer.contents().index(endCaret);
+      if (startContainer[0] === endContainer[0]) {
+        endOffset -= 1;
+      }
+      range = document.createRange();
+      range.setStart(startContainer.get(0), startOffset);
+      range.setEnd(endContainer.get(0), endOffset);
+      startCaret.remove();
+      endCaret.remove();
+      this.range(range);
+    } else {
+      startCaret.remove();
+      endCaret.remove();
+    }
+    this._selectionSaved = false;
+    return range;
+  };
+
+  return Selection;
+
+})(SimpleModule);
+
+Formatter = (function(superClass) {
+  extend(Formatter, superClass);
+
+  function Formatter() {
+    return Formatter.__super__.constructor.apply(this, arguments);
+  }
+
+  Formatter.pluginName = 'Formatter';
+
+  Formatter.prototype.opts = {
+    allowedTags: [],
+    allowedAttributes: {},
+    allowedStyles: {}
+  };
+
+  Formatter.prototype._init = function() {
+    this.editor = this._module;
+    this._allowedTags = $.merge(['br', 'span', 'a', 'img', 'b', 'strong', 'i', 'strike', 'u', 'font', 'p', 'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'h1', 'h2', 'h3', 'h4', 'hr'], this.opts.allowedTags);
+    this._allowedAttributes = $.extend({
+      img: ['src', 'alt', 'width', 'height', 'data-non-image'],
+      a: ['href', 'target'],
+      font: ['color'],
+      code: ['class']
+    }, this.opts.allowedAttributes);
+    this._allowedStyles = $.extend({
+      span: ['color', 'font-size'],
+      b: ['color'],
+      i: ['color'],
+      strong: ['color'],
+      strike: ['color'],
+      u: ['color'],
+      p: ['margin-left', 'text-align'],
+      h1: ['margin-left', 'text-align'],
+      h2: ['margin-left', 'text-align'],
+      h3: ['margin-left', 'text-align'],
+      h4: ['margin-left', 'text-align']
+    }, this.opts.allowedStyles);
+    return this.editor.body.on('click', 'a', function(e) {
+      return false;
+    });
+  };
+
+  Formatter.prototype.decorate = function($el) {
+    if ($el == null) {
+      $el = this.editor.body;
+    }
+    this.editor.trigger('decorate', [$el]);
+    return $el;
+  };
+
+  Formatter.prototype.undecorate = function($el) {
+    if ($el == null) {
+      $el = this.editor.body.clone();
+    }
+    this.editor.trigger('undecorate', [$el]);
+    return $el;
+  };
+
+  Formatter.prototype.autolink = function($el) {
+    var $link, $node, findLinkNode, k, lastIndex, len, linkNodes, match, re, replaceEls, subStr, text, uri;
+    if ($el == null) {
+      $el = this.editor.body;
+    }
+    linkNodes = [];
+    findLinkNode = function($parentNode) {
+      return $parentNode.contents().each(function(i, node) {
+        var $node, text;
+        $node = $(node);
+        if ($node.is('a') || $node.closest('a, pre', $el).length) {
+          return;
+        }
+        if (!$node.is('iframe') && $node.contents().length) {
+          return findLinkNode($node);
+        } else if ((text = $node.text()) && /https?:\/\/|www\./ig.test(text)) {
+          return linkNodes.push($node);
+        }
+      });
+    };
+    findLinkNode($el);
+    re = /(https?:\/\/|www\.)[\w\-\.\?&=\/#%:,@\!\+]+/ig;
+    for (k = 0, len = linkNodes.length; k < len; k++) {
+      $node = linkNodes[k];
+      text = $node.text();
+      replaceEls = [];
+      match = null;
+      lastIndex = 0;
+      while ((match = re.exec(text)) !== null) {
+        subStr = text.substring(lastIndex, match.index);
+        replaceEls.push(document.createTextNode(subStr));
+        lastIndex = re.lastIndex;
+        uri = /^(http(s)?:\/\/|\/)/.test(match[0]) ? match[0] : 'http://' + match[0];
+        $link = $("<a href=\"" + uri + "\" rel=\"nofollow\"></a>").text(match[0]);
+        replaceEls.push($link[0]);
+      }
+      replaceEls.push(document.createTextNode(text.substring(lastIndex)));
+      $node.replaceWith($(replaceEls));
+    }
+    return $el;
+  };
+
+  Formatter.prototype.format = function($el) {
+    var $node, blockNode, k, l, len, len1, n, node, ref, ref1;
+    if ($el == null) {
+      $el = this.editor.body;
+    }
+    if ($el.is(':empty')) {
+      $el.append('<p>' + this.editor.util.phBr + '</p>');
+      return $el;
+    }
+    ref = $el.contents();
+    for (k = 0, len = ref.length; k < len; k++) {
+      n = ref[k];
+      this.cleanNode(n, true);
+    }
+    ref1 = $el.contents();
+    for (l = 0, len1 = ref1.length; l < len1; l++) {
+      node = ref1[l];
+      $node = $(node);
+      if ($node.is('br')) {
+        if (typeof blockNode !== "undefined" && blockNode !== null) {
+          blockNode = null;
+        }
+        $node.remove();
+      } else if (this.editor.util.isBlockNode(node)) {
+        if ($node.is('li')) {
+          if (blockNode && blockNode.is('ul, ol')) {
+            blockNode.append(node);
+          } else {
+            blockNode = $('<ul/>').insertBefore(node);
+            blockNode.append(node);
+          }
+        } else {
+          blockNode = null;
+        }
+      } else {
+        if (!blockNode || blockNode.is('ul, ol')) {
+          blockNode = $('<p/>').insertBefore(node);
+        }
+        blockNode.append(node);
+        if (this.editor.util.isEmptyNode(blockNode)) {
+          blockNode.append(this.editor.util.phBr);
+        }
+      }
+    }
+    return $el;
+  };
+
+  Formatter.prototype.cleanNode = function(node, recursive) {
+    var $blockEls, $childImg, $node, $p, $td, allowedAttributes, attr, contents, isDecoration, k, l, len, len1, n, ref, ref1, text, textNode;
+    $node = $(node);
+    if (!($node.length > 0)) {
+      return;
+    }
+    if ($node[0].nodeType === 3) {
+      text = $node.text().replace(/(\r\n|\n|\r)/gm, '');
+      if (text) {
+        textNode = document.createTextNode(text);
+        $node.replaceWith(textNode);
+      } else {
+        $node.remove();
+      }
+      return;
+    }
+    contents = $node.is('iframe') ? null : $node.contents();
+    isDecoration = this.editor.util.isDecoratedNode($node);
+    if ($node.is(this._allowedTags.join(',')) || isDecoration) {
+      if ($node.is('a') && ($childImg = $node.find('img')).length > 0) {
+        $node.replaceWith($childImg);
+        $node = $childImg;
+        contents = null;
+      }
+      if ($node.is('td') && ($blockEls = $node.find(this.editor.util.blockNodes.join(','))).length > 0) {
+        $blockEls.each((function(_this) {
+          return function(i, blockEl) {
+            return $(blockEl).contents().unwrap();
+          };
+        })(this));
+        contents = $node.contents();
+      }
+      if ($node.is('img') && $node.hasClass('uploading')) {
+        $node.remove();
+      }
+      if (!isDecoration) {
+        allowedAttributes = this._allowedAttributes[$node[0].tagName.toLowerCase()];
+        ref = $.makeArray($node[0].attributes);
+        for (k = 0, len = ref.length; k < len; k++) {
+          attr = ref[k];
+          if (attr.name === 'style') {
+            continue;
+          }
+          if (!((allowedAttributes != null) && (ref1 = attr.name, indexOf.call(allowedAttributes, ref1) >= 0))) {
+            $node.removeAttr(attr.name);
+          }
+        }
+        this._cleanNodeStyles($node);
+        if ($node.is('span') && $node[0].attributes.length === 0) {
+          $node.contents().first().unwrap();
+        }
+      }
+    } else if ($node[0].nodeType === 1 && !$node.is(':empty')) {
+      if ($node.is('div, article, dl, header, footer, tr')) {
+        $node.append('<br/>');
+        contents.first().unwrap();
+      } else if ($node.is('table')) {
+        $p = $('<p/>');
+        $node.find('tr').each(function(i, tr) {
+          return $p.append($(tr).text() + '<br/>');
+        });
+        $node.replaceWith($p);
+        contents = null;
+      } else if ($node.is('thead, tfoot')) {
+        $node.remove();
+        contents = null;
+      } else if ($node.is('th')) {
+        $td = $('<td/>').append($node.contents());
+        $node.replaceWith($td);
+      } else {
+        contents.first().unwrap();
+      }
+    } else {
+      $node.remove();
+      contents = null;
+    }
+    if (recursive && (contents != null) && !$node.is('pre')) {
+      for (l = 0, len1 = contents.length; l < len1; l++) {
+        n = contents[l];
+        this.cleanNode(n, true);
+      }
+    }
+    return null;
+  };
+
+  Formatter.prototype._cleanNodeStyles = function($node) {
+    var allowedStyles, k, len, pair, ref, ref1, style, styleStr, styles;
+    styleStr = $node.attr('style');
+    if (!styleStr) {
+      return;
+    }
+    $node.removeAttr('style');
+    allowedStyles = this._allowedStyles[$node[0].tagName.toLowerCase()];
+    if (!(allowedStyles && allowedStyles.length > 0)) {
+      return $node;
+    }
+    styles = {};
+    ref = styleStr.split(';');
+    for (k = 0, len = ref.length; k < len; k++) {
+      style = ref[k];
+      style = $.trim(style);
+      pair = style.split(':');
+      if (pair.length !== 2) {
+        continue;
+      }
+      if (pair[0] === 'font-size' && pair[1].indexOf('px') > 0) {
+        if (parseInt(pair[1], 10) < 12) {
+          continue;
+        }
+      }
+      if (ref1 = pair[0], indexOf.call(allowedStyles, ref1) >= 0) {
+        styles[$.trim(pair[0])] = $.trim(pair[1]);
+      }
+    }
+    if (Object.keys(styles).length > 0) {
+      $node.css(styles);
+    }
+    return $node;
+  };
+
+  Formatter.prototype.clearHtml = function(html, lineBreak) {
+    var container, contents, result;
+    if (lineBreak == null) {
+      lineBreak = true;
+    }
+    container = $('<div/>').append(html);
+    contents = container.contents();
+    result = '';
+    contents.each((function(_this) {
+      return function(i, node) {
+        var $node, children;
+        if (node.nodeType === 3) {
+          return result += node.nodeValue;
+        } else if (node.nodeType === 1) {
+          $node = $(node);
+          children = $node.is('iframe') ? null : $node.contents();
+          if (children && children.length > 0) {
+            result += _this.clearHtml(children);
+          }
+          if (lineBreak && i < contents.length - 1 && $node.is('br, p, div, li,tr, pre, address, artticle, aside, dl, figcaption, footer, h1, h2,h3, h4, header')) {
+            return result += '\n';
+          }
+        }
+      };
+    })(this));
+    return result;
+  };
+
+  Formatter.prototype.beautify = function($contents) {
+    var uselessP;
+    uselessP = function($el) {
+      return !!($el.is('p') && !$el.text() && $el.children(':not(br)').length < 1);
+    };
+    return $contents.each(function(i, el) {
+      var $el, invalid;
+      $el = $(el);
+      invalid = $el.is(':not(img, br, col, td, hr, [class^="simditor-"]):empty');
+      if (invalid || uselessP($el)) {
+        $el.remove();
+      }
+      return $el.find(':not(img, br, col, td, hr, [class^="simditor-"]):empty').remove();
+    });
+  };
+
+  return Formatter;
+
+})(SimpleModule);
+
+InputManager = (function(superClass) {
+  extend(InputManager, superClass);
+
+  function InputManager() {
+    return InputManager.__super__.constructor.apply(this, arguments);
+  }
+
+  InputManager.pluginName = 'InputManager';
+
+  InputManager.prototype._modifierKeys = [16, 17, 18, 91, 93, 224];
+
+  InputManager.prototype._arrowKeys = [37, 38, 39, 40];
+
+  InputManager.prototype._init = function() {
+    var selectAllKey, submitKey;
+    this.editor = this._module;
+    this.throttledValueChanged = this.editor.util.throttle((function(_this) {
+      return function(params) {
+        return setTimeout(function() {
+          return _this.editor.trigger('valuechanged', params);
+        }, 10);
+      };
+    })(this), 300);
+    this.throttledSelectionChanged = this.editor.util.throttle((function(_this) {
+      return function() {
+        return _this.editor.trigger('selectionchanged');
+      };
+    })(this), 50);
+    $(document).on('selectionchange.simditor' + this.editor.id, (function(_this) {
+      return function(e) {
+        var triggerEvent;
+        if (!(_this.focused && !_this.editor.clipboard.pasting)) {
+          return;
+        }
+        triggerEvent = function() {
+          if (_this._selectionTimer) {
+            clearTimeout(_this._selectionTimer);
+            _this._selectionTimer = null;
+          }
+          if (_this.editor.selection._selection.rangeCount > 0) {
+            return _this.throttledSelectionChanged();
+          } else {
+            return _this._selectionTimer = setTimeout(function() {
+              _this._selectionTimer = null;
+              if (_this.focused) {
+                return triggerEvent();
+              }
+            }, 10);
+          }
+        };
+        return triggerEvent();
+      };
+    })(this));
+    this.editor.on('valuechanged', (function(_this) {
+      return function() {
+        var $rootBlocks;
+        _this.lastCaretPosition = null;
+        $rootBlocks = _this.editor.body.children().filter(function(i, node) {
+          return _this.editor.util.isBlockNode(node);
+        });
+        if (_this.focused && $rootBlocks.length === 0) {
+          _this.editor.selection.save();
+          _this.editor.formatter.format();
+          _this.editor.selection.restore();
+        }
+        _this.editor.body.find('hr, pre, .simditor-table').each(function(i, el) {
+          var $el, formatted;
+          $el = $(el);
+          if ($el.parent().is('blockquote') || $el.parent()[0] === _this.editor.body[0]) {
+            formatted = false;
+            if ($el.next().length === 0) {
+              $('<p/>').append(_this.editor.util.phBr).insertAfter($el);
+              formatted = true;
+            }
+            if ($el.prev().length === 0) {
+              $('<p/>').append(_this.editor.util.phBr).insertBefore($el);
+              formatted = true;
+            }
+            if (formatted) {
+              return _this.throttledValueChanged();
+            }
+          }
+        });
+        _this.editor.body.find('pre:empty').append(_this.editor.util.phBr);
+        if (!_this.editor.util.support.onselectionchange && _this.focused) {
+          return _this.throttledSelectionChanged();
+        }
+      };
+    })(this));
+    this.editor.body.on('keydown', $.proxy(this._onKeyDown, this)).on('keypress', $.proxy(this._onKeyPress, this)).on('keyup', $.proxy(this._onKeyUp, this)).on('mouseup', $.proxy(this._onMouseUp, this)).on('focus', $.proxy(this._onFocus, this)).on('blur', $.proxy(this._onBlur, this)).on('drop', $.proxy(this._onDrop, this)).on('input', $.proxy(this._onInput, this));
+    if (this.editor.util.browser.firefox) {
+      this.editor.hotkeys.add('cmd+left', (function(_this) {
+        return function(e) {
+          e.preventDefault();
+          _this.editor.selection._selection.modify('move', 'backward', 'lineboundary');
+          return false;
+        };
+      })(this));
+      this.editor.hotkeys.add('cmd+right', (function(_this) {
+        return function(e) {
+          e.preventDefault();
+          _this.editor.selection._selection.modify('move', 'forward', 'lineboundary');
+          return false;
+        };
+      })(this));
+      selectAllKey = this.editor.util.os.mac ? 'cmd+a' : 'ctrl+a';
+      this.editor.hotkeys.add(selectAllKey, (function(_this) {
+        return function(e) {
+          var $children, firstBlock, lastBlock, range;
+          $children = _this.editor.body.children();
+          if (!($children.length > 0)) {
+            return;
+          }
+          firstBlock = $children.first().get(0);
+          lastBlock = $children.last().get(0);
+          range = document.createRange();
+          range.setStart(firstBlock, 0);
+          range.setEnd(lastBlock, _this.editor.util.getNodeLength(lastBlock));
+          _this.editor.selection.range(range);
+          return false;
+        };
+      })(this));
+    }
+    submitKey = this.editor.util.os.mac ? 'cmd+enter' : 'ctrl+enter';
+    return this.editor.hotkeys.add(submitKey, (function(_this) {
+      return function(e) {
+        _this.editor.el.closest('form').find('button:submit').click();
+        return false;
+      };
+    })(this));
+  };
+
+  InputManager.prototype._onFocus = function(e) {
+    if (this.editor.clipboard.pasting) {
+      return;
+    }
+    this.editor.el.addClass('focus').removeClass('error');
+    this.focused = true;
+    return setTimeout((function(_this) {
+      return function() {
+        var $blockEl, range;
+        range = _this.editor.selection._selection.getRangeAt(0);
+        if (range.startContainer === _this.editor.body[0]) {
+          if (_this.lastCaretPosition) {
+            _this.editor.undoManager.caretPosition(_this.lastCaretPosition);
+          } else {
+            $blockEl = _this.editor.body.children().first();
+            range = document.createRange();
+            _this.editor.selection.setRangeAtStartOf($blockEl, range);
+          }
+        }
+        _this.lastCaretPosition = null;
+        _this.editor.triggerHandler('focus');
+        if (!_this.editor.util.support.onselectionchange) {
+          return _this.throttledSelectionChanged();
+        }
+      };
+    })(this), 0);
+  };
+
+  InputManager.prototype._onBlur = function(e) {
+    var ref;
+    if (this.editor.clipboard.pasting) {
+      return;
+    }
+    this.editor.el.removeClass('focus');
+    this.editor.sync();
+    this.focused = false;
+    this.lastCaretPosition = (ref = this.editor.undoManager.currentState()) != null ? ref.caret : void 0;
+    return this.editor.triggerHandler('blur');
+  };
+
+  InputManager.prototype._onMouseUp = function(e) {
+    if (!this.editor.util.support.onselectionchange) {
+      return this.throttledSelectionChanged();
+    }
+  };
+
+  InputManager.prototype._onKeyDown = function(e) {
+    var ref, ref1;
+    if (this.editor.triggerHandler(e) === false) {
+      return false;
+    }
+    if (this.editor.hotkeys.respondTo(e)) {
+      return;
+    }
+    if (this.editor.keystroke.respondTo(e)) {
+      this.throttledValueChanged();
+      return false;
+    }
+    if ((ref = e.which, indexOf.call(this._modifierKeys, ref) >= 0) || (ref1 = e.which, indexOf.call(this._arrowKeys, ref1) >= 0)) {
+      return;
+    }
+    if (this.editor.util.metaKey(e) && e.which === 86) {
+      return;
+    }
+    if (!this.editor.util.support.oninput) {
+      this.throttledValueChanged(['typing']);
+    }
+    return null;
+  };
+
+  InputManager.prototype._onKeyPress = function(e) {
+    if (this.editor.triggerHandler(e) === false) {
+      return false;
+    }
+  };
+
+  InputManager.prototype._onKeyUp = function(e) {
+    var p, ref;
+    if (this.editor.triggerHandler(e) === false) {
+      return false;
+    }
+    if (!this.editor.util.support.onselectionchange && (ref = e.which, indexOf.call(this._arrowKeys, ref) >= 0)) {
+      this.throttledValueChanged();
+      return;
+    }
+    if ((e.which === 8 || e.which === 46) && this.editor.util.isEmptyNode(this.editor.body)) {
+      this.editor.body.empty();
+      p = $('<p/>').append(this.editor.util.phBr).appendTo(this.editor.body);
+      this.editor.selection.setRangeAtStartOf(p);
+    }
+  };
+
+  InputManager.prototype._onDrop = function(e) {
+    if (this.editor.triggerHandler(e) === false) {
+      return false;
+    }
+    return this.throttledValueChanged();
+  };
+
+  InputManager.prototype._onInput = function(e) {
+    return this.throttledValueChanged(['oninput']);
+  };
+
+  return InputManager;
+
+})(SimpleModule);
+
+Keystroke = (function(superClass) {
+  extend(Keystroke, superClass);
+
+  function Keystroke() {
+    return Keystroke.__super__.constructor.apply(this, arguments);
+  }
+
+  Keystroke.pluginName = 'Keystroke';
+
+  Keystroke.prototype._init = function() {
+    this.editor = this._module;
+    this._keystrokeHandlers = {};
+    return this._initKeystrokeHandlers();
+  };
+
+  Keystroke.prototype.add = function(key, node, handler) {
+    key = key.toLowerCase();
+    key = this.editor.hotkeys.constructor.aliases[key] || key;
+    if (!this._keystrokeHandlers[key]) {
+      this._keystrokeHandlers[key] = {};
+    }
+    return this._keystrokeHandlers[key][node] = handler;
+  };
+
+  Keystroke.prototype.respondTo = function(e) {
+    var base, key, ref, result;
+    key = (ref = this.editor.hotkeys.constructor.keyNameMap[e.which]) != null ? ref.toLowerCase() : void 0;
+    if (!key) {
+      return;
+    }
+    if (key in this._keystrokeHandlers) {
+      result = typeof (base = this._keystrokeHandlers[key])['*'] === "function" ? base['*'](e) : void 0;
+      if (!result) {
+        this.editor.selection.startNodes().each((function(_this) {
+          return function(i, node) {
+            var handler, ref1;
+            if (node.nodeType !== Node.ELEMENT_NODE) {
+              return;
+            }
+            handler = (ref1 = _this._keystrokeHandlers[key]) != null ? ref1[node.tagName.toLowerCase()] : void 0;
+            result = typeof handler === "function" ? handler(e, $(node)) : void 0;
+            if (result === true || result === false) {
+              return false;
+            }
+          };
+        })(this));
+      }
+      if (result) {
+        return true;
+      }
+    }
+  };
+
+  Keystroke.prototype._initKeystrokeHandlers = function() {
+    var titleEnterHandler;
+    if (this.editor.util.browser.safari) {
+      this.add('enter', '*', (function(_this) {
+        return function(e) {
+          var $blockEl, $br;
+          if (!e.shiftKey) {
+            return;
+          }
+          $blockEl = _this.editor.selection.blockNodes().last();
+          if ($blockEl.is('pre')) {
+            return;
+          }
+          $br = $('<br/>');
+          if (_this.editor.selection.rangeAtEndOf($blockEl)) {
+            _this.editor.selection.insertNode($br);
+            _this.editor.selection.insertNode($('<br/>'));
+            _this.editor.selection.setRangeBefore($br);
+          } else {
+            _this.editor.selection.insertNode($br);
+          }
+          return true;
+        };
+      })(this));
+    }
+    if (this.editor.util.browser.webkit || this.editor.util.browser.msie) {
+      titleEnterHandler = (function(_this) {
+        return function(e, $node) {
+          var $p;
+          if (!_this.editor.selection.rangeAtEndOf($node)) {
+            return;
+          }
+          $p = $('<p/>').append(_this.editor.util.phBr).insertAfter($node);
+          _this.editor.selection.setRangeAtStartOf($p);
+          return true;
+        };
+      })(this);
+      this.add('enter', 'h1', titleEnterHandler);
+      this.add('enter', 'h2', titleEnterHandler);
+      this.add('enter', 'h3', titleEnterHandler);
+      this.add('enter', 'h4', titleEnterHandler);
+      this.add('enter', 'h5', titleEnterHandler);
+      this.add('enter', 'h6', titleEnterHandler);
+    }
+    this.add('backspace', '*', (function(_this) {
+      return function(e) {
+        var $blockEl, $prevBlockEl, $rootBlock, isWebkit;
+        $rootBlock = _this.editor.selection.rootNodes().first();
+        $prevBlockEl = $rootBlock.prev();
+        if ($prevBlockEl.is('hr') && _this.editor.selection.rangeAtStartOf($rootBlock)) {
+          _this.editor.selection.save();
+          $prevBlockEl.remove();
+          _this.editor.selection.restore();
+          return true;
+        }
+        $blockEl = _this.editor.selection.blockNodes().last();
+        isWebkit = _this.editor.util.browser.webkit;
+        if (isWebkit && _this.editor.selection.rangeAtStartOf($blockEl)) {
+          _this.editor.selection.save();
+          _this.editor.formatter.cleanNode($blockEl, true);
+          _this.editor.selection.restore();
+          return null;
+        }
+      };
+    })(this));
+    this.add('enter', 'li', (function(_this) {
+      return function(e, $node) {
+        var $cloneNode, listEl, newBlockEl, newListEl;
+        $cloneNode = $node.clone();
+        $cloneNode.find('ul, ol').remove();
+        if (!(_this.editor.util.isEmptyNode($cloneNode) && $node.is(_this.editor.selection.blockNodes().last()))) {
+          return;
+        }
+        listEl = $node.parent();
+        if ($node.next('li').length > 0) {
+          if (!_this.editor.util.isEmptyNode($node)) {
+            return;
+          }
+          if (listEl.parent('li').length > 0) {
+            newBlockEl = $('<li/>').append(_this.editor.util.phBr).insertAfter(listEl.parent('li'));
+            newListEl = $('<' + listEl[0].tagName + '/>').append($node.nextAll('li'));
+            newBlockEl.append(newListEl);
+          } else {
+            newBlockEl = $('<p/>').append(_this.editor.util.phBr).insertAfter(listEl);
+            newListEl = $('<' + listEl[0].tagName + '/>').append($node.nextAll('li'));
+            newBlockEl.after(newListEl);
+          }
+        } else {
+          if (listEl.parent('li').length > 0) {
+            newBlockEl = $('<li/>').insertAfter(listEl.parent('li'));
+            if ($node.contents().length > 0) {
+              newBlockEl.append($node.contents());
+            } else {
+              newBlockEl.append(_this.editor.util.phBr);
+            }
+          } else {
+            newBlockEl = $('<p/>').append(_this.editor.util.phBr).insertAfter(listEl);
+            if ($node.children('ul, ol').length > 0) {
+              newBlockEl.after($node.children('ul, ol'));
+            }
+          }
+        }
+        if ($node.prev('li').length) {
+          $node.remove();
+        } else {
+          listEl.remove();
+        }
+        _this.editor.selection.setRangeAtStartOf(newBlockEl);
+        return true;
+      };
+    })(this));
+    this.add('enter', 'pre', (function(_this) {
+      return function(e, $node) {
+        var $p, breakNode, range;
+        e.preventDefault();
+        if (e.shiftKey) {
+          $p = $('<p/>').append(_this.editor.util.phBr).insertAfter($node);
+          _this.editor.selection.setRangeAtStartOf($p);
+          return true;
+        }
+        range = _this.editor.selection.range();
+        breakNode = null;
+        range.deleteContents();
+        if (!_this.editor.util.browser.msie && _this.editor.selection.rangeAtEndOf($node)) {
+          breakNode = document.createTextNode('\n\n');
+          range.insertNode(breakNode);
+          range.setEnd(breakNode, 1);
+        } else {
+          breakNode = document.createTextNode('\n');
+          range.insertNode(breakNode);
+          range.setStartAfter(breakNode);
+        }
+        range.collapse(false);
+        _this.editor.selection.range(range);
+        return true;
+      };
+    })(this));
+    this.add('enter', 'blockquote', (function(_this) {
+      return function(e, $node) {
+        var $closestBlock, range;
+        $closestBlock = _this.editor.selection.blockNodes().last();
+        if (!($closestBlock.is('p') && !$closestBlock.next().length && _this.editor.util.isEmptyNode($closestBlock))) {
+          return;
+        }
+        $node.after($closestBlock);
+        range = document.createRange();
+        _this.editor.selection.setRangeAtStartOf($closestBlock, range);
+        return true;
+      };
+    })(this));
+    this.add('backspace', 'li', (function(_this) {
+      return function(e, $node) {
+        var $br, $childList, $newLi, $prevChildList, $prevNode, $textNode, isFF, range, text;
+        $childList = $node.children('ul, ol');
+        $prevNode = $node.prev('li');
+        if (!($childList.length > 0 && $prevNode.length > 0)) {
+          return false;
+        }
+        text = '';
+        $textNode = null;
+        $node.contents().each(function(i, n) {
+          if (n.nodeType === 1 && /UL|OL/.test(n.nodeName)) {
+            return false;
+          }
+          if (n.nodeType === 1 && /BR/.test(n.nodeName)) {
+            return;
+          }
+          if (n.nodeType === 3 && n.nodeValue) {
+            text += n.nodeValue;
+          } else if (n.nodeType === 1) {
+            text += $(n).text();
+          }
+          return $textNode = $(n);
+        });
+        isFF = _this.editor.util.browser.firefox && !$textNode.next('br').length;
+        if ($textNode && text.length === 1 && isFF) {
+          $br = $(_this.editor.util.phBr).insertAfter($textNode);
+          $textNode.remove();
+          _this.editor.selection.setRangeBefore($br);
+          return true;
+        } else if (text.length > 0) {
+          return false;
+        }
+        range = document.createRange();
+        $prevChildList = $prevNode.children('ul, ol');
+        if ($prevChildList.length > 0) {
+          $newLi = $('<li/>').append(_this.editor.util.phBr).appendTo($prevChildList);
+          $prevChildList.append($childList.children('li'));
+          $node.remove();
+          _this.editor.selection.setRangeAtEndOf($newLi, range);
+        } else {
+          _this.editor.selection.setRangeAtEndOf($prevNode, range);
+          $prevNode.append($childList);
+          $node.remove();
+          _this.editor.selection.range(range);
+        }
+        return true;
+      };
+    })(this));
+    this.add('backspace', 'pre', (function(_this) {
+      return function(e, $node) {
+        var $newNode, codeStr, range;
+        if (!_this.editor.selection.rangeAtStartOf($node)) {
+          return;
+        }
+        codeStr = $node.html().replace('\n', '<br/>') || _this.editor.util.phBr;
+        $newNode = $('<p/>').append(codeStr).insertAfter($node);
+        $node.remove();
+        range = document.createRange();
+        _this.editor.selection.setRangeAtStartOf($newNode, range);
+        return true;
+      };
+    })(this));
+    return this.add('backspace', 'blockquote', (function(_this) {
+      return function(e, $node) {
+        var $firstChild, range;
+        if (!_this.editor.selection.rangeAtStartOf($node)) {
+          return;
+        }
+        $firstChild = $node.children().first().unwrap();
+        range = document.createRange();
+        _this.editor.selection.setRangeAtStartOf($firstChild, range);
+        return true;
+      };
+    })(this));
+  };
+
+  return Keystroke;
+
+})(SimpleModule);
+
+UndoManager = (function(superClass) {
+  extend(UndoManager, superClass);
+
+  function UndoManager() {
+    return UndoManager.__super__.constructor.apply(this, arguments);
+  }
+
+  UndoManager.pluginName = 'UndoManager';
+
+  UndoManager.prototype._index = -1;
+
+  UndoManager.prototype._capacity = 20;
+
+  UndoManager.prototype._startPosition = null;
+
+  UndoManager.prototype._endPosition = null;
+
+  UndoManager.prototype._init = function() {
+    var redoShortcut, undoShortcut;
+    this.editor = this._module;
+    this._stack = [];
+    if (this.editor.util.os.mac) {
+      undoShortcut = 'cmd+z';
+      redoShortcut = 'shift+cmd+z';
+    } else if (this.editor.util.os.win) {
+      undoShortcut = 'ctrl+z';
+      redoShortcut = 'ctrl+y';
+    } else {
+      undoShortcut = 'ctrl+z';
+      redoShortcut = 'shift+ctrl+z';
+    }
+    this.editor.hotkeys.add(undoShortcut, (function(_this) {
+      return function(e) {
+        e.preventDefault();
+        _this.undo();
+        return false;
+      };
+    })(this));
+    this.editor.hotkeys.add(redoShortcut, (function(_this) {
+      return function(e) {
+        e.preventDefault();
+        _this.redo();
+        return false;
+      };
+    })(this));
+    this.throttledPushState = this.editor.util.throttle((function(_this) {
+      return function() {
+        return _this._pushUndoState();
+      };
+    })(this), 2000);
+    this.editor.on('valuechanged', (function(_this) {
+      return function(e, src) {
+        if (src === 'undo' || src === 'redo') {
+          return;
+        }
+        return _this.throttledPushState();
+      };
+    })(this));
+    this.editor.on('selectionchanged', (function(_this) {
+      return function(e) {
+        _this.resetCaretPosition();
+        return _this.update();
+      };
+    })(this));
+    this.editor.on('focus', (function(_this) {
+      return function(e) {
+        if (_this._stack.length === 0) {
+          return _this._pushUndoState();
+        }
+      };
+    })(this));
+    return this.editor.on('blur', (function(_this) {
+      return function(e) {
+        return _this.resetCaretPosition();
+      };
+    })(this));
+  };
+
+  UndoManager.prototype.resetCaretPosition = function() {
+    this._startPosition = null;
+    return this._endPosition = null;
+  };
+
+  UndoManager.prototype.startPosition = function() {
+    if (this.editor.selection._range) {
+      this._startPosition || (this._startPosition = this._getPosition('start'));
+    }
+    return this._startPosition;
+  };
+
+  UndoManager.prototype.endPosition = function() {
+    if (this.editor.selection._range) {
+      this._endPosition || (this._endPosition = (function(_this) {
+        return function() {
+          var range;
+          range = _this.editor.selection.range();
+          if (range.collapsed) {
+            return _this._startPosition;
+          }
+          return _this._getPosition('end');
+        };
+      })(this)());
+    }
+    return this._endPosition;
+  };
+
+  UndoManager.prototype._pushUndoState = function() {
+    var caret;
+    if (this.editor.triggerHandler('pushundostate') === false) {
+      return;
+    }
+    caret = this.caretPosition();
+    if (!caret.start) {
+      return;
+    }
+    this._index += 1;
+    this._stack.length = this._index;
+    this._stack.push({
+      html: this.editor.body.html(),
+      caret: this.caretPosition()
+    });
+    if (this._stack.length > this._capacity) {
+      this._stack.shift();
+      return this._index -= 1;
+    }
+  };
+
+  UndoManager.prototype.currentState = function() {
+    if (this._stack.length && this._index > -1) {
+      return this._stack[this._index];
+    } else {
+      return null;
+    }
+  };
+
+  UndoManager.prototype.undo = function() {
+    var state;
+    if (this._index < 1 || this._stack.length < 2) {
+      return;
+    }
+    this.editor.hidePopover();
+    this._index -= 1;
+    state = this._stack[this._index];
+    this.editor.body.get(0).innerHTML = state.html;
+    this.caretPosition(state.caret);
+    this.editor.body.find('.selected').removeClass('selected');
+    this.editor.sync();
+    return this.editor.trigger('valuechanged', ['undo']);
+  };
+
+  UndoManager.prototype.redo = function() {
+    var state;
+    if (this._index < 0 || this._stack.length < this._index + 2) {
+      return;
+    }
+    this.editor.hidePopover();
+    this._index += 1;
+    state = this._stack[this._index];
+    this.editor.body.get(0).innerHTML = state.html;
+    this.caretPosition(state.caret);
+    this.editor.body.find('.selected').removeClass('selected');
+    this.editor.sync();
+    return this.editor.trigger('valuechanged', ['redo']);
+  };
+
+  UndoManager.prototype.update = function() {
+    var currentState;
+    currentState = this.currentState();
+    if (!currentState) {
+      return;
+    }
+    currentState.html = this.editor.body.html();
+    return currentState.caret = this.caretPosition();
+  };
+
+  UndoManager.prototype._getNodeOffset = function(node, index) {
+    var $parent, merging, offset;
+    if ($.isNumeric(index)) {
+      $parent = $(node);
+    } else {
+      $parent = $(node).parent();
+    }
+    offset = 0;
+    merging = false;
+    $parent.contents().each(function(i, child) {
+      if (node === child || (index === i && i === 0)) {
+        return false;
+      }
+      if (child.nodeType === Node.TEXT_NODE) {
+        if (!merging && child.nodeValue.length > 0) {
+          offset += 1;
+          merging = true;
+        }
+      } else {
+        offset += 1;
+        merging = false;
+      }
+      if (index - 1 === i) {
+        return false;
+      }
+      return null;
+    });
+    return offset;
+  };
+
+  UndoManager.prototype._getPosition = function(type) {
+    var $nodes, node, nodes, offset, position, prevNode, range;
+    if (type == null) {
+      type = 'start';
+    }
+    range = this.editor.selection.range();
+    offset = range[type + "Offset"];
+    $nodes = this.editor.selection[type + "Nodes"]();
+    node = $nodes.first()[0];
+    if (node.nodeType === Node.TEXT_NODE) {
+      prevNode = node.previousSibling;
+      while (prevNode && prevNode.nodeType === Node.TEXT_NODE) {
+        node = prevNode;
+        offset += this.editor.util.getNodeLength(prevNode);
+        prevNode = prevNode.previousSibling;
+      }
+      nodes = $nodes.get();
+      nodes[0] = node;
+      $nodes = $(nodes);
+    } else {
+      offset = this._getNodeOffset(node, offset);
+    }
+    position = [offset];
+    $nodes.each((function(_this) {
+      return function(i, node) {
+        return position.unshift(_this._getNodeOffset(node));
+      };
+    })(this));
+    return position;
+  };
+
+  UndoManager.prototype._getNodeByPosition = function(position) {
+    var child, childNodes, i, k, len, node, offset, ref;
+    node = this.editor.body[0];
+    ref = position.slice(0, position.length - 1);
+    for (i = k = 0, len = ref.length; k < len; i = ++k) {
+      offset = ref[i];
+      childNodes = node.childNodes;
+      if (offset > childNodes.length - 1) {
+        if (i === position.length - 2 && $(node).is('pre:empty')) {
+          child = document.createTextNode('');
+          node.appendChild(child);
+          childNodes = node.childNodes;
+        } else {
+          node = null;
+          break;
+        }
+      }
+      node = childNodes[offset];
+    }
+    return node;
+  };
+
+  UndoManager.prototype.caretPosition = function(caret) {
+    var endContainer, endOffset, range, startContainer, startOffset;
+    if (!caret) {
+      range = this.editor.selection.range();
+      caret = this.editor.inputManager.focused && (range != null) ? {
+        start: this.startPosition(),
+        end: this.endPosition(),
+        collapsed: range.collapsed
+      } : {};
+      return caret;
+    } else {
+      if (!caret.start) {
+        return;
+      }
+      startContainer = this._getNodeByPosition(caret.start);
+      startOffset = caret.start[caret.start.length - 1];
+      if (caret.collapsed) {
+        endContainer = startContainer;
+        endOffset = startOffset;
+      } else {
+        endContainer = this._getNodeByPosition(caret.end);
+        endOffset = caret.start[caret.start.length - 1];
+      }
+      if (!startContainer || !endContainer) {
+        if (typeof console !== "undefined" && console !== null) {
+          if (typeof console.warn === "function") {
+            console.warn('simditor: invalid caret state');
+          }
+        }
+        return;
+      }
+
+      range = document.createRange();
+      range.setStart(startContainer, startOffset);
+      range.setEnd(endContainer, endOffset);
+      return this.editor.selection.range(range);
+    }
+  };
+
+  return UndoManager;
+
+})(SimpleModule);
+
+Util = (function(superClass) {
+  extend(Util, superClass);
+
+  function Util() {
+    return Util.__super__.constructor.apply(this, arguments);
+  }
+
+  Util.pluginName = 'Util';
+
+  Util.prototype._init = function() {
+    this.editor = this._module;
+    if (this.browser.msie && this.browser.version < 11) {
+      return this.phBr = '';
+    }
+  };
+
+  Util.prototype.phBr = '<br/>';
+
+  Util.prototype.os = (function() {
+    var os;
+    os = {};
+    if (/Mac/.test(navigator.appVersion)) {
+      os.mac = true;
+    } else if (/Linux/.test(navigator.appVersion)) {
+      os.linux = true;
+    } else if (/Win/.test(navigator.appVersion)) {
+      os.win = true;
+    } else if (/X11/.test(navigator.appVersion)) {
+      os.unix = true;
+    }
+    if (/Mobi/.test(navigator.appVersion)) {
+      os.mobile = true;
+    }
+    return os;
+  })();
+
+  Util.prototype.browser = (function() {
+    var chrome, edge, firefox, ie, ref, ref1, ref2, ref3, ref4, safari, ua;
+    ua = navigator.userAgent;
+    ie = /(msie|trident)/i.test(ua);
+    chrome = /chrome|crios/i.test(ua);
+    safari = /safari/i.test(ua) && !chrome;
+    firefox = /firefox/i.test(ua);
+    edge = /edge/i.test(ua);
+    if (ie) {
+      return {
+        msie: true,
+        version: ((ref = ua.match(/(msie |rv:)(\d+(\.\d+)?)/i)) != null ? ref[2] : void 0) * 1
+      };
+    } else if (edge) {
+      return {
+        edge: true,
+        webkit: true,
+        version: ((ref1 = ua.match(/edge\/(\d+(\.\d+)?)/i)) != null ? ref1[1] : void 0) * 1
+      };
+    } else if (chrome) {
+      return {
+        webkit: true,
+        chrome: true,
+        version: ((ref2 = ua.match(/(?:chrome|crios)\/(\d+(\.\d+)?)/i)) != null ? ref2[1] : void 0) * 1
+      };
+    } else if (safari) {
+      return {
+        webkit: true,
+        safari: true,
+        version: ((ref3 = ua.match(/version\/(\d+(\.\d+)?)/i)) != null ? ref3[1] : void 0) * 1
+      };
+    } else if (firefox) {
+      return {
+        mozilla: true,
+        firefox: true,
+        version: ((ref4 = ua.match(/firefox\/(\d+(\.\d+)?)/i)) != null ? ref4[1] : void 0) * 1
+      };
+    } else {
+      return {};
+    }
+  })();
+
+  Util.prototype.support = (function() {
+    return {
+      onselectionchange: (function() {
+        var e, onselectionchange;
+        onselectionchange = document.onselectionchange;
+        if (onselectionchange !== void 0) {
+          try {
+            document.onselectionchange = 0;
+            return document.onselectionchange === null;
+          } catch (_error) {
+            e = _error;
+          } finally {
+            document.onselectionchange = onselectionchange;
+          }
+        }
+        return false;
+      })(),
+      oninput: (function() {
+        return !/(msie|trident)/i.test(navigator.userAgent);
+      })()
+    };
+  })();
+
+  Util.prototype.reflow = function(el) {
+    if (el == null) {
+      el = document;
+    }
+    return $(el)[0].offsetHeight;
+  };
+
+  Util.prototype.metaKey = function(e) {
+    var isMac;
+    isMac = /Mac/.test(navigator.userAgent);
+    if (isMac) {
+      return e.metaKey;
+    } else {
+      return e.ctrlKey;
+    }
+  };
+
+  Util.prototype.isEmptyNode = function(node) {
+    var $node;
+    $node = $(node);
+    return $node.is(':empty') || (!$node.text() && !$node.find(':not(br, span, div)').length);
+  };
+
+  Util.prototype.isDecoratedNode = function(node) {
+    return $(node).is('[class^="simditor-"]');
+  };
+
+  Util.prototype.blockNodes = ["div", "p", "ul", "ol", "li", "blockquote", "hr", "pre", "h1", "h2", "h3", "h4", "h5", "table"];
+
+  Util.prototype.isBlockNode = function(node) {
+    node = $(node)[0];
+    if (!node || node.nodeType === 3) {
+      return false;
+    }
+    return new RegExp("^(" + (this.blockNodes.join('|')) + ")$").test(node.nodeName.toLowerCase());
+  };
+
+  Util.prototype.getNodeLength = function(node) {
+    node = $(node)[0];
+    switch (node.nodeType) {
+      case 7:
+      case 10:
+        return 0;
+      case 3:
+      case 8:
+        return node.length;
+      default:
+        return node.childNodes.length;
+    }
+  };
+
+  Util.prototype.dataURLtoBlob = function(dataURL) {
+    var BlobBuilder, arrayBuffer, bb, blobArray, byteString, hasArrayBufferViewSupport, hasBlobConstructor, i, intArray, k, mimeString, ref, supportBlob;
+    hasBlobConstructor = window.Blob && (function() {
+      var e;
+      try {
+        return Boolean(new Blob());
+      } catch (_error) {
+        e = _error;
+        return false;
+      }
+    })();
+    hasArrayBufferViewSupport = hasBlobConstructor && window.Uint8Array && (function() {
+      var e;
+      try {
+        return new Blob([new Uint8Array(100)]).size === 100;
+      } catch (_error) {
+        e = _error;
+        return false;
+      }
+    })();
+    BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder || window.MSBlobBuilder;
+    supportBlob = hasBlobConstructor || BlobBuilder;
+    if (!(supportBlob && window.atob && window.ArrayBuffer && window.Uint8Array)) {
+      return false;
+    }
+    if (dataURL.split(',')[0].indexOf('base64') >= 0) {
+      byteString = atob(dataURL.split(',')[1]);
+    } else {
+      byteString = decodeURIComponent(dataURL.split(',')[1]);
+    }
+    arrayBuffer = new ArrayBuffer(byteString.length);
+    intArray = new Uint8Array(arrayBuffer);
+    for (i = k = 0, ref = byteString.length; 0 <= ref ? k <= ref : k >= ref; i = 0 <= ref ? ++k : --k) {
+      intArray[i] = byteString.charCodeAt(i);
+    }
+    mimeString = dataURL.split(',')[0].split(':')[1].split(';')[0];
+    if (hasBlobConstructor) {
+      blobArray = hasArrayBufferViewSupport ? intArray : arrayBuffer;
+      return new Blob([blobArray], {
+        type: mimeString
+      });
+    }
+    bb = new BlobBuilder();
+    bb.append(arrayBuffer);
+    return bb.getBlob(mimeString);
+  };
+
+  Util.prototype.throttle = function(func, wait) {
+    var args, call, ctx, last, rtn, throttled, timeoutID;
+    last = 0;
+    timeoutID = 0;
+    ctx = args = rtn = null;
+    call = function() {
+      timeoutID = 0;
+      last = +new Date();
+      rtn = func.apply(ctx, args);
+      ctx = null;
+      return args = null;
+    };
+    throttled = function() {
+      var delta;
+      ctx = this;
+      args = arguments;
+      delta = new Date() - last;
+      if (!timeoutID) {
+        if (delta >= wait) {
+          call();
+        } else {
+          timeoutID = setTimeout(call, wait - delta);
+        }
+      }
+      return rtn;
+    };
+    throttled.clear = function() {
+      if (!timeoutID) {
+        return;
+      }
+      clearTimeout(timeoutID);
+      return call();
+    };
+    return throttled;
+  };
+
+  Util.prototype.formatHTML = function(html) {
+    var cursor, indentString, lastMatch, level, match, re, repeatString, result, str;
+    re = /<(\/?)(.+?)(\/?)>/g;
+    result = '';
+    level = 0;
+    lastMatch = null;
+    indentString = '  ';
+    repeatString = function(str, n) {
+      return new Array(n + 1).join(str);
+    };
+    while ((match = re.exec(html)) !== null) {
+      match.isBlockNode = $.inArray(match[2], this.blockNodes) > -1;
+      match.isStartTag = match[1] !== '/' && match[3] !== '/';
+      match.isEndTag = match[1] === '/' || match[3] === '/';
+      cursor = lastMatch ? lastMatch.index + lastMatch[0].length : 0;
+      if ((str = html.substring(cursor, match.index)).length > 0 && $.trim(str)) {
+        result += str;
+      }
+      if (match.isBlockNode && match.isEndTag && !match.isStartTag) {
+        level -= 1;
+      }
+      if (match.isBlockNode && match.isStartTag) {
+        if (!(lastMatch && lastMatch.isBlockNode && lastMatch.isEndTag)) {
+          result += '\n';
+        }
+        result += repeatString(indentString, level);
+      }
+      result += match[0];
+      if (match.isBlockNode && match.isEndTag) {
+        result += '\n';
+      }
+      if (match.isBlockNode && match.isStartTag) {
+        level += 1;
+      }
+      lastMatch = match;
+    }
+    return $.trim(result);
+  };
+
+  return Util;
+
+})(SimpleModule);
+
+Toolbar = (function(superClass) {
+  extend(Toolbar, superClass);
+
+  function Toolbar() {
+    return Toolbar.__super__.constructor.apply(this, arguments);
+  }
+
+  Toolbar.pluginName = 'Toolbar';
+
+  Toolbar.prototype.opts = {
+    toolbar: true,
+    toolbarFloat: true,
+    toolbarHidden: false,
+    toolbarFloatOffset: 0
+  };
+
+  Toolbar.prototype._tpl = {
+    wrapper: '<div class="simditor-toolbar"><ul></ul></div>',
+    separator: '<li><span class="separator"></span></li>'
+  };
+
+  Toolbar.prototype._init = function() {
+    var floatInitialized, initToolbarFloat, toolbarHeight;
+    this.editor = this._module;
+    if (!this.opts.toolbar) {
+      return;
+    }
+    if (!$.isArray(this.opts.toolbar)) {
+      this.opts.toolbar = ['bold', 'italic', 'underline', 'strikethrough', '|', 'ol', 'ul', 'blockquote', 'code', '|', 'link', 'image', '|', 'indent', 'outdent'];
+    }
+    this._render();
+    this.list.on('click', function(e) {
+      return false;
+    });
+    this.wrapper.on('mousedown', (function(_this) {
+      return function(e) {
+        return _this.list.find('.menu-on').removeClass('.menu-on');
+      };
+    })(this));
+    $(document).on('mousedown.simditor' + this.editor.id, (function(_this) {
+      return function(e) {
+        return _this.list.find('.menu-on').removeClass('.menu-on');
+      };
+    })(this));
+    if (!this.opts.toolbarHidden && this.opts.toolbarFloat) {
+      this.wrapper.css('top', this.opts.toolbarFloatOffset);
+      toolbarHeight = 0;
+      initToolbarFloat = (function(_this) {
+        return function() {
+          _this.wrapper.css('position', 'static');
+          _this.wrapper.width('auto');
+          _this.editor.util.reflow(_this.wrapper);
+          _this.wrapper.width(_this.wrapper.outerWidth());
+          _this.wrapper.css('left', _this.editor.util.os.mobile ? _this.wrapper.position().left : _this.wrapper.offset().left);
+          _this.wrapper.css('position', '');
+          toolbarHeight = _this.wrapper.outerHeight();
+          _this.editor.placeholderEl.css('top', toolbarHeight);
+          return true;
+        };
+      })(this);
+      floatInitialized = null;
+      $(window).on('resize.simditor-' + this.editor.id, function(e) {
+        return floatInitialized = initToolbarFloat();
+      });
+      $(window).on('scroll.simditor-' + this.editor.id, (function(_this) {
+        return function(e) {
+          var bottomEdge, scrollTop, topEdge;
+          if (!_this.wrapper.is(':visible')) {
+            return;
+          }
+          topEdge = _this.editor.wrapper.offset().top;
+          bottomEdge = topEdge + _this.editor.wrapper.outerHeight() - 80;
+          scrollTop = $(document).scrollTop() + _this.opts.toolbarFloatOffset;
+          if (scrollTop <= topEdge || scrollTop >= bottomEdge) {
+            _this.editor.wrapper.removeClass('toolbar-floating').css('padding-top', '');
+            if (_this.editor.util.os.mobile) {
+              return _this.wrapper.css('top', _this.opts.toolbarFloatOffset);
+            }
+          } else {
+            floatInitialized || (floatInitialized = initToolbarFloat());
+            _this.editor.wrapper.addClass('toolbar-floating').css('padding-top', toolbarHeight);
+            if (_this.editor.util.os.mobile) {
+              return _this.wrapper.css('top', scrollTop - topEdge + _this.opts.toolbarFloatOffset);
+            }
+          }
+        };
+      })(this));
+    }
+    this.editor.on('destroy', (function(_this) {
+      return function() {
+        return _this.buttons.length = 0;
+      };
+    })(this));
+    return $(document).on("mousedown.simditor-" + this.editor.id, (function(_this) {
+      return function(e) {
+        return _this.list.find('li.menu-on').removeClass('menu-on');
+      };
+    })(this));
+  };
+
+  Toolbar.prototype._render = function() {
+    var k, len, name, ref;
+    this.buttons = [];
+    this.wrapper = $(this._tpl.wrapper).prependTo(this.editor.wrapper);
+    this.list = this.wrapper.find('ul');
+    ref = this.opts.toolbar;
+    for (k = 0, len = ref.length; k < len; k++) {
+      name = ref[k];
+      if (name === '|') {
+        $(this._tpl.separator).appendTo(this.list);
+        continue;
+      }
+      if (!this.constructor.buttons[name]) {
+        throw new Error("simditor: invalid toolbar button " + name);
+        continue;
+      }
+      this.buttons.push(new this.constructor.buttons[name]({
+        editor: this.editor
+      }));
+    }
+    if (this.opts.toolbarHidden) {
+      return this.wrapper.hide();
+    }
+  };
+
+  Toolbar.prototype.findButton = function(name) {
+    var button;
+    button = this.list.find('.toolbar-item-' + name).data('button');
+    return button != null ? button : null;
+  };
+
+  Toolbar.addButton = function(btn) {
+    return this.buttons[btn.prototype.name] = btn;
+  };
+
+  Toolbar.buttons = {};
+
+  return Toolbar;
+
+})(SimpleModule);
+
+Indentation = (function(superClass) {
+  extend(Indentation, superClass);
+
+  function Indentation() {
+    return Indentation.__super__.constructor.apply(this, arguments);
+  }
+
+  Indentation.pluginName = 'Indentation';
+
+  Indentation.prototype.opts = {
+    tabIndent: true
+  };
+
+  Indentation.prototype._init = function() {
+    this.editor = this._module;
+    return this.editor.keystroke.add('tab', '*', (function(_this) {
+      return function(e) {
+        var codeButton;
+        codeButton = _this.editor.toolbar.findButton('code');
+        if (!(_this.opts.tabIndent || (codeButton && codeButton.active))) {
+          return;
+        }
+        return _this.indent(e.shiftKey);
+      };
+    })(this));
+  };
+
+  Indentation.prototype.indent = function(isBackward) {
+    var $blockNodes, $endNodes, $startNodes, nodes, result;
+    $startNodes = this.editor.selection.startNodes();
+    $endNodes = this.editor.selection.endNodes();
+    $blockNodes = this.editor.selection.blockNodes();
+    nodes = [];
+    $blockNodes = $blockNodes.each(function(i, node) {
+      var include, j, k, len, n;
+      include = true;
+      for (j = k = 0, len = nodes.length; k < len; j = ++k) {
+        n = nodes[j];
+        if ($.contains(node, n)) {
+          include = false;
+          break;
+        } else if ($.contains(n, node)) {
+          nodes.splice(j, 1, node);
+          include = false;
+          break;
+        }
+      }
+      if (include) {
+        return nodes.push(node);
+      }
+    });
+    $blockNodes = $(nodes);
+    result = false;
+    $blockNodes.each((function(_this) {
+      return function(i, blockEl) {
+        var r;
+        r = isBackward ? _this.outdentBlock(blockEl) : _this.indentBlock(blockEl);
+        if (r) {
+          return result = r;
+        }
+      };
+    })(this));
+    return result;
+  };
+
+  Indentation.prototype.indentBlock = function(blockEl) {
+    var $blockEl, $childList, $nextTd, $nextTr, $parentLi, $pre, $td, $tr, marginLeft, tagName;
+    $blockEl = $(blockEl);
+    if (!$blockEl.length) {
+      return;
+    }
+    if ($blockEl.is('pre')) {
+      $pre = this.editor.selection.containerNode();
+      if (!($pre.is($blockEl) || $pre.closest('pre').is($blockEl))) {
+        return;
+      }
+      this.indentText(this.editor.selection.range());
+    } else if ($blockEl.is('li')) {
+      $parentLi = $blockEl.prev('li');
+      if ($parentLi.length < 1) {
+        return;
+      }
+      this.editor.selection.save();
+      tagName = $blockEl.parent()[0].tagName;
+      $childList = $parentLi.children('ul, ol');
+      if ($childList.length > 0) {
+        $childList.append($blockEl);
+      } else {
+        $('<' + tagName + '/>').append($blockEl).appendTo($parentLi);
+      }
+      this.editor.selection.restore();
+    } else if ($blockEl.is('p, h1, h2, h3, h4')) {
+      marginLeft = parseInt($blockEl.css('margin-left')) || 0;
+      marginLeft = (Math.round(marginLeft / this.opts.indentWidth) + 1) * this.opts.indentWidth;
+      $blockEl.css('margin-left', marginLeft);
+    } else if ($blockEl.is('table') || $blockEl.is('.simditor-table')) {
+      $td = this.editor.selection.containerNode().closest('td, th');
+      $nextTd = $td.next('td, th');
+      if (!($nextTd.length > 0)) {
+        $tr = $td.parent('tr');
+        $nextTr = $tr.next('tr');
+        if ($nextTr.length < 1 && $tr.parent().is('thead')) {
+          $nextTr = $tr.parent('thead').next('tbody').find('tr:first');
+        }
+        $nextTd = $nextTr.find('td:first, th:first');
+      }
+      if (!($td.length > 0 && $nextTd.length > 0)) {
+        return;
+      }
+      this.editor.selection.setRangeAtEndOf($nextTd);
+    } else {
+      return false;
+    }
+    return true;
+  };
+
+  Indentation.prototype.indentText = function(range) {
+    var text, textNode;
+    text = range.toString().replace(/^(?=.+)/mg, '\u00A0\u00A0');
+    textNode = document.createTextNode(text || '\u00A0\u00A0');
+    range.deleteContents();
+    range.insertNode(textNode);
+    if (text) {
+      range.selectNode(textNode);
+      return this.editor.selection.range(range);
+    } else {
+      return this.editor.selection.setRangeAfter(textNode);
+    }
+  };
+
+  Indentation.prototype.outdentBlock = function(blockEl) {
+    var $blockEl, $parent, $parentLi, $pre, $prevTd, $prevTr, $td, $tr, marginLeft, range;
+    $blockEl = $(blockEl);
+    if (!($blockEl && $blockEl.length > 0)) {
+      return;
+    }
+    if ($blockEl.is('pre')) {
+      $pre = this.editor.selection.containerNode();
+      if (!($pre.is($blockEl) || $pre.closest('pre').is($blockEl))) {
+        return;
+      }
+      this.outdentText(range);
+    } else if ($blockEl.is('li')) {
+      $parent = $blockEl.parent();
+      $parentLi = $parent.parent('li');
+      this.editor.selection.save();
+      if ($parentLi.length < 1) {
+        range = document.createRange();
+        range.setStartBefore($parent[0]);
+        range.setEndBefore($blockEl[0]);
+        $parent.before(range.extractContents());
+        $('<p/>').insertBefore($parent).after($blockEl.children('ul, ol')).append($blockEl.contents());
+        $blockEl.remove();
+      } else {
+        if ($blockEl.next('li').length > 0) {
+          $('<' + $parent[0].tagName + '/>').append($blockEl.nextAll('li')).appendTo($blockEl);
+        }
+        $blockEl.insertAfter($parentLi);
+        if ($parent.children('li').length < 1) {
+          $parent.remove();
+        }
+      }
+      this.editor.selection.restore();
+    } else if ($blockEl.is('p, h1, h2, h3, h4')) {
+      marginLeft = parseInt($blockEl.css('margin-left')) || 0;
+      marginLeft = Math.max(Math.round(marginLeft / this.opts.indentWidth) - 1, 0) * this.opts.indentWidth;
+      $blockEl.css('margin-left', marginLeft === 0 ? '' : marginLeft);
+    } else if ($blockEl.is('table') || $blockEl.is('.simditor-table')) {
+      $td = this.editor.selection.containerNode().closest('td, th');
+      $prevTd = $td.prev('td, th');
+      if (!($prevTd.length > 0)) {
+        $tr = $td.parent('tr');
+        $prevTr = $tr.prev('tr');
+        if ($prevTr.length < 1 && $tr.parent().is('tbody')) {
+          $prevTr = $tr.parent('tbody').prev('thead').find('tr:first');
+        }
+        $prevTd = $prevTr.find('td:last, th:last');
+      }
+      if (!($td.length > 0 && $prevTd.length > 0)) {
+        return;
+      }
+      this.editor.selection.setRangeAtEndOf($prevTd);
+    } else {
+      return false;
+    }
+    return true;
+  };
+
+  Indentation.prototype.outdentText = function(range) {};
+
+  return Indentation;
+
+})(SimpleModule);
+
+Clipboard = (function(superClass) {
+  extend(Clipboard, superClass);
+
+  function Clipboard() {
+    return Clipboard.__super__.constructor.apply(this, arguments);
+  }
+
+  Clipboard.pluginName = 'Clipboard';
+
+  Clipboard.prototype.opts = {
+    pasteImage: false,
+    cleanPaste: false
+  };
+
+  Clipboard.prototype._init = function() {
+    this.editor = this._module;
+    if (this.opts.pasteImage && typeof this.opts.pasteImage !== 'string') {
+      this.opts.pasteImage = 'inline';
+    }
+    return this.editor.body.on('paste', (function(_this) {
+      return function(e) {
+        var range;
+        if (_this.pasting || _this._pasteBin) {
+          return;
+        }
+        if (_this.editor.triggerHandler(e) === false) {
+          return false;
+        }
+        range = _this.editor.selection.deleteRangeContents();
+        if (_this.editor.body.html()) {
+          if (!range.collapsed) {
+            range.collapse(true);
+          }
+        } else {
+          _this.editor.formatter.format();
+          _this.editor.selection.setRangeAtStartOf(_this.editor.body.find('p:first'));
+        }
+        if (_this._processPasteByClipboardApi(e)) {
+          return false;
+        }
+        _this.editor.inputManager.throttledValueChanged.clear();
+        _this.editor.inputManager.throttledSelectionChanged.clear();
+        _this.editor.undoManager.throttledPushState.clear();
+        _this.editor.selection.reset();
+        _this.editor.undoManager.resetCaretPosition();
+        _this.pasting = true;
+        return _this._getPasteContent(function(pasteContent) {
+          _this._processPasteContent(pasteContent);
+          _this._pasteInBlockEl = null;
+          _this._pastePlainText = null;
+          return _this.pasting = false;
+        });
+      };
+    })(this));
+  };
+
+  Clipboard.prototype._processPasteByClipboardApi = function(e) {
+    var imageFile, pasteItem, ref, uploadOpt;
+    if (this.editor.util.browser.edge) {
+      return;
+    }
+    if (e.originalEvent.clipboardData && e.originalEvent.clipboardData.items && e.originalEvent.clipboardData.items.length > 0) {
+      pasteItem = e.originalEvent.clipboardData.items[0];
+      if (/^image\//.test(pasteItem.type)) {
+        imageFile = pasteItem.getAsFile();
+        if (!((imageFile != null) && this.opts.pasteImage)) {
+          return;
+        }
+        if (!imageFile.name) {
+          imageFile.name = "Clipboard Image.png";
+        }
+        if (this.editor.triggerHandler('pasting', [imageFile]) === false) {
+          return;
+        }
+        uploadOpt = {};
+        uploadOpt[this.opts.pasteImage] = true;
+        if ((ref = this.editor.uploader) != null) {
+          ref.upload(imageFile, uploadOpt);
+        }
+        return true;
+      }
+    }
+  };
+
+  Clipboard.prototype._getPasteContent = function(callback) {
+    var state;
+    this._pasteBin = $('<div contenteditable="true" />').addClass('simditor-paste-bin').attr('tabIndex', '-1').appendTo(this.editor.el);
+    state = {
+      html: this.editor.body.html(),
+      caret: this.editor.undoManager.caretPosition()
+    };
+    this._pasteBin.focus();
+    return setTimeout((function(_this) {
+      return function() {
+        var pasteContent;
+        _this.editor.hidePopover();
+        _this.editor.body.get(0).innerHTML = state.html;
+        _this.editor.undoManager.caretPosition(state.caret);
+        _this.editor.body.focus();
+        _this.editor.selection.reset();
+        _this.editor.selection.range();
+        _this._pasteInBlockEl = _this.editor.selection.blockNodes().last();
+        _this._pastePlainText = _this.opts.cleanPaste || _this._pasteInBlockEl.is('pre, table');
+        if (_this._pastePlainText) {
+          pasteContent = _this.editor.formatter.clearHtml(_this._pasteBin.html(), true);
+        } else {
+          pasteContent = $('<div/>').append(_this._pasteBin.contents());
+          pasteContent.find('style').remove();
+          pasteContent.find('table colgroup').remove();
+          _this.editor.formatter.format(pasteContent);
+          _this.editor.formatter.decorate(pasteContent);
+          _this.editor.formatter.beautify(pasteContent.children());
+          pasteContent = pasteContent.contents();
+        }
+        _this._pasteBin.remove();
+        _this._pasteBin = null;
+        return callback(pasteContent);
+      };
+    })(this), 0);
+  };
+
+  Clipboard.prototype._processPasteContent = function(pasteContent) {
+    var $blockEl, $img, blob, children, dataURLtoBlob, img, insertPosition, k, l, lastLine, len, len1, len2, len3, len4, line, lines, m, node, o, q, ref, ref1, ref2, uploadOpt, uploader;
+    if (this.editor.triggerHandler('pasting', [pasteContent]) === false) {
+      return;
+    }
+    $blockEl = this._pasteInBlockEl;
+    if (!pasteContent) {
+      return;
+    } else if (this._pastePlainText) {
+      if ($blockEl.is('table')) {
+        lines = pasteContent.split('\n');
+        lastLine = lines.pop();
+        for (k = 0, len = lines.length; k < len; k++) {
+          line = lines[k];
+          this.editor.selection.insertNode(document.createTextNode(line));
+          this.editor.selection.insertNode($('<br/>'));
+        }
+        this.editor.selection.insertNode(document.createTextNode(lastLine));
+      } else {
+        pasteContent = $('<div/>').text(pasteContent);
+        ref = pasteContent.contents();
+        for (l = 0, len1 = ref.length; l < len1; l++) {
+          node = ref[l];
+          this.editor.selection.insertNode($(node)[0]);
+        }
+      }
+    } else if ($blockEl.is(this.editor.body)) {
+      for (m = 0, len2 = pasteContent.length; m < len2; m++) {
+        node = pasteContent[m];
+        this.editor.selection.insertNode(node);
+      }
+    } else if (pasteContent.length < 1) {
+      return;
+    } else if (pasteContent.length === 1) {
+      if (pasteContent.is('p')) {
+        children = pasteContent.contents();
+        if ($blockEl.is('h1, h2, h3, h4, h5')) {
+          if (children.length) {
+            children.css('font-size', '');
+          }
+        }
+        if (children.length === 1 && children.is('img')) {
+          $img = children;
+          if (/^data:image/.test($img.attr('src'))) {
+            if (!this.opts.pasteImage) {
+              return;
+            }
+            blob = this.editor.util.dataURLtoBlob($img.attr("src"));
+            blob.name = "Clipboard Image.png";
+            uploadOpt = {};
+            uploadOpt[this.opts.pasteImage] = true;
+            if ((ref1 = this.editor.uploader) != null) {
+              ref1.upload(blob, uploadOpt);
+            }
+            return;
+          } else if (new RegExp('^blob:' + location.origin + '/').test($img.attr('src'))) {
+            if (!this.opts.pasteImage) {
+              return;
+            }
+            uploadOpt = {};
+            uploadOpt[this.opts.pasteImage] = true;
+            dataURLtoBlob = this.editor.util.dataURLtoBlob;
+            uploader = this.editor.uploader;
+            img = new Image;
+            img.onload = function() {
+              var canvas;
+              canvas = document.createElement('canvas');
+              canvas.width = img.naturalWidth;
+              canvas.height = img.naturalHeight;
+              canvas.getContext('2d').drawImage(img, 0, 0);
+              blob = dataURLtoBlob(canvas.toDataURL('image/png'));
+              blob.name = 'Clipboard Image.png';
+              if (uploader !== null) {
+                uploader.upload(blob, uploadOpt);
+              }
+            };
+            img.src = $img.attr('src');
+            return;
+          } else if ($img.is('img[src^="webkit-fake-url://"]')) {
+            return;
+          }
+        }
+        for (o = 0, len3 = children.length; o < len3; o++) {
+          node = children[o];
+          this.editor.selection.insertNode(node);
+        }
+      } else if ($blockEl.is('p') && this.editor.util.isEmptyNode($blockEl)) {
+        $blockEl.replaceWith(pasteContent);
+        this.editor.selection.setRangeAtEndOf(pasteContent);
+      } else if (pasteContent.is('ul, ol')) {
+        if (pasteContent.find('li').length === 1) {
+          pasteContent = $('<div/>').text(pasteContent.text());
+          ref2 = pasteContent.contents();
+          for (q = 0, len4 = ref2.length; q < len4; q++) {
+            node = ref2[q];
+            this.editor.selection.insertNode($(node)[0]);
+          }
+        } else if ($blockEl.is('li')) {
+          $blockEl.parent().after(pasteContent);
+          this.editor.selection.setRangeAtEndOf(pasteContent);
+        } else {
+          $blockEl.after(pasteContent);
+          this.editor.selection.setRangeAtEndOf(pasteContent);
+        }
+      } else {
+        $blockEl.after(pasteContent);
+        this.editor.selection.setRangeAtEndOf(pasteContent);
+      }
+    } else {
+      if ($blockEl.is('li')) {
+        $blockEl = $blockEl.parent();
+      }
+      if (this.editor.selection.rangeAtStartOf($blockEl)) {
+        insertPosition = 'before';
+      } else if (this.editor.selection.rangeAtEndOf($blockEl)) {
+        insertPosition = 'after';
+      } else {
+        this.editor.selection.breakBlockEl($blockEl);
+        insertPosition = 'before';
+      }
+      $blockEl[insertPosition](pasteContent);
+      this.editor.selection.setRangeAtEndOf(pasteContent.last());
+    }
+    return this.editor.inputManager.throttledValueChanged();
+  };
+
+  return Clipboard;
+
+})(SimpleModule);
+
+Simditor = (function(superClass) {
+  extend(Simditor, superClass);
+
+  function Simditor() {
+    return Simditor.__super__.constructor.apply(this, arguments);
+  }
+
+  Simditor.connect(Util);
+
+  Simditor.connect(InputManager);
+
+  Simditor.connect(Selection);
+
+  Simditor.connect(UndoManager);
+
+  Simditor.connect(Keystroke);
+
+  Simditor.connect(Formatter);
+
+  Simditor.connect(Toolbar);
+
+  Simditor.connect(Indentation);
+
+  Simditor.connect(Clipboard);
+
+  Simditor.count = 0;
+
+  Simditor.prototype.opts = {
+    textarea: null,
+    placeholder: '',
+    defaultImage: 'images/image.png',
+    params: {},
+    upload: false,
+    indentWidth: 40
+  };
+
+  Simditor.prototype._init = function() {
+    var e, editor, uploadOpts;
+    this.textarea = $(this.opts.textarea);
+    this.opts.placeholder = this.opts.placeholder || this.textarea.attr('placeholder');
+    if (!this.textarea.length) {
+      throw new Error('simditor: param textarea is required.');
+      return;
+    }
+    editor = this.textarea.data('simditor');
+    if (editor != null) {
+      editor.destroy();
+    }
+    this.id = ++Simditor.count;
+    this._render();
+    if (simpleHotkeys) {
+      this.hotkeys = simpleHotkeys({
+        el: this.body
+      });
+    } else {
+      throw new Error('simditor: simple-hotkeys is required.');
+      return;
+    }
+    if (this.opts.upload && simpleUploader) {
+      uploadOpts = typeof this.opts.upload === 'object' ? this.opts.upload : {};
+      this.uploader = simpleUploader(uploadOpts);
+    }
+    this.on('initialized', (function(_this) {
+      return function() {
+        if (_this.opts.placeholder) {
+          _this.on('valuechanged', function() {
+            return _this._placeholder();
+          });
+        }
+        _this.setValue(_this.textarea.val().trim() || '');
+        if (_this.textarea.attr('autofocus')) {
+          return _this.focus();
+        }
+      };
+    })(this));
+    if (this.util.browser.mozilla) {
+      this.util.reflow();
+      try {
+        document.execCommand('enableObjectResizing', false, false);
+        return document.execCommand('enableInlineTableEditing', false, false);
+      } catch (_error) {
+        e = _error;
+      }
+    }
+  };
+
+  Simditor.prototype._tpl = "<div class=\"simditor\">\n  <div class=\"simditor-wrapper\">\n    <div class=\"simditor-placeholder\"></div>\n    <div class=\"simditor-body\" contenteditable=\"true\">\n    </div>\n  </div>\n</div>";
+
+  Simditor.prototype._render = function() {
+    var key, ref, results, val;
+    this.el = $(this._tpl).insertBefore(this.textarea);
+    this.wrapper = this.el.find('.simditor-wrapper');
+    this.body = this.wrapper.find('.simditor-body');
+    this.placeholderEl = this.wrapper.find('.simditor-placeholder').append(this.opts.placeholder);
+    this.el.data('simditor', this);
+    this.wrapper.append(this.textarea);
+    this.textarea.data('simditor', this).blur();
+    this.body.attr('tabindex', this.textarea.attr('tabindex'));
+    if (this.util.os.mac) {
+      this.el.addClass('simditor-mac');
+    } else if (this.util.os.linux) {
+      this.el.addClass('simditor-linux');
+    }
+    if (this.util.os.mobile) {
+      this.el.addClass('simditor-mobile');
+    }
+    if (this.opts.params) {
+      ref = this.opts.params;
+      results = [];
+      for (key in ref) {
+        val = ref[key];
+        results.push($('<input/>', {
+          type: 'hidden',
+          name: key,
+          value: val
+        }).insertAfter(this.textarea));
+      }
+      return results;
+    }
+  };
+
+  Simditor.prototype._placeholder = function() {
+    var children;
+    children = this.body.children();
+    if (children.length === 0 || (children.length === 1 && this.util.isEmptyNode(children) && parseInt(children.css('margin-left') || 0) < this.opts.indentWidth)) {
+      return this.placeholderEl.show();
+    } else {
+      return this.placeholderEl.hide();
+    }
+  };
+
+  Simditor.prototype.setValue = function(val) {
+    this.hidePopover();
+    this.textarea.val(val);
+    this.body.get(0).innerHTML = val;
+    this.formatter.format();
+    this.formatter.decorate();
+    this.util.reflow(this.body);
+    this.inputManager.lastCaretPosition = null;
+    return this.trigger('valuechanged');
+  };
+
+  Simditor.prototype.getValue = function() {
+    return this.sync();
+  };
+
+  Simditor.prototype.sync = function() {
+    var children, cloneBody, emptyP, firstP, lastP, val;
+    cloneBody = this.body.clone();
+    this.formatter.undecorate(cloneBody);
+    this.formatter.format(cloneBody);
+    this.formatter.autolink(cloneBody);
+    children = cloneBody.children();
+    lastP = children.last('p');
+    firstP = children.first('p');
+    while (lastP.is('p') && this.util.isEmptyNode(lastP)) {
+      emptyP = lastP;
+      lastP = lastP.prev('p');
+      emptyP.remove();
+    }
+    while (firstP.is('p') && this.util.isEmptyNode(firstP)) {
+      emptyP = firstP;
+      firstP = lastP.next('p');
+      emptyP.remove();
+    }
+    cloneBody.find('img.uploading').remove();
+    val = $.trim(cloneBody.html());
+    this.textarea.val(val);
+    return val;
+  };
+
+  Simditor.prototype.focus = function() {
+    var $blockEl, range;
+    if (!(this.body.is(':visible') && this.body.is('[contenteditable]'))) {
+      this.el.find('textarea:visible').focus();
+      return;
+    }
+    if (this.inputManager.lastCaretPosition) {
+      this.undoManager.caretPosition(this.inputManager.lastCaretPosition);
+      return this.inputManager.lastCaretPosition = null;
+    } else {
+      $blockEl = this.body.children().last();
+      if (!$blockEl.is('p')) {
+        $blockEl = $('<p/>').append(this.util.phBr).appendTo(this.body);
+      }
+      range = document.createRange();
+      return this.selection.setRangeAtEndOf($blockEl, range);
+    }
+  };
+
+  Simditor.prototype.blur = function() {
+    if (this.body.is(':visible') && this.body.is('[contenteditable]')) {
+      return this.body.blur();
+    } else {
+      return this.body.find('textarea:visible').blur();
+    }
+  };
+
+  Simditor.prototype.hidePopover = function() {
+    return this.el.find('.simditor-popover').each(function(i, popover) {
+      popover = $(popover).data('popover');
+      if (popover.active) {
+        return popover.hide();
+      }
+    });
+  };
+
+  Simditor.prototype.destroy = function() {
+    this.triggerHandler('destroy');
+    this.textarea.closest('form').off('.simditor .simditor-' + this.id);
+    this.selection.clear();
+    this.inputManager.focused = false;
+    this.textarea.insertBefore(this.el).hide().val('').removeData('simditor');
+    this.el.remove();
+    $(document).off('.simditor-' + this.id);
+    $(window).off('.simditor-' + this.id);
+    return this.off();
+  };
+
+  return Simditor;
+
+})(SimpleModule);
+
+Simditor.i18n = {
+  'zh-CN': {
+    'blockquote': '引用',
+    'bold': '加粗文字',
+    'code': '插入代码',
+    'color': '文字颜色',
+    'coloredText': '彩色文字',
+    'hr': '分隔线',
+    'image': '插入图片',
+    'externalImage': '外链图片',
+    'selectImage': '选择图片',
+    'uploadImage': '上传图片',
+    'uploadFailed': '上传失败了',
+    'uploadError': '上传出错了',
+    'imageUrl': '图片地址',
+    'imageSize': '图片尺寸',
+    'imageAlt': '图片描述',
+    'restoreImageSize': '还原图片尺寸',
+    'uploading': '正在上传',
+    'indent': '向右缩进',
+    'outdent': '向左缩进',
+    'italic': '斜体文字',
+    'link': '插入链接',
+    'linkText': '链接文字',
+    'linkUrl': '链接地址',
+    'linkTarget': '打开方式',
+    'openLinkInCurrentWindow': '在当前窗口中打开',
+    'openLinkInNewWindow': '在新窗口中打开',
+    'removeLink': '移除链接',
+    'ol': '有序列表',
+    'ul': '无序列表',
+    'strikethrough': '删除线文字',
+    'table': '表格',
+    'deleteRow': '删除行',
+    'insertRowAbove': '在上面插入行',
+    'insertRowBelow': '在下面插入行',
+    'deleteColumn': '删除列',
+    'insertColumnLeft': '在左边插入列',
+    'insertColumnRight': '在右边插入列',
+    'deleteTable': '删除表格',
+    'title': '标题',
+    'normalText': '普通文本',
+    'underline': '下划线文字',
+    'alignment': '水平对齐',
+    'alignCenter': '居中',
+    'alignLeft': '居左',
+    'alignRight': '居右',
+    'selectLanguage': '选择程序语言',
+    'fontScale': '字体大小',
+    'fontScaleXLarge': '超大字体',
+    'fontScaleLarge': '大号字体',
+    'fontScaleNormal': '正常大小',
+    'fontScaleSmall': '小号字体',
+    'fontScaleXSmall': '超小字体'
+  },
+  'en-US': {
+    'blockquote': 'Block Quote',
+    'bold': 'Bold',
+    'code': 'Code',
+    'color': 'Text Color',
+    'coloredText': 'Colored Text',
+    'hr': 'Horizontal Line',
+    'image': 'Insert Image',
+    'externalImage': 'External Image',
+    'selectImage': 'Select Image',
+    'uploadImage': 'Upload Image',
+    'uploadFailed': 'Upload failed',
+    'uploadError': 'Error occurs during upload',
+    'imageUrl': 'Url',
+    'imageSize': 'Size',
+    'imageAlt': 'Alt',
+    'restoreImageSize': 'Restore Origin Size',
+    'uploading': 'Uploading',
+    'indent': 'Indent',
+    'outdent': 'Outdent',
+    'italic': 'Italic',
+    'link': 'Insert Link',
+    'linkText': 'Text',
+    'linkUrl': 'Url',
+    'linkTarget': 'Target',
+    'openLinkInCurrentWindow': 'Open link in current window',
+    'openLinkInNewWindow': 'Open link in new window',
+    'removeLink': 'Remove Link',
+    'ol': 'Ordered List',
+    'ul': 'Unordered List',
+    'strikethrough': 'Strikethrough',
+    'table': 'Table',
+    'deleteRow': 'Delete Row',
+    'insertRowAbove': 'Insert Row Above',
+    'insertRowBelow': 'Insert Row Below',
+    'deleteColumn': 'Delete Column',
+    'insertColumnLeft': 'Insert Column Left',
+    'insertColumnRight': 'Insert Column Right',
+    'deleteTable': 'Delete Table',
+    'title': 'Title',
+    'normalText': 'Text',
+    'underline': 'Underline',
+    'alignment': 'Alignment',
+    'alignCenter': 'Align Center',
+    'alignLeft': 'Align Left',
+    'alignRight': 'Align Right',
+    'selectLanguage': 'Select Language',
+    'fontScale': 'Font Size',
+    'fontScaleXLarge': 'X Large Size',
+    'fontScaleLarge': 'Large Size',
+    'fontScaleNormal': 'Normal Size',
+    'fontScaleSmall': 'Small Size',
+    'fontScaleXSmall': 'X Small Size'
+  }
+};
+
+Button = (function(superClass) {
+  extend(Button, superClass);
+
+  Button.prototype._tpl = {
+    item: '<li><a tabindex="-1" unselectable="on" class="toolbar-item" href="javascript:;"><span></span></a></li>',
+    menuWrapper: '<div class="toolbar-menu"></div>',
+    menuItem: '<li><a tabindex="-1" unselectable="on" class="menu-item" href="javascript:;"><span></span></a></li>',
+    separator: '<li><span class="separator"></span></li>'
+  };
+
+  Button.prototype.name = '';
+
+  Button.prototype.icon = '';
+
+  Button.prototype.title = '';
+
+  Button.prototype.text = '';
+
+  Button.prototype.htmlTag = '';
+
+  Button.prototype.disableTag = '';
+
+  Button.prototype.menu = false;
+
+  Button.prototype.active = false;
+
+  Button.prototype.disabled = false;
+
+  Button.prototype.needFocus = true;
+
+  Button.prototype.shortcut = null;
+
+  function Button(opts) {
+    this.editor = opts.editor;
+    this.title = this._t(this.name);
+    Button.__super__.constructor.call(this, opts);
+  }
+
+  Button.prototype._init = function() {
+    var k, len, ref, tag;
+    this.render();
+    this.el.on('mousedown', (function(_this) {
+      return function(e) {
+        var exceed, noFocus, param;
+        e.preventDefault();
+        noFocus = _this.needFocus && !_this.editor.inputManager.focused;
+        if (_this.el.hasClass('disabled')) {
+          return false;
+        }
+        if (noFocus) {
+          _this.editor.focus();
+        }
+        if (_this.menu) {
+          _this.wrapper.toggleClass('menu-on').siblings('li').removeClass('menu-on');
+          if (_this.wrapper.is('.menu-on')) {
+            exceed = _this.menuWrapper.offset().left + _this.menuWrapper.outerWidth() + 5 - _this.editor.wrapper.offset().left - _this.editor.wrapper.outerWidth();
+            if (exceed > 0) {
+              _this.menuWrapper.css({
+                'left': 'auto',
+                'right': 0
+              });
+            }
+            _this.trigger('menuexpand');
+          }
+          return false;
+        }
+        param = _this.el.data('param');
+        _this.command(param);
+        return false;
+      };
+    })(this));
+    this.wrapper.on('click', 'a.menu-item', (function(_this) {
+      return function(e) {
+        var btn, noFocus, param;
+        e.preventDefault();
+        btn = $(e.currentTarget);
+        _this.wrapper.removeClass('menu-on');
+        noFocus = _this.needFocus && !_this.editor.inputManager.focused;
+        if (btn.hasClass('disabled') || noFocus) {
+          return false;
+        }
+        _this.editor.toolbar.wrapper.removeClass('menu-on');
+        param = btn.data('param');
+        if(btn.hasClass("menu-item-select-image")){
+            parent.Fast.api.open("general/attachment/select?element_id=&multiple=true&mimetype=image/*", "选择", {
+                callback: function (data) {
+                    var urlArr = data.url.split(/\,/);
+                    $.each(urlArr, function () {
+                        var url = Fast.api.cdnurl(this);
+                        _this.command(url);
+                    });
+                }
+            });
+            return false;
+        }else{
+          _this.command(param);
+        }
+        return false;
+      };
+    })(this));
+    this.wrapper.on('mousedown', 'a.menu-item', function(e) {
+      return false;
+    });
+    this.editor.on('blur', (function(_this) {
+      return function() {
+        var editorActive;
+        editorActive = _this.editor.body.is(':visible') && _this.editor.body.is('[contenteditable]');
+        if (!(editorActive && !_this.editor.clipboard.pasting)) {
+          return;
+        }
+        _this.setActive(false);
+        return _this.setDisabled(false);
+      };
+    })(this));
+    if (this.shortcut != null) {
+      this.editor.hotkeys.add(this.shortcut, (function(_this) {
+        return function(e) {
+          _this.el.mousedown();
+          return false;
+        };
+      })(this));
+    }
+    ref = this.htmlTag.split(',');
+    for (k = 0, len = ref.length; k < len; k++) {
+      tag = ref[k];
+      tag = $.trim(tag);
+      if (tag && $.inArray(tag, this.editor.formatter._allowedTags) < 0) {
+        this.editor.formatter._allowedTags.push(tag);
+      }
+    }
+    return this.editor.on('selectionchanged', (function(_this) {
+      return function(e) {
+        if (_this.editor.inputManager.focused) {
+          return _this._status();
+        }
+      };
+    })(this));
+  };
+
+  Button.prototype.iconClassOf = function(icon) {
+    if (icon) {
+      return "simditor-icon simditor-icon-" + icon;
+    } else {
+      return '';
+    }
+  };
+
+  Button.prototype.setIcon = function(icon) {
+    return this.el.find('span').removeClass().addClass(this.iconClassOf(icon)).text(this.text);
+  };
+
+  Button.prototype.render = function() {
+    this.wrapper = $(this._tpl.item).appendTo(this.editor.toolbar.list);
+    this.el = this.wrapper.find('a.toolbar-item');
+    this.el.attr('title', this.title).addClass("toolbar-item-" + this.name).data('button', this);
+    this.setIcon(this.icon);
+    if (!this.menu) {
+      return;
+    }
+    this.menuWrapper = $(this._tpl.menuWrapper).appendTo(this.wrapper);
+    this.menuWrapper.addClass("toolbar-menu-" + this.name);
+    return this.renderMenu();
+  };
+
+  Button.prototype.renderMenu = function() {
+    var $menuBtnEl, $menuItemEl, k, len, menuItem, ref, ref1, results;
+    if (!$.isArray(this.menu)) {
+      return;
+    }
+    this.menuEl = $('<ul/>').appendTo(this.menuWrapper);
+    ref = this.menu;
+    results = [];
+    for (k = 0, len = ref.length; k < len; k++) {
+      menuItem = ref[k];
+      if (menuItem === '|') {
+        $(this._tpl.separator).appendTo(this.menuEl);
+        continue;
+      }
+      $menuItemEl = $(this._tpl.menuItem).appendTo(this.menuEl);
+      $menuBtnEl = $menuItemEl.find('a.menu-item').attr({
+        'title': (ref1 = menuItem.title) != null ? ref1 : menuItem.text,
+        'data-param': menuItem.param
+      }).addClass('menu-item-' + menuItem.name);
+      if (menuItem.icon) {
+        results.push($menuBtnEl.find('span').addClass(this.iconClassOf(menuItem.icon)));
+      } else {
+        results.push($menuBtnEl.find('span').text(menuItem.text));
+      }
+    }
+    return results;
+  };
+
+  Button.prototype.setActive = function(active) {
+    if (active === this.active) {
+      return;
+    }
+    this.active = active;
+    return this.el.toggleClass('active', this.active);
+  };
+
+  Button.prototype.setDisabled = function(disabled) {
+    if (disabled === this.disabled) {
+      return;
+    }
+    this.disabled = disabled;
+    return this.el.toggleClass('disabled', this.disabled);
+  };
+
+  Button.prototype._disableStatus = function() {
+    var disabled, endNodes, startNodes;
+    startNodes = this.editor.selection.startNodes();
+    endNodes = this.editor.selection.endNodes();
+    disabled = startNodes.filter(this.disableTag).length > 0 || endNodes.filter(this.disableTag).length > 0;
+    this.setDisabled(disabled);
+    if (this.disabled) {
+      this.setActive(false);
+    }
+    return this.disabled;
+  };
+
+  Button.prototype._activeStatus = function() {
+    var active, endNode, endNodes, startNode, startNodes;
+    startNodes = this.editor.selection.startNodes();
+    endNodes = this.editor.selection.endNodes();
+    startNode = startNodes.filter(this.htmlTag);
+    endNode = endNodes.filter(this.htmlTag);
+    active = startNode.length > 0 && endNode.length > 0 && startNode.is(endNode);
+    this.node = active ? startNode : null;
+    this.setActive(active);
+    return this.active;
+  };
+
+  Button.prototype._status = function() {
+    this._disableStatus();
+    if (this.disabled) {
+      return;
+    }
+    return this._activeStatus();
+  };
+
+  Button.prototype.command = function(param) {};
+
+  Button.prototype._t = function() {
+    var args, ref, result;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    result = Button.__super__._t.apply(this, args);
+    if (!result) {
+      result = (ref = this.editor)._t.apply(ref, args);
+    }
+    return result;
+  };
+
+  return Button;
+
+})(SimpleModule);
+
+Simditor.Button = Button;
+
+Popover = (function(superClass) {
+  extend(Popover, superClass);
+
+  Popover.prototype.offset = {
+    top: 4,
+    left: 0
+  };
+
+  Popover.prototype.target = null;
+
+  Popover.prototype.active = false;
+
+  function Popover(opts) {
+    this.button = opts.button;
+    this.editor = opts.button.editor;
+    Popover.__super__.constructor.call(this, opts);
+  }
+
+  Popover.prototype._init = function() {
+    this.el = $('<div class="simditor-popover"></div>').appendTo(this.editor.el).data('popover', this);
+    this.render();
+    this.el.on('mouseenter', (function(_this) {
+      return function(e) {
+        return _this.el.addClass('hover');
+      };
+    })(this));
+    return this.el.on('mouseleave', (function(_this) {
+      return function(e) {
+        return _this.el.removeClass('hover');
+      };
+    })(this));
+  };
+
+  Popover.prototype.render = function() {};
+
+  Popover.prototype._initLabelWidth = function() {
+    var $fields;
+    $fields = this.el.find('.settings-field');
+    if (!($fields.length > 0)) {
+      return;
+    }
+    this._labelWidth = 0;
+    $fields.each((function(_this) {
+      return function(i, field) {
+        var $field, $label;
+        $field = $(field);
+        $label = $field.find('label');
+        if (!($label.length > 0)) {
+          return;
+        }
+        return _this._labelWidth = Math.max(_this._labelWidth, $label.width());
+      };
+    })(this));
+    return $fields.find('label').width(this._labelWidth);
+  };
+
+  Popover.prototype.show = function($target, position) {
+    if (position == null) {
+      position = 'bottom';
+    }
+    if ($target == null) {
+      return;
+    }
+    this.el.siblings('.simditor-popover').each(function(i, popover) {
+      popover = $(popover).data('popover');
+      if (popover.active) {
+        return popover.hide();
+      }
+    });
+    if (this.active && this.target) {
+      this.target.removeClass('selected');
+    }
+    this.target = $target.addClass('selected');
+    if (this.active) {
+      this.refresh(position);
+      return this.trigger('popovershow');
+    } else {
+      this.active = true;
+      this.el.css({
+        left: -9999
+      }).show();
+      if (!this._labelWidth) {
+        this._initLabelWidth();
+      }
+      this.editor.util.reflow();
+      this.refresh(position);
+      return this.trigger('popovershow');
+    }
+  };
+
+  Popover.prototype.hide = function() {
+    if (!this.active) {
+      return;
+    }
+    if (this.target) {
+      this.target.removeClass('selected');
+    }
+    this.target = null;
+    this.active = false;
+    this.el.hide();
+    return this.trigger('popoverhide');
+  };
+
+  Popover.prototype.refresh = function(position) {
+    var editorOffset, left, maxLeft, targetH, targetOffset, top;
+    if (position == null) {
+      position = 'bottom';
+    }
+    if (!this.active) {
+      return;
+    }
+    editorOffset = this.editor.el.offset();
+    targetOffset = this.target.offset();
+    targetH = this.target.outerHeight();
+    if (position === 'bottom') {
+      top = targetOffset.top - editorOffset.top + targetH;
+    } else if (position === 'top') {
+      top = targetOffset.top - editorOffset.top - this.el.height();
+    }
+    maxLeft = this.editor.wrapper.width() - this.el.outerWidth() - 10;
+    left = Math.min(targetOffset.left - editorOffset.left, maxLeft);
+    return this.el.css({
+      top: top + this.offset.top,
+      left: left + this.offset.left
+    });
+  };
+
+  Popover.prototype.destroy = function() {
+    this.target = null;
+    this.active = false;
+    this.editor.off('.linkpopover');
+    return this.el.remove();
+  };
+
+  Popover.prototype._t = function() {
+    var args, ref, result;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    result = Popover.__super__._t.apply(this, args);
+    if (!result) {
+      result = (ref = this.button)._t.apply(ref, args);
+    }
+    return result;
+  };
+
+  return Popover;
+
+})(SimpleModule);
+
+Simditor.Popover = Popover;
+
+TitleButton = (function(superClass) {
+  extend(TitleButton, superClass);
+
+  function TitleButton() {
+    return TitleButton.__super__.constructor.apply(this, arguments);
+  }
+
+  TitleButton.prototype.name = 'title';
+
+  TitleButton.prototype.htmlTag = 'h1, h2, h3, h4, h5';
+
+  TitleButton.prototype.disableTag = 'pre, table';
+
+  TitleButton.prototype._init = function() {
+    this.menu = [
+      {
+        name: 'normal',
+        text: this._t('normalText'),
+        param: 'p'
+      }, '|', {
+        name: 'h1',
+        text: this._t('title') + ' 1',
+        param: 'h1'
+      }, {
+        name: 'h2',
+        text: this._t('title') + ' 2',
+        param: 'h2'
+      }, {
+        name: 'h3',
+        text: this._t('title') + ' 3',
+        param: 'h3'
+      }, {
+        name: 'h4',
+        text: this._t('title') + ' 4',
+        param: 'h4'
+      }, {
+        name: 'h5',
+        text: this._t('title') + ' 5',
+        param: 'h5'
+      }
+    ];
+    return TitleButton.__super__._init.call(this);
+  };
+
+  TitleButton.prototype.setActive = function(active, param) {
+    TitleButton.__super__.setActive.call(this, active);
+    if (active) {
+      param || (param = this.node[0].tagName.toLowerCase());
+    }
+    this.el.removeClass('active-p active-h1 active-h2 active-h3 active-h4 active-h5');
+    if (active) {
+      return this.el.addClass('active active-' + param);
+    }
+  };
+
+  TitleButton.prototype.command = function(param) {
+    var $rootNodes;
+    $rootNodes = this.editor.selection.rootNodes();
+    this.editor.selection.save();
+    $rootNodes.each((function(_this) {
+      return function(i, node) {
+        var $node;
+        $node = $(node);
+        if ($node.is('blockquote') || $node.is(param) || $node.is(_this.disableTag) || _this.editor.util.isDecoratedNode($node)) {
+          return;
+        }
+        return $('<' + param + '/>').append($node.contents()).replaceAll($node);
+      };
+    })(this));
+    this.editor.selection.restore();
+    return this.editor.trigger('valuechanged');
+  };
+
+  return TitleButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(TitleButton);
+
+FontScaleButton = (function(superClass) {
+  extend(FontScaleButton, superClass);
+
+  function FontScaleButton() {
+    return FontScaleButton.__super__.constructor.apply(this, arguments);
+  }
+
+  FontScaleButton.prototype.name = 'fontScale';
+
+  FontScaleButton.prototype.icon = 'font';
+
+  FontScaleButton.prototype.htmlTag = 'span';
+
+  FontScaleButton.prototype.disableTag = 'pre, h1, h2, h3, h4, h5';
+
+  FontScaleButton.prototype.sizeMap = {
+    'x-large': '1.5em',
+    'large': '1.25em',
+    'small': '.75em',
+    'x-small': '.5em'
+  };
+
+  FontScaleButton.prototype._init = function() {
+    this.menu = [
+      {
+        name: '150%',
+        text: this._t('fontScaleXLarge'),
+        param: '5'
+      }, {
+        name: '125%',
+        text: this._t('fontScaleLarge'),
+        param: '4'
+      }, {
+        name: '100%',
+        text: this._t('fontScaleNormal'),
+        param: '3'
+      }, {
+        name: '75%',
+        text: this._t('fontScaleSmall'),
+        param: '2'
+      }, {
+        name: '50%',
+        text: this._t('fontScaleXSmall'),
+        param: '1'
+      }
+    ];
+    return FontScaleButton.__super__._init.call(this);
+  };
+
+  FontScaleButton.prototype._activeStatus = function() {
+    var active, endNode, endNodes, range, startNode, startNodes;
+    range = this.editor.selection.range();
+    startNodes = this.editor.selection.startNodes();
+    endNodes = this.editor.selection.endNodes();
+    startNode = startNodes.filter('span[style*="font-size"]');
+    endNode = endNodes.filter('span[style*="font-size"]');
+    active = startNodes.length > 0 && endNodes.length > 0 && startNode.is(endNode);
+    this.setActive(active);
+    return this.active;
+  };
+
+  FontScaleButton.prototype.command = function(param) {
+    var $scales, containerNode, range;
+    range = this.editor.selection.range();
+    if (range.collapsed) {
+      return;
+    }
+    this.editor.selection.range(range);
+    document.execCommand('styleWithCSS', false, true);
+    document.execCommand('fontSize', false, param);
+    document.execCommand('styleWithCSS', false, false);
+    this.editor.selection.reset();
+    this.editor.selection.range();
+    containerNode = this.editor.selection.containerNode();
+    if (containerNode[0].nodeType === Node.TEXT_NODE) {
+      $scales = containerNode.closest('span[style*="font-size"]');
+    } else {
+      $scales = containerNode.find('span[style*="font-size"]');
+    }
+    $scales.each((function(_this) {
+      return function(i, n) {
+        var $span, size;
+        $span = $(n);
+        size = n.style.fontSize;
+        if (/large|x-large|small|x-small/.test(size)) {
+          return $span.css('fontSize', _this.sizeMap[size]);
+        } else if (size === 'medium') {
+          return $span.replaceWith($span.contents());
+        }
+      };
+    })(this));
+    return this.editor.trigger('valuechanged');
+  };
+
+  return FontScaleButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(FontScaleButton);
+
+BoldButton = (function(superClass) {
+  extend(BoldButton, superClass);
+
+  function BoldButton() {
+    return BoldButton.__super__.constructor.apply(this, arguments);
+  }
+
+  BoldButton.prototype.name = 'bold';
+
+  BoldButton.prototype.icon = 'bold';
+
+  BoldButton.prototype.htmlTag = 'b, strong';
+
+  BoldButton.prototype.disableTag = 'pre';
+
+  BoldButton.prototype.shortcut = 'cmd+b';
+
+  BoldButton.prototype._init = function() {
+    if (this.editor.util.os.mac) {
+      this.title = this.title + ' ( Cmd + b )';
+    } else {
+      this.title = this.title + ' ( Ctrl + b )';
+      this.shortcut = 'ctrl+b';
+    }
+    return BoldButton.__super__._init.call(this);
+  };
+
+  BoldButton.prototype._activeStatus = function() {
+    var active;
+    active = document.queryCommandState('bold') === true;
+    this.setActive(active);
+    return this.active;
+  };
+
+  BoldButton.prototype.command = function() {
+    document.execCommand('bold');
+    if (!this.editor.util.support.oninput) {
+      this.editor.trigger('valuechanged');
+    }
+    return $(document).trigger('selectionchange');
+  };
+
+  return BoldButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(BoldButton);
+
+ItalicButton = (function(superClass) {
+  extend(ItalicButton, superClass);
+
+  function ItalicButton() {
+    return ItalicButton.__super__.constructor.apply(this, arguments);
+  }
+
+  ItalicButton.prototype.name = 'italic';
+
+  ItalicButton.prototype.icon = 'italic';
+
+  ItalicButton.prototype.htmlTag = 'i';
+
+  ItalicButton.prototype.disableTag = 'pre';
+
+  ItalicButton.prototype.shortcut = 'cmd+i';
+
+  ItalicButton.prototype._init = function() {
+    if (this.editor.util.os.mac) {
+      this.title = this.title + " ( Cmd + i )";
+    } else {
+      this.title = this.title + " ( Ctrl + i )";
+      this.shortcut = 'ctrl+i';
+    }
+    return ItalicButton.__super__._init.call(this);
+  };
+
+  ItalicButton.prototype._activeStatus = function() {
+    var active;
+    active = document.queryCommandState('italic') === true;
+    this.setActive(active);
+    return this.active;
+  };
+
+  ItalicButton.prototype.command = function() {
+    document.execCommand('italic');
+    if (!this.editor.util.support.oninput) {
+      this.editor.trigger('valuechanged');
+    }
+    return $(document).trigger('selectionchange');
+  };
+
+  return ItalicButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(ItalicButton);
+
+UnderlineButton = (function(superClass) {
+  extend(UnderlineButton, superClass);
+
+  function UnderlineButton() {
+    return UnderlineButton.__super__.constructor.apply(this, arguments);
+  }
+
+  UnderlineButton.prototype.name = 'underline';
+
+  UnderlineButton.prototype.icon = 'underline';
+
+  UnderlineButton.prototype.htmlTag = 'u';
+
+  UnderlineButton.prototype.disableTag = 'pre';
+
+  UnderlineButton.prototype.shortcut = 'cmd+u';
+
+  UnderlineButton.prototype.render = function() {
+    if (this.editor.util.os.mac) {
+      this.title = this.title + ' ( Cmd + u )';
+    } else {
+      this.title = this.title + ' ( Ctrl + u )';
+      this.shortcut = 'ctrl+u';
+    }
+    return UnderlineButton.__super__.render.call(this);
+  };
+
+  UnderlineButton.prototype._activeStatus = function() {
+    var active;
+    active = document.queryCommandState('underline') === true;
+    this.setActive(active);
+    return this.active;
+  };
+
+  UnderlineButton.prototype.command = function() {
+    document.execCommand('underline');
+    if (!this.editor.util.support.oninput) {
+      this.editor.trigger('valuechanged');
+    }
+    return $(document).trigger('selectionchange');
+  };
+
+  return UnderlineButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(UnderlineButton);
+
+ColorButton = (function(superClass) {
+  extend(ColorButton, superClass);
+
+  function ColorButton() {
+    return ColorButton.__super__.constructor.apply(this, arguments);
+  }
+
+  ColorButton.prototype.name = 'color';
+
+  ColorButton.prototype.icon = 'tint';
+
+  ColorButton.prototype.disableTag = 'pre';
+
+  ColorButton.prototype.menu = true;
+
+  ColorButton.prototype.render = function() {
+    var args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    return ColorButton.__super__.render.apply(this, args);
+  };
+
+  ColorButton.prototype.renderMenu = function() {
+    $('<ul class="color-list">\n  <li><a href="javascript:;" class="font-color font-color-1"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-2"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-3"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-4"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-5"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-6"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-7"></a></li>\n  <li><a href="javascript:;" class="font-color font-color-default"></a></li>\n</ul>').appendTo(this.menuWrapper);
+    this.menuWrapper.on('mousedown', '.color-list', function(e) {
+      return false;
+    });
+    return this.menuWrapper.on('click', '.font-color', (function(_this) {
+      return function(e) {
+        var $link, $p, hex, range, rgb, textNode;
+        _this.wrapper.removeClass('menu-on');
+        $link = $(e.currentTarget);
+        if ($link.hasClass('font-color-default')) {
+          $p = _this.editor.body.find('p, li');
+          if (!($p.length > 0)) {
+            return;
+          }
+          rgb = window.getComputedStyle($p[0], null).getPropertyValue('color');
+          hex = _this._convertRgbToHex(rgb);
+        } else {
+          rgb = window.getComputedStyle($link[0], null).getPropertyValue('background-color');
+          hex = _this._convertRgbToHex(rgb);
+        }
+        if (!hex) {
+          return;
+        }
+        range = _this.editor.selection.range();
+        if (!$link.hasClass('font-color-default') && range.collapsed) {
+          textNode = document.createTextNode(_this._t('coloredText'));
+          range.insertNode(textNode);
+          range.selectNodeContents(textNode);
+        }
+        _this.editor.selection.range(range);
+        document.execCommand('styleWithCSS', false, true);
+        document.execCommand('foreColor', false, hex);
+        document.execCommand('styleWithCSS', false, false);
+        if (!_this.editor.util.support.oninput) {
+          return _this.editor.trigger('valuechanged');
+        }
+      };
+    })(this));
+  };
+
+  ColorButton.prototype._convertRgbToHex = function(rgb) {
+    var match, re, rgbToHex;
+    re = /rgb\((\d+),\s?(\d+),\s?(\d+)\)/g;
+    match = re.exec(rgb);
+    if (!match) {
+      return '';
+    }
+    rgbToHex = function(r, g, b) {
+      var componentToHex;
+      componentToHex = function(c) {
+        var hex;
+        hex = c.toString(16);
+        if (hex.length === 1) {
+          return '0' + hex;
+        } else {
+          return hex;
+        }
+      };
+      return "#" + componentToHex(r) + componentToHex(g) + componentToHex(b);
+    };
+    return rgbToHex(match[1] * 1, match[2] * 1, match[3] * 1);
+  };
+
+  return ColorButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(ColorButton);
+
+ListButton = (function(superClass) {
+  extend(ListButton, superClass);
+
+  function ListButton() {
+    return ListButton.__super__.constructor.apply(this, arguments);
+  }
+
+  ListButton.prototype.type = '';
+
+  ListButton.prototype.disableTag = 'pre, table';
+
+  ListButton.prototype.command = function(param) {
+    var $list, $rootNodes, anotherType;
+    $rootNodes = this.editor.selection.blockNodes();
+    anotherType = this.type === 'ul' ? 'ol' : 'ul';
+    this.editor.selection.save();
+    $list = null;
+    $rootNodes.each((function(_this) {
+      return function(i, node) {
+        var $node;
+        $node = $(node);
+        if ($node.is('blockquote, li') || $node.is(_this.disableTag) || _this.editor.util.isDecoratedNode($node) || !$.contains(document, node)) {
+          return;
+        }
+        if ($node.is(_this.type)) {
+          $node.children('li').each(function(i, li) {
+            var $childList, $li;
+            $li = $(li);
+            $childList = $li.children('ul, ol').insertAfter($node);
+            return $('<p/>').append($(li).html() || _this.editor.util.phBr).insertBefore($node);
+          });
+          return $node.remove();
+        } else if ($node.is(anotherType)) {
+          return $('<' + _this.type + '/>').append($node.contents()).replaceAll($node);
+        } else if ($list && $node.prev().is($list)) {
+          $('<li/>').append($node.html() || _this.editor.util.phBr).appendTo($list);
+          return $node.remove();
+        } else {
+          $list = $("<" + _this.type + "><li></li></" + _this.type + ">");
+          $list.find('li').append($node.html() || _this.editor.util.phBr);
+          return $list.replaceAll($node);
+        }
+      };
+    })(this));
+    this.editor.selection.restore();
+    return this.editor.trigger('valuechanged');
+  };
+
+  return ListButton;
+
+})(Button);
+
+OrderListButton = (function(superClass) {
+  extend(OrderListButton, superClass);
+
+  function OrderListButton() {
+    return OrderListButton.__super__.constructor.apply(this, arguments);
+  }
+
+  OrderListButton.prototype.type = 'ol';
+
+  OrderListButton.prototype.name = 'ol';
+
+  OrderListButton.prototype.icon = 'list-ol';
+
+  OrderListButton.prototype.htmlTag = 'ol';
+
+  OrderListButton.prototype.shortcut = 'cmd+/';
+
+  OrderListButton.prototype._init = function() {
+    if (this.editor.util.os.mac) {
+      this.title = this.title + ' ( Cmd + / )';
+    } else {
+      this.title = this.title + ' ( ctrl + / )';
+      this.shortcut = 'ctrl+/';
+    }
+    return OrderListButton.__super__._init.call(this);
+  };
+
+  return OrderListButton;
+
+})(ListButton);
+
+UnorderListButton = (function(superClass) {
+  extend(UnorderListButton, superClass);
+
+  function UnorderListButton() {
+    return UnorderListButton.__super__.constructor.apply(this, arguments);
+  }
+
+  UnorderListButton.prototype.type = 'ul';
+
+  UnorderListButton.prototype.name = 'ul';
+
+  UnorderListButton.prototype.icon = 'list-ul';
+
+  UnorderListButton.prototype.htmlTag = 'ul';
+
+  UnorderListButton.prototype.shortcut = 'cmd+.';
+
+  UnorderListButton.prototype._init = function() {
+    if (this.editor.util.os.mac) {
+      this.title = this.title + ' ( Cmd + . )';
+    } else {
+      this.title = this.title + ' ( Ctrl + . )';
+      this.shortcut = 'ctrl+.';
+    }
+    return UnorderListButton.__super__._init.call(this);
+  };
+
+  return UnorderListButton;
+
+})(ListButton);
+
+Simditor.Toolbar.addButton(OrderListButton);
+
+Simditor.Toolbar.addButton(UnorderListButton);
+
+BlockquoteButton = (function(superClass) {
+  extend(BlockquoteButton, superClass);
+
+  function BlockquoteButton() {
+    return BlockquoteButton.__super__.constructor.apply(this, arguments);
+  }
+
+  BlockquoteButton.prototype.name = 'blockquote';
+
+  BlockquoteButton.prototype.icon = 'quote-left';
+
+  BlockquoteButton.prototype.htmlTag = 'blockquote';
+
+  BlockquoteButton.prototype.disableTag = 'pre, table';
+
+  BlockquoteButton.prototype.command = function() {
+    var $rootNodes, clearCache, nodeCache;
+    $rootNodes = this.editor.selection.rootNodes();
+    $rootNodes = $rootNodes.filter(function(i, node) {
+      return !$(node).parent().is('blockquote');
+    });
+    this.editor.selection.save();
+    nodeCache = [];
+    clearCache = (function(_this) {
+      return function() {
+        if (nodeCache.length > 0) {
+          $("<" + _this.htmlTag + "/>").insertBefore(nodeCache[0]).append(nodeCache);
+          return nodeCache.length = 0;
+        }
+      };
+    })(this);
+    $rootNodes.each((function(_this) {
+      return function(i, node) {
+        var $node;
+        $node = $(node);
+        if (!$node.parent().is(_this.editor.body)) {
+          return;
+        }
+        if ($node.is(_this.htmlTag)) {
+          clearCache();
+          return $node.children().unwrap();
+        } else if ($node.is(_this.disableTag) || _this.editor.util.isDecoratedNode($node)) {
+          return clearCache();
+        } else {
+          return nodeCache.push(node);
+        }
+      };
+    })(this));
+    clearCache();
+    this.editor.selection.restore();
+    return this.editor.trigger('valuechanged');
+  };
+
+  return BlockquoteButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(BlockquoteButton);
+
+CodeButton = (function(superClass) {
+  extend(CodeButton, superClass);
+
+  function CodeButton() {
+    return CodeButton.__super__.constructor.apply(this, arguments);
+  }
+
+  CodeButton.prototype.name = 'code';
+
+  CodeButton.prototype.icon = 'code';
+
+  CodeButton.prototype.htmlTag = 'pre';
+
+  CodeButton.prototype.disableTag = 'ul, ol, table';
+
+  CodeButton.prototype._init = function() {
+    CodeButton.__super__._init.call(this);
+    this.editor.on('decorate', (function(_this) {
+      return function(e, $el) {
+        return $el.find('pre').each(function(i, pre) {
+          return _this.decorate($(pre));
+        });
+      };
+    })(this));
+    return this.editor.on('undecorate', (function(_this) {
+      return function(e, $el) {
+        return $el.find('pre').each(function(i, pre) {
+          return _this.undecorate($(pre));
+        });
+      };
+    })(this));
+  };
+
+  CodeButton.prototype.render = function() {
+    var args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    CodeButton.__super__.render.apply(this, args);
+    return this.popover = new CodePopover({
+      button: this
+    });
+  };
+
+  CodeButton.prototype._checkMode = function() {
+    var $blockNodes, range;
+    range = this.editor.selection.range();
+    if (($blockNodes = $(range.cloneContents()).find(this.editor.util.blockNodes.join(','))) > 0 || (range.collapsed && this.editor.selection.startNodes().filter('code').length === 0)) {
+      this.inlineMode = false;
+      return this.htmlTag = 'pre';
+    } else {
+      this.inlineMode = true;
+      return this.htmlTag = 'code';
+    }
+  };
+
+  CodeButton.prototype._status = function() {
+    this._checkMode();
+    CodeButton.__super__._status.call(this);
+    if (this.inlineMode) {
+      return;
+    }
+    if (this.active) {
+      return this.popover.show(this.node);
+    } else {
+      return this.popover.hide();
+    }
+  };
+
+  CodeButton.prototype.decorate = function($pre) {
+    var $code, lang, ref, ref1;
+    $code = $pre.find('> code');
+    if ($code.length > 0) {
+      lang = (ref = $code.attr('class')) != null ? (ref1 = ref.match(/lang-(\S+)/)) != null ? ref1[1] : void 0 : void 0;
+      $code.contents().unwrap();
+      if (lang) {
+        return $pre.attr('data-lang', lang);
+      }
+    }
+  };
+
+  CodeButton.prototype.undecorate = function($pre) {
+    var $code, lang;
+    lang = $pre.attr('data-lang');
+    $code = $('<code/>');
+    if (lang && lang !== -1) {
+      $code.addClass('lang-' + lang);
+    }
+    return $pre.wrapInner($code).removeAttr('data-lang');
+  };
+
+  CodeButton.prototype.command = function() {
+    if (this.inlineMode) {
+      return this._inlineCommand();
+    } else {
+      return this._blockCommand();
+    }
+  };
+
+  CodeButton.prototype._blockCommand = function() {
+    var $rootNodes, clearCache, nodeCache, resultNodes;
+    $rootNodes = this.editor.selection.rootNodes();
+    nodeCache = [];
+    resultNodes = [];
+    clearCache = (function(_this) {
+      return function() {
+        var $pre;
+        if (!(nodeCache.length > 0)) {
+          return;
+        }
+        $pre = $("<" + _this.htmlTag + "/>").insertBefore(nodeCache[0]).text(_this.editor.formatter.clearHtml(nodeCache));
+        resultNodes.push($pre[0]);
+        return nodeCache.length = 0;
+      };
+    })(this);
+    $rootNodes.each((function(_this) {
+      return function(i, node) {
+        var $node, $p;
+        $node = $(node);
+        if ($node.is(_this.htmlTag)) {
+          clearCache();
+          $p = $('<p/>').append($node.html().replace('\n', '<br/>')).replaceAll($node);
+          return resultNodes.push($p[0]);
+        } else if ($node.is(_this.disableTag) || _this.editor.util.isDecoratedNode($node) || $node.is('blockquote')) {
+          return clearCache();
+        } else {
+          return nodeCache.push(node);
+        }
+      };
+    })(this));
+    clearCache();
+    this.editor.selection.setRangeAtEndOf($(resultNodes).last());
+    return this.editor.trigger('valuechanged');
+  };
+
+  CodeButton.prototype._inlineCommand = function() {
+    var $code, $contents, range;
+    range = this.editor.selection.range();
+    if (this.active) {
+      range.selectNodeContents(this.node[0]);
+      this.editor.selection.save(range);
+      this.node.contents().unwrap();
+      this.editor.selection.restore();
+    } else {
+      $contents = $(range.extractContents());
+      $code = $("<" + this.htmlTag + "/>").append($contents.contents());
+      range.insertNode($code[0]);
+      range.selectNodeContents($code[0]);
+      this.editor.selection.range(range);
+    }
+    return this.editor.trigger('valuechanged');
+  };
+
+  return CodeButton;
+
+})(Button);
+
+CodePopover = (function(superClass) {
+  extend(CodePopover, superClass);
+
+  function CodePopover() {
+    return CodePopover.__super__.constructor.apply(this, arguments);
+  }
+
+  CodePopover.prototype.render = function() {
+    var $option, k, lang, len, ref;
+    this._tpl = "<div class=\"code-settings\">\n  <div class=\"settings-field\">\n    <select class=\"select-lang\">\n      <option value=\"-1\">" + (this._t('selectLanguage')) + "</option>\n    </select>\n  </div>\n</div>";
+    this.langs = this.editor.opts.codeLanguages || [
+      {
+        name: 'Bash',
+        value: 'bash'
+      }, {
+        name: 'C++',
+        value: 'c++'
+      }, {
+        name: 'C#',
+        value: 'cs'
+      }, {
+        name: 'CSS',
+        value: 'css'
+      }, {
+        name: 'Erlang',
+        value: 'erlang'
+      }, {
+        name: 'Less',
+        value: 'less'
+      }, {
+        name: 'Sass',
+        value: 'sass'
+      }, {
+        name: 'Diff',
+        value: 'diff'
+      }, {
+        name: 'CoffeeScript',
+        value: 'coffeescript'
+      }, {
+        name: 'HTML,XML',
+        value: 'html'
+      }, {
+        name: 'JSON',
+        value: 'json'
+      }, {
+        name: 'Java',
+        value: 'java'
+      }, {
+        name: 'JavaScript',
+        value: 'js'
+      }, {
+        name: 'Markdown',
+        value: 'markdown'
+      }, {
+        name: 'Objective C',
+        value: 'oc'
+      }, {
+        name: 'PHP',
+        value: 'php'
+      }, {
+        name: 'Perl',
+        value: 'parl'
+      }, {
+        name: 'Python',
+        value: 'python'
+      }, {
+        name: 'Ruby',
+        value: 'ruby'
+      }, {
+        name: 'SQL',
+        value: 'sql'
+      }
+    ];
+    this.el.addClass('code-popover').append(this._tpl);
+    this.selectEl = this.el.find('.select-lang');
+    ref = this.langs;
+    for (k = 0, len = ref.length; k < len; k++) {
+      lang = ref[k];
+      $option = $('<option/>', {
+        text: lang.name,
+        value: lang.value
+      }).appendTo(this.selectEl);
+    }
+    this.selectEl.on('change', (function(_this) {
+      return function(e) {
+        var selected;
+        _this.lang = _this.selectEl.val();
+        selected = _this.target.hasClass('selected');
+        _this.target.removeClass().removeAttr('data-lang');
+        if (_this.lang !== -1) {
+          _this.target.attr('data-lang', _this.lang);
+        }
+        if (selected) {
+          _this.target.addClass('selected');
+        }
+        return _this.editor.trigger('valuechanged');
+      };
+    })(this));
+    return this.editor.on('valuechanged', (function(_this) {
+      return function(e) {
+        if (_this.active) {
+          return _this.refresh();
+        }
+      };
+    })(this));
+  };
+
+  CodePopover.prototype.show = function() {
+    var args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    CodePopover.__super__.show.apply(this, args);
+    this.lang = this.target.attr('data-lang');
+    if (this.lang != null) {
+      return this.selectEl.val(this.lang);
+    } else {
+      return this.selectEl.val(-1);
+    }
+  };
+
+  return CodePopover;
+
+})(Popover);
+
+Simditor.Toolbar.addButton(CodeButton);
+
+LinkButton = (function(superClass) {
+  extend(LinkButton, superClass);
+
+  function LinkButton() {
+    return LinkButton.__super__.constructor.apply(this, arguments);
+  }
+
+  LinkButton.prototype.name = 'link';
+
+  LinkButton.prototype.icon = 'link';
+
+  LinkButton.prototype.htmlTag = 'a';
+
+  LinkButton.prototype.disableTag = 'pre';
+
+  LinkButton.prototype.render = function() {
+    var args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    LinkButton.__super__.render.apply(this, args);
+    return this.popover = new LinkPopover({
+      button: this
+    });
+  };
+
+  LinkButton.prototype._status = function() {
+    LinkButton.__super__._status.call(this);
+    if (this.active && !this.editor.selection.rangeAtEndOf(this.node)) {
+      return this.popover.show(this.node);
+    } else {
+      return this.popover.hide();
+    }
+  };
+
+  LinkButton.prototype.command = function() {
+    var $contents, $link, $newBlock, linkText, range, txtNode;
+    range = this.editor.selection.range();
+    if (this.active) {
+      txtNode = document.createTextNode(this.node.text());
+      this.node.replaceWith(txtNode);
+      range.selectNode(txtNode);
+    } else {
+      $contents = $(range.extractContents());
+      linkText = this.editor.formatter.clearHtml($contents.contents(), false);
+      $link = $('<a/>', {
+        href: '',
+        target: '_blank',
+        text: linkText || this._t('linkText')
+      });
+      if (this.editor.selection.blockNodes().length > 0) {
+        range.insertNode($link[0]);
+      } else {
+        $newBlock = $('<p/>').append($link);
+        range.insertNode($newBlock[0]);
+      }
+      range.selectNodeContents($link[0]);
+      this.popover.one('popovershow', (function(_this) {
+        return function() {
+          if (linkText) {
+            _this.popover.urlEl.focus();
+            return _this.popover.urlEl[0].select();
+          } else {
+            _this.popover.textEl.focus();
+            return _this.popover.textEl[0].select();
+          }
+        };
+      })(this));
+    }
+    this.editor.selection.range(range);
+    return this.editor.trigger('valuechanged');
+  };
+
+  return LinkButton;
+
+})(Button);
+
+LinkPopover = (function(superClass) {
+  extend(LinkPopover, superClass);
+
+  function LinkPopover() {
+    return LinkPopover.__super__.constructor.apply(this, arguments);
+  }
+
+  LinkPopover.prototype.render = function() {
+    var tpl;
+    tpl = "<div class=\"link-settings\">\n  <div class=\"settings-field\">\n    <label>" + (this._t('linkText')) + "</label>\n    <input class=\"link-text\" type=\"text\"/>\n    <a class=\"btn-unlink\" href=\"javascript:;\" title=\"" + (this._t('removeLink')) + "\"\n      tabindex=\"-1\">\n      <span class=\"simditor-icon simditor-icon-unlink\"></span>\n    </a>\n  </div>\n  <div class=\"settings-field\">\n    <label>" + (this._t('linkUrl')) + "</label>\n    <input class=\"link-url\" type=\"text\"/>\n  </div>\n  <div class=\"settings-field\">\n    <label>" + (this._t('linkTarget')) + "</label>\n    <select class=\"link-target\">\n      <option value=\"_blank\">" + (this._t('openLinkInNewWindow')) + " (_blank)</option>\n      <option value=\"_self\">" + (this._t('openLinkInCurrentWindow')) + " (_self)</option>\n    </select>\n  </div>\n</div>";
+    this.el.addClass('link-popover').append(tpl);
+    this.textEl = this.el.find('.link-text');
+    this.urlEl = this.el.find('.link-url');
+    this.unlinkEl = this.el.find('.btn-unlink');
+    this.selectTarget = this.el.find('.link-target');
+    this.textEl.on('keyup', (function(_this) {
+      return function(e) {
+        if (e.which === 13) {
+          return;
+        }
+        _this.target.text(_this.textEl.val());
+        return _this.editor.inputManager.throttledValueChanged();
+      };
+    })(this));
+    this.urlEl.on('keyup', (function(_this) {
+      return function(e) {
+        var val;
+        if (e.which === 13) {
+          return;
+        }
+        val = _this.urlEl.val();
+        if (!(/https?:\/\/|^\//ig.test(val) || !val)) {
+          val = 'http://' + val;
+        }
+        _this.target.attr('href', val);
+        return _this.editor.inputManager.throttledValueChanged();
+      };
+    })(this));
+    $([this.urlEl[0], this.textEl[0]]).on('keydown', (function(_this) {
+      return function(e) {
+        var range;
+        if (e.which === 13 || e.which === 27 || (!e.shiftKey && e.which === 9 && $(e.target).hasClass('link-url'))) {
+          e.preventDefault();
+          range = document.createRange();
+          _this.editor.selection.setRangeAfter(_this.target, range);
+          _this.hide();
+          return _this.editor.inputManager.throttledValueChanged();
+        }
+      };
+    })(this));
+    this.unlinkEl.on('click', (function(_this) {
+      return function(e) {
+        var range, txtNode;
+        txtNode = document.createTextNode(_this.target.text());
+        _this.target.replaceWith(txtNode);
+        _this.hide();
+        range = document.createRange();
+        _this.editor.selection.setRangeAfter(txtNode, range);
+        return _this.editor.inputManager.throttledValueChanged();
+      };
+    })(this));
+    return this.selectTarget.on('change', (function(_this) {
+      return function(e) {
+        _this.target.attr('target', _this.selectTarget.val());
+        return _this.editor.inputManager.throttledValueChanged();
+      };
+    })(this));
+  };
+
+  LinkPopover.prototype.show = function() {
+    var args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    LinkPopover.__super__.show.apply(this, args);
+    this.textEl.val(this.target.text());
+    return this.urlEl.val(this.target.attr('href'));
+  };
+
+  return LinkPopover;
+
+})(Popover);
+
+Simditor.Toolbar.addButton(LinkButton);
+
+ImageButton = (function(superClass) {
+  extend(ImageButton, superClass);
+
+  function ImageButton() {
+    return ImageButton.__super__.constructor.apply(this, arguments);
+  }
+
+  ImageButton.prototype.name = 'image';
+
+  ImageButton.prototype.icon = 'picture-o';
+
+  ImageButton.prototype.htmlTag = 'img';
+
+  ImageButton.prototype.disableTag = 'pre, table';
+
+  ImageButton.prototype.defaultImage = '';
+
+  ImageButton.prototype.needFocus = false;
+
+  ImageButton.prototype._init = function() {
+    var item, k, len, ref;
+    if (this.editor.opts.imageButton) {
+      if (Array.isArray(this.editor.opts.imageButton)) {
+        this.menu = [];
+        ref = this.editor.opts.imageButton;
+        for (k = 0, len = ref.length; k < len; k++) {
+          item = ref[k];
+          this.menu.push({
+            name: item + '-image',
+            text: this._t(item + 'Image')
+          });
+        }
+      } else {
+        this.menu = false;
+      }
+    } else {
+      if (this.editor.uploader != null) {
+        this.menu = [
+          {
+            name: 'upload-image',
+            text: this._t('uploadImage')
+          }, {
+            name: 'external-image',
+            text: this._t('externalImage')
+          }, {
+              name: 'select-image',
+              text: this._t('selectImage')
+          }
+        ];
+      } else {
+        this.menu = false;
+      }
+    }
+    this.defaultImage = this.editor.opts.defaultImage;
+    this.editor.body.on('click', 'img:not([data-non-image])', (function(_this) {
+      return function(e) {
+        var $img, range;
+        $img = $(e.currentTarget);
+        range = document.createRange();
+        range.selectNode($img[0]);
+        _this.editor.selection.range(range);
+        if (!_this.editor.util.support.onselectionchange) {
+          _this.editor.trigger('selectionchanged');
+        }
+        return false;
+      };
+    })(this));
+    this.editor.body.on('mouseup', 'img:not([data-non-image])', function(e) {
+      return false;
+    });
+    this.editor.on('selectionchanged.image', (function(_this) {
+      return function() {
+        var $contents, $img, range;
+        range = _this.editor.selection.range();
+        if (range == null) {
+          return;
+        }
+        $contents = $(range.cloneContents()).contents();
+        if ($contents.length === 1 && $contents.is('img:not([data-non-image])')) {
+          $img = $(range.startContainer).contents().eq(range.startOffset);
+          return _this.popover.show($img);
+        } else {
+          return _this.popover.hide();
+        }
+      };
+    })(this));
+    this.editor.on('valuechanged.image', (function(_this) {
+      return function() {
+        var $masks;
+        $masks = _this.editor.wrapper.find('.simditor-image-loading');
+        if (!($masks.length > 0)) {
+          return;
+        }
+        return $masks.each(function(i, mask) {
+          var $img, $mask, file;
+          $mask = $(mask);
+          $img = $mask.data('img');
+          if (!($img && $img.parent().length > 0)) {
+            $mask.remove();
+            if ($img) {
+              file = $img.data('file');
+              if (file) {
+                _this.editor.uploader.cancel(file);
+                if (_this.editor.body.find('img.uploading').length < 1) {
+                  return _this.editor.uploader.trigger('uploadready', [file]);
+                }
+              }
+            }
+          }
+        });
+      };
+    })(this));
+    return ImageButton.__super__._init.call(this);
+  };
+
+  ImageButton.prototype.render = function() {
+    var args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    ImageButton.__super__.render.apply(this, args);
+    this.popover = new ImagePopover({
+      button: this
+    });
+    if (this.editor.opts.imageButton === 'upload') {
+      return this._initUploader(this.el);
+    }
+  };
+
+  ImageButton.prototype.renderMenu = function() {
+    ImageButton.__super__.renderMenu.call(this);
+    return this._initUploader();
+  };
+
+  ImageButton.prototype._initUploader = function($uploadItem) {
+    var $input, createInput, uploadProgress;
+    if ($uploadItem == null) {
+      $uploadItem = this.menuEl.find('.menu-item-upload-image');
+    }
+    if (this.editor.uploader == null) {
+      this.el.find('.btn-upload').remove();
+      return;
+    }
+    $input = null;
+    createInput = (function(_this) {
+      return function() {
+        if ($input) {
+          $input.remove();
+        }
+        return $input = $('<input/>', {
+          type: 'file',
+          title: _this._t('uploadImage'),
+          multiple: true,
+          accept: 'image/gif,image/jpeg,image/jpg,image/png,image/svg'
+        }).appendTo($uploadItem);
+      };
+    })(this);
+    createInput();
+    $uploadItem.on('click mousedown', 'input[type=file]', function(e) {
+      return e.stopPropagation();
+    });
+    $uploadItem.on('change', 'input[type=file]', (function(_this) {
+      return function(e) {
+        if (_this.editor.inputManager.focused) {
+          _this.editor.uploader.upload($input, {
+            inline: true
+          });
+          createInput();
+        } else {
+          _this.editor.one('focus', function(e) {
+            _this.editor.uploader.upload($input, {
+              inline: true
+            });
+            return createInput();
+          });
+          _this.editor.focus();
+        }
+        return _this.wrapper.removeClass('menu-on');
+      };
+    })(this));
+    this.editor.uploader.on('beforeupload', (function(_this) {
+      return function(e, file) {
+        var $img;
+        if (!file.inline) {
+          return;
+        }
+        if (file.img) {
+          $img = $(file.img);
+        } else {
+          $img = _this.createImage(file.name);
+          file.img = $img;
+        }
+        $img.addClass('uploading');
+        $img.data('file', file);
+        return _this.editor.uploader.readImageFile(file.obj, function(img) {
+          var src;
+          if (!$img.hasClass('uploading')) {
+            return;
+          }
+          src = img ? img.src : _this.defaultImage;
+          return _this.loadImage($img, src, function() {
+            if (_this.popover.active) {
+              _this.popover.refresh();
+              return _this.popover.srcEl.val(_this._t('uploading')).prop('disabled', true);
+            }
+          });
+        });
+      };
+    })(this));
+    uploadProgress = $.proxy(this.editor.util.throttle(function(e, file, loaded, total) {
+      var $img, $mask, percent;
+      if (!file.inline) {
+        return;
+      }
+      $mask = file.img.data('mask');
+      if (!$mask) {
+        return;
+      }
+      $img = $mask.data('img');
+      if (!($img.hasClass('uploading') && $img.parent().length > 0)) {
+        $mask.remove();
+        return;
+      }
+      percent = loaded / total;
+      percent = (percent * 100).toFixed(0);
+      if (percent > 99) {
+        percent = 99;
+      }
+      return $mask.find('.progress').height((100 - percent) + "%");
+    }, 500), this);
+    this.editor.uploader.on('uploadprogress', uploadProgress);
+    this.editor.uploader.on('uploadsuccess', (function(_this) {
+      return function(e, file, result) {
+        var $img, img_path, msg;
+        if (!file.inline) {
+          return;
+        }
+        $img = file.img;
+        if (!($img.hasClass('uploading') && $img.parent().length > 0)) {
+          return;
+        }
+        if (typeof result !== 'object') {
+          try {
+            result = $.parseJSON(result);
+          } catch (_error) {
+            e = _error;
+            result = {
+              success: false
+            };
+          }
+        }
+        if (result.success === false) {
+          msg = result.msg || _this._t('uploadFailed');
+          alert(msg);
+          img_path = _this.defaultImage;
+        } else {
+          img_path = result.file_path;
+        }
+        _this.loadImage($img, img_path, function() {
+          var $mask;
+          $img.removeData('file');
+          $img.removeClass('uploading').removeClass('loading');
+          $mask = $img.data('mask');
+          if ($mask) {
+            $mask.remove();
+          }
+          $img.removeData('mask');
+          _this.editor.trigger('valuechanged');
+          if (_this.editor.body.find('img.uploading').length < 1) {
+            return _this.editor.uploader.trigger('uploadready', [file, result]);
+          }
+        });
+        if (_this.popover.active) {
+          _this.popover.srcEl.prop('disabled', false);
+          return _this.popover.srcEl.val(result.file_path);
+        }
+      };
+    })(this));
+    return this.editor.uploader.on('uploaderror', (function(_this) {
+      return function(e, file, xhr) {
+        var $img, msg, result;
+        if (!file.inline) {
+          return;
+        }
+        if (xhr.statusText === 'abort') {
+          return;
+        }
+        if (xhr.responseText) {
+          try {
+            result = $.parseJSON(xhr.responseText);
+            msg = result.msg;
+          } catch (_error) {
+            e = _error;
+            msg = _this._t('uploadError');
+          }
+        }
+        $img = file.img;
+        if (!($img.hasClass('uploading') && $img.parent().length > 0)) {
+          return;
+        }
+        _this.loadImage($img, _this.defaultImage, function() {
+          var $mask;
+          $img.removeData('file');
+          $img.removeClass('uploading').removeClass('loading');
+          $mask = $img.data('mask');
+          if ($mask) {
+            $mask.remove();
+          }
+          return $img.removeData('mask');
+        });
+        if (_this.popover.active) {
+          _this.popover.srcEl.prop('disabled', false);
+          _this.popover.srcEl.val(_this.defaultImage);
+        }
+        _this.editor.trigger('valuechanged');
+        if (_this.editor.body.find('img.uploading').length < 1) {
+          return _this.editor.uploader.trigger('uploadready', [file, result]);
+        }
+      };
+    })(this));
+  };
+
+  ImageButton.prototype._status = function() {
+    return this._disableStatus();
+  };
+
+  ImageButton.prototype.loadImage = function($img, src, callback) {
+    var $mask, img, positionMask;
+    positionMask = (function(_this) {
+      return function() {
+        var imgOffset, wrapperOffset;
+        imgOffset = $img.offset();
+        wrapperOffset = _this.editor.wrapper.offset();
+        return $mask.css({
+          top: imgOffset.top - wrapperOffset.top,
+          left: imgOffset.left - wrapperOffset.left,
+          width: $img.width(),
+          height: $img.height()
+        }).show();
+      };
+    })(this);
+    $img.addClass('loading');
+    $mask = $img.data('mask');
+    if (!$mask) {
+      $mask = $('<div class="simditor-image-loading">\n  <div class="progress"></div>\n</div>').hide().appendTo(this.editor.wrapper);
+      positionMask();
+      $img.data('mask', $mask);
+      $mask.data('img', $img);
+    }
+    img = new Image();
+    img.onload = (function(_this) {
+      return function() {
+        var height, width;
+        if (!$img.hasClass('loading') && !$img.hasClass('uploading')) {
+          return;
+        }
+        width = img.width;
+        height = img.height;
+        $img.attr({
+          src: src,
+          width: width,
+          height: height,
+          'data-image-size': width + ',' + height
+        }).removeClass('loading');
+        if ($img.hasClass('uploading')) {
+          _this.editor.util.reflow(_this.editor.body);
+          positionMask();
+        } else {
+          $mask.remove();
+          $img.removeData('mask');
+        }
+        if ($.isFunction(callback)) {
+          return callback(img);
+        }
+      };
+    })(this);
+    img.onerror = function() {
+      if ($.isFunction(callback)) {
+        callback(false);
+      }
+      $mask.remove();
+      return $img.removeData('mask').removeClass('loading');
+    };
+    return img.src = src;
+  };
+
+  ImageButton.prototype.createImage = function(name) {
+    var $img, range;
+    if (name == null) {
+      name = 'Image';
+    }
+    if (!this.editor.inputManager.focused) {
+      this.editor.focus();
+    }
+    range = this.editor.selection.range();
+    range.deleteContents();
+    this.editor.selection.range(range);
+    $img = $('<img/>').attr('alt', name);
+    range.insertNode($img[0]);
+    this.editor.selection.setRangeAfter($img, range);
+    this.editor.trigger('valuechanged');
+    return $img;
+  };
+
+  ImageButton.prototype.command = function(src) {
+    var $img;
+
+    $img = this.createImage();
+    return this.loadImage($img, src || this.defaultImage, (function(_this) {
+      return function() {
+        _this.editor.trigger('valuechanged');
+        _this.editor.util.reflow($img);
+        $img.click();
+        return _this.popover.one('popovershow', function() {
+          _this.popover.srcEl.focus();
+          return _this.popover.srcEl[0].select();
+        });
+      };
+    })(this));
+  };
+
+  return ImageButton;
+
+})(Button);
+
+ImagePopover = (function(superClass) {
+  extend(ImagePopover, superClass);
+
+  function ImagePopover() {
+    return ImagePopover.__super__.constructor.apply(this, arguments);
+  }
+
+  ImagePopover.prototype.offset = {
+    top: 6,
+    left: -4
+  };
+
+  ImagePopover.prototype.render = function() {
+    var tpl;
+    tpl = "<div class=\"link-settings\">\n  <div class=\"settings-field\">\n    <label>" + (this._t('imageUrl')) + "</label>\n    <input class=\"image-src\" type=\"text\" tabindex=\"1\" />\n    <a class=\"btn-upload\" href=\"javascript:;\"\n      title=\"" + (this._t('uploadImage')) + "\" tabindex=\"-1\">\n      <span class=\"simditor-icon simditor-icon-upload\"></span>\n    </a>\n  </div>\n  <div class='settings-field'>\n    <label>" + (this._t('imageAlt')) + "</label>\n    <input class=\"image-alt\" id=\"image-alt\" type=\"text\" tabindex=\"1\" />\n  </div>\n  <div class=\"settings-field\">\n    <label>" + (this._t('imageSize')) + "</label>\n    <input class=\"image-size\" id=\"image-width\" type=\"text\" tabindex=\"2\" />\n    <span class=\"times\">×</span>\n    <input class=\"image-size\" id=\"image-height\" type=\"text\" tabindex=\"3\" />\n    <a class=\"btn-restore\" href=\"javascript:;\"\n      title=\"" + (this._t('restoreImageSize')) + "\" tabindex=\"-1\">\n      <span class=\"simditor-icon simditor-icon-undo\"></span>\n    </a>\n  </div>\n</div>";
+    this.el.addClass('image-popover').append(tpl);
+    this.srcEl = this.el.find('.image-src');
+    this.widthEl = this.el.find('#image-width');
+    this.heightEl = this.el.find('#image-height');
+    this.altEl = this.el.find('#image-alt');
+    this.srcEl.on('keydown', (function(_this) {
+      return function(e) {
+        var range;
+        if (!(e.which === 13 && !_this.target.hasClass('uploading'))) {
+          return;
+        }
+        e.preventDefault();
+        range = document.createRange();
+        _this.button.editor.selection.setRangeAfter(_this.target, range);
+        return _this.hide();
+      };
+    })(this));
+    this.srcEl.on('blur', (function(_this) {
+      return function(e) {
+        return _this._loadImage(_this.srcEl.val());
+      };
+    })(this));
+    this.el.find('.image-size').on('blur', (function(_this) {
+      return function(e) {
+        _this._resizeImg($(e.currentTarget));
+        return _this.el.data('popover').refresh();
+      };
+    })(this));
+    this.el.find('.image-size').on('keyup', (function(_this) {
+      return function(e) {
+        var inputEl;
+        inputEl = $(e.currentTarget);
+        if (!(e.which === 13 || e.which === 27 || e.which === 9)) {
+          return _this._resizeImg(inputEl, true);
+        }
+      };
+    })(this));
+    this.el.find('.image-size').on('keydown', (function(_this) {
+      return function(e) {
+        var $img, inputEl, range;
+        inputEl = $(e.currentTarget);
+        if (e.which === 13 || e.which === 27) {
+          e.preventDefault();
+          if (e.which === 13) {
+            _this._resizeImg(inputEl);
+          } else {
+            _this._restoreImg();
+          }
+          $img = _this.target;
+          _this.hide();
+          range = document.createRange();
+          return _this.button.editor.selection.setRangeAfter($img, range);
+        } else if (e.which === 9) {
+          return _this.el.data('popover').refresh();
+        }
+      };
+    })(this));
+    this.altEl.on('keydown', (function(_this) {
+      return function(e) {
+        var range;
+        if (e.which === 13) {
+          e.preventDefault();
+          range = document.createRange();
+          _this.button.editor.selection.setRangeAfter(_this.target, range);
+          return _this.hide();
+        }
+      };
+    })(this));
+    this.altEl.on('keyup', (function(_this) {
+      return function(e) {
+        if (e.which === 13 || e.which === 27 || e.which === 9) {
+          return;
+        }
+        _this.alt = _this.altEl.val();
+        return _this.target.attr('alt', _this.alt);
+      };
+    })(this));
+    this.el.find('.btn-restore').on('click', (function(_this) {
+      return function(e) {
+        _this._restoreImg();
+        return _this.el.data('popover').refresh();
+      };
+    })(this));
+    this.editor.on('valuechanged', (function(_this) {
+      return function(e) {
+        if (_this.active) {
+          return _this.refresh();
+        }
+      };
+    })(this));
+    return this._initUploader();
+  };
+
+  ImagePopover.prototype._initUploader = function() {
+    var $uploadBtn, createInput;
+    $uploadBtn = this.el.find('.btn-upload');
+    if (this.editor.uploader == null) {
+      $uploadBtn.remove();
+      return;
+    }
+    createInput = (function(_this) {
+      return function() {
+        if (_this.input) {
+          _this.input.remove();
+        }
+        return _this.input = $('<input/>', {
+          type: 'file',
+          title: _this._t('uploadImage'),
+          multiple: true,
+          accept: 'image/gif,image/jpeg,image/jpg,image/png,image/svg'
+        }).appendTo($uploadBtn);
+      };
+    })(this);
+    createInput();
+    this.el.on('click mousedown', 'input[type=file]', function(e) {
+      return e.stopPropagation();
+    });
+    return this.el.on('change', 'input[type=file]', (function(_this) {
+      return function(e) {
+        _this.editor.uploader.upload(_this.input, {
+          inline: true,
+          img: _this.target
+        });
+        return createInput();
+      };
+    })(this));
+  };
+
+  ImagePopover.prototype._resizeImg = function(inputEl, onlySetVal) {
+    var height, value, width;
+    if (onlySetVal == null) {
+      onlySetVal = false;
+    }
+    value = inputEl.val() * 1;
+    if (!(this.target && ($.isNumeric(value) || value < 0))) {
+      return;
+    }
+    if (inputEl.is(this.widthEl)) {
+      width = value;
+      height = this.height * value / this.width;
+      this.heightEl.val(height);
+    } else {
+      height = value;
+      width = this.width * value / this.height;
+      this.widthEl.val(width);
+    }
+    if (!onlySetVal) {
+      this.target.attr({
+        width: width,
+        height: height
+      });
+      return this.editor.trigger('valuechanged');
+    }
+  };
+
+  ImagePopover.prototype._restoreImg = function() {
+    var ref, size;
+    size = ((ref = this.target.data('image-size')) != null ? ref.split(",") : void 0) || [this.width, this.height];
+    this.target.attr({
+      width: size[0] * 1,
+      height: size[1] * 1
+    });
+    this.widthEl.val(size[0]);
+    this.heightEl.val(size[1]);
+    return this.editor.trigger('valuechanged');
+  };
+
+  ImagePopover.prototype._loadImage = function(src, callback) {
+    if (/^data:image/.test(src) && !this.editor.uploader) {
+      if (callback) {
+        callback(false);
+      }
+      return;
+    }
+    if (this.target.attr('src') === src) {
+      return;
+    }
+    return this.button.loadImage(this.target, src, (function(_this) {
+
+      return function(img) {
+        var blob;
+        if (!img) {
+          return;
+        }
+        if (_this.active) {
+          _this.width = img.width;
+          _this.height = img.height;
+          _this.widthEl.val(_this.width);
+          _this.heightEl.val(_this.height);
+        }
+        if (/^data:image/.test(src)) {
+          blob = _this.editor.util.dataURLtoBlob(src);
+          blob.name = "Base64 Image.png";
+          _this.editor.uploader.upload(blob, {
+            inline: true,
+            img: _this.target
+          });
+        } else {
+          _this.editor.trigger('valuechanged');
+        }
+        if (callback) {
+          return callback(img);
+        }
+      };
+    })(this));
+  };
+
+  ImagePopover.prototype.show = function() {
+    var $img, args;
+    args = 1 <= arguments.length ? slice.call(arguments, 0) : [];
+    ImagePopover.__super__.show.apply(this, args);
+    $img = this.target;
+    this.width = $img.width();
+    this.height = $img.height();
+    this.alt = $img.attr('alt');
+    if ($img.hasClass('uploading')) {
+      return this.srcEl.val(this._t('uploading')).prop('disabled', true);
+    } else {
+      this.srcEl.val($img.attr('src')).prop('disabled', false);
+      this.widthEl.val(this.width);
+      this.heightEl.val(this.height);
+      return this.altEl.val(this.alt);
+    }
+  };
+
+  return ImagePopover;
+
+})(Popover);
+
+Simditor.Toolbar.addButton(ImageButton);
+
+IndentButton = (function(superClass) {
+  extend(IndentButton, superClass);
+
+  function IndentButton() {
+    return IndentButton.__super__.constructor.apply(this, arguments);
+  }
+
+  IndentButton.prototype.name = 'indent';
+
+  IndentButton.prototype.icon = 'indent';
+
+  IndentButton.prototype._init = function() {
+    var hotkey;
+    hotkey = this.editor.opts.tabIndent === false ? '' : ' (Tab)';
+    this.title = this._t(this.name) + hotkey;
+    return IndentButton.__super__._init.call(this);
+  };
+
+  IndentButton.prototype._status = function() {};
+
+  IndentButton.prototype.command = function() {
+    return this.editor.indentation.indent();
+  };
+
+  return IndentButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(IndentButton);
+
+OutdentButton = (function(superClass) {
+  extend(OutdentButton, superClass);
+
+  function OutdentButton() {
+    return OutdentButton.__super__.constructor.apply(this, arguments);
+  }
+
+  OutdentButton.prototype.name = 'outdent';
+
+  OutdentButton.prototype.icon = 'outdent';
+
+  OutdentButton.prototype._init = function() {
+    var hotkey;
+    hotkey = this.editor.opts.tabIndent === false ? '' : ' (Shift + Tab)';
+    this.title = this._t(this.name) + hotkey;
+    return OutdentButton.__super__._init.call(this);
+  };
+
+  OutdentButton.prototype._status = function() {};
+
+  OutdentButton.prototype.command = function() {
+    return this.editor.indentation.indent(true);
+  };
+
+  return OutdentButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(OutdentButton);
+
+HrButton = (function(superClass) {
+  extend(HrButton, superClass);
+
+  function HrButton() {
+    return HrButton.__super__.constructor.apply(this, arguments);
+  }
+
+  HrButton.prototype.name = 'hr';
+
+  HrButton.prototype.icon = 'minus';
+
+  HrButton.prototype.htmlTag = 'hr';
+
+  HrButton.prototype._status = function() {};
+
+  HrButton.prototype.command = function() {
+    var $hr, $newBlock, $nextBlock, $rootBlock;
+    $rootBlock = this.editor.selection.rootNodes().first();
+    $nextBlock = $rootBlock.next();
+    if ($nextBlock.length > 0) {
+      this.editor.selection.save();
+    } else {
+      $newBlock = $('<p/>').append(this.editor.util.phBr);
+    }
+    $hr = $('<hr/>').insertAfter($rootBlock);
+    if ($newBlock) {
+      $newBlock.insertAfter($hr);
+      this.editor.selection.setRangeAtStartOf($newBlock);
+    } else {
+      this.editor.selection.restore();
+    }
+    return this.editor.trigger('valuechanged');
+  };
+
+  return HrButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(HrButton);
+
+TableButton = (function(superClass) {
+  extend(TableButton, superClass);
+
+  function TableButton() {
+    return TableButton.__super__.constructor.apply(this, arguments);
+  }
+
+  TableButton.prototype.name = 'table';
+
+  TableButton.prototype.icon = 'table';
+
+  TableButton.prototype.htmlTag = 'table';
+
+  TableButton.prototype.disableTag = 'pre, li, blockquote';
+
+  TableButton.prototype.menu = true;
+
+  TableButton.prototype._init = function() {
+    TableButton.__super__._init.call(this);
+    $.merge(this.editor.formatter._allowedTags, ['thead', 'th', 'tbody', 'tr', 'td', 'colgroup', 'col']);
+    $.extend(this.editor.formatter._allowedAttributes, {
+      td: ['rowspan', 'colspan'],
+      col: ['width']
+    });
+    $.extend(this.editor.formatter._allowedStyles, {
+      td: ['text-align'],
+      th: ['text-align']
+    });
+    this._initShortcuts();
+    this.editor.on('decorate', (function(_this) {
+      return function(e, $el) {
+        return $el.find('table').each(function(i, table) {
+          return _this.decorate($(table));
+        });
+      };
+    })(this));
+    this.editor.on('undecorate', (function(_this) {
+      return function(e, $el) {
+        return $el.find('table').each(function(i, table) {
+          return _this.undecorate($(table));
+        });
+      };
+    })(this));
+    this.editor.on('selectionchanged.table', (function(_this) {
+      return function(e) {
+        var $container, range;
+        _this.editor.body.find('.simditor-table td, .simditor-table th').removeClass('active');
+        range = _this.editor.selection.range();
+        if (!range) {
+          return;
+        }
+        $container = _this.editor.selection.containerNode();
+        if (range.collapsed && $container.is('.simditor-table')) {
+          if (_this.editor.selection.rangeAtStartOf($container)) {
+            $container = $container.find('th:first');
+          } else {
+            $container = $container.find('td:last');
+          }
+          _this.editor.selection.setRangeAtEndOf($container);
+        }
+        return $container.closest('td, th', _this.editor.body).addClass('active');
+      };
+    })(this));
+    this.editor.on('blur.table', (function(_this) {
+      return function(e) {
+        return _this.editor.body.find('.simditor-table td, .simditor-table th').removeClass('active');
+      };
+    })(this));
+    this.editor.keystroke.add('up', 'td', (function(_this) {
+      return function(e, $node) {
+        _this._tdNav($node, 'up');
+        return true;
+      };
+    })(this));
+    this.editor.keystroke.add('up', 'th', (function(_this) {
+      return function(e, $node) {
+        _this._tdNav($node, 'up');
+        return true;
+      };
+    })(this));
+    this.editor.keystroke.add('down', 'td', (function(_this) {
+      return function(e, $node) {
+        _this._tdNav($node, 'down');
+        return true;
+      };
+    })(this));
+    return this.editor.keystroke.add('down', 'th', (function(_this) {
+      return function(e, $node) {
+        _this._tdNav($node, 'down');
+        return true;
+      };
+    })(this));
+  };
+
+  TableButton.prototype._tdNav = function($td, direction) {
+    var $anotherTr, $tr, action, anotherTag, index, parentTag, ref;
+    if (direction == null) {
+      direction = 'up';
+    }
+    action = direction === 'up' ? 'prev' : 'next';
+    ref = direction === 'up' ? ['tbody', 'thead'] : ['thead', 'tbody'], parentTag = ref[0], anotherTag = ref[1];
+    $tr = $td.parent('tr');
+    $anotherTr = this["_" + action + "Row"]($tr);
+    if (!($anotherTr.length > 0)) {
+      return true;
+    }
+    index = $tr.find('td, th').index($td);
+    return this.editor.selection.setRangeAtEndOf($anotherTr.find('td, th').eq(index));
+  };
+
+  TableButton.prototype._nextRow = function($tr) {
+    var $nextTr;
+    $nextTr = $tr.next('tr');
+    if ($nextTr.length < 1 && $tr.parent('thead').length > 0) {
+      $nextTr = $tr.parent('thead').next('tbody').find('tr:first');
+    }
+    return $nextTr;
+  };
+
+  TableButton.prototype._prevRow = function($tr) {
+    var $prevTr;
+    $prevTr = $tr.prev('tr');
+    if ($prevTr.length < 1 && $tr.parent('tbody').length > 0) {
+      $prevTr = $tr.parent('tbody').prev('thead').find('tr');
+    }
+    return $prevTr;
+  };
+
+  TableButton.prototype.initResize = function($table) {
+    var $colgroup, $editor, $resizeHandle, $wrapper;
+    $wrapper = $table.parent('.simditor-table');
+    $editor = this.editor;
+    $colgroup = $table.find('colgroup');
+    if ($colgroup.length < 1) {
+      $colgroup = $('<colgroup/>').prependTo($table);
+      $table.find('thead tr th').each(function(i, td) {
+        var $col;
+        return $col = $('<col/>').appendTo($colgroup);
+      });
+      this.refreshTableWidth($table);
+    }
+    $resizeHandle = $('<div />', {
+      "class": 'simditor-resize-handle',
+      contenteditable: 'false'
+    }).appendTo($wrapper);
+    $wrapper.on('mousemove', 'td, th', function(e) {
+      var $col, $td, index, ref, ref1, x;
+      if ($wrapper.hasClass('resizing')) {
+        return;
+      }
+      $td = $(e.currentTarget);
+      x = e.pageX - $(e.currentTarget).offset().left;
+      if (x < 5 && $td.prev().length > 0) {
+        $td = $td.prev();
+      }
+      if ($td.next('td, th').length < 1) {
+        $resizeHandle.hide();
+        return;
+      }
+      if ((ref = $resizeHandle.data('td')) != null ? ref.is($td) : void 0) {
+        $resizeHandle.show();
+        return;
+      }
+      index = $td.parent().find('td, th').index($td);
+      $col = $colgroup.find('col').eq(index);
+      if ((ref1 = $resizeHandle.data('col')) != null ? ref1.is($col) : void 0) {
+        $resizeHandle.show();
+        return;
+      }
+      return $resizeHandle.css('left', $td.position().left + $td.outerWidth() - 5).data('td', $td).data('col', $col).show();
+    });
+    $wrapper.on('mouseleave', function(e) {
+      return $resizeHandle.hide();
+    });
+    return $wrapper.on('mousedown', '.simditor-resize-handle', function(e) {
+      var $handle, $leftCol, $leftTd, $rightCol, $rightTd, minWidth, startHandleLeft, startLeftWidth, startRightWidth, startX, tableWidth;
+      $handle = $(e.currentTarget);
+      $leftTd = $handle.data('td');
+      $leftCol = $handle.data('col');
+      $rightTd = $leftTd.next('td, th');
+      $rightCol = $leftCol.next('col');
+      startX = e.pageX;
+      startLeftWidth = $leftTd.outerWidth() * 1;
+      startRightWidth = $rightTd.outerWidth() * 1;
+      startHandleLeft = parseFloat($handle.css('left'));
+      tableWidth = $leftTd.closest('table').width();
+      minWidth = 50;
+      $(document).on('mousemove.simditor-resize-table', function(e) {
+        var deltaX, leftWidth, rightWidth;
+        deltaX = e.pageX - startX;
+        leftWidth = startLeftWidth + deltaX;
+        rightWidth = startRightWidth - deltaX;
+        if (leftWidth < minWidth) {
+          leftWidth = minWidth;
+          deltaX = minWidth - startLeftWidth;
+          rightWidth = startRightWidth - deltaX;
+        } else if (rightWidth < minWidth) {
+          rightWidth = minWidth;
+          deltaX = startRightWidth - minWidth;
+          leftWidth = startLeftWidth + deltaX;
+        }
+        $leftCol.attr('width', (leftWidth / tableWidth * 100) + '%');
+        $rightCol.attr('width', (rightWidth / tableWidth * 100) + '%');
+        return $handle.css('left', startHandleLeft + deltaX);
+      });
+      $(document).one('mouseup.simditor-resize-table', function(e) {
+        $editor.sync();
+        $(document).off('.simditor-resize-table');
+        return $wrapper.removeClass('resizing');
+      });
+      $wrapper.addClass('resizing');
+      return false;
+    });
+  };
+
+  TableButton.prototype._initShortcuts = function() {
+    this.editor.hotkeys.add('ctrl+alt+up', (function(_this) {
+      return function(e) {
+        _this.editMenu.find('.menu-item[data-param=insertRowAbove]').click();
+        return false;
+      };
+    })(this));
+    this.editor.hotkeys.add('ctrl+alt+down', (function(_this) {
+      return function(e) {
+        _this.editMenu.find('.menu-item[data-param=insertRowBelow]').click();
+        return false;
+      };
+    })(this));
+    this.editor.hotkeys.add('ctrl+alt+left', (function(_this) {
+      return function(e) {
+        _this.editMenu.find('.menu-item[data-param=insertColLeft]').click();
+        return false;
+      };
+    })(this));
+    return this.editor.hotkeys.add('ctrl+alt+right', (function(_this) {
+      return function(e) {
+        _this.editMenu.find('.menu-item[data-param=insertColRight]').click();
+        return false;
+      };
+    })(this));
+  };
+
+  TableButton.prototype.decorate = function($table) {
+    var $headRow, $tbody, $thead;
+    if ($table.parent('.simditor-table').length > 0) {
+      this.undecorate($table);
+    }
+    $table.wrap('<div class="simditor-table"></div>');
+    if ($table.find('thead').length < 1) {
+      $thead = $('<thead />');
+      $headRow = $table.find('tr').first();
+      $thead.append($headRow);
+      this._changeCellTag($headRow, 'th');
+      $tbody = $table.find('tbody');
+      if ($tbody.length > 0) {
+        $tbody.before($thead);
+      } else {
+        $table.prepend($thead);
+      }
+    }
+    this.initResize($table);
+    return $table.parent();
+  };
+
+  TableButton.prototype.undecorate = function($table) {
+    if (!($table.parent('.simditor-table').length > 0)) {
+      return;
+    }
+    return $table.parent().replaceWith($table);
+  };
+
+  TableButton.prototype.renderMenu = function() {
+    var $table;
+    $("<div class=\"menu-create-table\">\n</div>\n<div class=\"menu-edit-table\">\n  <ul>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"deleteRow\">\n        <span>" + (this._t('deleteRow')) + "</span>\n      </a>\n    </li>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"insertRowAbove\">\n        <span>" + (this._t('insertRowAbove')) + " ( Ctrl + Alt + ↑ )</span>\n      </a>\n    </li>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"insertRowBelow\">\n        <span>" + (this._t('insertRowBelow')) + " ( Ctrl + Alt + ↓ )</span>\n      </a>\n    </li>\n    <li><span class=\"separator\"></span></li>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"deleteCol\">\n        <span>" + (this._t('deleteColumn')) + "</span>\n      </a>\n    </li>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"insertColLeft\">\n        <span>" + (this._t('insertColumnLeft')) + " ( Ctrl + Alt + ← )</span>\n      </a>\n    </li>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"insertColRight\">\n        <span>" + (this._t('insertColumnRight')) + " ( Ctrl + Alt + → )</span>\n      </a>\n    </li>\n    <li><span class=\"separator\"></span></li>\n    <li>\n      <a tabindex=\"-1\" unselectable=\"on\" class=\"menu-item\"\n        href=\"javascript:;\" data-param=\"deleteTable\">\n        <span>" + (this._t('deleteTable')) + "</span>\n      </a>\n    </li>\n  </ul>\n</div>").appendTo(this.menuWrapper);
+    this.createMenu = this.menuWrapper.find('.menu-create-table');
+    this.editMenu = this.menuWrapper.find('.menu-edit-table');
+    $table = this.createTable(6, 6).appendTo(this.createMenu);
+    this.createMenu.on('mouseenter', 'td, th', (function(_this) {
+      return function(e) {
+        var $td, $tr, $trs, num;
+        _this.createMenu.find('td, th').removeClass('selected');
+        $td = $(e.currentTarget);
+        $tr = $td.parent();
+        num = $tr.find('td, th').index($td) + 1;
+        $trs = $tr.prevAll('tr').addBack();
+        if ($tr.parent().is('tbody')) {
+          $trs = $trs.add($table.find('thead tr'));
+        }
+        return $trs.find("td:lt(" + num + "), th:lt(" + num + ")").addClass('selected');
+      };
+    })(this));
+    this.createMenu.on('mouseleave', function(e) {
+      return $(e.currentTarget).find('td, th').removeClass('selected');
+    });
+    return this.createMenu.on('mousedown', 'td, th', (function(_this) {
+      return function(e) {
+        var $closestBlock, $td, $tr, colNum, rowNum;
+        _this.wrapper.removeClass('menu-on');
+        if (!_this.editor.inputManager.focused) {
+          return;
+        }
+        $td = $(e.currentTarget);
+        $tr = $td.parent();
+        colNum = $tr.find('td').index($td) + 1;
+        rowNum = $tr.prevAll('tr').length + 1;
+        if ($tr.parent().is('tbody')) {
+          rowNum += 1;
+        }
+        $table = _this.createTable(rowNum, colNum, true);
+        $closestBlock = _this.editor.selection.blockNodes().last();
+        if (_this.editor.util.isEmptyNode($closestBlock)) {
+          $closestBlock.replaceWith($table);
+        } else {
+          $closestBlock.after($table);
+        }
+        _this.decorate($table);
+        _this.editor.selection.setRangeAtStartOf($table.find('th:first'));
+        _this.editor.trigger('valuechanged');
+        return false;
+      };
+    })(this));
+  };
+
+  TableButton.prototype.createTable = function(row, col, phBr) {
+    var $table, $tbody, $td, $thead, $tr, c, k, l, r, ref, ref1;
+    $table = $('<table/>');
+    $thead = $('<thead/>').appendTo($table);
+    $tbody = $('<tbody/>').appendTo($table);
+    for (r = k = 0, ref = row; 0 <= ref ? k < ref : k > ref; r = 0 <= ref ? ++k : --k) {
+      $tr = $('<tr/>');
+      $tr.appendTo(r === 0 ? $thead : $tbody);
+      for (c = l = 0, ref1 = col; 0 <= ref1 ? l < ref1 : l > ref1; c = 0 <= ref1 ? ++l : --l) {
+        $td = $(r === 0 ? '<th/>' : '<td/>').appendTo($tr);
+        if (phBr) {
+          $td.append(this.editor.util.phBr);
+        }
+      }
+    }
+    return $table;
+  };
+
+  TableButton.prototype.refreshTableWidth = function($table) {
+    return setTimeout((function(_this) {
+      return function() {
+        var cols, tableWidth;
+        tableWidth = $table.width();
+        cols = $table.find('col');
+        return $table.find('thead tr th').each(function(i, td) {
+          var $col;
+          $col = cols.eq(i);
+          return $col.attr('width', ($(td).outerWidth() / tableWidth * 100) + '%');
+        });
+      };
+    })(this), 0);
+  };
+
+  TableButton.prototype.setActive = function(active) {
+    TableButton.__super__.setActive.call(this, active);
+    if (active) {
+      this.createMenu.hide();
+      return this.editMenu.show();
+    } else {
+      this.createMenu.show();
+      return this.editMenu.hide();
+    }
+  };
+
+  TableButton.prototype._changeCellTag = function($tr, tagName) {
+    return $tr.find('td, th').each(function(i, cell) {
+      var $cell;
+      $cell = $(cell);
+      return $cell.replaceWith("<" + tagName + ">" + ($cell.html()) + "</" + tagName + ">");
+    });
+  };
+
+  TableButton.prototype.deleteRow = function($td) {
+    var $newTr, $tr, index;
+    $tr = $td.parent('tr');
+    if ($tr.closest('table').find('tr').length < 1) {
+      return this.deleteTable($td);
+    } else {
+      $newTr = this._nextRow($tr);
+      if (!($newTr.length > 0)) {
+        $newTr = this._prevRow($tr);
+      }
+      index = $tr.find('td, th').index($td);
+      if ($tr.parent().is('thead')) {
+        $newTr.appendTo($tr.parent());
+        this._changeCellTag($newTr, 'th');
+      }
+      $tr.remove();
+      return this.editor.selection.setRangeAtEndOf($newTr.find('td, th').eq(index));
+    }
+  };
+
+  TableButton.prototype.insertRow = function($td, direction) {
+    var $newTr, $table, $tr, cellTag, colNum, i, index, k, ref;
+    if (direction == null) {
+      direction = 'after';
+    }
+    $tr = $td.parent('tr');
+    $table = $tr.closest('table');
+    colNum = 0;
+    $table.find('tr').each(function(i, tr) {
+      return colNum = Math.max(colNum, $(tr).find('td').length);
+    });
+    index = $tr.find('td, th').index($td);
+    $newTr = $('<tr/>');
+    cellTag = 'td';
+    if (direction === 'after' && $tr.parent().is('thead')) {
+      $tr.parent().next('tbody').prepend($newTr);
+    } else if (direction === 'before' && $tr.parent().is('thead')) {
+      $tr.before($newTr);
+      $tr.parent().next('tbody').prepend($tr);
+      this._changeCellTag($tr, 'td');
+      cellTag = 'th';
+    } else {
+      $tr[direction]($newTr);
+    }
+    for (i = k = 1, ref = colNum; 1 <= ref ? k <= ref : k >= ref; i = 1 <= ref ? ++k : --k) {
+      $("<" + cellTag + "/>").append(this.editor.util.phBr).appendTo($newTr);
+    }
+    return this.editor.selection.setRangeAtStartOf($newTr.find('td, th').eq(index));
+  };
+
+  TableButton.prototype.deleteCol = function($td) {
+    var $newTd, $table, $tr, index, noOtherCol, noOtherRow;
+    $tr = $td.parent('tr');
+    noOtherRow = $tr.closest('table').find('tr').length < 2;
+    noOtherCol = $td.siblings('td, th').length < 1;
+    if (noOtherRow && noOtherCol) {
+      return this.deleteTable($td);
+    } else {
+      index = $tr.find('td, th').index($td);
+      $newTd = $td.next('td, th');
+      if (!($newTd.length > 0)) {
+        $newTd = $tr.prev('td, th');
+      }
+      $table = $tr.closest('table');
+      $table.find('col').eq(index).remove();
+      $table.find('tr').each(function(i, tr) {
+        return $(tr).find('td, th').eq(index).remove();
+      });
+      this.refreshTableWidth($table);
+      return this.editor.selection.setRangeAtEndOf($newTd);
+    }
+  };
+
+  TableButton.prototype.insertCol = function($td, direction) {
+    var $col, $newCol, $newTd, $table, $tr, index, tableWidth, width;
+    if (direction == null) {
+      direction = 'after';
+    }
+    $tr = $td.parent('tr');
+    index = $tr.find('td, th').index($td);
+    $table = $td.closest('table');
+    $col = $table.find('col').eq(index);
+    $table.find('tr').each((function(_this) {
+      return function(i, tr) {
+        var $newTd, cellTag;
+        cellTag = $(tr).parent().is('thead') ? 'th' : 'td';
+        $newTd = $("<" + cellTag + "/>").append(_this.editor.util.phBr);
+        return $(tr).find('td, th').eq(index)[direction]($newTd);
+      };
+    })(this));
+    $newCol = $('<col/>');
+    $col[direction]($newCol);
+    tableWidth = $table.width();
+    width = Math.max(parseFloat($col.attr('width')) / 2, 50 / tableWidth * 100);
+    $col.attr('width', width + '%');
+    $newCol.attr('width', width + '%');
+    this.refreshTableWidth($table);
+    $newTd = direction === 'after' ? $td.next('td, th') : $td.prev('td, th');
+    return this.editor.selection.setRangeAtStartOf($newTd);
+  };
+
+  TableButton.prototype.deleteTable = function($td) {
+    var $block, $table;
+    $table = $td.closest('.simditor-table');
+    $block = $table.next('p');
+    $table.remove();
+    if ($block.length > 0) {
+      return this.editor.selection.setRangeAtStartOf($block);
+    }
+  };
+
+  TableButton.prototype.command = function(param) {
+    var $td;
+    $td = this.editor.selection.containerNode().closest('td, th');
+    if (!($td.length > 0)) {
+      return;
+    }
+    if (param === 'deleteRow') {
+      this.deleteRow($td);
+    } else if (param === 'insertRowAbove') {
+      this.insertRow($td, 'before');
+    } else if (param === 'insertRowBelow') {
+      this.insertRow($td);
+    } else if (param === 'deleteCol') {
+      this.deleteCol($td);
+    } else if (param === 'insertColLeft') {
+      this.insertCol($td, 'before');
+    } else if (param === 'insertColRight') {
+      this.insertCol($td);
+    } else if (param === 'deleteTable') {
+      this.deleteTable($td);
+    } else {
+      return;
+    }
+    return this.editor.trigger('valuechanged');
+  };
+
+  return TableButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(TableButton);
+
+StrikethroughButton = (function(superClass) {
+  extend(StrikethroughButton, superClass);
+
+  function StrikethroughButton() {
+    return StrikethroughButton.__super__.constructor.apply(this, arguments);
+  }
+
+  StrikethroughButton.prototype.name = 'strikethrough';
+
+  StrikethroughButton.prototype.icon = 'strikethrough';
+
+  StrikethroughButton.prototype.htmlTag = 'strike';
+
+  StrikethroughButton.prototype.disableTag = 'pre';
+
+  StrikethroughButton.prototype._activeStatus = function() {
+    var active;
+    active = document.queryCommandState('strikethrough') === true;
+    this.setActive(active);
+    return this.active;
+  };
+
+  StrikethroughButton.prototype.command = function() {
+    document.execCommand('strikethrough');
+    if (!this.editor.util.support.oninput) {
+      this.editor.trigger('valuechanged');
+    }
+    return $(document).trigger('selectionchange');
+  };
+
+  return StrikethroughButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(StrikethroughButton);
+
+AlignmentButton = (function(superClass) {
+  extend(AlignmentButton, superClass);
+
+  function AlignmentButton() {
+    return AlignmentButton.__super__.constructor.apply(this, arguments);
+  }
+
+  AlignmentButton.prototype.name = "alignment";
+
+  AlignmentButton.prototype.icon = 'align-left';
+
+  AlignmentButton.prototype.htmlTag = 'p, h1, h2, h3, h4, td, th';
+
+  AlignmentButton.prototype._init = function() {
+    this.menu = [
+      {
+        name: 'left',
+        text: this._t('alignLeft'),
+        icon: 'align-left',
+        param: 'left'
+      }, {
+        name: 'center',
+        text: this._t('alignCenter'),
+        icon: 'align-center',
+        param: 'center'
+      }, {
+        name: 'right',
+        text: this._t('alignRight'),
+        icon: 'align-right',
+        param: 'right'
+      }
+    ];
+    return AlignmentButton.__super__._init.call(this);
+  };
+
+  AlignmentButton.prototype.setActive = function(active, align) {
+    if (align == null) {
+      align = 'left';
+    }
+    if (align !== 'left' && align !== 'center' && align !== 'right') {
+      align = 'left';
+    }
+    if (align === 'left') {
+      AlignmentButton.__super__.setActive.call(this, false);
+    } else {
+      AlignmentButton.__super__.setActive.call(this, active);
+    }
+    this.el.removeClass('align-left align-center align-right');
+    if (active) {
+      this.el.addClass('align-' + align);
+    }
+    this.setIcon('align-' + align);
+    return this.menuEl.find('.menu-item').show().end().find('.menu-item-' + align).hide();
+  };
+
+  AlignmentButton.prototype._status = function() {
+    this.nodes = this.editor.selection.nodes().filter(this.htmlTag);
+    if (this.nodes.length < 1) {
+      this.setDisabled(true);
+      return this.setActive(false);
+    } else {
+      this.setDisabled(false);
+      return this.setActive(true, this.nodes.first().css('text-align'));
+    }
+  };
+
+  AlignmentButton.prototype.command = function(align) {
+    if (align !== 'left' && align !== 'center' && align !== 'right') {
+      throw new Error("simditor alignment button: invalid align " + align);
+    }
+    this.nodes.css({
+      'text-align': align === 'left' ? '' : align
+    });
+    this.editor.trigger('valuechanged');
+    return this.editor.inputManager.throttledSelectionChanged();
+  };
+
+  return AlignmentButton;
+
+})(Button);
+
+Simditor.Toolbar.addButton(AlignmentButton);
+
+return Simditor;
+
+}));

+ 261 - 0
addons/simditor/src/js/uploader.js

@@ -0,0 +1,261 @@
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define('simple-uploader', ["jquery","simple-module"], function ($, SimpleModule) {
+      return (root['uploader'] = factory($, SimpleModule));
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory(require("jquery"),require("simple-module"));
+  } else {
+    root.simple = root.simple || {};
+    root.simple['uploader'] = factory(jQuery,SimpleModule);
+  }
+}(this, function ($, SimpleModule) {
+
+var Uploader, uploader,
+  extend = function(child, parent) { for (var key in parent) { if (hasProp.call(parent, key)) child[key] = parent[key]; } function ctor() { this.constructor = child; } ctor.prototype = parent.prototype; child.prototype = new ctor(); child.__super__ = parent.prototype; return child; },
+  hasProp = {}.hasOwnProperty;
+
+Uploader = (function(superClass) {
+  extend(Uploader, superClass);
+
+  function Uploader() {
+    return Uploader.__super__.constructor.apply(this, arguments);
+  }
+
+  Uploader.count = 0;
+
+  Uploader.prototype.opts = {
+    url: '',
+    params: null,
+    fileKey: 'upload_file',
+    connectionCount: 3
+  };
+
+  Uploader.prototype._init = function() {
+    this.files = [];
+    this.queue = [];
+    this.id = ++Uploader.count;
+    this.on('uploadcomplete', (function(_this) {
+      return function(e, file) {
+        _this.files.splice($.inArray(file, _this.files), 1);
+        if (_this.queue.length > 0 && _this.files.length < _this.opts.connectionCount) {
+          return _this.upload(_this.queue.shift());
+        } else if (_this.files.length === 0) {
+          return _this.uploading = false;
+        }
+      };
+    })(this));
+    return $(window).on('beforeunload.uploader-' + this.id, (function(_this) {
+      return function(e) {
+        if (!_this.uploading) {
+          return;
+        }
+        e.originalEvent.returnValue = _this._t('leaveConfirm');
+        return _this._t('leaveConfirm');
+      };
+    })(this));
+  };
+
+  Uploader.prototype.generateId = (function() {
+    var id;
+    id = 0;
+    return function() {
+      return id += 1;
+    };
+  })();
+
+  Uploader.prototype.upload = function(file, opts) {
+    var f, i, key, len;
+    if (opts == null) {
+      opts = {};
+    }
+    if (file == null) {
+      return;
+    }
+    if ($.isArray(file) || file instanceof FileList) {
+      for (i = 0, len = file.length; i < len; i++) {
+        f = file[i];
+        this.upload(f, opts);
+      }
+    } else if ($(file).is('input:file')) {
+      key = $(file).attr('name');
+      if (key) {
+        opts.fileKey = key;
+      }
+      this.upload($.makeArray($(file)[0].files), opts);
+    } else if (!file.id || !file.obj) {
+      file = this.getFile(file);
+    }
+    if (!(file && file.obj)) {
+      return;
+    }
+    $.extend(file, opts);
+    if (this.files.length >= this.opts.connectionCount) {
+      this.queue.push(file);
+      return;
+    }
+    if (this.triggerHandler('beforeupload', [file]) === false) {
+      return;
+    }
+    this.files.push(file);
+    this._xhrUpload(file);
+    return this.uploading = true;
+  };
+
+  Uploader.prototype.getFile = function(fileObj) {
+    var name, ref, ref1;
+    if (fileObj instanceof window.File || fileObj instanceof window.Blob) {
+      name = (ref = fileObj.fileName) != null ? ref : fileObj.name;
+    } else {
+      return null;
+    }
+    return {
+      id: this.generateId(),
+      url: this.opts.url,
+      params: this.opts.params,
+      fileKey: this.opts.fileKey,
+      name: name,
+      size: (ref1 = fileObj.fileSize) != null ? ref1 : fileObj.size,
+      ext: name ? name.split('.').pop().toLowerCase() : '',
+      obj: fileObj
+    };
+  };
+
+  Uploader.prototype._xhrUpload = function(file) {
+    var formData, k, ref, v;
+    formData = new FormData();
+    formData.append(file.fileKey, file.obj);
+    formData.append("original_filename", file.name);
+    if (file.params) {
+      ref = file.params;
+      for (k in ref) {
+        v = ref[k];
+        formData.append(k, v);
+      }
+    }
+    return file.xhr = $.ajax({
+      url: file.url,
+      data: formData,
+      processData: false,
+      contentType: false,
+      type: 'POST',
+      headers: {
+        'X-File-Name': encodeURIComponent(file.name)
+      },
+      xhr: function() {
+        var req;
+        req = $.ajaxSettings.xhr();
+        if (req) {
+          req.upload.onprogress = (function(_this) {
+            return function(e) {
+              return _this.progress(e);
+            };
+          })(this);
+        }
+        return req;
+      },
+      progress: (function(_this) {
+        return function(e) {
+          if (!e.lengthComputable) {
+            return;
+          }
+          return _this.trigger('uploadprogress', [file, e.loaded, e.total]);
+        };
+      })(this),
+      error: (function(_this) {
+        return function(xhr, status, err) {
+          return _this.trigger('uploaderror', [file, xhr, status]);
+        };
+      })(this),
+      success: (function(_this) {
+        return function(result) {
+          _this.trigger('uploadprogress', [file, file.size, file.size]);
+          _this.trigger('uploadsuccess', [file, result]);
+          return $(document).trigger('uploadsuccess', [file, result, _this]);
+        };
+      })(this),
+      complete: (function(_this) {
+        return function(xhr, status) {
+          return _this.trigger('uploadcomplete', [file, xhr.responseText]);
+        };
+      })(this)
+    });
+  };
+
+  Uploader.prototype.cancel = function(file) {
+    var f, i, len, ref;
+    if (!file.id) {
+      ref = this.files;
+      for (i = 0, len = ref.length; i < len; i++) {
+        f = ref[i];
+        if (f.id === file * 1) {
+          file = f;
+          break;
+        }
+      }
+    }
+    this.trigger('uploadcancel', [file]);
+    if (file.xhr) {
+      file.xhr.abort();
+    }
+    return file.xhr = null;
+  };
+
+  Uploader.prototype.readImageFile = function(fileObj, callback) {
+    var fileReader, img;
+    if (!$.isFunction(callback)) {
+      return;
+    }
+    img = new Image();
+    img.onload = function() {
+      return callback(img);
+    };
+    img.onerror = function() {
+      return callback();
+    };
+    if (window.FileReader && FileReader.prototype.readAsDataURL && /^image/.test(fileObj.type)) {
+      fileReader = new FileReader();
+      fileReader.onload = function(e) {
+        return img.src = e.target.result;
+      };
+      return fileReader.readAsDataURL(fileObj);
+    } else {
+      return callback();
+    }
+  };
+
+  Uploader.prototype.destroy = function() {
+    var file, i, len, ref;
+    this.queue.length = 0;
+    ref = this.files;
+    for (i = 0, len = ref.length; i < len; i++) {
+      file = ref[i];
+      this.cancel(file);
+    }
+    $(window).off('.uploader-' + this.id);
+    return $(document).off('.uploader-' + this.id);
+  };
+
+  Uploader.i18n = {
+    'zh-CN': {
+      leaveConfirm: '正在上传文件,如果离开上传会自动取消'
+    }
+  };
+
+  Uploader.locale = 'zh-CN';
+
+  return Uploader;
+
+})(SimpleModule);
+
+uploader = function(opts) {
+  return new Uploader(opts);
+};
+
+return uploader;
+
+}));

+ 1 - 0
application/.htaccess

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

+ 4 - 0
application/UserException.php

@@ -0,0 +1,4 @@
+<?php
+namespace app;
+
+class UserException extends \Exception{}

+ 14 - 0
application/admin/behavior/AdminLog.php

@@ -0,0 +1,14 @@
+<?php
+
+namespace app\admin\behavior;
+
+class AdminLog
+{
+    public function run(&$params)
+    {
+        //只记录POST请求的日志
+        if (request()->isPost() && config('fastadmin.auto_record_log')) {
+            \app\admin\model\AdminLog::record();
+        }
+    }
+}

+ 383 - 0
application/admin/command/Addon.php

@@ -0,0 +1,383 @@
+<?php
+
+namespace app\admin\command;
+
+use think\addons\AddonException;
+use think\addons\Service;
+use think\Config;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Option;
+use think\console\Output;
+use think\Db;
+use think\Exception;
+use think\exception\PDOException;
+
+class Addon extends Command
+{
+
+    protected function configure()
+    {
+        $this
+            ->setName('addon')
+            ->addOption('name', 'a', Option::VALUE_REQUIRED, 'addon name', null)
+            ->addOption('action', 'c', Option::VALUE_REQUIRED, 'action(create/enable/disable/install/uninstall/refresh/upgrade/package/move)', 'create')
+            ->addOption('force', 'f', Option::VALUE_OPTIONAL, 'force override', null)
+            ->addOption('release', 'r', Option::VALUE_OPTIONAL, 'addon release version', null)
+            ->addOption('uid', 'u', Option::VALUE_OPTIONAL, 'fastadmin uid', null)
+            ->addOption('token', 't', Option::VALUE_OPTIONAL, 'fastadmin token', null)
+            ->addOption('local', 'l', Option::VALUE_OPTIONAL, 'local package', null)
+            ->setDescription('Addon manager');
+    }
+
+    protected function execute(Input $input, Output $output)
+    {
+        $name = $input->getOption('name') ?: '';
+        $action = $input->getOption('action') ?: '';
+        if (stripos($name, 'addons' . DS) !== false) {
+            $name = explode(DS, $name)[1];
+        }
+        //强制覆盖
+        $force = $input->getOption('force');
+        //版本
+        $release = $input->getOption('release') ?: '';
+        //uid
+        $uid = $input->getOption('uid') ?: '';
+        //token
+        $token = $input->getOption('token') ?: '';
+
+        include dirname(__DIR__) . DS . 'common.php';
+
+        if (!$name) {
+            throw new Exception('Addon name could not be empty');
+        }
+        if (!$action || !in_array($action, ['create', 'disable', 'enable', 'install', 'uninstall', 'refresh', 'upgrade', 'package', 'move'])) {
+            throw new Exception('Please input correct action name');
+        }
+
+        // 查询一次SQL,判断连接是否正常
+        Db::execute("SELECT 1");
+
+        $addonDir = ADDON_PATH . $name . DS;
+        switch ($action) {
+            case 'create':
+                //非覆盖模式时如果存在则报错
+                if (is_dir($addonDir) && !$force) {
+                    throw new Exception("addon already exists!\nIf you need to create again, use the parameter --force=true ");
+                }
+                //如果存在先移除
+                if (is_dir($addonDir)) {
+                    rmdirs($addonDir);
+                }
+                mkdir($addonDir, 0755, true);
+                mkdir($addonDir . DS . 'controller', 0755, true);
+                $menuList = \app\common\library\Menu::export($name);
+                $createMenu = $this->getCreateMenu($menuList);
+                $prefix = Config::get('database.prefix');
+                $createTableSql = '';
+                try {
+                    $result = Db::query("SHOW CREATE TABLE `" . $prefix . $name . "`;");
+                    if (isset($result[0]) && isset($result[0]['Create Table'])) {
+                        $createTableSql = $result[0]['Create Table'];
+                    }
+                } catch (PDOException $e) {
+
+                }
+
+                $data = [
+                    'name'               => $name,
+                    'addon'              => $name,
+                    'addonClassName'     => ucfirst($name),
+                    'addonInstallMenu'   => $createMenu ? "\$menu = " . var_export_short($createMenu) . ";\n\tMenu::create(\$menu);" : '',
+                    'addonUninstallMenu' => $menuList ? 'Menu::delete("' . $name . '");' : '',
+                    'addonEnableMenu'    => $menuList ? 'Menu::enable("' . $name . '");' : '',
+                    'addonDisableMenu'   => $menuList ? 'Menu::disable("' . $name . '");' : '',
+                ];
+                $this->writeToFile("addon", $data, $addonDir . ucfirst($name) . '.php');
+                $this->writeToFile("config", $data, $addonDir . 'config.php');
+                $this->writeToFile("info", $data, $addonDir . 'info.ini');
+                $this->writeToFile("controller", $data, $addonDir . 'controller' . DS . 'Index.php');
+                if ($createTableSql) {
+                    $createTableSql = str_replace("`" . $prefix, '`__PREFIX__', $createTableSql);
+                    file_put_contents($addonDir . 'install.sql', $createTableSql);
+                }
+
+                $output->info("Create Successed!");
+                break;
+            case 'disable':
+            case 'enable':
+                try {
+                    //调用启用、禁用的方法
+                    Service::$action($name, 0);
+                } catch (AddonException $e) {
+                    if ($e->getCode() != -3) {
+                        throw new Exception($e->getMessage());
+                    }
+                    if (!$force) {
+                        //如果有冲突文件则提醒
+                        $data = $e->getData();
+                        foreach ($data['conflictlist'] as $k => $v) {
+                            $output->warning($v);
+                        }
+                        $output->info("Are you sure you want to " . ($action == 'enable' ? 'override' : 'delete') . " all those files?  Type 'yes' to continue: ");
+                        $line = fgets(defined('STDIN') ? STDIN : fopen('php://stdin', 'r'));
+                        if (trim($line) != 'yes') {
+                            throw new Exception("Operation is aborted!");
+                        }
+                    }
+                    //调用启用、禁用的方法
+                    Service::$action($name, 1);
+                } catch (Exception $e) {
+                    throw new Exception($e->getMessage());
+                }
+                $output->info(ucfirst($action) . " Successed!");
+                break;
+            case 'install':
+                //非覆盖模式时如果存在则报错
+                if (is_dir($addonDir) && !$force) {
+                    throw new Exception("addon already exists!\nIf you need to install again, use the parameter --force=true ");
+                }
+                //如果存在先移除
+                if (is_dir($addonDir)) {
+                    rmdirs($addonDir);
+                }
+                // 获取本地路径
+                $local = $input->getOption('local');
+                try {
+                    Service::install($name, 0, ['version' => $release], $local);
+                } catch (AddonException $e) {
+                    if ($e->getCode() != -3) {
+                        throw new Exception($e->getMessage());
+                    }
+                    if (!$force) {
+                        //如果有冲突文件则提醒
+                        $data = $e->getData();
+                        foreach ($data['conflictlist'] as $k => $v) {
+                            $output->warning($v);
+                        }
+                        $output->info("Are you sure you want to override all those files?  Type 'yes' to continue: ");
+                        $line = fgets(defined('STDIN') ? STDIN : fopen('php://stdin', 'r'));
+                        if (trim($line) != 'yes') {
+                            throw new Exception("Operation is aborted!");
+                        }
+                    }
+                    Service::install($name, 1, ['version' => $release, 'uid' => $uid, 'token' => $token], $local);
+                } catch (Exception $e) {
+                    throw new Exception($e->getMessage());
+                }
+
+                $output->info("Install Successed!");
+                break;
+            case 'uninstall':
+                //非覆盖模式时如果存在则报错
+                if (!$force) {
+                    throw new Exception("If you need to uninstall addon, use the parameter --force=true ");
+                }
+                try {
+                    Service::uninstall($name, 0);
+                } catch (AddonException $e) {
+                    if ($e->getCode() != -3) {
+                        throw new Exception($e->getMessage());
+                    }
+                    if (!$force) {
+                        //如果有冲突文件则提醒
+                        $data = $e->getData();
+                        foreach ($data['conflictlist'] as $k => $v) {
+                            $output->warning($v);
+                        }
+                        $output->info("Are you sure you want to delete all those files?  Type 'yes' to continue: ");
+                        $line = fgets(defined('STDIN') ? STDIN : fopen('php://stdin', 'r'));
+                        if (trim($line) != 'yes') {
+                            throw new Exception("Operation is aborted!");
+                        }
+                    }
+                    Service::uninstall($name, 1);
+                } catch (Exception $e) {
+                    throw new Exception($e->getMessage());
+                }
+
+                $output->info("Uninstall Successed!");
+                break;
+            case 'refresh':
+                Service::refresh();
+                $output->info("Refresh Successed!");
+                break;
+            case 'upgrade':
+                Service::upgrade($name, ['version' => $release, 'uid' => $uid, 'token' => $token]);
+                $output->info("Upgrade Successed!");
+                break;
+            case 'package':
+                $infoFile = $addonDir . 'info.ini';
+                if (!is_file($infoFile)) {
+                    throw new Exception(__('Addon info file was not found'));
+                }
+
+                $info = get_addon_info($name);
+                if (!$info) {
+                    throw new Exception(__('Addon info file data incorrect'));
+                }
+                $infoname = isset($info['name']) ? $info['name'] : '';
+                if (!$infoname || !preg_match("/^[a-z]+$/i", $infoname) || $infoname != $name) {
+                    throw new Exception(__('Addon info name incorrect'));
+                }
+
+                $infoversion = isset($info['version']) ? $info['version'] : '';
+                if (!$infoversion || !preg_match("/^\d+\.\d+\.\d+$/i", $infoversion)) {
+                    throw new Exception(__('Addon info version incorrect'));
+                }
+
+                $addonTmpDir = RUNTIME_PATH . 'addons' . DS;
+                if (!is_dir($addonTmpDir)) {
+                    @mkdir($addonTmpDir, 0755, true);
+                }
+                $addonFile = $addonTmpDir . $infoname . '-' . $infoversion . '.zip';
+                if (!class_exists('ZipArchive')) {
+                    throw new Exception(__('ZinArchive not install'));
+                }
+                $zip = new \ZipArchive;
+                $zip->open($addonFile, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+
+                $files = new \RecursiveIteratorIterator(
+                    new \RecursiveDirectoryIterator($addonDir), \RecursiveIteratorIterator::LEAVES_ONLY
+                );
+
+                foreach ($files as $name => $file) {
+                    if (!$file->isDir()) {
+                        $filePath = $file->getRealPath();
+                        $relativePath = str_replace(DS, '/', substr($filePath, strlen($addonDir)));
+                        if (!in_array($file->getFilename(), ['.git', '.DS_Store', 'Thumbs.db'])) {
+                            $zip->addFile($filePath, $relativePath);
+                        }
+                    }
+                }
+                $zip->close();
+                $output->info("Package Successed!");
+                break;
+            case 'move':
+                $movePath = [
+                    'adminOnlySelfDir' => ['admin/behavior', 'admin/controller', 'admin/library', 'admin/model', 'admin/validate', 'admin/view'],
+                    'adminAllSubDir' => ['admin/lang'],
+                    'publicDir' => ['public/assets/addons', 'public/assets/js/backend']
+                ];
+                $paths = [];
+                $appPath = str_replace('/', DS, APP_PATH);
+                $rootPath = str_replace('/', DS, ROOT_PATH);
+                foreach ($movePath as $k => $items) {
+                    switch ($k) {
+                        case 'adminOnlySelfDir':
+                            foreach ($items as $v) {
+                                $v = str_replace('/', DS, $v);
+                                $oldPath = $appPath . $v . DS . $name;
+                                $newPath = $rootPath . "addons" . DS . $name . DS . "application" . DS . $v . DS . $name;
+                                $paths[$oldPath] = $newPath;
+                            }
+                            break;
+                        case 'adminAllSubDir':
+                            foreach ($items as $v) {
+                                $v = str_replace('/', DS, $v);
+                                $vPath = $appPath . $v;
+                                $list = scandir($vPath);
+                                foreach ($list as $_v) {
+                                    if (!in_array($_v, ['.', '..']) && is_dir($vPath . DS . $_v)) {
+                                        $oldPath = $appPath . $v . DS . $_v . DS . $name;
+                                        $newPath = $rootPath . "addons" . DS . $name . DS . "application" . DS . $v . DS . $_v . DS . $name;
+                                        $paths[$oldPath] = $newPath;
+                                    }
+                                }
+                            }
+                            break;
+                        case 'publicDir':
+                            foreach ($items as $v) {
+                                $v = str_replace('/', DS, $v);
+                                $oldPath = $rootPath . $v . DS . $name;
+                                $newPath = $rootPath . 'addons' . DS . $name . DS . $v . DS . $name;
+                                $paths[$oldPath] = $newPath;
+                            }
+                            break;
+                    }
+                }
+                foreach ($paths as $oldPath => $newPath) {
+                    if (is_dir($oldPath)) {
+                        if ($force) {
+                            if (is_dir($newPath)) {
+                                $list = scandir($newPath);
+                                foreach ($list as $_v) {
+                                    if (!in_array($_v, ['.', '..'])) {
+                                        $file = $newPath . DS . $_v;
+                                        @chmod($file, 0777);
+                                        @unlink($file);
+                                    }
+                                }
+                                @rmdir($newPath);
+                            }
+                        }
+                        copydirs($oldPath, $newPath);
+                    }
+                }
+                break;
+            default:
+                break;
+        }
+    }
+
+    /**
+     * 获取创建菜单的数组
+     * @param array $menu
+     * @return array
+     */
+    protected function getCreateMenu($menu)
+    {
+        $result = [];
+        foreach ($menu as $k => & $v) {
+            $arr = [
+                'name'  => $v['name'],
+                'title' => $v['title'],
+            ];
+            if ($v['icon'] != 'fa fa-circle-o') {
+                $arr['icon'] = $v['icon'];
+            }
+            if ($v['ismenu']) {
+                $arr['ismenu'] = $v['ismenu'];
+            }
+            if (isset($v['childlist']) && $v['childlist']) {
+                $arr['sublist'] = $this->getCreateMenu($v['childlist']);
+            }
+            $result[] = $arr;
+        }
+        return $result;
+    }
+
+    /**
+     * 写入到文件
+     * @param string $name
+     * @param array $data
+     * @param string $pathname
+     * @return mixed
+     */
+    protected function writeToFile($name, $data, $pathname)
+    {
+        $search = $replace = [];
+        foreach ($data as $k => $v) {
+            $search[] = "{%{$k}%}";
+            $replace[] = $v;
+        }
+        $stub = file_get_contents($this->getStub($name));
+        $content = str_replace($search, $replace, $stub);
+
+        if (!is_dir(dirname($pathname))) {
+            mkdir(strtolower(dirname($pathname)), 0755, true);
+        }
+        return file_put_contents($pathname, $content);
+    }
+
+    /**
+     * 获取基础模板
+     * @param string $name
+     * @return string
+     */
+    protected function getStub($name)
+    {
+        return __DIR__ . '/Addon/stubs/' . $name . '.stub';
+    }
+
+}

+ 68 - 0
application/admin/command/Addon/stubs/addon.stub

@@ -0,0 +1,68 @@
+<?php
+
+namespace addons\{%name%};
+
+use app\common\library\Menu;
+use think\Addons;
+
+/**
+ * 插件
+ */
+class {%addonClassName%} extends Addons
+{
+
+    /**
+     * 插件安装方法
+     * @return bool
+     */
+    public function install()
+    {
+        {%addonInstallMenu%}
+        return true;
+    }
+
+    /**
+     * 插件卸载方法
+     * @return bool
+     */
+    public function uninstall()
+    {
+        {%addonUninstallMenu%}
+        return true;
+    }
+
+    /**
+     * 插件启用方法
+     * @return bool
+     */
+    public function enable()
+    {
+        {%addonEnableMenu%}
+        return true;
+    }
+
+    /**
+     * 插件禁用方法
+     * @return bool
+     */
+    public function disable()
+    {
+        {%addonDisableMenu%}
+        return true;
+    }
+
+    /**
+     * 实现钩子方法
+     * @return mixed
+     */
+    public function testhook($param)
+    {
+        // 调用钩子时候的参数信息
+        print_r($param);
+        // 当前插件的配置信息,配置信息存在当前目录的config.php文件中,见下方
+        print_r($this->getConfig());
+        // 可以返回模板,模板文件默认读取的为插件目录中的文件。模板名不能为空!
+        //return $this->fetch('view/info');
+    }
+
+}

+ 40 - 0
application/admin/command/Addon/stubs/config.stub

@@ -0,0 +1,40 @@
+<?php
+
+return [
+    [
+        //配置唯一标识
+        'name'    => 'usernmae',
+        //显示的标题
+        'title'   => '用户名',
+        //类型
+        'type'    => 'string',
+        //数据字典
+        'content' => [
+        ],
+        //值
+        'value'   => '',
+        //验证规则 
+        'rule'    => 'required',
+        //错误消息
+        'msg'     => '',
+        //提示消息
+        'tip'     => '',
+        //成功消息
+        'ok'      => '',
+        //扩展信息
+        'extend'  => ''
+    ],
+    [
+        'name'    => 'password',
+        'title'   => '密码',
+        'type'    => 'string',
+        'content' => [
+        ],
+        'value'   => '',
+        'rule'    => 'required',
+        'msg'     => '',
+        'tip'     => '',
+        'ok'      => '',
+        'extend'  => ''
+    ],
+];

+ 15 - 0
application/admin/command/Addon/stubs/controller.stub

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

+ 7 - 0
application/admin/command/Addon/stubs/info.stub

@@ -0,0 +1,7 @@
+name = {%name%}
+title = 插件名称{%name%}
+intro = FastAdmin插件
+author = yourname
+website = https://www.fastadmin.net
+version = 1.0.0
+state = 1

+ 195 - 0
application/admin/command/Api.php

@@ -0,0 +1,195 @@
+<?php
+
+namespace app\admin\command;
+
+use app\admin\command\Api\library\Builder;
+use think\Config;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Option;
+use think\console\Output;
+use think\Exception;
+
+class Api extends Command
+{
+    protected function configure()
+    {
+        $site = Config::get('site');
+        $this
+            ->setName('api')
+            ->addOption('url', 'u', Option::VALUE_OPTIONAL, 'default api url', '')
+            ->addOption('module', 'm', Option::VALUE_OPTIONAL, 'module name(admin/index/api)', 'api')
+            ->addOption('output', 'o', Option::VALUE_OPTIONAL, 'output index file name', 'api.html')
+            ->addOption('template', 'e', Option::VALUE_OPTIONAL, '', 'index.html')
+            ->addOption('force', 'f', Option::VALUE_OPTIONAL, 'force override general file', false)
+            ->addOption('title', 't', Option::VALUE_OPTIONAL, 'document title', $site['name'] ?? '')
+            ->addOption('class', 'c', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'extend class', null)
+            ->addOption('language', 'l', Option::VALUE_OPTIONAL, 'language', 'zh-cn')
+            ->addOption('addon', 'a', Option::VALUE_OPTIONAL, 'addon name', null)
+            ->addOption('controller', 'r', Option::VALUE_REQUIRED | Option::VALUE_IS_ARRAY, 'controller name', null)
+            ->setDescription('Build Api document from controller');
+    }
+
+    protected function execute(Input $input, Output $output)
+    {
+        $apiDir = __DIR__ . DS . 'Api' . DS;
+
+        $force = $input->getOption('force');
+        $url = $input->getOption('url');
+        $language = $input->getOption('language');
+        $template = $input->getOption('template');
+        if (!preg_match("/^([a-z0-9]+)\.html\$/i", $template)) {
+            throw new Exception('template file not correct');
+        }
+        $language = $language ? $language : 'zh-cn';
+        $langFile = $apiDir . 'lang' . DS . $language . '.php';
+        if (!is_file($langFile)) {
+            throw new Exception('language file not found');
+        }
+        $lang = include_once $langFile;
+        // 目标目录
+        $output_dir = ROOT_PATH . 'public' . DS;
+        $output_file = $output_dir . $input->getOption('output');
+        if (is_file($output_file) && !$force) {
+            throw new Exception("api index file already exists!\nIf you need to rebuild again, use the parameter --force=true ");
+        }
+        // 模板文件
+        $template_dir = $apiDir . 'template' . DS;
+        $template_file = $template_dir . $template;
+        if (!is_file($template_file)) {
+            throw new Exception('template file not found');
+        }
+        // 额外的类
+        $classes = $input->getOption('class');
+        // 标题
+        $title = $input->getOption('title');
+        // 模块
+        $module = $input->getOption('module');
+        // 插件
+        $addon = $input->getOption('addon');
+
+        $moduleDir = $addonDir = '';
+        if ($addon) {
+            $addonInfo = get_addon_info($addon);
+            if (!$addonInfo) {
+                throw new Exception('addon not found');
+            }
+            $moduleDir = ADDON_PATH . $addon . DS;
+        } else {
+            $moduleDir = APP_PATH . $module . DS;
+        }
+        if (!is_dir($moduleDir)) {
+            throw new Exception('module not found');
+        }
+
+        if (version_compare(PHP_VERSION, '7.0.0', '<')) {
+            throw new Exception("Requires PHP version 7.0 or newer");
+        }
+
+        //控制器名
+        $controller = $input->getOption('controller') ?: [];
+        if (!$controller) {
+            $controllerDir = $moduleDir . Config::get('url_controller_layer') . DS;
+            $files = new \RecursiveIteratorIterator(
+                new \RecursiveDirectoryIterator($controllerDir),
+                \RecursiveIteratorIterator::LEAVES_ONLY
+            );
+
+            foreach ($files as $name => $file) {
+                if (!$file->isDir() && $file->getExtension() == 'php') {
+                    $filePath = $file->getRealPath();
+                    $classes[] = $this->get_class_from_file($filePath);
+                }
+            }
+        } else {
+            foreach ($controller as $index => $item) {
+                $filePath = $moduleDir . Config::get('url_controller_layer') . DS . $item . '.php';
+                $classes[] = $this->get_class_from_file($filePath);
+            }
+        }
+
+        $classes = array_unique(array_filter($classes));
+
+        $config = [
+            'sitename'    => config('site.name'),
+            'title'       => $title,
+            'author'      => config('site.name'),
+            'description' => '',
+            'apiurl'      => $url,
+            'language'    => $language,
+        ];
+
+        $builder = new Builder($classes);
+        $content = $builder->render($template_file, ['config' => $config, 'lang' => $lang]);
+
+        if (!file_put_contents($output_file, $content)) {
+            throw new Exception('Cannot save the content to ' . $output_file);
+        }
+        $output->info("Build Successed!");
+    }
+
+    /**
+     * get full qualified class name
+     *
+     * @param string $path_to_file
+     * @return string
+     * @author JBYRNE http://jarretbyrne.com/2015/06/197/
+     */
+    protected function get_class_from_file($path_to_file)
+    {
+        //Grab the contents of the file
+        $contents = file_get_contents($path_to_file);
+
+        //Start with a blank namespace and class
+        $namespace = $class = "";
+
+        //Set helper values to know that we have found the namespace/class token and need to collect the string values after them
+        $getting_namespace = $getting_class = false;
+
+        //Go through each token and evaluate it as necessary
+        foreach (token_get_all($contents) as $token) {
+
+            //If this token is the namespace declaring, then flag that the next tokens will be the namespace name
+            if (is_array($token) && $token[0] == T_NAMESPACE) {
+                $getting_namespace = true;
+            }
+
+            //If this token is the class declaring, then flag that the next tokens will be the class name
+            if (is_array($token) && $token[0] == T_CLASS) {
+                $getting_class = true;
+            }
+
+            //While we're grabbing the namespace name...
+            if ($getting_namespace === true) {
+
+                //If the token is a string or the namespace separator...
+                if (is_array($token) && in_array($token[0], [T_STRING, T_NS_SEPARATOR])) {
+
+                    //Append the token's value to the name of the namespace
+                    $namespace .= $token[1];
+                } elseif ($token === ';') {
+
+                    //If the token is the semicolon, then we're done with the namespace declaration
+                    $getting_namespace = false;
+                }
+            }
+
+            //While we're grabbing the class name...
+            if ($getting_class === true) {
+
+                //If the token is a string, it's the name of the class
+                if (is_array($token) && $token[0] == T_STRING) {
+
+                    //Store the token's value as the class name
+                    $class = $token[1];
+
+                    //Got what we need, stope here
+                    break;
+                }
+            }
+        }
+
+        //Build the fully-qualified class name and return it
+        return $namespace ? $namespace . '\\' . $class : $class;
+    }
+}

+ 25 - 0
application/admin/command/Api/lang/zh-cn.php

@@ -0,0 +1,25 @@
+<?php
+
+return [
+    'Info'             => '基础信息',
+    'Sandbox'          => '在线测试',
+    'Sampleoutput'     => '返回示例',
+    'Headers'          => 'Headers',
+    'Parameters'       => '参数',
+    'Body'             => '正文',
+    'Name'             => '名称',
+    'Type'             => '类型',
+    'Required'         => '必选',
+    'Description'      => '描述',
+    'Send'             => '提交',
+    'Reset'            => '重置',
+    'Tokentips'        => 'Token在会员注册或登录后都会返回,WEB端同时存在于Cookie中',
+    'Apiurltips'       => 'API接口URL',
+    'Savetips'         => '点击保存后Token和Api url都将保存在本地Localstorage中',
+    'Authorization'    => '权限',
+    'NeedLogin'        => '登录',
+    'NeedRight'        => '鉴权',
+    'ReturnHeaders'    => '响应头',
+    'ReturnParameters' => '返回参数',
+    'Response'         => '响应输出',
+];

+ 253 - 0
application/admin/command/Api/library/Builder.php

@@ -0,0 +1,253 @@
+<?php
+
+namespace app\admin\command\Api\library;
+
+use think\Config;
+
+/**
+ * @website https://github.com/calinrada/php-apidoc
+ * @author  Calin Rada <rada.calin@gmail.com>
+ * @author  Karson <karsonzhang@163.com>
+ */
+class Builder
+{
+
+    /**
+     *
+     * @var \think\View
+     */
+    public $view = null;
+
+    /**
+     * parse classes
+     * @var array
+     */
+    protected $classes = [];
+
+    /**
+     *
+     * @param array $classes
+     */
+    public function __construct($classes = [])
+    {
+        $this->classes = array_merge($this->classes, $classes);
+        $this->view = new \think\View(Config::get('template'), Config::get('view_replace_str'));
+    }
+
+    protected function extractAnnotations()
+    {
+        foreach ($this->classes as $class) {
+            $classAnnotation = Extractor::getClassAnnotations($class);
+            // 如果忽略
+            if (isset($classAnnotation['ApiInternal'])) {
+                continue;
+            }
+            Extractor::getClassMethodAnnotations($class);
+            //Extractor::getClassPropertyValues($class);
+        }
+        $allClassAnnotation = Extractor::getAllClassAnnotations();
+        $allClassMethodAnnotation = Extractor::getAllClassMethodAnnotations();
+        //$allClassPropertyValue = Extractor::getAllClassPropertyValues();
+
+//        foreach ($allClassMethodAnnotation as $className => &$methods) {
+//            foreach ($methods as &$method) {
+//                //权重判断
+//                if ($method && !isset($method['ApiWeigh']) && isset($allClassAnnotation[$className]['ApiWeigh'])) {
+//                    $method['ApiWeigh'] = $allClassAnnotation[$className]['ApiWeigh'];
+//                }
+//            }
+//        }
+//        unset($methods);
+        return [$allClassAnnotation, $allClassMethodAnnotation];
+    }
+
+    protected function generateHeadersTemplate($docs)
+    {
+        if (!isset($docs['ApiHeaders'])) {
+            return [];
+        }
+
+        $headerslist = array();
+        foreach ($docs['ApiHeaders'] as $params) {
+            $tr = array(
+                'name'        => $params['name'] ?? '',
+                'type'        => $params['type'] ?? 'string',
+                'sample'      => $params['sample'] ?? '',
+                'required'    => $params['required'] ?? false,
+                'description' => $params['description'] ?? '',
+            );
+            $headerslist[] = $tr;
+        }
+
+        return $headerslist;
+    }
+
+    protected function generateParamsTemplate($docs)
+    {
+        if (!isset($docs['ApiParams'])) {
+            return [];
+        }
+
+        $paramslist = array();
+        foreach ($docs['ApiParams'] as $params) {
+            $tr = array(
+                'name'        => $params['name'],
+                'type'        => $params['type'] ?? 'string',
+                'sample'      => $params['sample'] ?? '',
+                'required'    => $params['required'] ?? true,
+                'description' => $params['description'] ?? '',
+            );
+            $paramslist[] = $tr;
+        }
+
+        return $paramslist;
+    }
+
+    protected function generateReturnHeadersTemplate($docs)
+    {
+        if (!isset($docs['ApiReturnHeaders'])) {
+            return [];
+        }
+
+        $headerslist = array();
+        foreach ($docs['ApiReturnHeaders'] as $params) {
+            $tr = array(
+                'name'        => $params['name'] ?? '',
+                'type'        => 'string',
+                'sample'      => $params['sample'] ?? '',
+                'required'    => isset($params['required']) && $params['required'] ? 'Yes' : 'No',
+                'description' => $params['description'] ?? '',
+            );
+            $headerslist[] = $tr;
+        }
+
+        return $headerslist;
+    }
+
+    protected function generateReturnParamsTemplate($st_params)
+    {
+        if (!isset($st_params['ApiReturnParams'])) {
+            return [];
+        }
+
+        $paramslist = array();
+        foreach ($st_params['ApiReturnParams'] as $params) {
+            $tr = array(
+                'name'        => $params['name'] ?? '',
+                'type'        => $params['type'] ?? 'string',
+                'sample'      => $params['sample'] ?? '',
+                'description' => $params['description'] ?? '',
+            );
+            $paramslist[] = $tr;
+        }
+
+        return $paramslist;
+    }
+
+    protected function generateBadgeForMethod($data)
+    {
+        $method = strtoupper(is_array($data['ApiMethod'][0]) ? $data['ApiMethod'][0]['data'] : $data['ApiMethod'][0]);
+        $labes = array(
+            'POST'    => 'label-primary',
+            'GET'     => 'label-success',
+            'PUT'     => 'label-warning',
+            'DELETE'  => 'label-danger',
+            'PATCH'   => 'label-default',
+            'OPTIONS' => 'label-info'
+        );
+
+        return isset($labes[$method]) ? $labes[$method] : $labes['GET'];
+    }
+
+    public function parse()
+    {
+        list($allClassAnnotations, $allClassMethodAnnotations) = $this->extractAnnotations();
+
+        $sectorArr = [];
+        foreach ($allClassAnnotations as $index => &$allClassAnnotation) {
+            // 如果设置隐藏,则不显示在文档
+            if (isset($allClassAnnotation['ApiInternal'])) {
+                continue;
+            }
+            $sector = isset($allClassAnnotation['ApiSector']) ? $allClassAnnotation['ApiSector'][0] : $allClassAnnotation['ApiTitle'][0];
+            $sectorArr[$sector] = isset($allClassAnnotation['ApiWeigh']) ? $allClassAnnotation['ApiWeigh'][0] : 0;
+        }
+        unset($allClassAnnotation);
+
+        arsort($sectorArr);
+        $routes = include_once CONF_PATH . 'route.php';
+        $subdomain = false;
+        if (config('url_domain_deploy') && isset($routes['__domain__']) && isset($routes['__domain__']['api']) && $routes['__domain__']['api']) {
+            $subdomain = true;
+        }
+        $counter = 0;
+        $section = null;
+        $weigh = 0;
+        $docsList = [];
+        foreach ($allClassMethodAnnotations as $class => $methods) {
+            foreach ($methods as $name => $docs) {
+                if (isset($docs['ApiSector'][0])) {
+                    $section = is_array($docs['ApiSector'][0]) ? $docs['ApiSector'][0]['data'] : $docs['ApiSector'][0];
+                } else {
+                    $section = $class;
+                }
+                if (0 === count($docs)) {
+                    continue;
+                }
+                $route = is_array($docs['ApiRoute'][0]) ? $docs['ApiRoute'][0]['data'] : $docs['ApiRoute'][0];
+                if ($subdomain) {
+                    $route = substr($route, 4);
+                }
+                $docsList[$section][$name] = [
+                    'id'                => $counter,
+                    'method'            => is_array($docs['ApiMethod'][0]) ? $docs['ApiMethod'][0]['data'] : $docs['ApiMethod'][0],
+                    'methodLabel'       => $this->generateBadgeForMethod($docs),
+                    'section'           => $section,
+                    'route'             => $route,
+                    'title'             => is_array($docs['ApiTitle'][0]) ? $docs['ApiTitle'][0]['data'] : $docs['ApiTitle'][0],
+                    'summary'           => is_array($docs['ApiSummary'][0]) ? $docs['ApiSummary'][0]['data'] : $docs['ApiSummary'][0],
+                    'body'              => isset($docs['ApiBody'][0]) ? (is_array($docs['ApiBody'][0]) ? $docs['ApiBody'][0]['data'] : $docs['ApiBody'][0]) : '',
+                    'headersList'       => $this->generateHeadersTemplate($docs),
+                    'paramsList'        => $this->generateParamsTemplate($docs),
+                    'returnHeadersList' => $this->generateReturnHeadersTemplate($docs),
+                    'returnParamsList'  => $this->generateReturnParamsTemplate($docs),
+                    'weigh'             => is_array($docs['ApiWeigh'][0]) ? $docs['ApiWeigh'][0]['data'] : $docs['ApiWeigh'][0],
+                    'return'            => isset($docs['ApiReturn']) ? (is_array($docs['ApiReturn'][0]) ? $docs['ApiReturn'][0]['data'] : $docs['ApiReturn'][0]) : '',
+                    'needLogin'         => $docs['ApiPermissionLogin'][0],
+                    'needRight'         => $docs['ApiPermissionRight'][0],
+                ];
+                $counter++;
+            }
+        }
+
+        //重建排序
+        foreach ($docsList as $index => &$methods) {
+            $methodSectorArr = [];
+            foreach ($methods as $name => $method) {
+                $methodSectorArr[$name] = isset($method['weigh']) ? $method['weigh'] : 0;
+            }
+            arsort($methodSectorArr);
+            $methods = array_merge(array_flip(array_keys($methodSectorArr)), $methods);
+        }
+        $docsList = array_merge(array_flip(array_keys($sectorArr)), $docsList);
+        return $docsList;
+    }
+
+    public function getView()
+    {
+        return $this->view;
+    }
+
+    /**
+     * 渲染
+     * @param string $template
+     * @param array  $vars
+     * @return string
+     */
+    public function render($template, $vars = [])
+    {
+        $docsList = $this->parse();
+
+        return $this->view->display(file_get_contents($template), array_merge($vars, ['docsList' => $docsList]));
+    }
+}

+ 544 - 0
application/admin/command/Api/library/Extractor.php

@@ -0,0 +1,544 @@
+<?php
+
+namespace app\admin\command\Api\library;
+
+use Exception;
+
+/**
+ * Class imported from https://github.com/eriknyk/Annotations
+ * @author  Erik Amaru Ortiz https://github.com/eriknyk‎
+ *
+ * @license http://opensource.org/licenses/bsd-license.php The BSD License
+ * @author  Calin Rada <rada.calin@gmail.com>
+ */
+class Extractor
+{
+
+    /**
+     * Static array to store already parsed annotations
+     * @var array
+     */
+    private static $annotationCache;
+
+    private static $classAnnotationCache;
+
+    private static $classMethodAnnotationCache;
+
+    private static $classPropertyValueCache;
+
+    /**
+     * Indicates that annotations should has strict behavior, 'false' by default
+     * @var boolean
+     */
+    private $strict = false;
+
+    /**
+     * Stores the default namespace for Objects instance, usually used on methods like getMethodAnnotationsObjects()
+     * @var string
+     */
+    public $defaultNamespace = '';
+
+    /**
+     * Sets strict variable to true/false
+     * @param bool $value boolean value to indicate that annotations to has strict behavior
+     */
+    public function setStrict($value)
+    {
+        $this->strict = (bool)$value;
+    }
+
+    /**
+     * Sets default namespace to use in object instantiation
+     * @param string $namespace default namespace
+     */
+    public function setDefaultNamespace($namespace)
+    {
+        $this->defaultNamespace = $namespace;
+    }
+
+    /**
+     * Gets default namespace used in object instantiation
+     * @return string $namespace default namespace
+     */
+    public function getDefaultAnnotationNamespace()
+    {
+        return $this->defaultNamespace;
+    }
+
+    /**
+     * Gets all anotations with pattern @SomeAnnotation() from a given class
+     *
+     * @param string $className class name to get annotations
+     * @return array  self::$classAnnotationCache all annotated elements
+     */
+    public static function getClassAnnotations($className)
+    {
+        if (!isset(self::$classAnnotationCache[$className])) {
+            $class = new \ReflectionClass($className);
+            $annotationArr = self::parseAnnotations($class->getDocComment());
+            $annotationArr['ApiTitle'] = !isset($annotationArr['ApiTitle'][0]) || !trim($annotationArr['ApiTitle'][0]) ? [$class->getShortName()] : $annotationArr['ApiTitle'];
+            self::$classAnnotationCache[$className] = $annotationArr;
+        }
+
+        return self::$classAnnotationCache[$className];
+    }
+
+    /**
+     * 获取类所有方法的属性配置
+     * @param $className
+     * @return mixed
+     * @throws \ReflectionException
+     */
+    public static function getClassMethodAnnotations($className)
+    {
+        $class = new \ReflectionClass($className);
+
+        foreach ($class->getMethods() as $object) {
+            self::$classMethodAnnotationCache[$className][$object->name] = self::getMethodAnnotations($className, $object->name);
+        }
+
+        return self::$classMethodAnnotationCache[$className];
+    }
+
+    public static function getClassPropertyValues($className)
+    {
+        $class = new \ReflectionClass($className);
+
+        foreach ($class->getProperties() as $object) {
+            self::$classPropertyValueCache[$className][$object->name] = self::getClassPropertyValue($className, $object->name);
+        }
+
+        return self::$classMethodAnnotationCache[$className];
+    }
+
+    public static function getAllClassAnnotations()
+    {
+        return self::$classAnnotationCache;
+    }
+
+    public static function getAllClassMethodAnnotations()
+    {
+        return self::$classMethodAnnotationCache;
+    }
+
+    public static function getAllClassPropertyValues()
+    {
+        return self::$classPropertyValueCache;
+    }
+
+    public static function getClassPropertyValue($className, $property)
+    {
+        $_SERVER['REQUEST_METHOD'] = 'GET';
+        $reflectionClass = new \ReflectionClass($className);
+        $reflectionProperty = $reflectionClass->getProperty($property);
+        $reflectionProperty->setAccessible(true);
+        return $reflectionProperty->getValue($reflectionClass->newInstanceWithoutConstructor());
+    }
+
+    /**
+     * Gets all anotations with pattern @SomeAnnotation() from a determinated method of a given class
+     *
+     * @param string $className  class name
+     * @param string $methodName method name to get annotations
+     * @return array  self::$annotationCache all annotated elements of a method given
+     */
+    public static function getMethodAnnotations($className, $methodName)
+    {
+        if (!isset(self::$annotationCache[$className . '::' . $methodName])) {
+            try {
+                $method = new \ReflectionMethod($className, $methodName);
+                $class = new \ReflectionClass($className);
+                if (!$method->isPublic() || $method->isConstructor()) {
+                    $annotations = array();
+                } else {
+                    $annotations = self::consolidateAnnotations($method, $class);
+                }
+            } catch (\ReflectionException $e) {
+                $annotations = array();
+            }
+
+            self::$annotationCache[$className . '::' . $methodName] = $annotations;
+        }
+
+        return self::$annotationCache[$className . '::' . $methodName];
+    }
+
+    /**
+     * Gets all anotations with pattern @SomeAnnotation() from a determinated method of a given class
+     * and instance its abcAnnotation class
+     *
+     * @param string $className  class name
+     * @param string $methodName method name to get annotations
+     * @return array  self::$annotationCache all annotated objects of a method given
+     */
+    public function getMethodAnnotationsObjects($className, $methodName)
+    {
+        $annotations = $this->getMethodAnnotations($className, $methodName);
+        $objects = array();
+
+        $i = 0;
+
+        foreach ($annotations as $annotationClass => $listParams) {
+            $annotationClass = ucfirst($annotationClass);
+            $class = $this->defaultNamespace . $annotationClass . 'Annotation';
+
+            // verify is the annotation class exists, depending if Annotations::strict is true
+            // if not, just skip the annotation instance creation.
+            if (!class_exists($class)) {
+                if ($this->strict) {
+                    throw new Exception(sprintf('Runtime Error: Annotation Class Not Found: %s', $class));
+                } else {
+                    // silent skip & continue
+                    continue;
+                }
+            }
+
+            if (empty($objects[$annotationClass])) {
+                $objects[$annotationClass] = new $class();
+            }
+
+            foreach ($listParams as $params) {
+                if (is_array($params)) {
+                    foreach ($params as $key => $value) {
+                        $objects[$annotationClass]->set($key, $value);
+                    }
+                } else {
+                    $objects[$annotationClass]->set($i++, $params);
+                }
+            }
+        }
+
+        return $objects;
+    }
+
+    private static function consolidateAnnotations($method, $class)
+    {
+        $dockblockClass = $class->getDocComment();
+        $docblockMethod = $method->getDocComment();
+        $methodName = $method->getName();
+
+        $methodAnnotations = self::parseAnnotations($docblockMethod);
+        $methodAnnotations['ApiTitle'] = !isset($methodAnnotations['ApiTitle'][0]) || !trim($methodAnnotations['ApiTitle'][0]) ? [$method->getName()] : $methodAnnotations['ApiTitle'];
+
+        $classAnnotations = self::parseAnnotations($dockblockClass);
+        $classAnnotations['ApiTitle'] = !isset($classAnnotations['ApiTitle'][0]) || !trim($classAnnotations['ApiTitle'][0]) ? [$class->getShortName()] : $classAnnotations['ApiTitle'];
+
+        if (isset($methodAnnotations['ApiInternal']) || $methodName == '_initialize' || $methodName == '_empty') {
+            return [];
+        }
+
+        $properties = $class->getDefaultProperties();
+        $noNeedLogin = isset($properties['noNeedLogin']) ? (is_array($properties['noNeedLogin']) ? $properties['noNeedLogin'] : [$properties['noNeedLogin']]) : [];
+        $noNeedRight = isset($properties['noNeedRight']) ? (is_array($properties['noNeedRight']) ? $properties['noNeedRight'] : [$properties['noNeedRight']]) : [];
+
+        preg_match_all("/\*[\s]+(.*)(\\r\\n|\\r|\\n)/U", str_replace('/**', '', $docblockMethod), $methodArr);
+        preg_match_all("/\*[\s]+(.*)(\\r\\n|\\r|\\n)/U", str_replace('/**', '', $dockblockClass), $classArr);
+
+        if (!isset($methodAnnotations['ApiMethod'])) {
+            $methodAnnotations['ApiMethod'] = ['get'];
+        }
+        if (!isset($methodAnnotations['ApiWeigh'])) {
+            $methodAnnotations['ApiWeigh'] = [0];
+        }
+        if (!isset($methodAnnotations['ApiSummary'])) {
+            $methodAnnotations['ApiSummary'] = $methodAnnotations['ApiTitle'];
+        }
+
+        if ($methodAnnotations) {
+            foreach ($classAnnotations as $name => $valueClass) {
+                if (count($valueClass) !== 1) {
+                    continue;
+                }
+
+                if ($name === 'ApiRoute') {
+                    if (isset($methodAnnotations[$name])) {
+                        $methodAnnotations[$name] = [rtrim($valueClass[0], '/') . $methodAnnotations[$name][0]];
+                    } else {
+                        $methodAnnotations[$name] = [rtrim($valueClass[0], '/') . '/' . $method->getName()];
+                    }
+                }
+
+                if ($name === 'ApiSector') {
+                    $methodAnnotations[$name] = $valueClass;
+                }
+            }
+        }
+        if (!isset($methodAnnotations['ApiRoute'])) {
+            $urlArr = [];
+            $className = $class->getName();
+
+            list($prefix, $suffix) = explode('\\' . \think\Config::get('url_controller_layer') . '\\', $className);
+            $prefixArr = explode('\\', $prefix);
+            $suffixArr = explode('\\', $suffix);
+            if ($prefixArr[0] == \think\Config::get('app_namespace')) {
+                $prefixArr[0] = '';
+            }
+            $urlArr = array_merge($urlArr, $prefixArr);
+            $urlArr[] = implode('.', array_map(function ($item) {
+                return \think\Loader::parseName($item);
+            }, $suffixArr));
+            $urlArr[] = $method->getName();
+
+            $methodAnnotations['ApiRoute'] = [implode('/', $urlArr)];
+        }
+        if (!isset($methodAnnotations['ApiSector'])) {
+            $methodAnnotations['ApiSector'] = isset($classAnnotations['ApiSector']) ? $classAnnotations['ApiSector'] : $classAnnotations['ApiTitle'];
+        }
+        if (!isset($methodAnnotations['ApiParams'])) {
+            $params = self::parseCustomAnnotations($docblockMethod, 'param');
+            foreach ($params as $k => $v) {
+                $arr = explode(' ', preg_replace("/[\s]+/", " ", $v));
+                $methodAnnotations['ApiParams'][] = [
+                    'name'        => isset($arr[1]) ? str_replace('$', '', $arr[1]) : '',
+                    'nullable'    => false,
+                    'type'        => isset($arr[0]) ? $arr[0] : 'string',
+                    'description' => isset($arr[2]) ? $arr[2] : ''
+                ];
+            }
+        }
+        $methodAnnotations['ApiPermissionLogin'] = [!in_array('*', $noNeedLogin) && !in_array($methodName, $noNeedLogin)];
+        $methodAnnotations['ApiPermissionRight'] = !$methodAnnotations['ApiPermissionLogin'][0] ? [false] : [!in_array('*', $noNeedRight) && !in_array($methodName, $noNeedRight)];
+        return $methodAnnotations;
+    }
+
+    /**
+     * Parse annotations
+     *
+     * @param string $docblock
+     * @param string $name
+     * @return array  parsed annotations params
+     */
+    private static function parseCustomAnnotations($docblock, $name = 'param')
+    {
+        $annotations = array();
+
+        $docblock = substr($docblock, 3, -2);
+        if (preg_match_all('/@' . $name . '(?:\s*(?:\(\s*)?(.*?)(?:\s*\))?)??\s*(?:\n|\*\/)/', $docblock, $matches)) {
+            foreach ($matches[1] as $k => $v) {
+                $annotations[] = $v;
+            }
+        }
+        return $annotations;
+    }
+
+    /**
+     * Parse annotations
+     *
+     * @param string $docblock
+     * @return array  parsed annotations params
+     */
+    private static function parseAnnotations($docblock)
+    {
+        $annotations = array();
+
+        // Strip away the docblock header and footer to ease parsing of one line annotations
+        $docblock = substr($docblock, 3, -2);
+        if (preg_match_all('/@(?<name>[A-Za-z_-]+)[\s\t]*\((?<args>(?:(?!\)).)*)\)\r?/s', $docblock, $matches)) {
+            $numMatches = count($matches[0]);
+            for ($i = 0; $i < $numMatches; ++$i) {
+                $name = $matches['name'][$i];
+                $value = '';
+                // annotations has arguments
+                if (isset($matches['args'][$i])) {
+                    $argsParts = trim($matches['args'][$i]);
+                    if ($name == 'ApiReturn') {
+                        $value = $argsParts;
+                    } elseif ($matches['args'][$i] != '') {
+                        $argsParts = preg_replace("/\{(\w+)\}/", '#$1#', $argsParts);
+                        $value = self::parseArgs($argsParts);
+                        if (is_string($value)) {
+                            $value = preg_replace("/\#(\w+)\#/", '{$1}', $argsParts);
+                        }
+                    }
+                }
+
+                $annotations[$name][] = $value;
+            }
+        }
+        if (stripos($docblock, '@ApiInternal') !== false) {
+            $annotations['ApiInternal'] = [true];
+        }
+        if (!isset($annotations['ApiTitle'])) {
+            preg_match_all("/\*[\s]+(.*)(\\r\\n|\\r|\\n)/U", str_replace('/**', '', $docblock), $matchArr);
+            $title = isset($matchArr[1]) && isset($matchArr[1][0]) ? $matchArr[1][0] : '';
+            $annotations['ApiTitle'] = [$title];
+        }
+
+        return $annotations;
+    }
+
+    /**
+     * Parse individual annotation arguments
+     *
+     * @param string $content arguments string
+     * @return array  annotated arguments
+     */
+    private static function parseArgs($content)
+    {
+        // Replace initial stars
+        $content = preg_replace('/^\s*\*/m', '', $content);
+
+        $data = array();
+        $len = strlen($content);
+        $i = 0;
+        $var = '';
+        $val = '';
+        $level = 1;
+
+        $prevDelimiter = '';
+        $nextDelimiter = '';
+        $nextToken = '';
+        $composing = false;
+        $type = 'plain';
+        $delimiter = null;
+        $quoted = false;
+        $tokens = array('"', '"', '{', '}', ',', '=');
+
+        while ($i <= $len) {
+            $prev_c = substr($content, $i - 1, 1);
+            $c = substr($content, $i++, 1);
+
+            if ($c === '"' && $prev_c !== "\\") {
+                $delimiter = $c;
+                //open delimiter
+                if (!$composing && empty($prevDelimiter) && empty($nextDelimiter)) {
+                    $prevDelimiter = $nextDelimiter = $delimiter;
+                    $val = '';
+                    $composing = true;
+                    $quoted = true;
+                } else {
+                    // close delimiter
+                    if ($c !== $nextDelimiter) {
+                        throw new Exception(sprintf(
+                            "Parse Error: enclosing error -> expected: [%s], given: [%s]",
+                            $nextDelimiter,
+                            $c
+                        ));
+                    }
+
+                    // validating syntax
+                    if ($i < $len) {
+                        if (',' !== substr($content, $i, 1) && '\\' !== $prev_c) {
+                            throw new Exception(sprintf(
+                                "Parse Error: missing comma separator near: ...%s<--",
+                                substr($content, ($i - 10), $i)
+                            ));
+                        }
+                    }
+
+                    $prevDelimiter = $nextDelimiter = '';
+                    $composing = false;
+                    $delimiter = null;
+                }
+            } elseif (!$composing && in_array($c, $tokens)) {
+                switch ($c) {
+                    case '=':
+                        $prevDelimiter = $nextDelimiter = '';
+                        $level = 2;
+                        $composing = false;
+                        $type = 'assoc';
+                        $quoted = false;
+                        break;
+                    case ',':
+                        $level = 3;
+
+                        // If composing flag is true yet,
+                        // it means that the string was not enclosed, so it is parsing error.
+                        if ($composing === true && !empty($prevDelimiter) && !empty($nextDelimiter)) {
+                            throw new Exception(sprintf(
+                                "Parse Error: enclosing error -> expected: [%s], given: [%s]",
+                                $nextDelimiter,
+                                $c
+                            ));
+                        }
+
+                        $prevDelimiter = $nextDelimiter = '';
+                        break;
+                    case '{':
+                        $subc = '';
+                        $subComposing = true;
+
+                        while ($i <= $len) {
+                            $c = substr($content, $i++, 1);
+
+                            if (isset($delimiter) && $c === $delimiter) {
+                                throw new Exception(sprintf(
+                                    "Parse Error: Composite variable is not enclosed correctly."
+                                ));
+                            }
+
+                            if ($c === '}') {
+                                $subComposing = false;
+                                break;
+                            }
+                            $subc .= $c;
+                        }
+
+                        // if the string is composing yet means that the structure of var. never was enclosed with '}'
+                        if ($subComposing) {
+                            throw new Exception(sprintf(
+                                "Parse Error: Composite variable is not enclosed correctly. near: ...%s'",
+                                $subc
+                            ));
+                        }
+
+                        $val = self::parseArgs($subc);
+                        break;
+                }
+            } else {
+                if ($level == 1) {
+                    $var .= $c;
+                } elseif ($level == 2) {
+                    $val .= $c;
+                }
+            }
+
+            if ($level === 3 || $i === $len) {
+                if ($type == 'plain' && $i === $len) {
+                    $data = self::castValue($var);
+                } else {
+                    $data[trim($var)] = self::castValue($val, !$quoted);
+                }
+
+                $level = 1;
+                $var = $val = '';
+                $composing = false;
+                $quoted = false;
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Try determinate the original type variable of a string
+     *
+     * @param string  $val  string containing possibles variables that can be cast to bool or int
+     * @param boolean $trim indicate if the value passed should be trimmed after to try cast
+     * @return mixed   returns the value converted to original type if was possible
+     */
+    private static function castValue($val, $trim = false)
+    {
+        if (is_array($val)) {
+            foreach ($val as $key => $value) {
+                $val[$key] = self::castValue($value);
+            }
+        } elseif (is_string($val)) {
+            if ($trim) {
+                $val = trim($val);
+            }
+            $val = stripslashes($val);
+            $tmp = strtolower($val);
+
+            if ($tmp === 'false' || $tmp === 'true') {
+                $val = $tmp === 'true';
+            } elseif (is_numeric($val)) {
+                return $val + 0;
+            }
+
+            unset($tmp);
+        }
+
+        return $val;
+    }
+}

+ 663 - 0
application/admin/command/Api/template/index.html

@@ -0,0 +1,663 @@
+<!DOCTYPE html>
+<html lang="{$config.language}">
+    <head>
+        <meta charset="utf-8">
+        <meta http-equiv="X-UA-Compatible" content="IE=edge">
+        <meta name="viewport" content="width=device-width, initial-scale=1.0">
+        <meta name="description" content="">
+        <title>{$config.title}</title>
+
+        <!-- Bootstrap Core CSS -->
+        <link href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" rel="stylesheet">
+
+        <!-- Plugin CSS -->
+        <link href="https://cdn.staticfile.org/font-awesome/4.7.0/css/font-awesome.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]-->
+
+        <style type="text/css">
+            body {
+                padding-top: 70px; margin-bottom: 15px;
+                -webkit-font-smoothing: antialiased;
+                -moz-osx-font-smoothing: grayscale;
+                font-family: "Roboto", "SF Pro SC", "SF Pro Display", "SF Pro Icons", "PingFang SC", BlinkMacSystemFont, -apple-system, "Segoe UI", "Microsoft Yahei", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif;
+                font-weight: 400;
+            }
+            h2        { font-size: 1.2em; }
+            hr        { margin-top: 10px; }
+            .tab-pane { padding-top: 10px; }
+            .mt0      { margin-top: 0px; }
+            .footer   { font-size: 12px; color: #666; }
+            .docs-list .label    { display: inline-block; min-width: 65px; padding: 0.3em 0.6em 0.3em; }
+            .string   { color: green; }
+            .number   { color: darkorange; }
+            .boolean  { color: blue; }
+            .null     { color: magenta; }
+            .key      { color: red; }
+            .popover  { max-width: 400px; max-height: 400px; overflow-y: auto;}
+            .list-group.panel > .list-group-item {
+            }
+            .list-group-item:last-child {
+                border-radius:0;
+            }
+            h4.panel-title a {
+                font-weight:normal;
+                font-size:14px;
+            }
+            h4.panel-title a .text-muted {
+                font-size:12px;
+                font-weight:normal;
+                font-family: 'Verdana';
+            }
+            #sidebar {
+                width: 220px;
+                position: fixed;
+                margin-left: -240px;
+                overflow-y:auto;
+            }
+            #sidebar > .list-group {
+                margin-bottom:0;
+            }
+            #sidebar > .list-group > a{
+                text-indent:0;
+            }
+            #sidebar .child > a .tag{
+                position: absolute;
+                right: 10px;
+                top: 11px;
+            }
+            #sidebar .child > a .pull-right{
+                margin-left:3px;
+            }
+            #sidebar .child {
+                border:1px solid #ddd;
+                border-bottom:none;
+            }
+            #sidebar .child:last-child {
+                border-bottom:1px solid #ddd;
+            }
+            #sidebar .child > a {
+                border:0;
+                min-height: 40px;
+            }
+            #sidebar .list-group a.current {
+                background:#f5f5f5;
+            }
+            @media (max-width: 1620px){
+                #sidebar {
+                    margin:0;
+                }
+                #accordion {
+                    padding-left:235px;
+                }
+            }
+            @media (max-width: 768px){
+                #sidebar {
+                    display: none;
+                }
+                #accordion {
+                    padding-left:0px;
+                }
+            }
+            .label-primary {
+                background-color: #248aff;
+            }
+            .docs-list .panel .panel-body .table {
+                margin-bottom: 0;
+            }
+
+        </style>
+    </head>
+    <body>
+        <!-- Fixed navbar -->
+        <div class="navbar navbar-default navbar-fixed-top" role="navigation">
+            <div class="container">
+                <div class="navbar-header">
+                    <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
+                        <span class="sr-only">Toggle navigation</span>
+                        <span class="icon-bar"></span>
+                        <span class="icon-bar"></span>
+                        <span class="icon-bar"></span>
+                    </button>
+                    <a class="navbar-brand" href="./" target="_blank">{$config.title}</a>
+                </div>
+                <div class="navbar-collapse collapse">
+                    <form class="navbar-form navbar-right">
+                        <div class="form-group">
+                            Token:
+                        </div>
+                        <div class="form-group">
+                            <input type="text" class="form-control input-sm" data-toggle="tooltip" title="{$lang.Tokentips}" placeholder="token" id="token" />
+                        </div>
+                        <div class="form-group">
+                            Apiurl:
+                        </div>
+                        <div class="form-group">
+                            <input id="apiUrl" type="text" class="form-control input-sm" data-toggle="tooltip" title="{$lang.Apiurltips}" placeholder="https://api.mydomain.com" value="{$config.apiurl}" />
+                        </div>
+                        <div class="form-group">
+                            <button type="button" class="btn btn-success btn-sm" data-toggle="tooltip" title="{$lang.Savetips}" id="save_data">
+                                <span class="glyphicon glyphicon-floppy-disk" aria-hidden="true"></span>
+                            </button>
+                        </div>
+                    </form>
+                </div><!--/.nav-collapse -->
+            </div>
+        </div>
+
+        <div class="container">
+            <!-- menu -->
+            <div id="sidebar">
+                <div class="list-group panel">
+                    {foreach name="docsList" id="docs"}
+                    <a href="#{$key}" class="list-group-item" data-toggle="collapse" data-parent="#sidebar">{$key}  <i class="fa fa-caret-down"></i></a>
+                    <div class="child collapse" id="{$key}">
+                        {foreach name="docs" id="api" }
+                        <a href="#{$api.route|md5}" md5="{$api.route|md5}" data-id="{$api.id}" class="list-group-item api-list">{$api.title}
+                            <span class="tag">
+                                {if $api.needRight}
+                                    <span class="label label-danger pull-right">鉴</span>
+                                {/if}
+                                {if $api.needLogin}
+                                    <span class="label label-success pull-right noneedlogin">登</span>
+                                {/if}
+                            </span>
+                        </a>
+                        {/foreach}
+                    </div>
+                    {/foreach}
+                </div>
+            </div>
+            <div class="panel-group docs-list" id="accordion">
+                {foreach name="docsList" id="docs"}
+                <h2>{$key}</h2>
+                <hr>
+                {foreach name="docs" id="api" }
+                <div class="panel panel-default">
+                    <div class="panel-heading" id="heading-{$api.id}">
+                        <h4 class="panel-title">
+                            <span class="label {$api.methodLabel}">{$api.method|strtoupper}</span>
+                            <a data-toggle="collapse" data-parent="#accordion{$api.id}" href="#collapseOne{$api.id}"> {$api.title} <span class="text-muted">{$api.route}</span></a>
+                        </h4>
+                    </div>
+                    <div id="collapseOne{$api.id}" class="panel-collapse collapse">
+                        <div class="panel-body">
+
+                            <!-- Nav tabs -->
+                            <ul class="nav nav-tabs" id="doctab{$api.id}">
+                                <li class="active"><a href="#info{$api.id}" data-toggle="tab">{$lang.Info}</a></li>
+                                <li><a href="#sandbox{$api.id}" data-toggle="tab">{$lang.Sandbox}</a></li>
+                                <li><a href="#sample{$api.id}" data-toggle="tab">{$lang.Sampleoutput}</a></li>
+                            </ul>
+
+                            <!-- Tab panes -->
+                            <div class="tab-content">
+
+                                <div class="tab-pane active" id="info{$api.id}">
+                                    <div class="well">
+                                        {$api.summary}
+                                    </div>
+                                    <div class="panel panel-default">
+                                        <div class="panel-heading"><strong>{$lang.Authorization}</strong></div>
+                                        <div class="panel-body">
+                                            <table class="table table-hover">
+                                                <tbody>
+                                                <tr>
+                                                    <td>{$lang.NeedLogin}</td>
+                                                    <td>{$api.needLogin?'是':'否'}</td>
+                                                </tr>
+                                                <tr>
+                                                    <td>{$lang.NeedRight}</td>
+                                                    <td>{$api.needRight?'是':'否'}</td>
+                                                </tr>
+                                                </tbody>
+                                            </table>
+                                        </div>
+                                    </div>
+                                    <div class="panel panel-default">
+                                        <div class="panel-heading"><strong>{$lang.Headers}</strong></div>
+                                        <div class="panel-body">
+                                            {if $api.headersList}
+                                            <table class="table table-hover">
+                                                <thead>
+                                                    <tr>
+                                                        <th>{$lang.Name}</th>
+                                                        <th>{$lang.Type}</th>
+                                                        <th>{$lang.Required}</th>
+                                                        <th>{$lang.Description}</th>
+                                                    </tr>
+                                                </thead>
+                                                <tbody>
+                                                    {foreach name="api['headersList']" id="header"}
+                                                    <tr>
+                                                        <td>{$header.name}</td>
+                                                        <td>{$header.type}</td>
+                                                        <td>{$header.required?'是':'否'}</td>
+                                                        <td>{$header.description}</td>
+                                                    </tr>
+                                                    {/foreach}
+                                                </tbody>
+                                            </table>
+                                            {else /}
+                                            无
+                                            {/if}
+                                        </div>
+                                    </div>
+                                    <div class="panel panel-default">
+                                        <div class="panel-heading"><strong>{$lang.Parameters}</strong></div>
+                                        <div class="panel-body">
+                                            {if $api.paramsList}
+                                            <table class="table table-hover">
+                                                <thead>
+                                                    <tr>
+                                                        <th>{$lang.Name}</th>
+                                                        <th>{$lang.Type}</th>
+                                                        <th>{$lang.Required}</th>
+                                                        <th>{$lang.Description}</th>
+                                                    </tr>
+                                                </thead>
+                                                <tbody>
+                                                    {foreach name="api['paramsList']" id="param"}
+                                                    <tr>
+                                                        <td>{$param.name}</td>
+                                                        <td>{$param.type}</td>
+                                                        <td>{:$param.required?'是':'否'}</td>
+                                                        <td>{$param.description}</td>
+                                                    </tr>
+                                                    {/foreach}
+                                                </tbody>
+                                            </table>
+                                            {else /}
+                                            无
+                                            {/if}
+                                        </div>
+                                    </div>
+                                    <div class="panel panel-default">
+                                        <div class="panel-heading"><strong>{$lang.Body}</strong></div>
+                                        <div class="panel-body">
+                                            {$api.body|default='无'}
+                                        </div>
+                                    </div>
+                                </div><!-- #info -->
+
+                                <div class="tab-pane" id="sandbox{$api.id}">
+                                    <div class="row">
+                                        <div class="col-md-12">
+                                            {if $api.headersList}
+                                            <div class="panel panel-default">
+                                                <div class="panel-heading"><strong>{$lang.Headers}</strong></div>
+                                                <div class="panel-body">
+                                                    <div class="headers">
+                                                        {foreach name="api['headersList']" id="param"}
+                                                        <div class="form-group">
+                                                            <label class="control-label" for="{$param.name}">{$param.name}</label>
+                                                            <input type="{$param.type}" class="form-control input-sm" id="{$param.name}" {if $param.required}required{/if} placeholder="{$param.description} - Ex: {$param.sample}" name="{$param.name}">
+                                                        </div>
+                                                        {/foreach}
+                                                    </div>
+                                                </div>
+                                            </div>
+                                            {/if}
+                                            <div class="panel panel-default">
+                                                <div class="panel-heading"><strong>{$lang.Parameters}</strong>
+                                                <div class="pull-right">
+                                                    <a href="javascript:" class="btn btn-xs btn-info btn-append">追加</a>
+                                                </div>
+                                                </div>
+                                                <div class="panel-body">
+                                                    <form enctype="application/x-www-form-urlencoded" role="form" action="{$api.route}" method="{$api.method}" name="form{$api.id}" id="form{$api.id}">
+                                                        {if $api.paramsList}
+                                                        {foreach name="api['paramsList']" id="param"}
+                                                        <div class="form-group">
+                                                            <label class="control-label" for="{$param.name}">{$param.name}</label>
+                                                            <input type="{$param.type}" class="form-control input-sm" id="{$param.name}" {if $param.required}required{/if} placeholder="{$param.description}{if $param.sample} - 例: {$param.sample}{/if}" name="{$param.name}">
+                                                        </div>
+                                                        {/foreach}
+                                                        {else /}
+                                                        <div class="form-group">
+                                                            无
+                                                        </div>
+                                                        {/if}
+                                                        <div class="form-group form-group-submit">
+                                                            <button type="submit" class="btn btn-success send" rel="{$api.id}">{$lang.Send}</button>
+                                                            <button type="reset" class="btn btn-info" rel="{$api.id}">{$lang.Reset}</button>
+                                                        </div>
+                                                    </form>
+                                                </div>
+                                            </div>
+                                            <div class="panel panel-default">
+                                                <div class="panel-heading"><strong>{$lang.Response}</strong></div>
+                                                <div class="panel-body">
+                                                    <div class="row">
+                                                        <div class="col-md-12" style="overflow-x:auto">
+                                                            <pre id="response_headers{$api.id}"></pre>
+                                                            <pre id="response{$api.id}"></pre>
+                                                        </div>
+                                                    </div>
+                                                </div>
+                                            </div>
+                                            <div class="panel panel-default">
+                                                <div class="panel-heading"><strong>{$lang.ReturnParameters}</strong></div>
+                                                <div class="panel-body">
+                                                    {if $api.returnParamsList}
+                                                    <table class="table table-hover">
+                                                        <thead>
+                                                            <tr>
+                                                                <th>{$lang.Name}</th>
+                                                                <th>{$lang.Type}</th>
+                                                                <th>{$lang.Description}</th>
+                                                            </tr>
+                                                        </thead>
+                                                        <tbody>
+                                                            {foreach name="api['returnParamsList']" id="param"}
+                                                            <tr>
+                                                                <td>{$param.name}</td>
+                                                                <td>{$param.type}</td>
+                                                                <td>{$param.description}</td>
+                                                            </tr>
+                                                            {/foreach}
+                                                        </tbody>
+                                                    </table>
+                                                    {else /}
+                                                    无
+                                                    {/if}
+                                                </div>
+                                            </div>
+                                        </div>
+                                    </div>
+                                </div><!-- #sandbox -->
+
+                                <div class="tab-pane" id="sample{$api.id}">
+                                    <div class="row">
+                                        <div class="col-md-12">
+                                            <pre id="sample_response{$api.id}">{$api.return|default='无'}</pre>
+                                        </div>
+                                    </div>
+                                </div><!-- #sample -->
+
+                            </div><!-- .tab-content -->
+                        </div>
+                    </div>
+                </div>
+                {/foreach}
+                {/foreach}
+            </div>
+
+            <hr>
+
+            <div class="row mt0 footer">
+                <div class="col-md-6" align="left">
+
+                </div>
+                <div class="col-md-6" align="right">
+                    Generated on {:date('Y-m-d H:i:s')} <a href="./" target="_blank">{$config.sitename}</a>
+                </div>
+            </div>
+
+        </div> <!-- /container -->
+
+        <!-- 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>
+
+        <script type="text/javascript">
+            function syntaxHighlight(json) {
+                if (typeof json != 'string') {
+                    json = JSON.stringify(json, undefined, 2);
+                }
+                json = json.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
+                return json.replace(/("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, function (match) {
+                    var cls = 'number';
+                    if (/^"/.test(match)) {
+                        if (/:$/.test(match)) {
+                            cls = 'key';
+                        } else {
+                            cls = 'string';
+                        }
+                    } else if (/true|false/.test(match)) {
+                        cls = 'boolean';
+                    } else if (/null/.test(match)) {
+                        cls = 'null';
+                    }
+                    return '<span class="' + cls + '">' + match + '</span>';
+                });
+            }
+
+            function prepareStr(str) {
+                try {
+                    return syntaxHighlight(JSON.stringify(JSON.parse(str.replace(/'/g, '"')), null, 2));
+                } catch (e) {
+                    return str;
+                }
+            }
+            var storage = (function () {
+                var uid = new Date;
+                var storage;
+                var result;
+                try {
+                    (storage = window.localStorage).setItem(uid, uid);
+                    result = storage.getItem(uid) == uid;
+                    storage.removeItem(uid);
+                    return result && storage;
+                } catch (exception) {
+                }
+            }());
+
+            $.fn.serializeObject = function ()
+            {
+                var o = {};
+                var a = this.serializeArray();
+                $.each(a, function () {
+                    if (!this.value) {
+                        return;
+                    }
+                    if (o[this.name] !== undefined) {
+                        if (!o[this.name].push) {
+                            o[this.name] = [o[this.name]];
+                        }
+                        o[this.name].push(this.value || '');
+                    } else {
+                        o[this.name] = this.value || '';
+                    }
+                });
+                return o;
+            };
+
+            $(document).ready(function () {
+
+                if (storage) {
+                    storage.getItem('token') && $('#token').val(storage.getItem('token'));
+                    storage.getItem('apiUrl') && $('#apiUrl').val(storage.getItem('apiUrl'));
+                }
+
+                $('[data-toggle="tooltip"]').tooltip({
+                    placement: 'bottom'
+                });
+
+                $(window).on("resize", function(){
+                    $("#sidebar").css("max-height", $(window).height()-80);
+                });
+
+                $(window).trigger("resize");
+
+                $(document).on("click", "#sidebar .list-group > .list-group-item", function(){
+                    $("#sidebar .list-group > .list-group-item").removeClass("current");
+                    $(this).addClass("current");
+                });
+                $(document).on("click", "#sidebar .child a", function(){
+                    var heading = $("#heading-"+$(this).data("id"));
+                    if(!heading.next().hasClass("in")){
+                        $("a", heading).trigger("click");
+                    }
+                    $("html,body").animate({scrollTop:heading.offset().top-70});
+                });
+
+                $('code[id^=response]').hide();
+
+                $.each($('pre[id^=sample_response],pre[id^=sample_post_body]'), function () {
+                    if ($(this).html() == 'NA') {
+                        return;
+                    }
+                    var str = prepareStr($(this).html());
+                    $(this).html(str);
+                });
+
+                $("[data-toggle=popover]").popover({placement: 'right'});
+
+                $('[data-toggle=popover]').on('shown.bs.popover', function () {
+                    var $sample = $(this).parent().find(".popover-content"),
+                            str = $(this).data('content');
+                    if (typeof str == "undefined" || str === "") {
+                        return;
+                    }
+                    var str = prepareStr(str);
+                    $sample.html('<pre>' + str + '</pre>');
+                });
+
+                $(document).on('click', '#save_data', function (e) {
+                    if (storage) {
+                        storage.setItem('token', $('#token').val());
+                        storage.setItem('apiUrl', $('#apiUrl').val());
+                    } else {
+                        alert('Your browser does not support local storage');
+                    }
+                });
+                $(document).on('click', '.btn-append', function (e) {
+                    $($("#appendtpl").html()).insertBefore($(this).closest(".panel").find(".form-group-submit"));
+                    return false;
+                });
+                $(document).on('click', '.btn-remove', function (e) {
+                    $(this).closest(".form-group").remove();
+                    return false;
+                });
+                $(document).on('keyup', '.input-custom-name', function (e) {
+                    $(this).closest(".row").find(".input-custom-value").attr("name", $(this).val());
+                    return false;
+                });
+
+                $(document).on('click', '.send', function (e) {
+                    e.preventDefault();
+                    var form = $(this).closest('form');
+                    //added /g to get all the matched params instead of only first
+                    var matchedParamsInRoute = $(form).attr('action').match(/[^{]+(?=\})/g);
+                    var theId = $(this).attr('rel');
+                    //keep a copy of action attribute in order to modify the copy
+                    //instead of the initial attribute
+                    var url = $(form).attr('action');
+                    var method = $(form).prop('method').toLowerCase() || 'get';
+
+                    var formData = new FormData();
+
+                    $(form).find('input').each(function (i, input) {
+                        if ($(input).attr('type').toLowerCase() == 'file') {
+                            formData.append($(input).attr('name'), $(input)[0].files[0]);
+                            method = 'post';
+                        } else {
+                            formData.append($(input).attr('name'), $(input).val())
+                        }
+                    });
+
+                    var index, key, value;
+
+                    if (matchedParamsInRoute) {
+                        var params = {};
+                        formData.forEach(function(value, key){
+                            params[key] = value;
+                        });
+                        for (index = 0; index < matchedParamsInRoute.length; ++index) {
+                            try {
+                                key = matchedParamsInRoute[index];
+                                value = params[key];
+                                if (typeof value == "undefined")
+                                    value = "";
+                                url = url.replace("\{" + key + "\}", value);
+                                formData.delete(key);
+                            } catch (err) {
+                                console.log(err);
+                            }
+                        }
+                    }
+
+                    var headers = {};
+
+                    var token = $('#token').val();
+                    if (token.length > 0) {
+                        headers['token'] = token;
+                    }
+
+                    $("#sandbox" + theId + " .headers input[type=text]").each(function () {
+                        val = $(this).val();
+                        if (val.length > 0) {
+                            headers[$(this).prop('name')] = val;
+                        }
+                    });
+
+                    $.ajax({
+                        url: $('#apiUrl').val() + url,
+                        data: method == 'get' ? $(form).serialize() : formData,
+                        type: method,
+                        dataType: 'json',
+                        contentType: false,
+                        processData: false,
+                        headers: headers,
+                        xhrFields: {
+                            withCredentials: true
+                        },
+                        success: function (data, textStatus, xhr) {
+                            if (typeof data === 'object') {
+                                var str = JSON.stringify(data, null, 2);
+                                $('#response' + theId).html(syntaxHighlight(str));
+                            } else {
+                                $('#response' + theId).html(data || '');
+                            }
+                            $('#response_headers' + theId).html('HTTP ' + xhr.status + ' ' + xhr.statusText + '<br/><br/>' + xhr.getAllResponseHeaders());
+                            $('#response' + theId).show();
+                        },
+                        error: function (xhr, textStatus, error) {
+                            try {
+                                var str = JSON.stringify($.parseJSON(xhr.responseText), null, 2);
+                            } catch (e) {
+                                var str = xhr.responseText;
+                            }
+                            $('#response_headers' + theId).html('HTTP ' + xhr.status + ' ' + xhr.statusText + '<br/><br/>' + xhr.getAllResponseHeaders());
+                            $('#response' + theId).html(syntaxHighlight(str));
+                            $('#response' + theId).show();
+                        }
+                    });
+                    return false;
+                });
+                /*$('.api-list').click(function (){
+                })*/
+                let hash=location.hash
+                if(hash){
+                    hash=hash.replace('#','')
+                    let api=$('.api-list[md5='+hash+']')
+                    api.trigger('click')
+                    api.parent().prev().click()
+                }
+            });
+        </script>
+        <script type="text/html" id="appendtpl">
+            <div class="form-group">
+                <label class="control-label">自定义</label>
+                <div class="row">
+                    <div class="col-xs-4">
+                        <input type="text" class="form-control input-sm input-custom-name" placeholder="名称">
+                    </div>
+                    <div class="col-xs-6">
+                        <input type="text" class="form-control input-sm input-custom-value" placeholder="值">
+                    </div>
+                    <div class="col-xs-2 text-center">
+                        <a href="javascript:" class="btn btn-sm btn-danger btn-remove">删除</a>
+                    </div>
+                </div>
+            </div>
+        </script>
+    </body>
+</html>

+ 1520 - 0
application/admin/command/Crud.php

@@ -0,0 +1,1520 @@
+<?php
+
+namespace app\admin\command;
+
+use fast\Form;
+use think\Config;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Option;
+use think\console\Output;
+use think\Db;
+use think\Exception;
+use think\exception\ErrorException;
+use think\Lang;
+use think\Loader;
+
+class Crud extends Command
+{
+    protected $stubList = [];
+
+    protected $internalKeywords = [
+        'abstract', 'and', 'array', 'as', 'break', 'callable', 'case', 'catch', 'class', 'clone', 'const', 'continue', 'declare', 'default', 'die', 'do', 'echo', 'else', 'elseif', 'empty', 'enddeclare', 'endfor', 'endforeach', 'endif', 'endswitch', 'endwhile', 'eval', 'exit', 'extends', 'final', 'for', 'foreach', 'function', 'global', 'goto', 'if', 'implements', 'include', 'include_once', 'instanceof', 'insteadof', 'interface', 'isset', 'list', 'namespace', 'new', 'or', 'print', 'private', 'protected', 'public', 'require', 'require_once', 'return', 'static', 'switch', 'throw', 'trait', 'try', 'unset', 'use', 'var', 'while', 'xor'
+    ];
+
+    /**
+     * 受保护的系统表, crud不会生效
+     */
+    protected $systemTables = [
+       'admin', 'admin_log', 'auth_group', 'auth_group_access', 'auth_rule', 
+       'attachment', 'config', 'category', 'ems', 'sms',
+       'user', 'user_group', 'user_rule', 'user_score_log', 'user_token',
+    ];
+
+    /**
+     * Selectpage搜索字段关联
+     */
+    protected $fieldSelectpageMap = [
+        'nickname' => ['user_id', 'user_ids', 'admin_id', 'admin_ids']
+    ];
+
+    /**
+     * Enum类型识别为单选框的结尾字符,默认会识别为单选下拉列表
+     */
+    protected $enumRadioSuffix = ['data', 'state', 'status'];
+
+    /**
+     * Set类型识别为复选框的结尾字符,默认会识别为多选下拉列表
+     */
+    protected $setCheckboxSuffix = ['data', 'state', 'status'];
+
+    /**
+     * Int类型识别为日期时间的结尾字符,默认会识别为日期文本框
+     */
+    protected $intDateSuffix = ['time'];
+
+    /**
+     * 开关后缀
+     */
+    protected $switchSuffix = ['switch'];
+
+    /**
+     * 富文本后缀
+     */
+    protected $editorSuffix = ['content'];
+
+    /**
+     * 城市后缀
+     */
+    protected $citySuffix = ['city'];
+
+    /**
+     * JSON后缀
+     */
+    protected $jsonSuffix = ['json'];
+
+    /**
+     * Selectpage对应的后缀
+     */
+    protected $selectpageSuffix = ['_id', '_ids'];
+
+    /**
+     * Selectpage多选对应的后缀
+     */
+    protected $selectpagesSuffix = ['_ids'];
+
+    /**
+     * 以指定字符结尾的字段格式化函数
+     */
+    protected $fieldFormatterSuffix = [
+        'status' => ['type' => ['varchar', 'enum'], 'name' => 'status'],
+        'icon'   => 'icon',
+        'flag'   => 'flag',
+        'url'    => 'url',
+        'image'  => 'image',
+        'images' => 'images',
+        'avatar' => 'image',
+        'switch' => 'toggle',
+        'time'   => ['type' => ['int', 'timestamp'], 'name' => 'datetime']
+    ];
+
+    /**
+     * 识别为图片字段
+     */
+    protected $imageField = ['image', 'images', 'avatar', 'avatars'];
+
+    /**
+     * 识别为文件字段
+     */
+    protected $fileField = ['file', 'files'];
+
+    /**
+     * 保留字段
+     */
+    protected $reservedField = ['admin_id'];
+
+    /**
+     * 排除字段
+     */
+    protected $ignoreFields = [];
+
+    /**
+     * 排序字段
+     */
+    protected $sortField = 'weigh';
+
+    /**
+     * 筛选字段
+     * @var string
+     */
+    protected $headingFilterField = 'status';
+
+    /**
+     * 添加时间字段
+     * @var string
+     */
+    protected $createTimeField = 'createtime';
+
+    /**
+     * 更新时间字段
+     * @var string
+     */
+    protected $updateTimeField = 'updatetime';
+
+    /**
+     * 软删除时间字段
+     * @var string
+     */
+    protected $deleteTimeField = 'deletetime';
+
+    /**
+     * 编辑器的Class
+     */
+    protected $editorClass = 'editor';
+
+    /**
+     * langList的key最长字节数
+     */
+    protected $fieldMaxLen = 0;
+
+    protected function configure()
+    {
+        $this
+            ->setName('crud')
+            ->addOption('table', 't', Option::VALUE_REQUIRED, 'table name without prefix', null)
+            ->addOption('controller', 'c', Option::VALUE_OPTIONAL, 'controller name', null)
+            ->addOption('model', 'm', Option::VALUE_OPTIONAL, 'model name', null)
+            ->addOption('fields', 'i', Option::VALUE_OPTIONAL, 'model visible fields', null)
+            ->addOption('force', 'f', Option::VALUE_OPTIONAL, 'force override or force delete,without tips', null)
+            ->addOption('local', 'l', Option::VALUE_OPTIONAL, 'local model', 1)
+            ->addOption('relation', 'r', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'relation table name without prefix', null)
+            ->addOption('relationmodel', 'e', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'relation model name', null)
+            ->addOption('relationforeignkey', 'k', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'relation foreign key', null)
+            ->addOption('relationprimarykey', 'p', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'relation primary key', null)
+            ->addOption('relationfields', 's', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'relation table fields', null)
+            ->addOption('relationmode', 'o', Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'relation table mode,hasone or belongsto', null)
+            ->addOption('delete', 'd', Option::VALUE_OPTIONAL, 'delete all files generated by CRUD', null)
+            ->addOption('menu', 'u', Option::VALUE_OPTIONAL, 'create menu when CRUD completed', null)
+            ->addOption('setcheckboxsuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate checkbox component with suffix', null)
+            ->addOption('enumradiosuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate radio component with suffix', null)
+            ->addOption('imagefield', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate image component with suffix', null)
+            ->addOption('filefield', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate file component with suffix', null)
+            ->addOption('intdatesuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate date component with suffix', null)
+            ->addOption('switchsuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate switch component with suffix', null)
+            ->addOption('citysuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate citypicker component with suffix', null)
+            ->addOption('jsonsuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate fieldlist component with suffix', null)
+            ->addOption('editorsuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate editor component with suffix', null)
+            ->addOption('selectpagesuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate selectpage component with suffix', null)
+            ->addOption('selectpagessuffix', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'automatically generate multiple selectpage component with suffix', null)
+            ->addOption('ignorefields', null, Option::VALUE_OPTIONAL | Option::VALUE_IS_ARRAY, 'ignore fields', null)
+            ->addOption('sortfield', null, Option::VALUE_OPTIONAL, 'sort field', null)
+            ->addOption('headingfilterfield', null, Option::VALUE_OPTIONAL, 'heading filter field', null)
+            ->addOption('editorclass', null, Option::VALUE_OPTIONAL, 'automatically generate editor class', null)
+            ->addOption('db', null, Option::VALUE_OPTIONAL, 'database config name', 'database')
+            ->setDescription('Build CRUD controller and model from table');
+    }
+
+    protected function execute(Input $input, Output $output)
+    {
+        $adminPath = dirname(__DIR__) . DS;
+        //数据库
+        $db = $input->getOption('db');
+        //表名
+        $table = $input->getOption('table') ?: '';
+        //自定义控制器
+        $controller = $input->getOption('controller');
+        //自定义模型
+        $model = $input->getOption('model');
+        $model = $model ? $model : $controller;
+        //验证器类
+        $validate = $model;
+        //自定义显示字段
+        $fields = $input->getOption('fields');
+        //强制覆盖
+        $force = $input->getOption('force');
+        //是否为本地model,为0时表示为全局model将会把model放在app/common/model中
+        $local = $input->getOption('local');
+        
+        if (!$table) {
+            throw new Exception('table name can\'t empty');
+        }
+        
+    
+        //是否生成菜单
+        $menu = $input->getOption("menu");
+        //关联表
+        $relation = $input->getOption('relation');
+        //自定义关联表模型
+        $relationModels = $input->getOption('relationmodel');
+        //模式
+        $relationMode = $mode = $input->getOption('relationmode');
+        //外键
+        $relationForeignKey = $input->getOption('relationforeignkey');
+        //主键
+        $relationPrimaryKey = $input->getOption('relationprimarykey');
+        //关联表显示字段
+        $relationFields = $input->getOption('relationfields');
+        //复选框后缀
+        $setcheckboxsuffix = $input->getOption('setcheckboxsuffix');
+        //单选框后缀
+        $enumradiosuffix = $input->getOption('enumradiosuffix');
+        //图片后缀
+        $imagefield = $input->getOption('imagefield');
+        //文件后缀
+        $filefield = $input->getOption('filefield');
+        //日期后缀
+        $intdatesuffix = $input->getOption('intdatesuffix');
+        //开关后缀
+        $switchsuffix = $input->getOption('switchsuffix');
+        //城市后缀
+        $citysuffix = $input->getOption('citysuffix');
+        //JSON配置后缀
+        $jsonsuffix = $input->getOption('jsonsuffix');
+        //selectpage后缀
+        $selectpagesuffix = $input->getOption('selectpagesuffix');
+        //selectpage多选后缀
+        $selectpagessuffix = $input->getOption('selectpagessuffix');
+        //排除字段
+        $ignoreFields = $input->getOption('ignorefields');
+        //排序字段
+        $sortfield = $input->getOption('sortfield');
+        //顶部筛选过滤字段
+        $headingfilterfield = $input->getOption('headingfilterfield');
+        //编辑器Class
+        $editorclass = $input->getOption('editorclass');
+        if ($setcheckboxsuffix) {
+            $this->setCheckboxSuffix = $setcheckboxsuffix;
+        }
+        if ($enumradiosuffix) {
+            $this->enumRadioSuffix = $enumradiosuffix;
+        }
+        if ($imagefield) {
+            $this->imageField = $imagefield;
+        }
+        if ($filefield) {
+            $this->fileField = $filefield;
+        }
+        if ($intdatesuffix) {
+            $this->intDateSuffix = $intdatesuffix;
+        }
+        if ($switchsuffix) {
+            $this->switchSuffix = $switchsuffix;
+        }
+        if ($citysuffix) {
+            $this->citySuffix = $citysuffix;
+        }
+        if ($jsonsuffix) {
+            $this->jsonSuffix = $jsonsuffix;
+        }
+        if ($selectpagesuffix) {
+            $this->selectpageSuffix = $selectpagesuffix;
+        }
+        if ($selectpagessuffix) {
+            $this->selectpagesSuffix = $selectpagessuffix;
+        }
+        if ($ignoreFields) {
+            $this->ignoreFields = $ignoreFields;
+        }
+        if ($editorclass) {
+            $this->editorClass = $editorclass;
+        }
+        if ($sortfield) {
+            $this->sortField = $sortfield;
+        }
+        if ($headingfilterfield) {
+            $this->headingFilterField = $headingfilterfield;
+        }
+
+        $this->reservedField = array_merge($this->reservedField, [$this->createTimeField, $this->updateTimeField, $this->deleteTimeField]);
+
+        $dbconnect = Db::connect($db);
+        $dbname = Config::get($db . '.database');
+        $prefix = Config::get($db . '.prefix');
+
+        //系统表无法生成,防止后台错乱
+        if(in_array(str_replace($prefix,"",$table),$this->systemTables)){
+            throw new Exception('system table can\'t be crud');
+        }
+
+        //模块
+        $moduleName = 'admin';
+        $modelModuleName = $local ? $moduleName : 'common';
+        $validateModuleName = $local ? $moduleName : 'common';
+
+        //检查主表
+        $modelName = $table = stripos($table, $prefix) === 0 ? substr($table, strlen($prefix)) : $table;
+        $modelTableType = 'table';
+        $modelTableTypeName = $modelTableName = $modelName;
+        $modelTableInfo = $dbconnect->query("SHOW TABLE STATUS LIKE '{$modelTableName}'", [], true);
+        if (!$modelTableInfo) {
+            $modelTableType = 'name';
+            $modelTableName = $prefix . $modelName;
+            $modelTableInfo = $dbconnect->query("SHOW TABLE STATUS LIKE '{$modelTableName}'", [], true);
+            if (!$modelTableInfo) {
+                throw new Exception("table not found");
+            }
+        }
+        $modelTableInfo = $modelTableInfo[0];
+
+        $relations = [];
+        //检查关联表
+        if ($relation) {
+            $relationArr = $relation;
+            $relations = [];
+
+            foreach ($relationArr as $index => $relationTable) {
+                $relationName = stripos($relationTable, $prefix) === 0 ? substr($relationTable, strlen($prefix)) : $relationTable;
+                $relationTableType = 'table';
+                $relationTableTypeName = $relationTableName = $relationName;
+                $relationTableInfo = $dbconnect->query("SHOW TABLE STATUS LIKE '{$relationTableName}'", [], true);
+                if (!$relationTableInfo) {
+                    $relationTableType = 'name';
+                    $relationTableName = $prefix . $relationName;
+                    $relationTableInfo = $dbconnect->query("SHOW TABLE STATUS LIKE '{$relationTableName}'", [], true);
+                    if (!$relationTableInfo) {
+                        throw new Exception("relation table not found");
+                    }
+                }
+                $relationTableInfo = $relationTableInfo[0];
+                $relationModel = isset($relationModels[$index]) ? $relationModels[$index] : '';
+
+                list($relationNamespace, $relationName, $relationFile) = $this->getModelData($modelModuleName, $relationModel, $relationName);
+
+                $relations[] = [
+                    //关联表基础名
+                    'relationName'          => $relationName,
+                    //关联表类命名空间
+                    'relationNamespace'     => $relationNamespace,
+                    //关联模型名
+                    'relationModel'         => $relationModel,
+                    //关联文件
+                    'relationFile'          => $relationFile,
+                    //关联表名称
+                    'relationTableName'     => $relationTableName,
+                    //关联表信息
+                    'relationTableInfo'     => $relationTableInfo,
+                    //关联模型表类型(name或table)
+                    'relationTableType'     => $relationTableType,
+                    //关联模型表类型名称
+                    'relationTableTypeName' => $relationTableTypeName,
+                    //关联模式
+                    'relationFields'        => isset($relationFields[$index]) ? explode(',', $relationFields[$index]) : [],
+                    //关联模式
+                    'relationMode'          => isset($relationMode[$index]) ? $relationMode[$index] : 'belongsto',
+                    //关联表外键
+                    'relationForeignKey'    => isset($relationForeignKey[$index]) ? $relationForeignKey[$index] : Loader::parseName($relationName) . '_id',
+                    //关联表主键
+                    'relationPrimaryKey'    => isset($relationPrimaryKey[$index]) ? $relationPrimaryKey[$index] : '',
+                ];
+            }
+        }
+
+        //根据表名匹配对应的Fontawesome图标
+        $iconPath = ROOT_PATH . str_replace('/', DS, '/public/assets/libs/font-awesome/less/variables.less');
+        $iconName = is_file($iconPath) && stripos(file_get_contents($iconPath), '@fa-var-' . $table . ':') ? 'fa fa-' . $table : 'fa fa-circle-o';
+
+        //控制器
+        list($controllerNamespace, $controllerName, $controllerFile, $controllerArr) = $this->getControllerData($moduleName, $controller, $table);
+        //模型
+        list($modelNamespace, $modelName, $modelFile, $modelArr) = $this->getModelData($modelModuleName, $model, $table);
+        //验证器
+        list($validateNamespace, $validateName, $validateFile, $validateArr) = $this->getValidateData($validateModuleName, $validate, $table);
+
+        //处理基础文件名,取消所有下划线并转换为小写
+        $baseNameArr = $controllerArr;
+        $baseFileName = Loader::parseName(array_pop($baseNameArr), 0);
+        array_push($baseNameArr, $baseFileName);
+        $controllerBaseName = strtolower(implode(DS, $baseNameArr));
+        $controllerUrl = strtolower(implode('/', $baseNameArr));
+
+        //视图文件
+        $viewArr = $controllerArr;
+        $lastValue = array_pop($viewArr);
+        $viewArr[] = Loader::parseName($lastValue, 0);
+        array_unshift($viewArr, 'view');
+        $viewDir = $adminPath . strtolower(implode(DS, $viewArr)) . DS;
+
+        //最终将生成的文件路径
+        $javascriptFile = ROOT_PATH . 'public' . DS . 'assets' . DS . 'js' . DS . 'backend' . DS . $controllerBaseName . '.js';
+        $addFile = $viewDir . 'add.html';
+        $editFile = $viewDir . 'edit.html';
+        $indexFile = $viewDir . 'index.html';
+        $recyclebinFile = $viewDir . 'recyclebin.html';
+        $langFile = $adminPath . 'lang' . DS . Lang::detect() . DS . $controllerBaseName . '.php';
+
+        //是否为删除模式
+        $delete = $input->getOption('delete');
+        if ($delete) {
+            $readyFiles = [$controllerFile, $modelFile, $validateFile, $addFile, $editFile, $indexFile, $recyclebinFile, $langFile, $javascriptFile];
+            foreach ($readyFiles as $k => $v) {
+                $output->warning($v);
+            }
+            if (!$force) {
+                $output->info("Are you sure you want to delete all those files?  Type 'yes' to continue: ");
+                $line = fgets(defined('STDIN') ? STDIN : fopen('php://stdin', 'r'));
+                if (trim($line) != 'yes') {
+                    throw new Exception("Operation is aborted!");
+                }
+            }
+            foreach ($readyFiles as $k => $v) {
+                if (file_exists($v)) {
+                    unlink($v);
+                }
+                //删除空文件夹
+                switch ($v) {
+                    case $modelFile:
+                        $this->removeEmptyBaseDir($v, $modelArr);
+                        break;
+                    case $validateFile:
+                        $this->removeEmptyBaseDir($v, $validateArr);
+                        break;
+                    case $addFile:
+                    case $editFile:
+                    case $indexFile:
+                    case $recyclebinFile:
+                        $this->removeEmptyBaseDir($v, $viewArr);
+                        break;
+                    default:
+                        $this->removeEmptyBaseDir($v, $controllerArr);
+                }
+            }
+
+            //继续删除菜单
+            if ($menu) {
+                exec("php think menu -c {$controllerUrl} -d 1 -f 1");
+            }
+
+            $output->info("Delete Successed");
+            return;
+        }
+
+        //非覆盖模式时如果存在控制器文件则报错
+        if (is_file($controllerFile) && !$force) {
+            throw new Exception("controller already exists!\nIf you need to rebuild again, use the parameter --force=true ");
+        }
+
+        //非覆盖模式时如果存在模型文件则报错
+        if (is_file($modelFile) && !$force) {
+            throw new Exception("model already exists!\nIf you need to rebuild again, use the parameter --force=true ");
+        }
+
+        //非覆盖模式时如果存在验证文件则报错
+        if (is_file($validateFile) && !$force) {
+            throw new Exception("validate already exists!\nIf you need to rebuild again, use the parameter --force=true ");
+        }
+
+        require $adminPath . 'common.php';
+
+        //从数据库中获取表字段信息
+        $sql = "SELECT * FROM `information_schema`.`columns` "
+            . "WHERE TABLE_SCHEMA = ? AND table_name = ? "
+            . "ORDER BY ORDINAL_POSITION";
+        //加载主表的列
+        $columnList = $dbconnect->query($sql, [$dbname, $modelTableName]);
+        $fieldArr = [];
+        foreach ($columnList as $k => $v) {
+            $fieldArr[] = $v['COLUMN_NAME'];
+        }
+
+        // 加载关联表的列
+        foreach ($relations as $index => &$relation) {
+            $relationColumnList = $dbconnect->query($sql, [$dbname, $relation['relationTableName']]);
+
+            $relationFieldList = [];
+            foreach ($relationColumnList as $k => $v) {
+                $relationFieldList[] = $v['COLUMN_NAME'];
+            }
+            if (!$relation['relationPrimaryKey']) {
+                foreach ($relationColumnList as $k => $v) {
+                    if ($v['COLUMN_KEY'] == 'PRI') {
+                        $relation['relationPrimaryKey'] = $v['COLUMN_NAME'];
+                        break;
+                    }
+                }
+            }
+            // 如果主键为空
+            if (!$relation['relationPrimaryKey']) {
+                throw new Exception('Relation Primary key not found!');
+            }
+            // 如果主键不在表字段中
+            if (!in_array($relation['relationPrimaryKey'], $relationFieldList)) {
+                throw new Exception('Relation Primary key not found in table!');
+            }
+            $relation['relationColumnList'] = $relationColumnList;
+            $relation['relationFieldList'] = $relationFieldList;
+        }
+        unset($relation);
+
+        $addList = [];
+        $editList = [];
+        $javascriptList = [];
+        $langList = [];
+        $field = 'id';
+        $order = 'id';
+        $priDefined = false;
+        $priKey = '';
+        $relationPrimaryKey = '';
+        foreach ($columnList as $k => $v) {
+            if ($v['COLUMN_KEY'] == 'PRI') {
+                $priKey = $v['COLUMN_NAME'];
+                break;
+            }
+        }
+        if (!$priKey) {
+            throw new Exception('Primary key not found!');
+        }
+
+        $order = $priKey;
+
+        //如果是关联模型
+        foreach ($relations as $index => &$relation) {
+            if ($relation['relationMode'] == 'hasone') {
+                $relationForeignKey = $relation['relationForeignKey'] ? $relation['relationForeignKey'] : $table . "_id";
+                $relationPrimaryKey = $relation['relationPrimaryKey'] ? $relation['relationPrimaryKey'] : $priKey;
+
+                if (!in_array($relationForeignKey, $relation['relationFieldList'])) {
+                    throw new Exception('relation table [' . $relation['relationTableName'] . '] must be contain field [' . $relationForeignKey . ']');
+                }
+                if (!in_array($relationPrimaryKey, $fieldArr)) {
+                    throw new Exception('table [' . $modelTableName . '] must be contain field [' . $relationPrimaryKey . ']');
+                }
+            } else {
+                $relationForeignKey = $relation['relationForeignKey'] ? $relation['relationForeignKey'] : Loader::parseName($relation['relationName']) . "_id";
+                $relationPrimaryKey = $relation['relationPrimaryKey'] ? $relation['relationPrimaryKey'] : $relation['relationPriKey'];
+                if (!in_array($relationForeignKey, $fieldArr)) {
+                    throw new Exception('table [' . $modelTableName . '] must be contain field [' . $relationForeignKey . ']');
+                }
+                if (!in_array($relationPrimaryKey, $relation['relationFieldList'])) {
+                    throw new Exception('relation table [' . $relation['relationTableName'] . '] must be contain field [' . $relationPrimaryKey . ']');
+                }
+            }
+            $relation['relationForeignKey'] = $relationForeignKey;
+            $relation['relationPrimaryKey'] = $relationPrimaryKey;
+            $relation['relationClassName'] = $modelNamespace != $relation['relationNamespace'] ? $relation['relationNamespace'] . '\\' . $relation['relationName'] : $relation['relationName'];
+        }
+        unset($relation);
+
+        try {
+            Form::setEscapeHtml(false);
+            $setAttrArr = [];
+            $getAttrArr = [];
+            $getEnumArr = [];
+            $appendAttrList = [];
+            $controllerAssignList = [];
+            $headingHtml = '{:build_heading()}';
+            $recyclebinHtml = '';
+
+            //循环所有字段,开始构造视图的HTML和JS信息
+            foreach ($columnList as $k => $v) {
+                $field = $v['COLUMN_NAME'];
+                $itemArr = [];
+                // 这里构建Enum和Set类型的列表数据
+                if (in_array($v['DATA_TYPE'], ['enum', 'set', 'tinyint'])) {
+                    if ($v['DATA_TYPE'] !== 'tinyint') {
+                        $itemArr = substr($v['COLUMN_TYPE'], strlen($v['DATA_TYPE']) + 1, -1);
+                        $itemArr = explode(',', str_replace("'", '', $itemArr));
+                    }
+                    $itemArr = $this->getItemArray($itemArr, $field, $v['COLUMN_COMMENT']);
+                    //如果类型为tinyint且有使用备注数据
+                    if ($itemArr && $v['DATA_TYPE'] == 'tinyint') {
+                        $v['DATA_TYPE'] = 'enum';
+                    }
+                }
+                // 语言列表
+                if ($v['COLUMN_COMMENT'] != '') {
+                    $langList[] = $this->getLangItem($field, $v['COLUMN_COMMENT']);
+                }
+                $inputType = '';
+                //保留字段不能修改和添加
+                if ($v['COLUMN_KEY'] != 'PRI' && !in_array($field, $this->reservedField) && !in_array($field, $this->ignoreFields)) {
+                    $inputType = $this->getFieldType($v);
+
+                    // 如果是number类型时增加一个步长
+                    $step = $inputType == 'number' && $v['NUMERIC_SCALE'] > 0 ? "0." . str_repeat(0, $v['NUMERIC_SCALE'] - 1) . "1" : 0;
+
+                    $attrArr = ['id' => "c-{$field}"];
+                    $cssClassArr = ['form-control'];
+                    $fieldName = "row[{$field}]";
+                    $defaultValue = $v['COLUMN_DEFAULT'];
+                    $editValue = "{\$row.{$field}|htmlentities}";
+                    // 如果默认值非null,则是一个必选项
+                    if ($v['IS_NULLABLE'] == 'NO') {
+                        $attrArr['data-rule'] = 'required';
+                    }
+
+                    //如果字段类型为无符号型,则设置<input min=0>
+                    if (stripos($v['COLUMN_TYPE'], 'unsigned') !== false) {
+                        $attrArr['min'] = 0;
+                    }
+
+                    if ($inputType == 'select') {
+                        $cssClassArr[] = 'selectpicker';
+                        $attrArr['class'] = implode(' ', $cssClassArr);
+                        if ($v['DATA_TYPE'] == 'set') {
+                            $attrArr['multiple'] = '';
+                            $fieldName .= "[]";
+                        }
+                        $attrArr['name'] = $fieldName;
+
+                        $this->getEnum($getEnumArr, $controllerAssignList, $field, $itemArr, $v['DATA_TYPE'] == 'set' ? 'multiple' : 'select');
+
+                        $itemArr = $this->getLangArray($itemArr, false);
+                        //添加一个获取器
+                        $this->getAttr($getAttrArr, $field, $v['DATA_TYPE'] == 'set' ? 'multiple' : 'select');
+                        if ($v['DATA_TYPE'] == 'set') {
+                            $this->setAttr($setAttrArr, $field, $inputType);
+                        }
+                        $this->appendAttr($appendAttrList, $field);
+                        $formAddElement = $this->getReplacedStub('html/select', ['field' => $field, 'fieldName' => $fieldName, 'fieldList' => $this->getFieldListName($field), 'attrStr' => Form::attributes($attrArr), 'selectedValue' => $defaultValue]);
+                        $formEditElement = $this->getReplacedStub('html/select', ['field' => $field, 'fieldName' => $fieldName, 'fieldList' => $this->getFieldListName($field), 'attrStr' => Form::attributes($attrArr), 'selectedValue' => "\$row.{$field}"]);
+                    } elseif ($inputType == 'datetime') {
+                        $cssClassArr[] = 'datetimepicker';
+                        $attrArr['class'] = implode(' ', $cssClassArr);
+                        $format = "YYYY-MM-DD HH:mm:ss";
+                        $phpFormat = "Y-m-d H:i:s";
+                        $fieldFunc = '';
+                        switch ($v['DATA_TYPE']) {
+                            case 'year':
+                                $format = "YYYY";
+                                $phpFormat = 'Y';
+                                break;
+                            case 'date':
+                                $format = "YYYY-MM-DD";
+                                $phpFormat = 'Y-m-d';
+                                break;
+                            case 'time':
+                                $format = "HH:mm:ss";
+                                $phpFormat = 'H:i:s';
+                                break;
+                            case 'timestamp':
+                                $fieldFunc = 'datetime';
+                            // no break
+                            case 'datetime':
+                                $format = "YYYY-MM-DD HH:mm:ss";
+                                $phpFormat = 'Y-m-d H:i:s';
+                                break;
+                            default:
+                                $fieldFunc = 'datetime';
+                                $this->getAttr($getAttrArr, $field, $inputType);
+                                $this->setAttr($setAttrArr, $field, $inputType);
+                                $this->appendAttr($appendAttrList, $field);
+                                break;
+                        }
+                        $defaultDateTime = "{:date('{$phpFormat}')}";
+                        $attrArr['data-date-format'] = $format;
+                        $attrArr['data-use-current'] = "true";
+                        $formAddElement = Form::text($fieldName, $defaultDateTime, $attrArr);
+                        $formEditElement = Form::text($fieldName, ($fieldFunc ? "{:\$row.{$field}?{$fieldFunc}(\$row.{$field}):''}" : "{\$row.{$field}{$fieldFunc}}"), $attrArr);
+                    } elseif ($inputType == 'checkbox' || $inputType == 'radio') {
+                        unset($attrArr['data-rule']);
+                        $fieldName = $inputType == 'checkbox' ? $fieldName .= "[]" : $fieldName;
+                        $attrArr['name'] = "row[{$fieldName}]";
+
+                        $this->getEnum($getEnumArr, $controllerAssignList, $field, $itemArr, $inputType);
+                        $itemArr = $this->getLangArray($itemArr, false);
+                        //添加一个获取器
+                        $this->getAttr($getAttrArr, $field, $inputType);
+                        if ($inputType == 'checkbox') {
+                            $this->setAttr($setAttrArr, $field, $inputType);
+                        }
+                        $this->appendAttr($appendAttrList, $field);
+                        $defaultValue = $inputType == 'radio' && !$defaultValue ? key($itemArr) : $defaultValue;
+
+                        $formAddElement = $this->getReplacedStub('html/' . $inputType, ['field' => $field, 'fieldName' => $fieldName, 'fieldList' => $this->getFieldListName($field), 'attrStr' => Form::attributes($attrArr), 'selectedValue' => $defaultValue]);
+                        $formEditElement = $this->getReplacedStub('html/' . $inputType, ['field' => $field, 'fieldName' => $fieldName, 'fieldList' => $this->getFieldListName($field), 'attrStr' => Form::attributes($attrArr), 'selectedValue' => "\$row.{$field}"]);
+                    } elseif ($inputType == 'textarea' && !$this->isMatchSuffix($field, $this->selectpagesSuffix) && !$this->isMatchSuffix($field, $this->imageField)) {
+                        $cssClassArr[] = $this->isMatchSuffix($field, $this->editorSuffix) ? $this->editorClass : '';
+                        $attrArr['class'] = implode(' ', $cssClassArr);
+                        $attrArr['rows'] = 5;
+                        $formAddElement = Form::textarea($fieldName, $defaultValue, $attrArr);
+                        $formEditElement = Form::textarea($fieldName, $editValue, $attrArr);
+                    } elseif ($inputType == 'switch') {
+                        unset($attrArr['data-rule']);
+                        if ($defaultValue === '1' || $defaultValue === 'Y') {
+                            $yes = $defaultValue;
+                            $no = $defaultValue === '1' ? '0' : 'N';
+                        } else {
+                            $no = $defaultValue;
+                            $yes = $defaultValue === '0' ? '1' : 'Y';
+                        }
+                        if (!$itemArr) {
+                            $itemArr = [$yes => 'Yes', $no => 'No'];
+                        }
+                        $stateNoClass = 'fa-flip-horizontal text-gray';
+                        $formAddElement = $this->getReplacedStub('html/' . $inputType, ['field' => $field, 'fieldName' => $fieldName, 'fieldYes' => $yes, 'fieldNo' => $no, 'attrStr' => Form::attributes($attrArr), 'fieldValue' => $defaultValue, 'fieldSwitchClass' => $defaultValue == $no ? $stateNoClass : '']);
+                        $formEditElement = $this->getReplacedStub('html/' . $inputType, ['field' => $field, 'fieldName' => $fieldName, 'fieldYes' => $yes, 'fieldNo' => $no, 'attrStr' => Form::attributes($attrArr), 'fieldValue' => "{\$row.{$field}}", 'fieldSwitchClass' => "{eq name=\"\$row.{$field}\" value=\"{$no}\"}fa-flip-horizontal text-gray{/eq}"]);
+                    } elseif ($inputType == 'citypicker') {
+                        $attrArr['class'] = implode(' ', $cssClassArr);
+                        $attrArr['data-toggle'] = "city-picker";
+                        $formAddElement = sprintf("<div class='control-relative'>%s</div>", Form::input('text', $fieldName, $defaultValue, $attrArr));
+                        $formEditElement = sprintf("<div class='control-relative'>%s</div>", Form::input('text', $fieldName, $editValue, $attrArr));
+                    } elseif ($inputType == 'fieldlist') {
+                        $itemArr = $this->getItemArray($itemArr, $field, $v['COLUMN_COMMENT']);
+                        $itemKey = isset($itemArr['key']) ? ucfirst($itemArr['key']) : 'Key';
+                        $itemValue = isset($itemArr['value']) ? ucfirst($itemArr['value']) : 'Value';
+                        $formAddElement = $this->getReplacedStub('html/' . $inputType, ['field' => $field, 'fieldName' => $fieldName, 'itemKey' => $itemKey, 'itemValue' => $itemValue, 'fieldValue' => $defaultValue]);
+                        $formEditElement = $this->getReplacedStub('html/' . $inputType, ['field' => $field, 'fieldName' => $fieldName, 'itemKey' => $itemKey, 'itemValue' => $itemValue, 'fieldValue' => $editValue]);
+                    } else {
+                        $search = $replace = '';
+                        //特殊字段为关联搜索
+                        if ($this->isMatchSuffix($field, $this->selectpageSuffix)) {
+                            $inputType = 'text';
+                            $defaultValue = '';
+                            $attrArr['data-rule'] = 'required';
+                            $cssClassArr[] = 'selectpage';
+                            $selectpageController = str_replace('_', '/', substr($field, 0, strripos($field, '_')));
+                            $attrArr['data-source'] = $selectpageController . "/index";
+                            //如果是类型表需要特殊处理下
+                            if ($selectpageController == 'category') {
+                                $attrArr['data-source'] = 'category/selectpage';
+                                $attrArr['data-params'] = '##replacetext##';
+                                $search = '"##replacetext##"';
+                                $replace = '\'{"custom[type]":"' . $table . '"}\'';
+                            } elseif ($selectpageController == 'admin') {
+                                $attrArr['data-source'] = 'auth/admin/selectpage';
+                            } elseif ($selectpageController == 'user') {
+                                $attrArr['data-source'] = 'user/user/index';
+                            }
+                            if ($this->isMatchSuffix($field, $this->selectpagesSuffix)) {
+                                $attrArr['data-multiple'] = 'true';
+                            }
+                            foreach ($this->fieldSelectpageMap as $m => $n) {
+                                if (in_array($field, $n)) {
+                                    $attrArr['data-field'] = $m;
+                                    break;
+                                }
+                            }
+                        }
+                        //因为有自动完成可输入其它内容
+                        $step = array_intersect($cssClassArr, ['selectpage']) ? 0 : $step;
+                        $attrArr['class'] = implode(' ', $cssClassArr);
+                        $isUpload = false;
+                        if ($this->isMatchSuffix($field, array_merge($this->imageField, $this->fileField))) {
+                            $isUpload = true;
+                        }
+                        //如果是步长则加上步长
+                        if ($step) {
+                            $attrArr['step'] = $step;
+                        }
+                        //如果是图片加上个size
+                        if ($isUpload) {
+                            $attrArr['size'] = 50;
+                        }
+
+                        $formAddElement = Form::input($inputType, $fieldName, $defaultValue, $attrArr);
+                        $formEditElement = Form::input($inputType, $fieldName, $editValue, $attrArr);
+                        if ($search && $replace) {
+                            $formAddElement = str_replace($search, $replace, $formAddElement);
+                            $formEditElement = str_replace($search, $replace, $formEditElement);
+                        }
+                        //如果是图片或文件
+                        if ($isUpload) {
+                            $formAddElement = $this->getImageUpload($field, $formAddElement);
+                            $formEditElement = $this->getImageUpload($field, $formEditElement);
+                        }
+                    }
+                    //构造添加和编辑HTML信息
+                    $addList[] = $this->getFormGroup($field, $formAddElement);
+                    $editList[] = $this->getFormGroup($field, $formEditElement);
+                }
+
+                //过滤text类型字段
+                if ($v['DATA_TYPE'] != 'text' && $inputType != 'fieldlist') {
+                    //主键
+                    if ($v['COLUMN_KEY'] == 'PRI' && !$priDefined) {
+                        $priDefined = true;
+                        $javascriptList[] = "{checkbox: true}";
+                    }
+                    if ($this->deleteTimeField == $field) {
+                        $recyclebinHtml = $this->getReplacedStub('html/recyclebin-html', ['controllerUrl' => $controllerUrl]);
+                        continue;
+                    }
+                    if (!$fields || in_array($field, explode(',', $fields))) {
+                        //构造JS列信息
+                        $javascriptList[] = $this->getJsColumn($field, $v['DATA_TYPE'], $inputType && in_array($inputType, ['select', 'checkbox', 'radio']) ? '_text' : '', $itemArr);
+                    }
+                    if ($this->headingFilterField && $this->headingFilterField == $field && $itemArr) {
+                        $headingHtml = $this->getReplacedStub('html/heading-html', ['field' => $field, 'fieldName' => Loader::parseName($field, 1, false)]);
+                    }
+                    //排序方式,如果有指定排序字段,否则按主键排序
+                    $order = $field == $this->sortField ? $this->sortField : $order;
+                }
+            }
+
+            //循环关联表,追加语言包和JS列
+            foreach ($relations as $index => $relation) {
+                foreach ($relation['relationColumnList'] as $k => $v) {
+                    // 不显示的字段直接过滤掉
+                    if ($relation['relationFields'] && !in_array($v['COLUMN_NAME'], $relation['relationFields'])) {
+                        continue;
+                    }
+
+                    $relationField = strtolower($relation['relationName']) . "." . $v['COLUMN_NAME'];
+                    // 语言列表
+                    if ($v['COLUMN_COMMENT'] != '') {
+                        $langList[] = $this->getLangItem($relationField, $v['COLUMN_COMMENT']);
+                    }
+
+                    //过滤text类型字段
+                    if ($v['DATA_TYPE'] != 'text') {
+                        //构造JS列信息
+                        $javascriptList[] = $this->getJsColumn($relationField, $v['DATA_TYPE']);
+                    }
+                }
+            }
+
+            //JS最后一列加上操作列
+            $javascriptList[] = str_repeat(" ", 24) . "{field: 'operate', title: __('Operate'), table: table, events: Table.api.events.operate, formatter: Table.api.formatter.operate}";
+            $addList = implode("\n", array_filter($addList));
+            $editList = implode("\n", array_filter($editList));
+            $javascriptList = implode(",\n", array_filter($javascriptList));
+            $langList = implode(",\n", array_filter($langList));
+            //数组等号对齐
+            $langList = array_filter(explode(",\n", $langList . ",\n"));
+            foreach ($langList as &$line) {
+                if (preg_match("/^\s+'([^']+)'\s*=>\s*'([^']+)'\s*/is", $line, $matches)) {
+                    $line = "    '{$matches[1]}'" . str_pad('=>', ($this->fieldMaxLen - strlen($matches[1]) + 3), ' ', STR_PAD_LEFT) . " '{$matches[2]}'";
+                }
+            }
+            unset($line);
+            $langList = implode(",\n", array_filter($langList));
+
+            //表注释
+            $tableComment = $modelTableInfo['Comment'];
+            $tableComment = mb_substr($tableComment, -1) == '表' ? mb_substr($tableComment, 0, -1) . '管理' : $tableComment;
+
+            $modelInit = '';
+            if ($priKey != $order) {
+                $modelInit = $this->getReplacedStub('mixins' . DS . 'modelinit', ['order' => $order]);
+            }
+
+            $data = [
+                'modelConnection'         => $db == 'database' ? '' : "protected \$connection = '{$db}';",
+                'controllerNamespace'     => $controllerNamespace,
+                'modelNamespace'          => $modelNamespace,
+                'validateNamespace'       => $validateNamespace,
+                'controllerUrl'           => $controllerUrl,
+                'controllerName'          => $controllerName,
+                'controllerAssignList'    => implode("\n", $controllerAssignList),
+                'modelName'               => $modelName,
+                'modelTableName'          => $modelTableName,
+                'modelTableType'          => $modelTableType,
+                'modelTableTypeName'      => $modelTableTypeName,
+                'validateName'            => $validateName,
+                'tableComment'            => $tableComment,
+                'iconName'                => $iconName,
+                'pk'                      => $priKey,
+                'order'                   => $order,
+                'table'                   => $table,
+                'tableName'               => $modelTableName,
+                'addList'                 => $addList,
+                'editList'                => $editList,
+                'javascriptList'          => $javascriptList,
+                'langList'                => $langList,
+                'softDeleteClassPath'     => in_array($this->deleteTimeField, $fieldArr) ? "use traits\model\SoftDelete;" : '',
+                'softDelete'              => in_array($this->deleteTimeField, $fieldArr) ? "use SoftDelete;" : '',
+                'modelAutoWriteTimestamp' => in_array($this->createTimeField, $fieldArr) || in_array($this->updateTimeField, $fieldArr) ? "'int'" : 'false',
+                'createTime'              => in_array($this->createTimeField, $fieldArr) ? "'{$this->createTimeField}'" : 'false',
+                'updateTime'              => in_array($this->updateTimeField, $fieldArr) ? "'{$this->updateTimeField}'" : 'false',
+                'deleteTime'              => in_array($this->deleteTimeField, $fieldArr) ? "'{$this->deleteTimeField}'" : 'false',
+                'relationSearch'          => $relations ? 'true' : 'false',
+                'relationWithList'        => '',
+                'relationMethodList'      => '',
+                'controllerIndex'         => '',
+                'recyclebinJs'            => '',
+                'headingHtml'             => $headingHtml,
+                'recyclebinHtml'          => $recyclebinHtml,
+                'visibleFieldList'        => $fields ? "\$row->visible(['" . implode("','", array_filter(in_array($priKey,explode(',', $fields))?explode(',', $fields):explode(',',$priKey.','.$fields))) . "']);" : '',
+                'appendAttrList'          => implode(",\n", $appendAttrList),
+                'getEnumList'             => implode("\n\n", $getEnumArr),
+                'getAttrList'             => implode("\n\n", $getAttrArr),
+                'setAttrList'             => implode("\n\n", $setAttrArr),
+                'modelInit'               => $modelInit,
+            ];
+
+            //如果使用关联模型
+            if ($relations) {
+                $relationWithList = $relationMethodList = $relationVisibleFieldList = [];
+                foreach ($relations as $index => $relation) {
+                    //需要构造关联的方法
+                    $relation['relationMethod'] = strtolower($relation['relationName']);
+
+                    //关联的模式
+                    $relation['relationMode'] = $relation['relationMode'] == 'hasone' ? 'hasOne' : 'belongsTo';
+
+                    //关联字段
+                    $relation['relationPrimaryKey'] = $relation['relationPrimaryKey'] ? $relation['relationPrimaryKey'] : $priKey;
+
+                    //预载入的方法
+                    $relationWithList[] = $relation['relationMethod'];
+
+                    unset($relation['relationColumnList'], $relation['relationFieldList'], $relation['relationTableInfo']);
+
+                    //构造关联模型的方法
+                    $relationMethodList[] = $this->getReplacedStub('mixins' . DS . 'modelrelationmethod', $relation);
+
+                    //如果设置了显示主表字段,则必须显式将关联表字段显示
+                    if ($fields) {
+                        $relationVisibleFieldList[] = "\$row->visible(['{$relation['relationMethod']}']);";
+                    }
+
+                    //显示的字段
+                    if ($relation['relationFields']) {
+                        $relationVisibleFieldList[] = "\$row->getRelation('" . $relation['relationMethod'] . "')->visible(['" . implode("','", $relation['relationFields']) . "']);";
+                    }
+                }
+
+                $data['relationWithList'] = "->with(['" . implode("','", $relationWithList) . "'])";
+                $data['relationMethodList'] = implode("\n\n", $relationMethodList);
+                $data['relationVisibleFieldList'] = implode("\n\t\t\t\t", $relationVisibleFieldList);
+
+                //需要重写index方法
+                $data['controllerIndex'] = $this->getReplacedStub('controllerindex', $data);
+            } elseif ($fields) {
+                $data = array_merge($data, ['relationWithList' => '', 'relationMethodList' => '', 'relationVisibleFieldList' => '']);
+                //需要重写index方法
+                $data['controllerIndex'] = $this->getReplacedStub('controllerindex', $data);
+            }
+
+            // 生成控制器文件
+            $this->writeToFile('controller', $data, $controllerFile);
+            // 生成模型文件
+            $this->writeToFile('model', $data, $modelFile);
+
+            if ($relations) {
+                foreach ($relations as $i => $relation) {
+                    $relation['modelNamespace'] = $data['modelNamespace'];
+                    if (!is_file($relation['relationFile'])) {
+                        // 生成关联模型文件
+                        $this->writeToFile('relationmodel', $relation, $relation['relationFile']);
+                    }
+                }
+            }
+            // 生成验证文件
+            $this->writeToFile('validate', $data, $validateFile);
+            // 生成视图文件
+            $this->writeToFile('add', $data, $addFile);
+            $this->writeToFile('edit', $data, $editFile);
+            $this->writeToFile('index', $data, $indexFile);
+            if ($recyclebinHtml) {
+                $this->writeToFile('recyclebin', $data, $recyclebinFile);
+                $recyclebinTitle = in_array('title', $fieldArr) ? 'title' : (in_array('name', $fieldArr) ? 'name' : '');
+                $recyclebinTitleJs = $recyclebinTitle ? "\n                        {field: '{$recyclebinTitle}', title: __('" . (ucfirst($recyclebinTitle)) . "'), align: 'left'}," : '';
+                $data['recyclebinJs'] = $this->getReplacedStub('mixins/recyclebinjs', ['deleteTimeField' => $this->deleteTimeField, 'recyclebinTitleJs' => $recyclebinTitleJs, 'controllerUrl' => $controllerUrl]);
+            }
+            // 生成JS文件
+            $this->writeToFile('javascript', $data, $javascriptFile);
+            // 生成语言文件
+            $this->writeToFile('lang', $data, $langFile);
+        } catch (ErrorException $e) {
+            throw new Exception("Code: " . $e->getCode() . "\nLine: " . $e->getLine() . "\nMessage: " . $e->getMessage() . "\nFile: " . $e->getFile());
+        }
+
+        //继续生成菜单
+        if ($menu) {
+            exec("php think menu -c {$controllerUrl}");
+        }
+
+        $output->info("Build Successed");
+    }
+
+    protected function getEnum(&$getEnum, &$controllerAssignList, $field, $itemArr = '', $inputType = '')
+    {
+        if (!in_array($inputType, ['datetime', 'select', 'multiple', 'checkbox', 'radio'])) {
+            return;
+        }
+        $fieldList = $this->getFieldListName($field);
+        $methodName = 'get' . ucfirst($fieldList);
+        foreach ($itemArr as $k => &$v) {
+            $v = "__('" . mb_ucfirst($v) . "')";
+        }
+        unset($v);
+        $itemString = $this->getArrayString($itemArr);
+        $getEnum[] = <<<EOD
+    public function {$methodName}()
+    {
+        return [{$itemString}];
+    }
+EOD;
+        $controllerAssignList[] = <<<EOD
+        \$this->view->assign("{$fieldList}", \$this->model->{$methodName}());
+EOD;
+    }
+
+    protected function getAttr(&$getAttr, $field, $inputType = '')
+    {
+        if (!in_array($inputType, ['datetime', 'select', 'multiple', 'checkbox', 'radio'])) {
+            return;
+        }
+        $attrField = ucfirst($this->getCamelizeName($field));
+        $getAttr[] = $this->getReplacedStub("mixins" . DS . $inputType, ['field' => $field, 'methodName' => "get{$attrField}TextAttr", 'listMethodName' => "get{$attrField}List"]);
+    }
+
+    protected function setAttr(&$setAttr, $field, $inputType = '')
+    {
+        if (!in_array($inputType, ['datetime', 'checkbox', 'select'])) {
+            return;
+        }
+        $attrField = ucfirst($this->getCamelizeName($field));
+        if ($inputType == 'datetime') {
+            $return = <<<EOD
+return \$value === '' ? null : (\$value && !is_numeric(\$value) ? strtotime(\$value) : \$value);
+EOD;
+        } elseif (in_array($inputType, ['checkbox', 'select'])) {
+            $return = <<<EOD
+return is_array(\$value) ? implode(',', \$value) : \$value;
+EOD;
+        }
+        $setAttr[] = <<<EOD
+    protected function set{$attrField}Attr(\$value)
+    {
+        $return
+    }
+EOD;
+    }
+
+    protected function appendAttr(&$appendAttrList, $field)
+    {
+        $appendAttrList[] = <<<EOD
+        '{$field}_text'
+EOD;
+    }
+
+    /**
+     * 移除相对的空目录
+     * @param $parseFile
+     * @param $parseArr
+     * @return bool
+     */
+    protected function removeEmptyBaseDir($parseFile, $parseArr)
+    {
+        if (count($parseArr) > 1) {
+            $parentDir = dirname($parseFile);
+            for ($i = 0; $i < count($parseArr); $i++) {
+                try {
+                    $iterator = new \FilesystemIterator($parentDir);
+                    $isDirEmpty = !$iterator->valid();
+                    if ($isDirEmpty) {
+                        rmdir($parentDir);
+                        $parentDir = dirname($parentDir);
+                    } else {
+                        return true;
+                    }
+                } catch (\UnexpectedValueException $e) {
+                    return false;
+                }
+            }
+        }
+        return true;
+    }
+
+    /**
+     * 获取控制器相关信息
+     * @param $module
+     * @param $controller
+     * @param $table
+     * @return array
+     */
+    protected function getControllerData($module, $controller, $table)
+    {
+        return $this->getParseNameData($module, $controller, $table, 'controller');
+    }
+
+    /**
+     * 获取模型相关信息
+     * @param $module
+     * @param $model
+     * @param $table
+     * @return array
+     */
+    protected function getModelData($module, $model, $table)
+    {
+        return $this->getParseNameData($module, $model, $table, 'model');
+    }
+
+    /**
+     * 获取验证器相关信息
+     * @param $module
+     * @param $validate
+     * @param $table
+     * @return array
+     */
+    protected function getValidateData($module, $validate, $table)
+    {
+        return $this->getParseNameData($module, $validate, $table, 'validate');
+    }
+
+    /**
+     * 获取已解析相关信息
+     * @param string $module 模块名称
+     * @param string $name   自定义名称
+     * @param string $table  数据表名
+     * @param string $type   解析类型,本例中为controller、model、validate
+     * @return array
+     */
+    protected function getParseNameData($module, $name, $table, $type)
+    {
+        $arr = [];
+        if (!$name) {
+            $parseName = Loader::parseName($table, 1);
+            $parseArr = [$table];
+        } else {
+            $name = str_replace(['.', '/', '\\'], '/', $name);
+            $arr = explode('/', $name);
+            $parseName = ucfirst(array_pop($arr));
+            $parseArr = $arr;
+            array_push($parseArr, $parseName);
+        }
+        //类名不能为内部关键字
+        if (in_array(strtolower($parseName), $this->internalKeywords)) {
+            throw new Exception('Unable to use internal variable:' . $parseName);
+        }
+        $appNamespace = Config::get('app_namespace');
+        $parseNamespace = "{$appNamespace}\\{$module}\\{$type}" . ($arr ? "\\" . implode("\\", $arr) : "");
+        $moduleDir = APP_PATH . $module . DS;
+        $parseFile = $moduleDir . $type . DS . ($arr ? implode(DS, $arr) . DS : '') . $parseName . '.php';
+        return [$parseNamespace, $parseName, $parseFile, $parseArr];
+    }
+
+    /**
+     * 写入到文件
+     * @param string $name
+     * @param array  $data
+     * @param string $pathname
+     * @return mixed
+     */
+    protected function writeToFile($name, $data, $pathname)
+    {
+        foreach ($data as $index => &$datum) {
+            $datum = is_array($datum) ? '' : $datum;
+        }
+        unset($datum);
+        $content = $this->getReplacedStub($name, $data);
+
+        if (!is_dir(dirname($pathname))) {
+            mkdir(dirname($pathname), 0755, true);
+        }
+        return file_put_contents($pathname, $content);
+    }
+
+    /**
+     * 获取替换后的数据
+     * @param string $name
+     * @param array  $data
+     * @return string
+     */
+    protected function getReplacedStub($name, $data)
+    {
+        foreach ($data as $index => &$datum) {
+            $datum = is_array($datum) ? '' : $datum;
+        }
+        unset($datum);
+        $search = $replace = [];
+        foreach ($data as $k => $v) {
+            $search[] = "{%{$k}%}";
+            $replace[] = $v;
+        }
+        $stubname = $this->getStub($name);
+        if (isset($this->stubList[$stubname])) {
+            $stub = $this->stubList[$stubname];
+        } else {
+            $this->stubList[$stubname] = $stub = file_get_contents($stubname);
+        }
+        $content = str_replace($search, $replace, $stub);
+        return $content;
+    }
+
+    /**
+     * 获取基础模板
+     * @param string $name
+     * @return string
+     */
+    protected function getStub($name)
+    {
+        return __DIR__ . DS . 'Crud' . DS . 'stubs' . DS . $name . '.stub';
+    }
+
+    protected function getLangItem($field, $content)
+    {
+        if ($content || !Lang::has($field)) {
+            $this->fieldMaxLen = strlen($field) > $this->fieldMaxLen ? strlen($field) : $this->fieldMaxLen;
+            $content = str_replace(',', ',', $content);
+            if (stripos($content, ':') !== false && stripos($content, ',') && stripos($content, '=') !== false) {
+                list($fieldLang, $item) = explode(':', $content);
+                $itemArr = [$field => $fieldLang];
+                foreach (explode(',', $item) as $k => $v) {
+                    $valArr = explode('=', $v);
+                    if (count($valArr) == 2) {
+                        list($key, $value) = $valArr;
+                        $itemArr[$field . ' ' . $key] = $value;
+                        $this->fieldMaxLen = strlen($field . ' ' . $key) > $this->fieldMaxLen ? strlen($field . ' ' . $key) : $this->fieldMaxLen;
+                    }
+                }
+            } else {
+                $itemArr = [$field => $content];
+            }
+            $resultArr = [];
+            foreach ($itemArr as $k => $v) {
+                $resultArr[] = "    '" . mb_ucfirst($k) . "' => '{$v}'";
+            }
+            return implode(",\n", $resultArr);
+        } else {
+            return '';
+        }
+    }
+
+    /**
+     * 读取数据和语言数组列表
+     * @param array   $arr
+     * @param boolean $withTpl
+     * @return array
+     */
+    protected function getLangArray($arr, $withTpl = true)
+    {
+        $langArr = [];
+        foreach ($arr as $k => $v) {
+            $langArr[$k] = is_numeric($k) ? ($withTpl ? "{:" : "") . "__('" . mb_ucfirst($v) . "')" . ($withTpl ? "}" : "") : $v;
+        }
+        return $langArr;
+    }
+
+    /**
+     * 将数据转换成带字符串
+     * @param array $arr
+     * @return string
+     */
+    protected function getArrayString($arr)
+    {
+        if (!is_array($arr)) {
+            return $arr;
+        }
+        $stringArr = [];
+        foreach ($arr as $k => $v) {
+            $is_var = in_array(substr($v, 0, 1), ['$', '_']);
+            if (!$is_var) {
+                $v = str_replace("'", "\'", $v);
+                $k = str_replace("'", "\'", $k);
+            }
+            $stringArr[] = "'" . $k . "' => " . ($is_var ? $v : "'{$v}'");
+        }
+        return implode(", ", $stringArr);
+    }
+
+    protected function getItemArray($item, $field, $comment)
+    {
+        $itemArr = [];
+        $comment = str_replace(',', ',', $comment);
+        if (stripos($comment, ':') !== false && stripos($comment, ',') && stripos($comment, '=') !== false) {
+            list($fieldLang, $item) = explode(':', $comment);
+            $itemArr = [];
+            foreach (explode(',', $item) as $k => $v) {
+                $valArr = explode('=', $v);
+                if (count($valArr) == 2) {
+                    list($key, $value) = $valArr;
+                    $itemArr[$key] = $field . ' ' . $key;
+                }
+            }
+        } else {
+            foreach ($item as $k => $v) {
+                $itemArr[$v] = is_numeric($v) ? $field . ' ' . $v : $v;
+            }
+        }
+        return $itemArr;
+    }
+
+    protected function getFieldType(& $v)
+    {
+        $inputType = 'text';
+        switch ($v['DATA_TYPE']) {
+            case 'bigint':
+            case 'int':
+            case 'mediumint':
+            case 'smallint':
+            case 'tinyint':
+                $inputType = 'number';
+                break;
+            case 'enum':
+            case 'set':
+                $inputType = 'select';
+                break;
+            case 'decimal':
+            case 'double':
+            case 'float':
+                $inputType = 'number';
+                break;
+            case 'longtext':
+            case 'text':
+            case 'mediumtext':
+            case 'smalltext':
+            case 'tinytext':
+                $inputType = 'textarea';
+                break;
+            case 'year':
+            case 'date':
+            case 'time':
+            case 'datetime':
+            case 'timestamp':
+                $inputType = 'datetime';
+                break;
+            default:
+                break;
+        }
+        $fieldsName = $v['COLUMN_NAME'];
+        // 指定后缀说明也是个时间字段
+        if ($this->isMatchSuffix($fieldsName, $this->intDateSuffix)) {
+            $inputType = 'datetime';
+        }
+        // 指定后缀结尾且类型为enum,说明是个单选框
+        if ($this->isMatchSuffix($fieldsName, $this->enumRadioSuffix) && $v['DATA_TYPE'] == 'enum') {
+            $inputType = "radio";
+        }
+        // 指定后缀结尾且类型为set,说明是个复选框
+        if ($this->isMatchSuffix($fieldsName, $this->setCheckboxSuffix) && $v['DATA_TYPE'] == 'set') {
+            $inputType = "checkbox";
+        }
+        // 指定后缀结尾且类型为char或tinyint且长度为1,说明是个Switch复选框
+        if ($this->isMatchSuffix($fieldsName, $this->switchSuffix) && ($v['COLUMN_TYPE'] == 'tinyint(1)' || $v['COLUMN_TYPE'] == 'char(1)') && $v['COLUMN_DEFAULT'] !== '' && $v['COLUMN_DEFAULT'] !== null) {
+            $inputType = "switch";
+        }
+        // 指定后缀结尾城市选择框
+        if ($this->isMatchSuffix($fieldsName, $this->citySuffix) && ($v['DATA_TYPE'] == 'varchar' || $v['DATA_TYPE'] == 'char')) {
+            $inputType = "citypicker";
+        }
+        // 指定后缀结尾JSON配置
+        if ($this->isMatchSuffix($fieldsName, $this->jsonSuffix) && ($v['DATA_TYPE'] == 'varchar' || $v['DATA_TYPE'] == 'text')) {
+            $inputType = "fieldlist";
+        }
+        return $inputType;
+    }
+
+    /**
+     * 判断是否符合指定后缀
+     * @param string $field     字段名称
+     * @param mixed  $suffixArr 后缀
+     * @return boolean
+     */
+    protected function isMatchSuffix($field, $suffixArr)
+    {
+        $suffixArr = is_array($suffixArr) ? $suffixArr : explode(',', $suffixArr);
+        foreach ($suffixArr as $k => $v) {
+            if (preg_match("/{$v}$/i", $field)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 获取表单分组数据
+     * @param string $field
+     * @param string $content
+     * @return string
+     */
+    protected function getFormGroup($field, $content)
+    {
+        $langField = mb_ucfirst($field);
+        return <<<EOD
+    <div class="form-group">
+        <label class="control-label col-xs-12 col-sm-2">{:__('{$langField}')}:</label>
+        <div class="col-xs-12 col-sm-8">
+            {$content}
+        </div>
+    </div>
+EOD;
+    }
+
+    /**
+     * 获取图片模板数据
+     * @param string $field
+     * @param string $content
+     * @return string
+     */
+    protected function getImageUpload($field, $content)
+    {
+        $uploadfilter = $selectfilter = '';
+        if ($this->isMatchSuffix($field, $this->imageField)) {
+            $uploadfilter = ' data-mimetype="image/gif,image/jpeg,image/png,image/jpg,image/bmp"';
+            $selectfilter = ' data-mimetype="image/*"';
+        }
+        $multiple = substr($field, -1) == 's' ? ' data-multiple="true"' : ' data-multiple="false"';
+        $preview = ' data-preview-id="p-' . $field . '"';
+        $previewcontainer = $preview ? '<ul class="row list-inline faupload-preview" id="p-' . $field . '"></ul>' : '';
+        return <<<EOD
+<div class="input-group">
+                {$content}
+                <div class="input-group-addon no-border no-padding">
+                    <span><button type="button" id="faupload-{$field}" class="btn btn-danger faupload" data-input-id="c-{$field}"{$uploadfilter}{$multiple}{$preview}><i class="fa fa-upload"></i> {:__('Upload')}</button></span>
+                    <span><button type="button" id="fachoose-{$field}" class="btn btn-primary fachoose" data-input-id="c-{$field}"{$selectfilter}{$multiple}><i class="fa fa-list"></i> {:__('Choose')}</button></span>
+                </div>
+                <span class="msg-box n-right" for="c-{$field}"></span>
+            </div>
+            {$previewcontainer}
+EOD;
+    }
+
+    /**
+     * 获取JS列数据
+     * @param string $field
+     * @param string $datatype
+     * @param string $extend
+     * @param array  $itemArr
+     * @return string
+     */
+    protected function getJsColumn($field, $datatype = '', $extend = '', $itemArr = [])
+    {
+        $lang = mb_ucfirst($field);
+        $formatter = '';
+        foreach ($this->fieldFormatterSuffix as $k => $v) {
+            if (preg_match("/{$k}$/i", $field)) {
+                if (is_array($v)) {
+                    if (in_array($datatype, $v['type'])) {
+                        $formatter = $v['name'];
+                        break;
+                    }
+                } else {
+                    $formatter = $v;
+                    break;
+                }
+            }
+        }
+        $html = str_repeat(" ", 24) . "{field: '{$field}', title: __('{$lang}')";
+
+        if ($datatype == 'set') {
+            $formatter = 'label';
+        }
+        foreach ($itemArr as $k => &$v) {
+            if (substr($v, 0, 3) !== '__(') {
+                $v = "__('" . mb_ucfirst($v) . "')";
+            }
+        }
+        unset($v);
+        $searchList = json_encode($itemArr, JSON_FORCE_OBJECT | JSON_UNESCAPED_UNICODE);
+        $searchList = str_replace(['":"', '"}', ')","'], ['":', '}', '),"'], $searchList);
+        if ($itemArr) {
+            $html .= ", searchList: " . $searchList;
+        }
+
+        // 文件、图片、权重等字段默认不加入搜索栏,字符串类型默认LIKE
+        $noSearchFiles = ['file$', 'files$', 'image$', 'images$', '^weigh$'];
+        if (preg_match("/" . implode('|', $noSearchFiles) . "/i", $field)) {
+            $html .= ", operate: false";
+        } else if (in_array($datatype, ['varchar'])) {
+            $html .= ", operate: 'LIKE'";
+        }
+
+        if (in_array($datatype, ['date', 'datetime']) || $formatter === 'datetime') {
+            $html .= ", operate:'RANGE', addclass:'datetimerange', autocomplete:false";
+        } elseif (in_array($datatype, ['float', 'double', 'decimal'])) {
+            $html .= ", operate:'BETWEEN'";
+        }
+        if (in_array($datatype, ['set'])) {
+            $html .= ", operate:'FIND_IN_SET'";
+        }
+        if (in_array($formatter, ['image', 'images'])) {
+            $html .= ", events: Table.api.events.image";
+        }
+        if (in_array($formatter, ['toggle'])) {
+            $html .= ", table: table";
+        }
+        if ($itemArr && !$formatter) {
+            $formatter = 'normal';
+        }
+        if ($formatter) {
+            $html .= ", formatter: Table.api.formatter." . $formatter . "}";
+        } else {
+            $html .= "}";
+        }
+        return $html;
+    }
+
+    protected function getCamelizeName($uncamelized_words, $separator = '_')
+    {
+        $uncamelized_words = $separator . str_replace($separator, " ", strtolower($uncamelized_words));
+        return ltrim(str_replace(" ", "", ucwords($uncamelized_words)), $separator);
+    }
+
+    protected function getFieldListName($field)
+    {
+        return $this->getCamelizeName($field) . 'List';
+    }
+}

+ 11 - 0
application/admin/command/Crud/stubs/add.stub

@@ -0,0 +1,11 @@
+<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
+
+{%addList%}
+    <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>
+</form>

+ 40 - 0
application/admin/command/Crud/stubs/controller.stub

@@ -0,0 +1,40 @@
+<?php
+
+namespace {%controllerNamespace%};
+
+use app\common\controller\Backend;
+
+/**
+ * {%tableComment%}
+ *
+ * @icon {%iconName%}
+ */
+class {%controllerName%} extends Backend
+{
+    
+    /**
+     * {%modelName%}模型对象
+     * @var \{%modelNamespace%}\{%modelName%}
+     */
+    protected $model = null;
+
+    public function _initialize()
+    {
+        parent::_initialize();
+        $this->model = new \{%modelNamespace%}\{%modelName%};
+{%controllerAssignList%}
+    }
+
+    public function import()
+    {
+        parent::import();
+    }
+
+    /**
+     * 默认生成的控制器所继承的父类中有index/add/edit/del/multi五个基础方法、destroy/restore/recyclebin三个回收站方法
+     * 因此在当前控制器中可不用编写增删改查的代码,除非需要自己控制这部分逻辑
+     * 需要将application/admin/library/traits/Backend.php中对应的方法复制到当前控制器,然后进行修改
+     */
+    
+{%controllerIndex%}
+}

+ 34 - 0
application/admin/command/Crud/stubs/controllerindex.stub

@@ -0,0 +1,34 @@
+
+    /**
+     * 查看
+     */
+    public function index()
+    {
+        //当前是否为关联查询
+        $this->relationSearch = {%relationSearch%};
+        //设置过滤方法
+        $this->request->filter(['strip_tags', 'trim']);
+        if ($this->request->isAjax()) {
+            //如果发送的来源是Selectpage,则转发到Selectpage
+            if ($this->request->request('keyField')) {
+                return $this->selectpage();
+            }
+            list($where, $sort, $order, $offset, $limit) = $this->buildparams();
+
+            $list = $this->model
+                    {%relationWithList%}
+                    ->where($where)
+                    ->order($sort, $order)
+                    ->paginate($limit);
+
+            foreach ($list as $row) {
+                {%visibleFieldList%}
+                {%relationVisibleFieldList%}
+            }
+
+            $result = array("total" => $list->total(), "rows" => $list->items());
+
+            return json($result);
+        }
+        return $this->view->fetch();
+    }

+ 11 - 0
application/admin/command/Crud/stubs/edit.stub

@@ -0,0 +1,11 @@
+<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
+
+{%editList%}
+    <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>
+</form>

+ 6 - 0
application/admin/command/Crud/stubs/html/checkbox.stub

@@ -0,0 +1,6 @@
+
+            <div class="checkbox">
+            {foreach name="{%fieldList%}" item="vo"}
+            <label for="{%fieldName%}-{$key}"><input id="{%fieldName%}-{$key}" name="{%fieldName%}" type="checkbox" value="{$key}" {in name="key" value="{%selectedValue%}"}checked{/in} /> {$vo}</label> 
+            {/foreach}
+            </div>

+ 10 - 0
application/admin/command/Crud/stubs/html/fieldlist.stub

@@ -0,0 +1,10 @@
+
+            <dl class="fieldlist" data-name="{%fieldName%}">
+                <dd>
+                    <ins>{:__('{%itemKey%}')}</ins>
+                    <ins>{:__('{%itemValue%}')}</ins>
+                </dd>
+                <dd><a href="javascript:;" class="btn btn-sm btn-success btn-append"><i class="fa fa-plus"></i> {:__('Append')}</a></dd>
+                <textarea name="{%fieldName%}" class="form-control hide" cols="30" rows="5">{%fieldValue%}</textarea>
+            </dl>
+

+ 10 - 0
application/admin/command/Crud/stubs/html/heading-html.stub

@@ -0,0 +1,10 @@
+
+    <div class="panel-heading">
+        {:build_heading(null,FALSE)}
+        <ul class="nav nav-tabs" data-field="{%field%}">
+            <li class="{:$Think.get.{%field%} === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
+            {foreach name="{%fieldName%}List" item="vo"}
+            <li class="{:$Think.get.{%field%} === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key}" data-toggle="tab">{$vo}</a></li>
+            {/foreach}
+        </ul>
+    </div>

+ 6 - 0
application/admin/command/Crud/stubs/html/radio.stub

@@ -0,0 +1,6 @@
+
+            <div class="radio">
+            {foreach name="{%fieldList%}" item="vo"}
+            <label for="{%fieldName%}-{$key}"><input id="{%fieldName%}-{$key}" name="{%fieldName%}" type="radio" value="{$key}" {in name="key" value="{%selectedValue%}"}checked{/in} /> {$vo}</label> 
+            {/foreach}
+            </div>

+ 1 - 0
application/admin/command/Crud/stubs/html/recyclebin-html.stub

@@ -0,0 +1 @@
+<a class="btn btn-success btn-recyclebin btn-dialog {:$auth->check('{%controllerUrl%}/recyclebin')?'':'hide'}" href="{%controllerUrl%}/recyclebin" title="{:__('Recycle bin')}"><i class="fa fa-recycle"></i> {:__('Recycle bin')}</a>

+ 6 - 0
application/admin/command/Crud/stubs/html/select.stub

@@ -0,0 +1,6 @@
+            
+            <select {%attrStr%}>
+                {foreach name="{%fieldList%}" item="vo"}
+                    <option value="{$key}" {in name="key" value="{%selectedValue%}"}selected{/in}>{$vo}</option>
+                {/foreach}
+            </select>

+ 5 - 0
application/admin/command/Crud/stubs/html/switch.stub

@@ -0,0 +1,5 @@
+
+            <input {%attrStr%} name="{%fieldName%}" type="hidden" value="{%fieldValue%}">
+            <a href="javascript:;" data-toggle="switcher" class="btn-switcher" data-input-id="c-{%field%}" data-yes="{%fieldYes%}" data-no="{%fieldNo%}" >
+                <i class="fa fa-toggle-on text-success {%fieldSwitchClass%} fa-2x"></i>
+            </a>

+ 35 - 0
application/admin/command/Crud/stubs/index.stub

@@ -0,0 +1,35 @@
+<div class="panel panel-default panel-intro">
+    {%headingHtml%}
+
+    <div class="panel-body">
+        <div id="myTabContent" class="tab-content">
+            <div class="tab-pane fade active in" id="one">
+                <div class="widget-body no-padding">
+                    <div id="toolbar" class="toolbar">
+                        <a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}" ><i class="fa fa-refresh"></i> </a>
+                        <a href="javascript:;" class="btn btn-success btn-add {:$auth->check('{%controllerUrl%}/add')?'':'hide'}" title="{:__('Add')}" ><i class="fa fa-plus"></i> {:__('Add')}</a>
+                        <a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('{%controllerUrl%}/edit')?'':'hide'}" title="{:__('Edit')}" ><i class="fa fa-pencil"></i> {:__('Edit')}</a>
+                        <a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('{%controllerUrl%}/del')?'':'hide'}" title="{:__('Delete')}" ><i class="fa fa-trash"></i> {:__('Delete')}</a>
+                        <a href="javascript:;" class="btn btn-danger btn-import {:$auth->check('{%controllerUrl%}/import')?'':'hide'}" title="{:__('Import')}" id="btn-import-file" data-url="ajax/upload" data-mimetype="csv,xls,xlsx" data-multiple="false"><i class="fa fa-upload"></i> {:__('Import')}</a>
+
+                        <div class="dropdown btn-group {:$auth->check('{%controllerUrl%}/multi')?'':'hide'}">
+                            <a class="btn btn-primary btn-more dropdown-toggle btn-disabled disabled" data-toggle="dropdown"><i class="fa fa-cog"></i> {:__('More')}</a>
+                            <ul class="dropdown-menu text-left" role="menu">
+                                <li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:;" data-params="status=normal"><i class="fa fa-eye"></i> {:__('Set to normal')}</a></li>
+                                <li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:;" data-params="status=hidden"><i class="fa fa-eye-slash"></i> {:__('Set to hidden')}</a></li>
+                            </ul>
+                        </div>
+
+                        {%recyclebinHtml%}
+                    </div>
+                    <table id="table" class="table table-striped table-bordered table-hover table-nowrap"
+                           data-operate-edit="{:$auth->check('{%controllerUrl%}/edit')}" 
+                           data-operate-del="{:$auth->check('{%controllerUrl%}/del')}" 
+                           width="100%">
+                    </table>
+                </div>
+            </div>
+
+        </div>
+    </div>
+</div>

+ 48 - 0
application/admin/command/Crud/stubs/javascript.stub

@@ -0,0 +1,48 @@
+define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
+
+    var Controller = {
+        index: function () {
+            // 初始化表格参数配置
+            Table.api.init({
+                extend: {
+                    index_url: '{%controllerUrl%}/index' + location.search,
+                    add_url: '{%controllerUrl%}/add',
+                    edit_url: '{%controllerUrl%}/edit',
+                    del_url: '{%controllerUrl%}/del',
+                    multi_url: '{%controllerUrl%}/multi',
+                    import_url: '{%controllerUrl%}/import',
+                    table: '{%table%}',
+                }
+            });
+
+            var table = $("#table");
+
+            // 初始化表格
+            table.bootstrapTable({
+                url: $.fn.bootstrapTable.defaults.extend.index_url,
+                pk: '{%pk%}',
+                sortName: '{%order%}',
+                columns: [
+                    [
+                        {%javascriptList%}
+                    ]
+                ]
+            });
+
+            // 为表格绑定事件
+            Table.api.bindevent(table);
+        },{%recyclebinJs%}
+        add: function () {
+            Controller.api.bindevent();
+        },
+        edit: function () {
+            Controller.api.bindevent();
+        },
+        api: {
+            bindevent: function () {
+                Form.api.bindevent($("form[role=form]"));
+            }
+        }
+    };
+    return Controller;
+});

+ 5 - 0
application/admin/command/Crud/stubs/lang.stub

@@ -0,0 +1,5 @@
+<?php
+
+return [
+{%langList%}
+];

+ 8 - 0
application/admin/command/Crud/stubs/mixins/checkbox.stub

@@ -0,0 +1,8 @@
+
+    public function {%methodName%}($value, $data)
+    {
+        $value = $value ? $value : (isset($data['{%field%}']) ? $data['{%field%}'] : '');
+        $valueArr = explode(',', $value);
+        $list = $this->{%listMethodName%}();
+        return implode(',', array_intersect_key($list, array_flip($valueArr)));
+    }

+ 6 - 0
application/admin/command/Crud/stubs/mixins/datetime.stub

@@ -0,0 +1,6 @@
+
+    public function {%methodName%}($value, $data)
+    {
+        $value = $value ? $value : (isset($data['{%field%}']) ? $data['{%field%}'] : '');
+        return is_numeric($value) ? date("Y-m-d H:i:s", $value) : $value;
+    }

+ 1 - 0
application/admin/command/Crud/stubs/mixins/enum.stub

@@ -0,0 +1 @@
+

+ 8 - 0
application/admin/command/Crud/stubs/mixins/modelinit.stub

@@ -0,0 +1,8 @@
+
+    protected static function init()
+    {
+        self::afterInsert(function ($row) {
+            $pk = $row->getPk();
+            $row->getQuery()->where($pk, $row[$pk])->update(['{%order%}' => $row[$pk]]);
+        });
+    }

+ 5 - 0
application/admin/command/Crud/stubs/mixins/modelrelationmethod.stub

@@ -0,0 +1,5 @@
+
+    public function {%relationMethod%}()
+    {
+        return $this->{%relationMode%}('{%relationClassName%}', '{%relationForeignKey%}', '{%relationPrimaryKey%}', [], 'LEFT')->setEagerlyType(0);
+    }

+ 8 - 0
application/admin/command/Crud/stubs/mixins/multiple.stub

@@ -0,0 +1,8 @@
+
+    public function {%methodName%}($value, $data)
+    {
+        $value = $value ? $value : (isset($data['{%field%}']) ? $data['{%field%}'] : '');
+        $valueArr = explode(',', $value);
+        $list = $this->{%listMethodName%}();
+        return implode(',', array_intersect_key($list, array_flip($valueArr)));
+    }

+ 7 - 0
application/admin/command/Crud/stubs/mixins/radio.stub

@@ -0,0 +1,7 @@
+
+    public function {%methodName%}($value, $data)
+    {
+        $value = $value ? $value : (isset($data['{%field%}']) ? $data['{%field%}'] : '');
+        $list = $this->{%listMethodName%}();
+        return isset($list[$value]) ? $list[$value] : '';
+    }

+ 60 - 0
application/admin/command/Crud/stubs/mixins/recyclebinjs.stub

@@ -0,0 +1,60 @@
+
+        recyclebin: function () {
+            // 初始化表格参数配置
+            Table.api.init({
+                extend: {
+                    'dragsort_url': ''
+                }
+            });
+
+            var table = $("#table");
+
+            // 初始化表格
+            table.bootstrapTable({
+                url: '{%controllerUrl%}/recyclebin' + location.search,
+                pk: 'id',
+                sortName: 'id',
+                columns: [
+                    [
+                        {checkbox: true},
+                        {field: 'id', title: __('Id')},{%recyclebinTitleJs%}
+                        {
+                            field: '{%deleteTimeField%}',
+                            title: __('Deletetime'),
+                            operate: 'RANGE',
+                            addclass: 'datetimerange',
+                            formatter: Table.api.formatter.datetime
+                        },
+                        {
+                            field: 'operate',
+                            width: '130px',
+                            title: __('Operate'),
+                            table: table,
+                            events: Table.api.events.operate,
+                            buttons: [
+                                {
+                                    name: 'Restore',
+                                    text: __('Restore'),
+                                    classname: 'btn btn-xs btn-info btn-ajax btn-restoreit',
+                                    icon: 'fa fa-rotate-left',
+                                    url: '{%controllerUrl%}/restore',
+                                    refresh: true
+                                },
+                                {
+                                    name: 'Destroy',
+                                    text: __('Destroy'),
+                                    classname: 'btn btn-xs btn-danger btn-ajax btn-destroyit',
+                                    icon: 'fa fa-times',
+                                    url: '{%controllerUrl%}/destroy',
+                                    refresh: true
+                                }
+                            ],
+                            formatter: Table.api.formatter.operate
+                        }
+                    ]
+                ]
+            });
+
+            // 为表格绑定事件
+            Table.api.bindevent(table);
+        },

+ 7 - 0
application/admin/command/Crud/stubs/mixins/select.stub

@@ -0,0 +1,7 @@
+
+    public function {%methodName%}($value, $data)
+    {
+        $value = $value ? $value : (isset($data['{%field%}']) ? $data['{%field%}'] : '');
+        $list = $this->{%listMethodName%}();
+        return isset($list[$value]) ? $list[$value] : '';
+    }

+ 40 - 0
application/admin/command/Crud/stubs/model.stub

@@ -0,0 +1,40 @@
+<?php
+
+namespace {%modelNamespace%};
+
+use think\Model;
+{%softDeleteClassPath%}
+
+class {%modelName%} extends Model
+{
+
+    {%softDelete%}
+
+    {%modelConnection%}
+
+    // 表名
+    protected ${%modelTableType%} = '{%modelTableTypeName%}';
+    
+    // 自动写入时间戳字段
+    protected $autoWriteTimestamp = {%modelAutoWriteTimestamp%};
+
+    // 定义时间戳字段名
+    protected $createTime = {%createTime%};
+    protected $updateTime = {%updateTime%};
+    protected $deleteTime = {%deleteTime%};
+
+    // 追加属性
+    protected $append = [
+{%appendAttrList%}
+    ];
+    
+{%modelInit%}
+    
+{%getEnumList%}
+
+{%getAttrList%}
+
+{%setAttrList%}
+
+{%relationMethodList%}
+}

+ 25 - 0
application/admin/command/Crud/stubs/recyclebin.stub

@@ -0,0 +1,25 @@
+<div class="panel panel-default panel-intro">
+    {:build_heading()}
+
+    <div class="panel-body">
+        <div id="myTabContent" class="tab-content">
+            <div class="tab-pane fade active in" id="one">
+                <div class="widget-body no-padding">
+                    <div id="toolbar" class="toolbar">
+                        {:build_toolbar('refresh')}
+                        <a class="btn btn-info btn-multi btn-disabled disabled {:$auth->check('{%controllerUrl%}/restore')?'':'hide'}" href="javascript:;" data-url="{%controllerUrl%}/restore" data-action="restore"><i class="fa fa-rotate-left"></i> {:__('Restore')}</a>
+                        <a class="btn btn-danger btn-multi btn-disabled disabled {:$auth->check('{%controllerUrl%}/destroy')?'':'hide'}" href="javascript:;" data-url="{%controllerUrl%}/destroy" data-action="destroy"><i class="fa fa-times"></i> {:__('Destroy')}</a>
+                        <a class="btn btn-success btn-restoreall {:$auth->check('{%controllerUrl%}/restore')?'':'hide'}" href="javascript:;" data-url="{%controllerUrl%}/restore" title="{:__('Restore all')}"><i class="fa fa-rotate-left"></i> {:__('Restore all')}</a>
+                        <a class="btn btn-danger btn-destroyall {:$auth->check('{%controllerUrl%}/destroy')?'':'hide'}" href="javascript:;" data-url="{%controllerUrl%}/destroy" title="{:__('Destroy all')}"><i class="fa fa-times"></i> {:__('Destroy all')}</a>
+                    </div>
+                    <table id="table" class="table table-striped table-bordered table-hover"
+                           data-operate-restore="{:$auth->check('{%controllerUrl%}/restore')}"
+                           data-operate-destroy="{:$auth->check('{%controllerUrl%}/destroy')}"
+                           width="100%">
+                    </table>
+                </div>
+            </div>
+
+        </div>
+    </div>
+</div>

+ 12 - 0
application/admin/command/Crud/stubs/relationmodel.stub

@@ -0,0 +1,12 @@
+<?php
+
+namespace {%modelNamespace%};
+
+use think\Model;
+
+class {%relationName%} extends Model
+{
+    // 表名
+    protected ${%relationTableType%} = '{%relationTableTypeName%}';
+    
+}

+ 27 - 0
application/admin/command/Crud/stubs/validate.stub

@@ -0,0 +1,27 @@
+<?php
+
+namespace {%validateNamespace%};
+
+use think\Validate;
+
+class {%validateName%} extends Validate
+{
+    /**
+     * 验证规则
+     */
+    protected $rule = [
+    ];
+    /**
+     * 提示消息
+     */
+    protected $message = [
+    ];
+    /**
+     * 验证场景
+     */
+    protected $scene = [
+        'add'  => [],
+        'edit' => [],
+    ];
+    
+}

+ 314 - 0
application/admin/command/Install.php

@@ -0,0 +1,314 @@
+<?php
+
+namespace app\admin\command;
+
+use fast\Random;
+use PDO;
+use think\Config;
+use think\console\Command;
+use think\console\Input;
+use think\console\input\Option;
+use think\console\Output;
+use think\Db;
+use think\Exception;
+use think\Lang;
+use think\Request;
+use think\View;
+
+class Install extends Command
+{
+    protected $model = null;
+    /**
+     * @var \think\View 视图类实例
+     */
+    protected $view;
+
+    /**
+     * @var \think\Request Request 实例
+     */
+    protected $request;
+
+    protected function configure()
+    {
+        $config = Config::get('database');
+        $this
+            ->setName('install')
+            ->addOption('hostname', 'a', Option::VALUE_OPTIONAL, 'mysql hostname', $config['hostname'])
+            ->addOption('hostport', 'o', Option::VALUE_OPTIONAL, 'mysql hostport', $config['hostport'])
+            ->addOption('database', 'd', Option::VALUE_OPTIONAL, 'mysql database', $config['database'])
+            ->addOption('prefix', 'r', Option::VALUE_OPTIONAL, 'table prefix', $config['prefix'])
+            ->addOption('username', 'u', Option::VALUE_OPTIONAL, 'mysql username', $config['username'])
+            ->addOption('password', 'p', Option::VALUE_OPTIONAL, 'mysql password', $config['password'])
+            ->addOption('force', 'f', Option::VALUE_OPTIONAL, 'force override', false)
+            ->setDescription('New installation of FastAdmin');
+    }
+
+    /**
+     * 命令行安装
+     */
+    protected function execute(Input $input, Output $output)
+    {
+        define('INSTALL_PATH', APP_PATH . 'admin' . DS . 'command' . DS . 'Install' . DS);
+        // 覆盖安装
+        $force = $input->getOption('force');
+        $hostname = $input->getOption('hostname');
+        $hostport = $input->getOption('hostport');
+        $database = $input->getOption('database');
+        $prefix = $input->getOption('prefix');
+        $username = $input->getOption('username');
+        $password = $input->getOption('password');
+
+        $installLockFile = INSTALL_PATH . "install.lock";
+        if (is_file($installLockFile) && !$force) {
+            throw new Exception("\nFastAdmin already installed!\nIf you need to reinstall again, use the parameter --force=true ");
+        }
+
+        $adminUsername = 'admin';
+        $adminPassword = Random::alnum(10);
+        $adminEmail = 'admin@admin.com';
+        $siteName = __('My Website');
+
+        $adminName = $this->installation($hostname, $hostport, $database, $username, $password, $prefix, $adminUsername, $adminPassword, $adminEmail, $siteName);
+        if ($adminName) {
+            $output->highlight("Admin url:http://www.yoursite.com/{$adminName}");
+        }
+
+        $output->highlight("Admin username:{$adminUsername}");
+        $output->highlight("Admin password:{$adminPassword}");
+
+        \think\Cache::rm('__menu__');
+
+        $output->info("Install Successed!");
+    }
+
+    /**
+     * PC端安装
+     */
+    public function index()
+    {
+        $this->view = View::instance(Config::get('template'), Config::get('view_replace_str'));
+        $this->request = Request::instance();
+
+        define('INSTALL_PATH', APP_PATH . 'admin' . DS . 'command' . DS . 'Install' . DS);
+        $langSet = strtolower($this->request->langset());
+        if (!$langSet || in_array($langSet, ['zh-cn', 'zh-hans-cn'])) {
+            Lang::load(INSTALL_PATH . 'zh-cn.php');
+        }
+
+        $installLockFile = INSTALL_PATH . "install.lock";
+
+        if (is_file($installLockFile)) {
+            echo __('The system has been installed. If you need to reinstall, please remove %s first', 'install.lock');
+            exit;
+        }
+        $output = function ($code, $msg, $url = null, $data = null) {
+            return json(['code' => $code, 'msg' => $msg, 'url' => $url, 'data' => $data]);
+        };
+
+        if ($this->request->isPost()) {
+            $mysqlHostname = $this->request->post('mysqlHostname', '127.0.0.1');
+            $mysqlHostport = $this->request->post('mysqlHostport', '3306');
+            $hostArr = explode(':', $mysqlHostname);
+            if (count($hostArr) > 1) {
+                $mysqlHostname = $hostArr[0];
+                $mysqlHostport = $hostArr[1];
+            }
+            $mysqlUsername = $this->request->post('mysqlUsername', 'root');
+            $mysqlPassword = $this->request->post('mysqlPassword', '');
+            $mysqlDatabase = $this->request->post('mysqlDatabase', '');
+            $mysqlPrefix = $this->request->post('mysqlPrefix', 'fa_');
+            $adminUsername = $this->request->post('adminUsername', 'admin');
+            $adminPassword = $this->request->post('adminPassword', '');
+            $adminPasswordConfirmation = $this->request->post('adminPasswordConfirmation', '');
+            $adminEmail = $this->request->post('adminEmail', 'admin@admin.com');
+            $siteName = $this->request->post('siteName', __('My Website'));
+
+            if ($adminPassword !== $adminPasswordConfirmation) {
+                return $output(0, __('The two passwords you entered did not match'));
+            }
+
+            $adminName = '';
+            try {
+                $adminName = $this->installation($mysqlHostname, $mysqlHostport, $mysqlDatabase, $mysqlUsername, $mysqlPassword, $mysqlPrefix, $adminUsername, $adminPassword, $adminEmail, $siteName);
+            } catch (\PDOException $e) {
+                throw new Exception($e->getMessage());
+            } catch (\Exception $e) {
+                return $output(0, $e->getMessage());
+            }
+            return $output(1, __('Install Successed'), null, ['adminName' => $adminName]);
+        }
+        $errInfo = '';
+        try {
+            $this->checkenv();
+        } catch (\Exception $e) {
+            $errInfo = $e->getMessage();
+        }
+        return $this->view->fetch(INSTALL_PATH . "install.html", ['errInfo' => $errInfo]);
+    }
+
+    /**
+     * 执行安装
+     */
+    protected function installation($mysqlHostname, $mysqlHostport, $mysqlDatabase, $mysqlUsername, $mysqlPassword, $mysqlPrefix, $adminUsername, $adminPassword, $adminEmail = null, $siteName = null)
+    {
+        $this->checkenv();
+
+        if ($mysqlDatabase == '') {
+            throw new Exception(__('Please input correct database'));
+        }
+        if (!preg_match("/^\w{3,12}$/", $adminUsername)) {
+            throw new Exception(__('Please input correct username'));
+        }
+        if (!preg_match("/^[\S]{6,16}$/", $adminPassword)) {
+            throw new Exception(__('Please input correct password'));
+        }
+        if ($siteName == '' || preg_match("/fast" . "admin/i", $siteName)) {
+            throw new Exception(__('Please input correct website'));
+        }
+
+        $sql = file_get_contents(INSTALL_PATH . 'fastadmin.sql');
+
+        $sql = str_replace("`fa_", "`{$mysqlPrefix}", $sql);
+
+        // 先尝试能否自动创建数据库
+        $config = Config::get('database');
+        try {
+            $pdo = new PDO("{$config['type']}:host={$mysqlHostname}" . ($mysqlHostport ? ";port={$mysqlHostport}" : ''), $mysqlUsername, $mysqlPassword);
+            $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+            $pdo->query("CREATE DATABASE IF NOT EXISTS `{$mysqlDatabase}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;");
+
+            // 连接install命令中指定的数据库
+            $instance = Db::connect([
+                'type'     => "{$config['type']}",
+                'hostname' => "{$mysqlHostname}",
+                'hostport' => "{$mysqlHostport}",
+                'database' => "{$mysqlDatabase}",
+                'username' => "{$mysqlUsername}",
+                'password' => "{$mysqlPassword}",
+                'prefix'   => "{$mysqlPrefix}",
+            ]);
+
+            // 查询一次SQL,判断连接是否正常
+            $instance->execute("SELECT 1");
+
+            // 调用原生PDO对象进行批量查询
+            $instance->getPdo()->exec($sql);
+        } catch (\PDOException $e) {
+            throw new Exception($e->getMessage());
+        }
+        // 后台入口文件
+        $adminFile = ROOT_PATH . 'public' . DS . 'admin.php';
+
+        // 数据库配置文件
+        $dbConfigFile = APP_PATH . 'database.php';
+        $dbConfigText = @file_get_contents($dbConfigFile);
+        $callback = function ($matches) use ($mysqlHostname, $mysqlHostport, $mysqlUsername, $mysqlPassword, $mysqlDatabase, $mysqlPrefix) {
+            $field = "mysql" . ucfirst($matches[1]);
+            $replace = $$field;
+            if ($matches[1] == 'hostport' && $mysqlHostport == 3306) {
+                $replace = '';
+            }
+            return "'{$matches[1]}'{$matches[2]}=>{$matches[3]}Env::get('database.{$matches[1]}', '{$replace}'),";
+        };
+        $dbConfigText = preg_replace_callback("/'(hostname|database|username|password|hostport|prefix)'(\s+)=>(\s+)Env::get\((.*)\)\,/", $callback, $dbConfigText);
+
+        // 检测能否成功写入数据库配置
+        $result = @file_put_contents($dbConfigFile, $dbConfigText);
+        if (!$result) {
+            throw new Exception(__('The current permissions are insufficient to write the file %s', 'application/database.php'));
+        }
+
+        // 设置新的Token随机密钥key
+        $oldTokenKey = config('token.key');
+        $newTokenKey = \fast\Random::alnum(32);
+        $coreConfigFile = CONF_PATH . 'config.php';
+        $coreConfigText = @file_get_contents($coreConfigFile);
+        $coreConfigText = preg_replace("/'key'(\s+)=>(\s+)'{$oldTokenKey}'/", "'key'\$1=>\$2'{$newTokenKey}'", $coreConfigText);
+
+        $result = @file_put_contents($coreConfigFile, $coreConfigText);
+        if (!$result) {
+            throw new Exception(__('The current permissions are insufficient to write the file %s', 'application/config.php'));
+        }
+
+        // 变更默认管理员密码
+        $adminPassword = $adminPassword ? $adminPassword : Random::alnum(8);
+        $adminEmail = $adminEmail ? $adminEmail : "admin@admin.com";
+        $newSalt = substr(md5(uniqid(true)), 0, 6);
+        $newPassword = md5(md5($adminPassword) . $newSalt);
+        $data = ['username' => $adminUsername, 'email' => $adminEmail, 'password' => $newPassword, 'salt' => $newSalt];
+        $instance->name('admin')->where('username', 'admin')->update($data);
+
+        // 变更前台默认用户的密码,随机生成
+        $newSalt = substr(md5(uniqid(true)), 0, 6);
+        $newPassword = md5(md5(Random::alnum(8)) . $newSalt);
+        $instance->name('user')->where('username', 'admin')->update(['password' => $newPassword, 'salt' => $newSalt]);
+
+        // 修改后台入口
+        $adminName = '';
+        if (is_file($adminFile)) {
+            $adminName = Random::alpha(10) . '.php';
+            rename($adminFile, ROOT_PATH . 'public' . DS . $adminName);
+        }
+
+        //修改站点名称
+        if ($siteName != config('site.name')) {
+            $instance->name('config')->where('name', 'name')->update(['value' => $siteName]);
+            $siteConfigFile = CONF_PATH . 'extra' . DS . 'site.php';
+            $siteConfig = include $siteConfigFile;
+            $configList = $instance->name("config")->select();
+            foreach ($configList as $k => $value) {
+                if (in_array($value['type'], ['selects', 'checkbox', 'images', 'files'])) {
+                    $value['value'] = explode(',', $value['value']);
+                }
+                if ($value['type'] == 'array') {
+                    $value['value'] = (array)json_decode($value['value'], true);
+                }
+                $siteConfig[$value['name']] = $value['value'];
+            }
+            $siteConfig['name'] = $siteName;
+            file_put_contents($siteConfigFile, '<?php' . "\n\nreturn " . var_export_short($siteConfig) . ";\n");
+        }
+
+        $installLockFile = INSTALL_PATH . "install.lock";
+        //检测能否成功写入lock文件
+        $result = @file_put_contents($installLockFile, 1);
+        if (!$result) {
+            throw new Exception(__('The current permissions are insufficient to write the file %s', 'application/admin/command/Install/install.lock'));
+        }
+
+        return $adminName;
+    }
+
+    /**
+     * 检测环境
+     */
+    protected function checkenv()
+    {
+        // 检测目录是否存在
+        $checkDirs = [
+            'thinkphp',
+            'vendor',
+            'public' . DS . 'assets' . DS . 'libs'
+        ];
+
+        //数据库配置文件
+        $dbConfigFile = APP_PATH . 'database.php';
+
+        if (version_compare(PHP_VERSION, '7.1.0', '<')) {
+            throw new Exception(__("The current version %s is too low, please use PHP 7.1 or higher", PHP_VERSION));
+        }
+        if (!extension_loaded("PDO")) {
+            throw new Exception(__("PDO is not currently installed and cannot be installed"));
+        }
+        if (!is_really_writable($dbConfigFile)) {
+            throw new Exception(__('The current permissions are insufficient to write the configuration file application/database.php'));
+        }
+        foreach ($checkDirs as $k => $v) {
+            if (!is_dir(ROOT_PATH . $v)) {
+                throw new Exception(__('Please go to the official website to download the full package or resource package and try to install'));
+                break;
+            }
+        }
+        return true;
+    }
+}

+ 599 - 0
application/admin/command/Install/fastadmin.sql

@@ -0,0 +1,599 @@
+/*
+ FastAdmin Install SQL
+ Date: 2020-06-11 22:11:09
+*/
+
+SET FOREIGN_KEY_CHECKS = 0;
+
+-- ----------------------------
+-- Table structure for fa_admin
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_admin`;
+CREATE TABLE `fa_admin` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `username` varchar(20) DEFAULT '' COMMENT '用户名',
+  `nickname` varchar(50) DEFAULT '' COMMENT '昵称',
+  `password` varchar(32) DEFAULT '' COMMENT '密码',
+  `salt` varchar(30) DEFAULT '' COMMENT '密码盐',
+  `avatar` varchar(255) DEFAULT '' COMMENT '头像',
+  `email` varchar(100) DEFAULT '' COMMENT '电子邮箱',
+  `loginfailure` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '失败次数',
+  `logintime` int(10) DEFAULT NULL COMMENT '登录时间',
+  `loginip` varchar(50) DEFAULT NULL COMMENT '登录IP',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `token` varchar(59) DEFAULT '' COMMENT 'Session标识',
+  `status` varchar(30) NOT NULL DEFAULT 'normal' COMMENT '状态',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `username` (`username`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='管理员表';
+
+-- ----------------------------
+-- Records of fa_admin
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_admin` VALUES (1, 'admin', 'Admin', '', '', '/assets/img/avatar.png', 'admin@admin.com', 0, 1491635035, '127.0.0.1',1491635035, 1491635035, '', 'normal');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_admin_log
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_admin_log`;
+CREATE TABLE `fa_admin_log` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
+  `username` varchar(30) DEFAULT '' COMMENT '管理员名字',
+  `url` varchar(1500) DEFAULT '' COMMENT '操作页面',
+  `title` varchar(100) DEFAULT '' COMMENT '日志标题',
+  `content` text NOT NULL COMMENT '内容',
+  `ip` varchar(50) DEFAULT '' COMMENT 'IP',
+  `useragent` varchar(255) DEFAULT '' COMMENT 'User-Agent',
+  `createtime` int(10) DEFAULT NULL COMMENT '操作时间',
+  PRIMARY KEY (`id`),
+  KEY `name` (`username`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='管理员日志表';
+
+-- ----------------------------
+-- Table structure for fa_area
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_area`;
+CREATE TABLE `fa_area` (
+  `id` int(10) NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `pid` int(10) DEFAULT NULL COMMENT '父id',
+  `shortname` varchar(100) DEFAULT NULL COMMENT '简称',
+  `name` varchar(100) DEFAULT NULL COMMENT '名称',
+  `mergename` varchar(255) DEFAULT NULL COMMENT '全称',
+  `level` tinyint(4) DEFAULT NULL COMMENT '层级 0 1 2 省市区县',
+  `pinyin` varchar(100) DEFAULT NULL COMMENT '拼音',
+  `code` varchar(100) DEFAULT NULL COMMENT '长途区号',
+  `zip` varchar(100) DEFAULT NULL COMMENT '邮编',
+  `first` varchar(50) DEFAULT NULL COMMENT '首字母',
+  `lng` varchar(100) DEFAULT NULL COMMENT '经度',
+  `lat` varchar(100) DEFAULT NULL COMMENT '纬度',
+  PRIMARY KEY (`id`),
+  KEY `pid` (`pid`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='地区表';
+
+-- ----------------------------
+-- Table structure for fa_attachment
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_attachment`;
+CREATE TABLE `fa_attachment` (
+  `id` int(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `category` varchar(50) DEFAULT '' COMMENT '类别',
+  `admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
+  `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '会员ID',
+  `url` varchar(255) DEFAULT '' COMMENT '物理路径',
+  `imagewidth` varchar(30) DEFAULT '' COMMENT '宽度',
+  `imageheight` varchar(30) DEFAULT '' COMMENT '高度',
+  `imagetype` varchar(30) DEFAULT '' COMMENT '图片类型',
+  `imageframes` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '图片帧数',
+  `filename` varchar(100) DEFAULT '' COMMENT '文件名称',
+  `filesize` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '文件大小',
+  `mimetype` varchar(100) DEFAULT '' COMMENT 'mime类型',
+  `extparam` varchar(255) DEFAULT '' COMMENT '透传数据',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建日期',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `uploadtime` int(10) DEFAULT NULL COMMENT '上传时间',
+  `storage` varchar(100) NOT NULL DEFAULT 'local' COMMENT '存储位置',
+  `sha1` varchar(40) DEFAULT '' COMMENT '文件 sha1编码',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='附件表';
+
+-- ----------------------------
+-- Records of fa_attachment
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_attachment` VALUES (1, '', 1, 0, '/assets/img/qrcode.png', '150', '150', 'png', 0, 'qrcode.png', 21859, 'image/png', '', 1491635035, 1491635035, 1491635035, 'local', '17163603d0263e4838b9387ff2cd4877e8b018f6');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_auth_group
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_auth_group`;
+CREATE TABLE `fa_auth_group` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `pid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '父组别',
+  `name` varchar(100) DEFAULT '' COMMENT '组名',
+  `rules` text NOT NULL COMMENT '规则ID',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `status` varchar(30) DEFAULT '' COMMENT '状态',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='分组表';
+
+-- ----------------------------
+-- Records of fa_auth_group
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_auth_group` VALUES (1, 0, 'Admin group', '*', 1491635035, 1491635035, 'normal');
+INSERT INTO `fa_auth_group` VALUES (2, 1, 'Second group', '13,14,16,15,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,40,41,42,43,44,45,46,47,48,49,50,55,56,57,58,59,60,61,62,63,64,65,1,9,10,11,7,6,8,2,4,5', 1491635035, 1491635035, 'normal');
+INSERT INTO `fa_auth_group` VALUES (3, 2, 'Third group', '1,4,9,10,11,13,14,15,16,17,40,41,42,43,44,45,46,47,48,49,50,55,56,57,58,59,60,61,62,63,64,65,5', 1491635035, 1491635035, 'normal');
+INSERT INTO `fa_auth_group` VALUES (4, 1, 'Second group 2', '1,4,13,14,15,16,17,55,56,57,58,59,60,61,62,63,64,65', 1491635035, 1491635035, 'normal');
+INSERT INTO `fa_auth_group` VALUES (5, 2, 'Third group 2', '1,2,6,7,8,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34', 1491635035, 1491635035, 'normal');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_auth_group_access
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_auth_group_access`;
+CREATE TABLE `fa_auth_group_access` (
+  `uid` int(10) unsigned NOT NULL COMMENT '会员ID',
+  `group_id` int(10) unsigned NOT NULL COMMENT '级别ID',
+  UNIQUE KEY `uid_group_id` (`uid`,`group_id`),
+  KEY `uid` (`uid`),
+  KEY `group_id` (`group_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='权限分组表';
+
+-- ----------------------------
+-- Records of fa_auth_group_access
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_auth_group_access` VALUES (1, 1);
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_auth_rule
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_auth_rule`;
+CREATE TABLE `fa_auth_rule` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `type` enum('menu','file') NOT NULL DEFAULT 'file' COMMENT 'menu为菜单,file为权限节点',
+  `pid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '父ID',
+  `name` varchar(100) DEFAULT '' COMMENT '规则名称',
+  `title` varchar(50) DEFAULT '' COMMENT '规则名称',
+  `icon` varchar(50) DEFAULT '' COMMENT '图标',
+  `url` varchar(255) DEFAULT '' COMMENT '规则URL',
+  `condition` varchar(255) DEFAULT '' COMMENT '条件',
+  `remark` varchar(255) DEFAULT '' COMMENT '备注',
+  `ismenu` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '是否为菜单',
+  `menutype` enum('addtabs','blank','dialog','ajax') DEFAULT NULL COMMENT '菜单类型',
+  `extend` varchar(255) DEFAULT '' COMMENT '扩展属性',
+  `py` varchar(30) DEFAULT '' COMMENT '拼音首字母',
+  `pinyin` varchar(100) DEFAULT '' COMMENT '拼音',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `weigh` int(10) NOT NULL DEFAULT '0' COMMENT '权重',
+  `status` varchar(30) DEFAULT '' COMMENT '状态',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`) USING BTREE,
+  KEY `pid` (`pid`),
+  KEY `weigh` (`weigh`)
+) ENGINE=InnoDB AUTO_INCREMENT=66 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='节点表';
+
+-- ----------------------------
+-- Records of fa_auth_rule
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_auth_rule` VALUES (1, 'file', 0, 'dashboard', 'Dashboard', 'fa fa-dashboard', '', '', 'Dashboard tips', 1, NULL, '', 'kzt', 'kongzhitai', 1491635035, 1491635035, 143, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (2, 'file', 0, 'general', 'General', 'fa fa-cogs', '', '', '', 1, NULL, '', 'cggl', 'changguiguanli', 1491635035, 1491635035, 137, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (3, 'file', 0, 'category', 'Category', 'fa fa-leaf', '', '', 'Category tips', 1, NULL, '', 'flgl', 'fenleiguanli', 1491635035, 1491635035, 119, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (4, 'file', 0, 'addon', 'Addon', 'fa fa-rocket', '', '', 'Addon tips', 1, NULL, '', 'cjgl', 'chajianguanli', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (5, 'file', 0, 'auth', 'Auth', 'fa fa-group', '', '', '', 1, NULL, '', 'qxgl', 'quanxianguanli', 1491635035, 1491635035, 99, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (6, 'file', 2, 'general/config', 'Config', 'fa fa-cog', '', '', 'Config tips', 1, NULL, '', 'xtpz', 'xitongpeizhi', 1491635035, 1491635035, 60, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (7, 'file', 2, 'general/attachment', 'Attachment', 'fa fa-file-image-o', '', '', 'Attachment tips', 1, NULL, '', 'fjgl', 'fujianguanli', 1491635035, 1491635035, 53, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (8, 'file', 2, 'general/profile', 'Profile', 'fa fa-user', '', '', '', 1, NULL, '', 'grzl', 'gerenziliao', 1491635035, 1491635035, 34, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (9, 'file', 5, 'auth/admin', 'Admin', 'fa fa-user', '', '', 'Admin tips', 1, NULL, '', 'glygl', 'guanliyuanguanli', 1491635035, 1491635035, 118, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (10, 'file', 5, 'auth/adminlog', 'Admin log', 'fa fa-list-alt', '', '', 'Admin log tips', 1, NULL, '', 'glyrz', 'guanliyuanrizhi', 1491635035, 1491635035, 113, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (11, 'file', 5, 'auth/group', 'Group', 'fa fa-group', '', '', 'Group tips', 1, NULL, '', 'jsz', 'juesezu', 1491635035, 1491635035, 109, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (12, 'file', 5, 'auth/rule', 'Rule', 'fa fa-bars', '', '', 'Rule tips', 1, NULL, '', 'cdgz', 'caidanguize', 1491635035, 1491635035, 104, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (13, 'file', 1, 'dashboard/index', 'View', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 136, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (14, 'file', 1, 'dashboard/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 135, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (15, 'file', 1, 'dashboard/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 133, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (16, 'file', 1, 'dashboard/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 134, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (17, 'file', 1, 'dashboard/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 132, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (18, 'file', 6, 'general/config/index', 'View', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 52, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (19, 'file', 6, 'general/config/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 51, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (20, 'file', 6, 'general/config/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 50, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (21, 'file', 6, 'general/config/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 49, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (22, 'file', 6, 'general/config/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 48, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (23, 'file', 7, 'general/attachment/index', 'View', 'fa fa-circle-o', '', '', 'Attachment tips', 0, NULL, '', '', '', 1491635035, 1491635035, 59, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (24, 'file', 7, 'general/attachment/select', 'Select attachment', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 58, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (25, 'file', 7, 'general/attachment/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 57, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (26, 'file', 7, 'general/attachment/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 56, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (27, 'file', 7, 'general/attachment/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 55, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (28, 'file', 7, 'general/attachment/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 54, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (29, 'file', 8, 'general/profile/index', 'View', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 33, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (30, 'file', 8, 'general/profile/update', 'Update profile', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 32, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (31, 'file', 8, 'general/profile/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 31, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (32, 'file', 8, 'general/profile/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 30, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (33, 'file', 8, 'general/profile/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 29, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (34, 'file', 8, 'general/profile/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 28, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (35, 'file', 3, 'category/index', 'View', 'fa fa-circle-o', '', '', 'Category tips', 0, NULL, '', '', '', 1491635035, 1491635035, 142, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (36, 'file', 3, 'category/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 141, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (37, 'file', 3, 'category/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 140, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (38, 'file', 3, 'category/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 139, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (39, 'file', 3, 'category/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 138, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (40, 'file', 9, 'auth/admin/index', 'View', 'fa fa-circle-o', '', '', 'Admin tips', 0, NULL, '', '', '', 1491635035, 1491635035, 117, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (41, 'file', 9, 'auth/admin/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 116, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (42, 'file', 9, 'auth/admin/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 115, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (43, 'file', 9, 'auth/admin/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 114, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (44, 'file', 10, 'auth/adminlog/index', 'View', 'fa fa-circle-o', '', '', 'Admin log tips', 0, NULL, '', '', '', 1491635035, 1491635035, 112, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (45, 'file', 10, 'auth/adminlog/detail', 'Detail', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 111, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (46, 'file', 10, 'auth/adminlog/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 110, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (47, 'file', 11, 'auth/group/index', 'View', 'fa fa-circle-o', '', '', 'Group tips', 0, NULL, '', '', '', 1491635035, 1491635035, 108, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (48, 'file', 11, 'auth/group/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 107, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (49, 'file', 11, 'auth/group/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 106, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (50, 'file', 11, 'auth/group/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 105, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (51, 'file', 12, 'auth/rule/index', 'View', 'fa fa-circle-o', '', '', 'Rule tips', 0, NULL, '', '', '', 1491635035, 1491635035, 103, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (52, 'file', 12, 'auth/rule/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 102, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (53, 'file', 12, 'auth/rule/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 101, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (54, 'file', 12, 'auth/rule/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 100, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (55, 'file', 4, 'addon/index', 'View', 'fa fa-circle-o', '', '', 'Addon tips', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (56, 'file', 4, 'addon/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (57, 'file', 4, 'addon/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (58, 'file', 4, 'addon/del', 'Delete', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (59, 'file', 4, 'addon/downloaded', 'Local addon', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (60, 'file', 4, 'addon/state', 'Update state', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (63, 'file', 4, 'addon/config', 'Setting', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (64, 'file', 4, 'addon/refresh', 'Refresh', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (65, 'file', 4, 'addon/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (66, 'file', 0, 'user', 'User', 'fa fa-user-circle', '', '', '', 1, NULL, '', 'hygl', 'huiyuanguanli', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (67, 'file', 66, 'user/user', 'User', 'fa fa-user', '', '', '', 1, NULL, '', 'hygl', 'huiyuanguanli', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (68, 'file', 67, 'user/user/index', 'View', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (69, 'file', 67, 'user/user/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (70, 'file', 67, 'user/user/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (71, 'file', 67, 'user/user/del', 'Del', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (72, 'file', 67, 'user/user/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (73, 'file', 66, 'user/group', 'User group', 'fa fa-users', '', '', '', 1, NULL, '', 'hyfz', 'huiyuanfenzu', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (74, 'file', 73, 'user/group/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (75, 'file', 73, 'user/group/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (76, 'file', 73, 'user/group/index', 'View', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (77, 'file', 73, 'user/group/del', 'Del', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (78, 'file', 73, 'user/group/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (79, 'file', 66, 'user/rule', 'User rule', 'fa fa-circle-o', '', '', '', 1, NULL, '', 'hygz', 'huiyuanguize', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (80, 'file', 79, 'user/rule/index', 'View', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (81, 'file', 79, 'user/rule/del', 'Del', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (82, 'file', 79, 'user/rule/add', 'Add', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (83, 'file', 79, 'user/rule/edit', 'Edit', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+INSERT INTO `fa_auth_rule` VALUES (84, 'file', 79, 'user/rule/multi', 'Multi', 'fa fa-circle-o', '', '', '', 0, NULL, '', '', '', 1491635035, 1491635035, 0, 'normal');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_category
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_category`;
+CREATE TABLE `fa_category` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `pid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '父ID',
+  `type` varchar(30) DEFAULT '' COMMENT '栏目类型',
+  `name` varchar(30) DEFAULT '',
+  `nickname` varchar(50) DEFAULT '',
+  `flag` set('hot','index','recommend') DEFAULT '',
+  `image` varchar(100) DEFAULT '' COMMENT '图片',
+  `keywords` varchar(255) DEFAULT '' COMMENT '关键字',
+  `description` varchar(255) DEFAULT '' COMMENT '描述',
+  `diyname` varchar(30) DEFAULT '' COMMENT '自定义名称',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `weigh` int(10) NOT NULL DEFAULT '0' COMMENT '权重',
+  `status` varchar(30) DEFAULT '' COMMENT '状态',
+  PRIMARY KEY (`id`),
+  KEY `weigh` (`weigh`,`id`),
+  KEY `pid` (`pid`)
+) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='分类表';
+
+-- ----------------------------
+-- Records of fa_category
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_category` VALUES (1, 0, 'page', '官方新闻', 'news', 'recommend', '/assets/img/qrcode.png', '', '', 'news', 1491635035, 1491635035, 1, 'normal');
+INSERT INTO `fa_category` VALUES (2, 0, 'page', '移动应用', 'mobileapp', 'hot', '/assets/img/qrcode.png', '', '', 'mobileapp', 1491635035, 1491635035, 2, 'normal');
+INSERT INTO `fa_category` VALUES (3, 2, 'page', '微信公众号', 'wechatpublic', 'index', '/assets/img/qrcode.png', '', '', 'wechatpublic', 1491635035, 1491635035, 3, 'normal');
+INSERT INTO `fa_category` VALUES (4, 2, 'page', 'Android开发', 'android', 'recommend', '/assets/img/qrcode.png', '', '', 'android', 1491635035, 1491635035, 4, 'normal');
+INSERT INTO `fa_category` VALUES (5, 0, 'page', '软件产品', 'software', 'recommend', '/assets/img/qrcode.png', '', '', 'software', 1491635035, 1491635035, 5, 'normal');
+INSERT INTO `fa_category` VALUES (6, 5, 'page', '网站建站', 'website', 'recommend', '/assets/img/qrcode.png', '', '', 'website', 1491635035, 1491635035, 6, 'normal');
+INSERT INTO `fa_category` VALUES (7, 5, 'page', '企业管理软件', 'company', 'index', '/assets/img/qrcode.png', '', '', 'company', 1491635035, 1491635035, 7, 'normal');
+INSERT INTO `fa_category` VALUES (8, 6, 'page', 'PC端', 'website-pc', 'recommend', '/assets/img/qrcode.png', '', '', 'website-pc', 1491635035, 1491635035, 8, 'normal');
+INSERT INTO `fa_category` VALUES (9, 6, 'page', '移动端', 'website-mobile', 'recommend', '/assets/img/qrcode.png', '', '', 'website-mobile', 1491635035, 1491635035, 9, 'normal');
+INSERT INTO `fa_category` VALUES (10, 7, 'page', 'CRM系统 ', 'company-crm', 'recommend', '/assets/img/qrcode.png', '', '', 'company-crm', 1491635035, 1491635035, 10, 'normal');
+INSERT INTO `fa_category` VALUES (11, 7, 'page', 'SASS平台软件', 'company-sass', 'recommend', '/assets/img/qrcode.png', '', '', 'company-sass', 1491635035, 1491635035, 11, 'normal');
+INSERT INTO `fa_category` VALUES (12, 0, 'test', '测试1', 'test1', 'recommend', '/assets/img/qrcode.png', '', '', 'test1', 1491635035, 1491635035, 12, 'normal');
+INSERT INTO `fa_category` VALUES (13, 0, 'test', '测试2', 'test2', 'recommend', '/assets/img/qrcode.png', '', '', 'test2', 1491635035, 1491635035, 13, 'normal');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_config
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_config`;
+CREATE TABLE `fa_config` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(30) DEFAULT '' COMMENT '变量名',
+  `group` varchar(30) DEFAULT '' COMMENT '分组',
+  `title` varchar(100) DEFAULT '' COMMENT '变量标题',
+  `tip` varchar(100) DEFAULT '' COMMENT '变量描述',
+  `type` varchar(30) DEFAULT '' COMMENT '类型:string,text,int,bool,array,datetime,date,file',
+  `value` text COMMENT '变量值',
+  `content` text COMMENT '变量字典数据',
+  `rule` varchar(100) DEFAULT '' COMMENT '验证规则',
+  `extend` varchar(255) DEFAULT '' COMMENT '扩展属性',
+  `setting` varchar(255) DEFAULT '' COMMENT '配置',
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `name` (`name`)
+) ENGINE=InnoDB AUTO_INCREMENT=18 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='系统配置';
+
+-- ----------------------------
+-- Records of fa_config
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_config` VALUES (1, 'name', 'basic', 'Site name', '请填写站点名称', 'string', '我的网站', '', 'required', '', '');
+INSERT INTO `fa_config` VALUES (2, 'beian', 'basic', 'Beian', '粤ICP备15000000号-1', 'string', '', '', '', '', '');
+INSERT INTO `fa_config` VALUES (3, 'cdnurl', 'basic', 'Cdn url', '如果全站静态资源使用第三方云储存请配置该值', 'string', '', '', '', '', '');
+INSERT INTO `fa_config` VALUES (4, 'version', 'basic', 'Version', '如果静态资源有变动请重新配置该值', 'string', '1.0.1', '', 'required', '', '');
+INSERT INTO `fa_config` VALUES (5, 'timezone', 'basic', 'Timezone', '', 'string', 'Asia/Shanghai', '', 'required', '', '');
+INSERT INTO `fa_config` VALUES (6, 'forbiddenip', 'basic', 'Forbidden ip', '一行一条记录', 'text', '', '', '', '', '');
+INSERT INTO `fa_config` VALUES (7, 'languages', 'basic', 'Languages', '', 'array', '{\"backend\":\"zh-cn\",\"frontend\":\"zh-cn\"}', '', 'required', '', '');
+INSERT INTO `fa_config` VALUES (8, 'fixedpage', 'basic', 'Fixed page', '请尽量输入左侧菜单栏存在的链接', 'string', 'dashboard', '', 'required', '', '');
+INSERT INTO `fa_config` VALUES (9, 'categorytype', 'dictionary', 'Category type', '', 'array', '{\"default\":\"Default\",\"page\":\"Page\",\"article\":\"Article\",\"test\":\"Test\"}', '', '', '', '');
+INSERT INTO `fa_config` VALUES (10, 'configgroup', 'dictionary', 'Config group', '', 'array', '{\"basic\":\"Basic\",\"email\":\"Email\",\"dictionary\":\"Dictionary\",\"user\":\"User\",\"example\":\"Example\"}', '', '', '', '');
+INSERT INTO `fa_config` VALUES (11, 'mail_type', 'email', 'Mail type', '选择邮件发送方式', 'select', '1', '[\"请选择\",\"SMTP\"]', '', '', '');
+INSERT INTO `fa_config` VALUES (12, 'mail_smtp_host', 'email', 'Mail smtp host', '错误的配置发送邮件会导致服务器超时', 'string', 'smtp.qq.com', '', '', '', '');
+INSERT INTO `fa_config` VALUES (13, 'mail_smtp_port', 'email', 'Mail smtp port', '(不加密默认25,SSL默认465,TLS默认587)', 'string', '465', '', '', '', '');
+INSERT INTO `fa_config` VALUES (14, 'mail_smtp_user', 'email', 'Mail smtp user', '(填写完整用户名)', 'string', '10000', '', '', '', '');
+INSERT INTO `fa_config` VALUES (15, 'mail_smtp_pass', 'email', 'Mail smtp password', '(填写您的密码或授权码)', 'string', 'password', '', '', '', '');
+INSERT INTO `fa_config` VALUES (16, 'mail_verify_type', 'email', 'Mail vertify type', '(SMTP验证方式[推荐SSL])', 'select', '2', '[\"无\",\"TLS\",\"SSL\"]', '', '', '');
+INSERT INTO `fa_config` VALUES (17, 'mail_from', 'email', 'Mail from', '', 'string', '10000@qq.com', '', '', '', '');
+INSERT INTO `fa_config` VALUES (18, 'attachmentcategory', 'dictionary', 'Attachment category', '', 'array', '{\"category1\":\"Category1\",\"category2\":\"Category2\",\"custom\":\"Custom\"}', '', '', '', '');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_ems
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_ems`;
+CREATE TABLE `fa_ems`  (
+  `id` int(10) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `event` varchar(30) DEFAULT '' COMMENT '事件',
+  `email` varchar(100) DEFAULT '' COMMENT '邮箱',
+  `code` varchar(10) DEFAULT '' COMMENT '验证码',
+  `times` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '验证次数',
+  `ip` varchar(30) DEFAULT '' COMMENT 'IP',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='邮箱验证码表';
+
+-- ----------------------------
+-- Table structure for fa_sms
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_sms`;
+CREATE TABLE `fa_sms` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `event` varchar(30) DEFAULT '' COMMENT '事件',
+  `mobile` varchar(20) DEFAULT '' COMMENT '手机号',
+  `code` varchar(10) DEFAULT '' COMMENT '验证码',
+  `times` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '验证次数',
+  `ip` varchar(30) DEFAULT '' COMMENT 'IP',
+  `createtime` int(10) unsigned DEFAULT '0' COMMENT '创建时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='短信验证码表';
+
+-- ----------------------------
+-- Table structure for fa_test
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_test`;
+CREATE TABLE `fa_test` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `admin_id` int(10) DEFAULT '0' COMMENT '管理员ID',
+  `category_id` int(10) unsigned DEFAULT '0' COMMENT '分类ID(单选)',
+  `category_ids` varchar(100) COMMENT '分类ID(多选)',
+  `week` enum('monday','tuesday','wednesday') COMMENT '星期(单选):monday=星期一,tuesday=星期二,wednesday=星期三',
+  `flag` set('hot','index','recommend') DEFAULT '' COMMENT '标志(多选):hot=热门,index=首页,recommend=推荐',
+  `genderdata` enum('male','female') DEFAULT 'male' COMMENT '性别(单选):male=男,female=女',
+  `hobbydata` set('music','reading','swimming') COMMENT '爱好(多选):music=音乐,reading=读书,swimming=游泳',
+  `title` varchar(100) DEFAULT '' COMMENT '标题',
+  `content` text COMMENT '内容',
+  `image` varchar(100) DEFAULT '' COMMENT '图片',
+  `images` varchar(1500) DEFAULT '' COMMENT '图片组',
+  `attachfile` varchar(100) DEFAULT '' COMMENT '附件',
+  `keywords` varchar(255) DEFAULT '' COMMENT '关键字',
+  `description` varchar(255) DEFAULT '' COMMENT '描述',
+  `city` varchar(100) DEFAULT '' COMMENT '省市',
+  `json` varchar(255) DEFAULT NULL COMMENT '配置:key=名称,value=值',
+  `price` decimal(10,2) unsigned DEFAULT '0.00' COMMENT '价格',
+  `views` int(10) unsigned DEFAULT '0' COMMENT '点击',
+  `startdate` date DEFAULT NULL COMMENT '开始日期',
+  `activitytime` datetime DEFAULT NULL COMMENT '活动时间(datetime)',
+  `year` year(4) DEFAULT NULL COMMENT '年',
+  `times` time DEFAULT NULL COMMENT '时间',
+  `refreshtime` int(10) DEFAULT NULL COMMENT '刷新时间(int)',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `deletetime` int(10) DEFAULT NULL COMMENT '删除时间',
+  `weigh` int(10) DEFAULT '0' COMMENT '权重',
+  `switch` tinyint(1) DEFAULT '0' COMMENT '开关',
+  `status` enum('normal','hidden') DEFAULT 'normal' COMMENT '状态',
+  `state` enum('0','1','2') DEFAULT '1' COMMENT '状态值:0=禁用,1=正常,2=推荐',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='测试表';
+
+-- ----------------------------
+-- Records of fa_test
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_test` VALUES (1, 0, 12, '12,13', 'monday', 'hot,index', 'male', 'music,reading', '我是一篇测试文章', '<p>我是测试内容</p>', '/assets/img/avatar.png', '/assets/img/avatar.png,/assets/img/qrcode.png', '/assets/img/avatar.png', '关键字', '描述', '广西壮族自治区/百色市/平果县', '{\"a\":\"1\",\"b\":\"2\"}', 0.00, 0, '2017-07-10', '2017-07-10 18:24:45', 2017, '18:24:45', 1491635035, 1491635035, 1491635035, NULL, 0, 1, 'normal', '1');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_user
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_user`;
+CREATE TABLE `fa_user` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `group_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '组别ID',
+  `username` varchar(32) DEFAULT '' COMMENT '用户名',
+  `nickname` varchar(50) DEFAULT '' COMMENT '昵称',
+  `password` varchar(32) DEFAULT '' COMMENT '密码',
+  `salt` varchar(30) DEFAULT '' COMMENT '密码盐',
+  `email` varchar(100) DEFAULT '' COMMENT '电子邮箱',
+  `mobile` varchar(11) DEFAULT '' COMMENT '手机号',
+  `avatar` varchar(255) DEFAULT '' COMMENT '头像',
+  `level` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '等级',
+  `gender` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '性别',
+  `birthday` date DEFAULT NULL COMMENT '生日',
+  `bio` varchar(100) DEFAULT '' COMMENT '格言',
+  `money` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '余额',
+  `score` int(10) NOT NULL DEFAULT '0' COMMENT '积分',
+  `successions` int(10) unsigned NOT NULL DEFAULT '1' COMMENT '连续登录天数',
+  `maxsuccessions` int(10) unsigned NOT NULL DEFAULT '1' COMMENT '最大连续登录天数',
+  `prevtime` int(10) DEFAULT NULL COMMENT '上次登录时间',
+  `logintime` int(10) DEFAULT NULL COMMENT '登录时间',
+  `loginip` varchar(50) DEFAULT '' COMMENT '登录IP',
+  `loginfailure` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '失败次数',
+  `joinip` varchar(50) DEFAULT '' COMMENT '加入IP',
+  `jointime` int(10) DEFAULT NULL COMMENT '加入时间',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `token` varchar(50) DEFAULT '' COMMENT 'Token',
+  `status` varchar(30) DEFAULT '' COMMENT '状态',
+  `verification` varchar(255) DEFAULT '' COMMENT '验证',
+  PRIMARY KEY (`id`),
+  KEY `username` (`username`),
+  KEY `email` (`email`),
+  KEY `mobile` (`mobile`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='会员表';
+
+-- ----------------------------
+-- Records of fa_user
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_user` VALUES (1, 1, 'admin', 'admin', '', '', 'admin@163.com', '13888888888', '', 0, 0, '2017-04-08', '', 0, 0, 1, 1, 1491635035, 1491635035, '127.0.0.1', 0, '127.0.0.1', 1491635035, 0, 1491635035, '', 'normal','');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_user_group
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_user_group`;
+CREATE TABLE `fa_user_group` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `name` varchar(50) DEFAULT '' COMMENT '组名',
+  `rules` text COMMENT '权限节点',
+  `createtime` int(10) DEFAULT NULL COMMENT '添加时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `status` enum('normal','hidden') DEFAULT NULL COMMENT '状态',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='会员组表';
+
+-- ----------------------------
+-- Records of fa_user_group
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_user_group` VALUES (1, '默认组', '1,2,3,4,5,6,7,8,9,10,11,12', 1491635035, 1491635035, 'normal');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_user_money_log
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_user_money_log`;
+CREATE TABLE `fa_user_money_log` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '会员ID',
+  `money` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '变更余额',
+  `before` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '变更前余额',
+  `after` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '变更后余额',
+  `memo` varchar(255) DEFAULT '' COMMENT '备注',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='会员余额变动表';
+
+-- ----------------------------
+-- Table structure for fa_user_rule
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_user_rule`;
+CREATE TABLE `fa_user_rule` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `pid` int(10) DEFAULT NULL COMMENT '父ID',
+  `name` varchar(50) DEFAULT NULL COMMENT '名称',
+  `title` varchar(50) DEFAULT '' COMMENT '标题',
+  `remark` varchar(100) DEFAULT NULL COMMENT '备注',
+  `ismenu` tinyint(1) DEFAULT NULL COMMENT '是否菜单',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `weigh` int(10) DEFAULT '0' COMMENT '权重',
+  `status` enum('normal','hidden') DEFAULT NULL COMMENT '状态',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='会员规则表';
+
+-- ----------------------------
+-- Records of fa_user_rule
+-- ----------------------------
+BEGIN;
+INSERT INTO `fa_user_rule` VALUES (1, 0, 'index', 'Frontend', '', 1, 1491635035, 1491635035, 1, 'normal');
+INSERT INTO `fa_user_rule` VALUES (2, 0, 'api', 'API Interface', '', 1, 1491635035, 1491635035, 2, 'normal');
+INSERT INTO `fa_user_rule` VALUES (3, 1, 'user', 'User Module', '', 1, 1491635035, 1491635035, 12, 'normal');
+INSERT INTO `fa_user_rule` VALUES (4, 2, 'user', 'User Module', '', 1, 1491635035, 1491635035, 11, 'normal');
+INSERT INTO `fa_user_rule` VALUES (5, 3, 'index/user/login', 'Login', '', 0, 1491635035, 1491635035, 5, 'normal');
+INSERT INTO `fa_user_rule` VALUES (6, 3, 'index/user/register', 'Register', '', 0, 1491635035, 1491635035, 7, 'normal');
+INSERT INTO `fa_user_rule` VALUES (7, 3, 'index/user/index', 'User Center', '', 0, 1491635035, 1491635035, 9, 'normal');
+INSERT INTO `fa_user_rule` VALUES (8, 3, 'index/user/profile', 'Profile', '', 0, 1491635035, 1491635035, 4, 'normal');
+INSERT INTO `fa_user_rule` VALUES (9, 4, 'api/user/login', 'Login', '', 0, 1491635035, 1491635035, 6, 'normal');
+INSERT INTO `fa_user_rule` VALUES (10, 4, 'api/user/register', 'Register', '', 0, 1491635035, 1491635035, 8, 'normal');
+INSERT INTO `fa_user_rule` VALUES (11, 4, 'api/user/index', 'User Center', '', 0, 1491635035, 1491635035, 10, 'normal');
+INSERT INTO `fa_user_rule` VALUES (12, 4, 'api/user/profile', 'Profile', '', 0, 1491635035, 1491635035, 3, 'normal');
+COMMIT;
+
+-- ----------------------------
+-- Table structure for fa_user_score_log
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_user_score_log`;
+CREATE TABLE `fa_user_score_log` (
+  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
+  `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '会员ID',
+  `score` int(10) NOT NULL DEFAULT '0' COMMENT '变更积分',
+  `before` int(10) NOT NULL DEFAULT '0' COMMENT '变更前积分',
+  `after` int(10) NOT NULL DEFAULT '0' COMMENT '变更后积分',
+  `memo` varchar(255) DEFAULT '' COMMENT '备注',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  PRIMARY KEY (`id`)
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='会员积分变动表';
+
+-- ----------------------------
+-- Table structure for fa_user_token
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_user_token`;
+CREATE TABLE `fa_user_token` (
+  `token` varchar(50) NOT NULL COMMENT 'Token',
+  `user_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '会员ID',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `expiretime` int(10) DEFAULT NULL COMMENT '过期时间',
+  PRIMARY KEY (`token`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='会员Token表';
+
+-- ----------------------------
+-- Table structure for fa_version
+-- ----------------------------
+DROP TABLE IF EXISTS `fa_version`;
+CREATE TABLE `fa_version`  (
+  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT 'ID',
+  `oldversion` varchar(30) DEFAULT '' COMMENT '旧版本号',
+  `newversion` varchar(30) DEFAULT '' COMMENT '新版本号',
+  `packagesize` varchar(30) DEFAULT '' COMMENT '包大小',
+  `content` varchar(500) DEFAULT '' COMMENT '升级内容',
+  `downloadurl` varchar(255) DEFAULT '' COMMENT '下载地址',
+  `enforce` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '强制更新',
+  `createtime` int(10) DEFAULT NULL COMMENT '创建时间',
+  `updatetime` int(10) DEFAULT NULL COMMENT '更新时间',
+  `weigh` int(10) NOT NULL DEFAULT 0 COMMENT '权重',
+  `status` varchar(30) DEFAULT '' COMMENT '状态',
+  PRIMARY KEY (`id`) USING BTREE
+) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT='版本表';
+
+SET FOREIGN_KEY_CHECKS = 1;

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff