浏览代码

Merge branch 'dev' into feat_demo

wangweimin 5 年之前
父节点
当前提交
6921469fc3
共有 13 个文件被更改,包括 150 次插入55 次删除
  1. 2 0
      .gitattributes
  2. 5 5
      README.rst
  3. 二进制
      docs/assets/demo.gif
  4. 二进制
      docs/assets/demo.png
  5. 9 1
      docs/conf.py
  6. 21 15
      docs/index.rst
  7. 12 1
      docs/spec.rst
  8. 36 11
      pywebio/html/js/pywebio.js
  9. 53 17
      pywebio/input.py
  10. 4 1
      pywebio/io_ctrl.py
  11. 4 2
      pywebio/output.py
  12. 3 1
      pywebio/platform/flask.py
  13. 1 1
      requirements.txt

+ 2 - 0
.gitattributes

@@ -1,3 +1,5 @@
+# https://github.com/github/linguist#overrides
+
 pywebio/html/js/* linguist-vendored
 pywebio/html/js/* linguist-vendored
 pywebio/html/css/* linguist-vendored
 pywebio/html/css/* linguist-vendored
 pywebio/html/codemirror/* linguist-vendored
 pywebio/html/codemirror/* linguist-vendored

+ 5 - 5
README.rst

@@ -28,14 +28,14 @@ Quick start
 
 
 .. code-block:: python
 .. code-block:: python
 
 
-    from pywebio.input import input
+    from pywebio.input import input, FLOAT
     from pywebio.output import put_text
     from pywebio.output import put_text
 
 
     def bmi():
     def bmi():
-        height = input("请输入你的身高(cm):")
-        weight = input("请输入你的体重(kg):")
+        height = input("请输入你的身高(cm):", type=FLOAT)
+        weight = input("请输入你的体重(kg):", type=FLOAT)
 
 
-        BMI = float(weight) / (float(height) / 100) ** 2
+        BMI = weight / (height / 100) ** 2
 
 
         top_status = [(14.9, '极瘦'), (18.4, '偏瘦'),
         top_status = [(14.9, '极瘦'), (18.4, '偏瘦'),
                       (22.9, '正常'), (27.5, '过重'),
                       (22.9, '正常'), (27.5, '过重'),
@@ -98,4 +98,4 @@ Quick start
 Document
 Document
 ------------
 ------------
 
 
-使用手册和开发文档见 `https://pywebio.readthedocs.io <https://pywebio.readthedocs.io>`_
+使用手册和实现文档见 `https://pywebio.readthedocs.io <https://pywebio.readthedocs.io>`_

二进制
docs/assets/demo.gif


二进制
docs/assets/demo.png


+ 9 - 1
docs/conf.py

@@ -9,7 +9,6 @@
 # Ensure we get the local copy of tornado instead of what's on the standard path
 # Ensure we get the local copy of tornado instead of what's on the standard path
 import os
 import os
 import sys
 import sys
-import sphinx_rtd_theme
 
 
 sys.path.insert(0, os.path.abspath(".."))
 sys.path.insert(0, os.path.abspath(".."))
 import pywebio
 import pywebio
@@ -59,3 +58,12 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
 html_theme = "sphinx_rtd_theme"
 html_theme = "sphinx_rtd_theme"
 
 
 # -- Extension configuration -------------------------------------------------
 # -- Extension configuration -------------------------------------------------
+
+from sphinx.builders.html import StandaloneHTMLBuilder
+
+StandaloneHTMLBuilder.supported_image_types = [
+    'image/svg+xml',
+    'image/gif',
+    'image/png',
+    'image/jpeg'
+]

+ 21 - 15
docs/index.rst

@@ -21,6 +21,10 @@ Install
 
 
    pip3 install pywebio
    pip3 install pywebio
 
 
+Pypi上的包更新可能滞后,可以使用以下命令安装开发版本::
+
+    pip3 install https://code.aliyun.com/wang0618/pywebio/repository/archive.zip
+
 **系统要求**: PyWebIO要求 Python 版本在 3.5.2 及以上
 **系统要求**: PyWebIO要求 Python 版本在 3.5.2 及以上
 
 
 .. _hello_word:
 .. _hello_word:
@@ -30,24 +34,25 @@ Hello, world
 
 
 这是一个使用PywWebIO计算 `BMI指数 <https://en.wikipedia.org/wiki/Body_mass_index>`_ 的脚本::
 这是一个使用PywWebIO计算 `BMI指数 <https://en.wikipedia.org/wiki/Body_mass_index>`_ 的脚本::
 
 
-   # A simple script to calculate BMI
-   from pywebio.input import input
-   from pywebio.output import put_text
+    # A simple script to calculate BMI
+    from pywebio.input import input, FLOAT
+    from pywebio.output import put_text, set_output_fixed_height
 
 
-   def bmi():
-       height = input("请输入你的身高(cm):")
-       weight = input("请输入你的体重(kg):")
+    def bmi():
+        set_output_fixed_height(True)
+        height = input("请输入你的身高(cm):", type=FLOAT)
+        weight = input("请输入你的体重(kg):", type=FLOAT)
 
 
-       BMI = float(weight) / (float(height) / 100) ** 2
+        BMI = weight / (height / 100) ** 2
 
 
-       top_status = [(14.9, '极瘦'), (18.4, '偏瘦'),
-                     (22.9, '正常'), (27.5, '过重'),
-                     (40.0, '肥胖'), (float('inf'), '非常肥胖')]
+        top_status = [(14.9, '极瘦'), (18.4, '偏瘦'),
+                      (22.9, '正常'), (27.5, '过重'),
+                      (40.0, '肥胖'), (float('inf'), '非常肥胖')]
 
 
-       for top, status in top_status:
-           if BMI <= top:
-               put_text('你的 BMI 值: %.1f,身体状态:%s' % (BMI, status))
-               break
+        for top, status in top_status:
+            if BMI <= top:
+                put_text('你的 BMI 值: %.1f,身体状态:%s' % (BMI, status))
+                break
 
 
    if __name__ == '__main__':
    if __name__ == '__main__':
        bmi()
        bmi()
@@ -55,6 +60,7 @@ Hello, world
 如果没有使用PywWebIO,这只是一个非常简单的脚本,而通过使用PywWebIO提供的输入输出函数,你可以在浏览器中与代码进行交互:
 如果没有使用PywWebIO,这只是一个非常简单的脚本,而通过使用PywWebIO提供的输入输出函数,你可以在浏览器中与代码进行交互:
 
 
 .. image:: /assets/demo.*
 .. image:: /assets/demo.*
+   :align: center
 
 
 将上面代码最后一行对 ``bmi()`` 的直接调用改为使用 `pywebio.start_server(bmi, port=80) <pywebio.platform.start_server>` 便可以在80端口提供 ``bmi()`` 服务。
 将上面代码最后一行对 ``bmi()`` 的直接调用改为使用 `pywebio.start_server(bmi, port=80) <pywebio.platform.start_server>` 便可以在80端口提供 ``bmi()`` 服务。
 
 
@@ -93,7 +99,7 @@ Indices and tables
 Discussion and support
 Discussion and support
 ----------------------
 ----------------------
 
 
-* Need help when use PyWebIO? Send me Email ``wang0.618&qq.com`` (replace ``&`` whit ``@`` ).
+* Need help when use PyWebIO? Send me Email ``wang0.618&qq.com`` (replace ``&`` with ``@`` ).
 
 
 * Report bugs on the `GitHub issue <https://github.com/wang0618/pywebio/issues>`_.
 * Report bugs on the `GitHub issue <https://github.com/wang0618/pywebio/issues>`_.
 
 

+ 12 - 1
docs/spec.rst

@@ -58,6 +58,11 @@ input_group:
      - list
      - list
      - 输入项
      - 输入项
 
 
+   * - cancelable
+     - False
+     - bool
+     - 表单是否可以取消。若 ``cancelable=True`` 则会在表单底部显示一个"取消"按钮,用户点击取消按钮后,触发 ``from_cancel`` 事件
+
 
 
 ``inputs`` 字段为输入项组成的列表,每一输入项为一个 ``dict``,字段如下:
 ``inputs`` 字段为输入项组成的列表,每一输入项为一个 ``dict``,字段如下:
 
 
@@ -116,7 +121,7 @@ input_group:
 
 
 * actions
 * actions
 
 
-  * buttons: 选项列表。``{label:选项标签, value:选项值, [disabled:是否禁止选择]}``
+  * buttons: 选项列表。``{label:选项标签, value:选项值, [type: 按钮类型 'submit'/'reset'/'cancel'], [disabled:是否禁止选择]}``
 
 
 
 
 
 
@@ -238,3 +243,9 @@ from_submit:
 
 
 事件 ``data`` 字段为表单 * ``name`` -> 表单值* 的字典
 事件 ``data`` 字段为表单 * ``name`` -> 表单值* 的字典
 
 
+from_cancel:
+^^^^^^^^^^^^^^^
+取消输入表单
+
+事件 ``data`` 字段为 ``None``
+

+ 36 - 11
pywebio/html/js/pywebio.js

@@ -415,14 +415,24 @@
                     <div class="ws-form-submit-btns">
                     <div class="ws-form-submit-btns">
                         <button type="submit" class="btn btn-primary">提交</button>
                         <button type="submit" class="btn btn-primary">提交</button>
                         <button type="reset" class="btn btn-warning">重置</button>
                         <button type="reset" class="btn btn-warning">重置</button>
+                        {{#cancelable}}<button type="button" class="pywebio_cancel_btn btn btn-danger">取消</button>{{/cancelable}}
                     </div>
                     </div>
                 </form>
                 </form>
             </div>
             </div>
         </div>`;
         </div>`;
+        var that = this;
 
 
-        const html = Mustache.render(tpl, {label: this.spec.label});
+        const html = Mustache.render(tpl, {label: this.spec.label, cancelable: this.spec.cancelable});
         this.element = $(html);
         this.element = $(html);
 
 
+        this.element.find('.pywebio_cancel_btn').on('click', function (e) {
+            that.webio_session.send_message({
+                event: "from_cancel",
+                task_id: that.task_id,
+                data: null
+            });
+        });
+
         // 如果表单最后一个输入元素为actions组件,则隐藏默认的"提交"/"重置"按钮
         // 如果表单最后一个输入元素为actions组件,则隐藏默认的"提交"/"重置"按钮
         if (this.spec.inputs.length && this.spec.inputs[this.spec.inputs.length - 1].type === 'actions')
         if (this.spec.inputs.length && this.spec.inputs[this.spec.inputs.length - 1].type === 'actions')
             this.element.find('.ws-form-submit-btns').hide();
             this.element.find('.ws-form-submit-btns').hide();
@@ -449,7 +459,6 @@
         }
         }
 
 
         // 事件绑定
         // 事件绑定
-        var that = this;
         this.element.on('submit', 'form', function (e) {
         this.element.on('submit', 'form', function (e) {
             e.preventDefault(); // avoid to execute the actual submit of the form.
             e.preventDefault(); // avoid to execute the actual submit of the form.
             var data = {};
             var data = {};
@@ -592,7 +601,7 @@
             'help_text': '',
             'help_text': '',
             'options': '',
             'options': '',
             'datalist': '',
             'datalist': '',
-            'multiple':''
+            'multiple': ''
         };
         };
         for (var key in this.spec) {
         for (var key in this.spec) {
             if (key in ignore_keys) continue;
             if (key in ignore_keys) continue;
@@ -666,7 +675,9 @@
                 'matchBrackets': true,  //括号匹配
                 'matchBrackets': true,  //括号匹配
                 'lineWrapping': true,  //自动换行
                 'lineWrapping': true,  //自动换行
             };
             };
-            for (var k in that.spec.code) config[k] = that.spec.code[k];
+            for (var k in that.spec.code)
+                config[k] = that.spec.code[k];
+
             CodeMirror.autoLoadMode(that.code_mirror, config.mode);
             CodeMirror.autoLoadMode(that.code_mirror, config.mode);
             if (config.theme)
             if (config.theme)
                 load_codemirror_theme(config.theme);
                 load_codemirror_theme(config.theme);
@@ -777,7 +788,7 @@
     function ButtonsController(webio_session, task_id, spec) {
     function ButtonsController(webio_session, task_id, spec) {
         FormItemController.apply(this, arguments);
         FormItemController.apply(this, arguments);
 
 
-        this.last_checked_value = null;  // 上次点击按钮的value
+        this.submit_value = null;  // 提交表单时按钮组的value
         this.create_element();
         this.create_element();
     }
     }
 
 
@@ -787,7 +798,7 @@
 <div class="form-group">
 <div class="form-group">
     {{#label}}<label>{{label}}</label>  <br> {{/label}} 
     {{#label}}<label>{{label}}</label>  <br> {{/label}} 
     {{#buttons}}
     {{#buttons}}
-    <button type="submit" value="{{value}}" aria-describedby="{{name}}_help" {{#disabled}}disabled{{/disabled}} class="btn btn-primary">{{label}}</button>
+    <button type="{{btn_type}}" data-type="{{type}}" value="{{value}}" aria-describedby="{{name}}_help" {{#disabled}}disabled{{/disabled}} class="btn btn-primary">{{label}}</button>
     {{/buttons}}
     {{/buttons}}
     <div class="invalid-feedback">{{invalid_feedback}}</div>  <!-- input 添加 is-invalid 类 -->
     <div class="invalid-feedback">{{invalid_feedback}}</div>  <!-- input 添加 is-invalid 类 -->
     <div class="valid-feedback">{{valid_feedback}}</div> <!-- input 添加 is-valid 类 -->
     <div class="valid-feedback">{{valid_feedback}}</div> <!-- input 添加 is-valid 类 -->
@@ -795,14 +806,28 @@
 </div>`;
 </div>`;
 
 
     ButtonsController.prototype.create_element = function () {
     ButtonsController.prototype.create_element = function () {
+        for (var b of this.spec.buttons) b['btn_type'] = b.type === "submit" ? "submit" : "button";
+
         const html = Mustache.render(buttons_tpl, this.spec);
         const html = Mustache.render(buttons_tpl, this.spec);
         this.element = $(html);
         this.element = $(html);
 
 
-        // todo:是否有必要监听click事件,因为点击后即提交了表单
         var that = this;
         var that = this;
         this.element.find('button').on('click', function (e) {
         this.element.find('button').on('click', function (e) {
             var btn = $(this);
             var btn = $(this);
-            that.last_checked_value = btn.val();
+            if (btn.data('type') === 'submit') {
+                that.submit_value = btn.val();
+                // 不可以使用 btn.parents('form').submit(), 会导致input 的required属性失效
+            } else if (btn.data('type') === 'reset') {
+                btn.parents('form').trigger("reset");
+            } else if (btn.data('type') === 'cancel') {
+                that.webio_session.send_message({
+                    event: "from_cancel",
+                    task_id: that.task_id,
+                    data: null
+                });
+            } else {
+                console.error("`actions` input: unknown button type '%s'", btn.data('type'));
+            }
         });
         });
     };
     };
 
 
@@ -821,7 +846,7 @@
     };
     };
 
 
     ButtonsController.prototype.get_value = function () {
     ButtonsController.prototype.get_value = function () {
-        return this.last_checked_value;
+        return this.submit_value;
     };
     };
 
 
     function FileInputController(webio_session, task_id, spec) {
     function FileInputController(webio_session, task_id, spec) {
@@ -1022,8 +1047,8 @@
     function WebIOController(webio_session, output_container_elem, input_container_elem) {
     function WebIOController(webio_session, output_container_elem, input_container_elem) {
         WebIOSession_ = webio_session;
         WebIOSession_ = webio_session;
         webio_session.on_session_close = function () {
         webio_session.on_session_close = function () {
-            $('#favicon32').attr('href', 'image/favicon_closed_32.png');  // todo:remove hard code
-            $('#favicon16').attr('href', 'image/favicon_closed_16.png');
+            $('#favicon32').attr('href', 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAByElEQVRYR82XLUzDUBDH/9emYoouYHAYMGCAYJAYEhxiW2EOSOYwkKBQKBIwuIUPN2g7gSPBIDF8GWbA4DAjG2qitEfesi6lbGxlXd5q393/fr333t07QpdfPp8f0nV9CcACEU0DGAOgN9yrAN6Y+QnATbVavcrlcp/dSFMnI9M0J1RV3WHmFQCJTvaN9RoRXbiuu28YxstfPm0BbNtOMPMeEW0C0LoMHDZzmPmIiHbT6XStlUZLgEKhMK5p2iWAyX8GDruVHMdZzmazr+GFXwCmac4oinINYCSm4L5M2fO8RcMwHoO6PwAaf37bh+BNCMdx5oOZaAKIPQdwF2Pa2yWwBGDOPxNNAMuyDohoK+a0t5Rj5sNMJrMtFusA4qopivLcw2mPyu14njclrmgdoFgsnjLzWlSVXuyJ6CyVSq2TqHDJZPI9QpHpJW7Qt1apVEbJsqwVIjqPSzWKDjOvCoBjItqI4hiXLTOfkG3b9wBm4xKNqPMgAMoAhiM6xmX+IQC+AKhxKUbUcQcCQPoWyD2E0q+h9EIkvRRLb0YD0Y4FhNQHiQCQ/iQTEFIfpX4Nl/os9yGkDiY+hNTRLNhSpQ2n4b7er/H8G7N6BRSbHvW5AAAAAElFTkSuQmCC');
+            $('#favicon16').attr('href', 'data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAA0ElEQVQ4T62TPQrCQBCF30tA8BZW9mJtY+MNEtKr2HkWK0Xtw+4NbGysxVorbyEKyZMNRiSgmJ/tZufNNzO7M0ThxHHc8zxvSnIIoPNyXyXt0zRdR1F0+gxhblhr25IWJMcA3vcFviRtSc6DILg5XyZ0wQB2AAbFir7YBwAjB8kAxpg1ycmfwZlM0iYMwyldz77vH3+U/Y2rJEn6NMYsSc7KZM+1kla01p4BdKsAAFwc4A6gVRHwaARQr4Xaj1j7G2sPUiOjnEMqL9PnDJRd5ycpJXsd2f2NIAAAAABJRU5ErkJggg==');
         };
         };
 
 
         this.output_ctrl = new OutputController(webio_session, output_container_elem);
         this.output_ctrl = new OutputController(webio_session, output_container_elem);

+ 53 - 17
pywebio/input.py

@@ -27,7 +27,6 @@
 import logging
 import logging
 from base64 import b64decode
 from base64 import b64decode
 from collections.abc import Mapping
 from collections.abc import Mapping
-from typing import Coroutine
 
 
 from .io_ctrl import single_input, input_control
 from .io_ctrl import single_input, input_control
 
 
@@ -59,7 +58,7 @@ def _parse_args(kwargs):
 
 
 
 
 def input(label='', type=TEXT, *, valid_func=None, name=None, value=None, placeholder=None, required=None,
 def input(label='', type=TEXT, *, valid_func=None, name=None, value=None, placeholder=None, required=None,
-          readonly=None, datalist=None, help_text=None, **other_html_attrs) -> Coroutine:
+          readonly=None, datalist=None, help_text=None, **other_html_attrs):
     r"""文本输入
     r"""文本输入
 
 
     :param str label: 输入框标签
     :param str label: 输入框标签
@@ -250,20 +249,28 @@ def _parse_action_buttons(buttons):
     :param label:
     :param label:
     :param actions: action 列表
     :param actions: action 列表
     action 可用形式:
     action 可用形式:
-        {label:, value:, [disabled:]}
-        (label, value, [disabled])
-        value 单值,label等于value
-    :return:
+
+        * dict: ``{label:选项标签, value:选项值, [type: 按钮类型], [disabled:是否禁止选择]}``
+        * tuple or list: ``(label, value, [type], [disabled])``
+        * 单值: 此时label和value使用相同的值
+
+    :return: 规格化后的 buttons
     """
     """
     act_res = []
     act_res = []
     for act in buttons:
     for act in buttons:
         if isinstance(act, Mapping):
         if isinstance(act, Mapping):
-            assert 'value' in act and 'label' in act, 'actions item must have value and label key'
+            assert 'label' in act, 'actions item must have label key'
+            assert 'value' in act or act.get('type', 'submit') != 'submit', \
+                'actions item must have value key for submit type'
         elif isinstance(act, (list, tuple)):
         elif isinstance(act, (list, tuple)):
-            assert len(act) in (2, 3), 'actions item format error'
-            act = dict(zip(('label', 'value', 'disabled'), act))
+            assert len(act) in (2, 3, 4), 'actions item format error'
+            act = dict(zip(('label', 'value', 'type', 'disabled'), act))
         else:
         else:
             act = dict(value=act, label=act)
             act = dict(value=act, label=act)
+
+        act.setdefault('type', 'submit')
+        assert act['type'] in ('submit', 'reset', 'cancel'), \
+            "submit type muse be 'submit' or 'reset' or 'cancel', not %r" % act['type']
         act_res.append(act)
         act_res.append(act)
 
 
     return act_res
     return act_res
@@ -271,18 +278,43 @@ def _parse_action_buttons(buttons):
 
 
 def actions(label='', buttons=None, name=None, help_text=None):
 def actions(label='', buttons=None, name=None, help_text=None):
     r"""按钮选项。
     r"""按钮选项。
-    在浏览器上显示为一组按钮,与其他输入组件不同,用户点击按钮后会立即将整个表单提交,而其他输入组件则需要手动点击表单的"提交"按钮。
+    在浏览器上显示为一组按钮,与其他输入组件不同,用户点击按钮后会立即将整个表单提交(除非设置按钮的 ``type='reset'`` ),
+    而其他输入组件则需要手动点击表单的"提交"按钮。
 
 
     当 ``actions()`` 作为 `input_group()` 的 ``inputs`` 中最后一个输入项时,表单默认的提交按钮会被当前 ``actions()`` 替换。
     当 ``actions()`` 作为 `input_group()` 的 ``inputs`` 中最后一个输入项时,表单默认的提交按钮会被当前 ``actions()`` 替换。
 
 
     :param list buttons: 选项列表。列表项的可用形式有:
     :param list buttons: 选项列表。列表项的可用形式有:
 
 
-        * dict: ``{label:选项标签, value:选项值, [disabled:是否禁止选择]}``
-        * tuple or list: ``(label, value, [disabled])``
+        * dict: ``{label:选项标签, value:选项值, [type: 按钮类型], [disabled:是否禁止选择]}`` .
+          若 ``type='reset'/'cancel'`` 可省略 ``value``
+        * tuple or list: ``(label, value, [type], [disabled])``
         * 单值: 此时label和value使用相同的值
         * 单值: 此时label和value使用相同的值
 
 
+       ``type`` 可选值为:
+
+        * ``'submit'`` : 点击按钮后,将整个表单提交。 ``'submit'`` 为 ``type`` 的默认值
+        * ``'cancel'`` : 取消输入。点击按钮后, ``actions()`` 将直接返回 ``None``
+        * ``'reset'`` : 点击按钮后,将整个表单重置,输入项将变为初始状态。
+          注意:点击 ``type=reset`` 的按钮后,并不会提交表单, ``actions()`` 调用也不会返回
+
     :param - label, name, help_text: 与 `input` 输入函数的同名参数含义一致
     :param - label, name, help_text: 与 `input` 输入函数的同名参数含义一致
-    :return: 用户点击的按钮的值
+    :return: 若用户点击当前按钮组中的某一按钮而触发表单提交,返回用户点击的按钮的值。
+       若用户点击 ``type=cancel`` 按钮或通过其它方式提交表单,则返回 ``None``
+
+    使用示例::
+
+        info = input_group('Add user', [
+            input('username', type=TEXT, name='username', required=True),
+            input('password', type=PASSWORD, name='password', required=True),
+            actions('actions', [
+                {'label': '提交', 'value': 'submit'},
+                {'label': '重置', 'type': 'reset'},
+                {'label': '取消', 'type': 'cancel'},
+            ], name='action', help_text='actions'),
+        ])
+        if info is not None:
+            save_user(info['username'], info['password'])
+
     """
     """
     assert buttons is not None, ValueError('Required `buttons` parameter in actions()')
     assert buttons is not None, ValueError('Required `buttons` parameter in actions()')
 
 
@@ -293,7 +325,8 @@ def actions(label='', buttons=None, name=None, help_text=None):
     return single_input(item_spec, valid_func, lambda d: d)
     return single_input(item_spec, valid_func, lambda d: d)
 
 
 
 
-def file_upload(label='', accept=None, name=None, placeholder='Choose file', required=None, help_text=None, **other_html_attrs):
+def file_upload(label='', accept=None, name=None, placeholder='Choose file', required=None, help_text=None,
+                **other_html_attrs):
     r"""文件上传。
     r"""文件上传。
 
 
     :param accept: 单值或列表, 表示可接受的文件类型。单值或列表项支持的形式有:
     :param accept: 单值或列表, 表示可接受的文件类型。单值或列表项支持的形式有:
@@ -323,7 +356,7 @@ def file_upload(label='', accept=None, name=None, placeholder='Choose file', req
     return single_input(item_spec, valid_func, read_file)
     return single_input(item_spec, valid_func, read_file)
 
 
 
 
-def input_group(label='', inputs=None, valid_func=None):
+def input_group(label='', inputs=None, valid_func=None, cancelable=True):
     r"""输入组。向页面上展示一组输入
     r"""输入组。向页面上展示一组输入
 
 
     :param str label: 输入组标签
     :param str label: 输入组标签
@@ -345,7 +378,10 @@ def input_group(label='', inputs=None, valid_func=None):
 
 
             print(data['name'], data['age'])
             print(data['name'], data['age'])
 
 
-    :return: 返回一个 ``dict`` , 其键为输入项的 ``name`` 值,字典值为输入项的值
+    :param bool cancelable: 表单是否可以取消。若 ``cancelable=True`` 则会在表单底部显示一个"取消"按钮。
+       注意:若 ``inputs`` 中最后一项输入为 `actions()` ,则忽略 ``cancelable``
+
+    :return: 若用户取消表单,返回 ``None`` ,否则返回一个 ``dict`` , 其键为输入项的 ``name`` 值,字典值为输入项的值
     """
     """
     assert inputs is not None, ValueError('Required `inputs` parameter in input_group()')
     assert inputs is not None, ValueError('Required `inputs` parameter in input_group()')
 
 
@@ -379,6 +415,6 @@ def input_group(label='', inputs=None, valid_func=None):
                 i['auto_focus'] = True
                 i['auto_focus'] = True
                 break
                 break
 
 
-    spec = dict(label=label, inputs=spec_inputs)
+    spec = dict(label=label, inputs=spec_inputs, cancelable=cancelable)
     return input_control(spec, preprocess_funcs=preprocess_funcs, item_valid_funcs=item_valid_funcs,
     return input_control(spec, preprocess_funcs=preprocess_funcs, item_valid_funcs=item_valid_funcs,
                          form_valid_funcs=valid_func)
                          form_valid_funcs=valid_func)

+ 4 - 1
pywebio/io_ctrl.py

@@ -4,10 +4,10 @@
 
 
 """
 """
 import logging
 import logging
+from functools import wraps
 
 
 from .session import get_session_implement, CoroutineBasedSession, get_current_task_id, get_current_session
 from .session import get_session_implement, CoroutineBasedSession, get_current_task_id, get_current_session
 from .utils import run_as_function, to_coroutine
 from .utils import run_as_function, to_coroutine
-from functools import wraps
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
 
 
@@ -138,6 +138,9 @@ def input_event_handle(item_valid_funcs, form_valid_funcs, preprocess_funcs):
 
 
             if all_valid:
             if all_valid:
                 break
                 break
+        elif event_name == 'from_cancel':
+            data = None
+            break
         else:
         else:
             logger.warning("Unhandled Event: %s", event)
             logger.warning("Unhandled Event: %s", event)
 
 

+ 4 - 2
pywebio/output.py

@@ -393,8 +393,10 @@ def put_image(content, format=None, title='', anchor=None, before=None, after=No
     format = '' if format is None else ('image/%s' % format)
     format = '' if format is None else ('image/%s' % format)
 
 
     b64content = b64encode(content).decode('ascii')
     b64content = b64encode(content).decode('ascii')
-    put_html(f'<img src="data:{format};base64, {b64content}" alt="{title}" />',
-             anchor=anchor, before=before, after=after)
+    html = r'<img src="data:{format};base64, {b64content}" alt="{title}" />'.format(format=format,
+                                                                                    b64content=b64content,
+                                                                                    title=title)
+    put_html(html, anchor=anchor, before=before, after=after)
 
 
 
 
 def put_file(name, content, anchor=None, before=None, after=None):
 def put_file(name, content, anchor=None, before=None, after=None):

+ 3 - 1
pywebio/platform/flask.py

@@ -34,7 +34,9 @@ from ..utils import STATIC_PATH
 from ..utils import random_str, LRUDict
 from ..utils import random_str, LRUDict
 
 
 # todo: use lock to avoid thread race condition
 # todo: use lock to avoid thread race condition
-_webio_sessions: Dict[str, AbstractSession] = {}  # WebIOSessionID -> WebIOSession()
+
+# type: Dict[str, AbstractSession]
+_webio_sessions = {}  # WebIOSessionID -> WebIOSession()
 _webio_expire = LRUDict()  # WebIOSessionID -> last active timestamp
 _webio_expire = LRUDict()  # WebIOSessionID -> last active timestamp
 
 
 DEFAULT_SESSION_EXPIRE_SECONDS = 60  # 超过60s会话不活跃则视为会话过期
 DEFAULT_SESSION_EXPIRE_SECONDS = 60  # 超过60s会话不活跃则视为会话过期

+ 1 - 1
requirements.txt

@@ -1 +1 @@
-tornado>=4.2.0
+tornado>=4.3.0