ソースを参照

feat: `file_upload()` support multiple upload and set file max size

wangweimin 4 年 前
コミット
8daf2d72e1

+ 6 - 1
docs/spec.rst

@@ -138,6 +138,11 @@ input_group
   * buttons: 选项列表。``{label:选项标签, value:选项值, [type: 按钮类型 'submit'/'reset'/'cancel'/'callback'], [disabled:是否禁止选择]}`` .
     当 type 为 'callback' 时,value 字段表示回调函数的callback_id
 
+* file:
+
+   * multiple: 是否允许多文件上传
+   * max_size: 单个文件的最大大小,超过限制将会禁止上传
+   * max_total_size: 所有文件的最大大小,超过限制将会禁止上传
 
 update_input
 ^^^^^^^^^^^^^^^
@@ -150,7 +155,7 @@ update_input
 * target_value: str,可选。 用于在checkbox, radio, actions输入中过滤input(这些类型的输入项包含多个html input元素)
 * attributes: dist 需要更新的内容
 
-  * valid_status: bool 输入值的有效性,通过/不通过
+  * valid_status: 为bool时,表示设置输入值的有效性,通过/不通过; 为0时,表示清空valid_status标志
   * value: 输入项的值
   * placeholder:
   * invalid_feedback

+ 38 - 10
pywebio/input.py

@@ -429,8 +429,22 @@ def actions(label='', buttons=None, name=None, help_text=None):
     return single_input(item_spec, valid_func, partial(preprocess_func, value_setter=value_setter))
 
 
-def file_upload(label='', accept=None, name=None, placeholder='Choose file', required=None, help_text=None,
-                **other_html_attrs):
+def _parse_file_size(size):
+    if isinstance(size, (int, float)):
+        return int(size)
+    assert isinstance(size, str), '`size` must be int/float/str, got %s' % type(size)
+
+    for idx, i in enumerate(['k', 'm', 'g'], 1):
+        if i in size:
+            s = size.lower().replace(i, '')
+            base = 2 ** (idx * 10)
+            return int(float(s) * base)
+
+    return 0
+
+
+def file_upload(label='', accept=None, name=None, placeholder='Choose file', multiple=False, max_size=0,
+                max_total_size=0, required=None, help_text=None, **other_html_attrs):
     r"""文件上传。
 
     :param accept: 单值或列表, 表示可接受的文件类型。单值或列表项支持的形式有:
@@ -443,18 +457,32 @@ def file_upload(label='', accept=None, name=None, placeholder='Choose file', req
 
     :type accept: str or list
     :param str placeholder: 未上传文件时,文件上传框内显示的文本
-    :param bool required: 是否必须要上传文件
+    :param bool multiple: 是否允许多文件上传
+    :param int/str max_size: 单个文件的最大大小,超过限制将会禁止上传。默认为0,表示不限制上传文件的大小。
+
+       可以为数字表示的字节数,或以 `K` / `M` / `G` 结尾的表示的字符串(分别表示 千字节、兆字节、吉字节,大小写不敏感)。例如:
+       ``max_size=500`` , ``max_size='40K'`` , ``max_size='3M'``
+
+    :param int/str max_total_size: 所有文件的最大大小,超过限制将会禁止上传。仅在 ``multiple=True`` 时可用,默认不限制上传文件的大小。 格式同 ``max_size`` 参数
+    :param bool required: 是否必须要上传文件。默认为 `False`
     :param - label, name, help_text, other_html_attrs: 与 `input` 输入函数的同名参数含义一致
-    :return: 用户没有上传文件时,返回 ``None`` ;上传文件返回dict: ``{'filename': 文件名, 'content':文件二进制数据(bytes object)}``
+    :return: ``multiple=False`` 时(默认),返回dict: ``{'filename': 文件名, 'content':文件二进制数据(bytes object), mime_type: 文件的MIME类型, last_modified: 文件上次修改时间(时间戳) }`` ;
+       若用户没有上传文件,返回 ``None`` 。
+
+       ``multiple=True`` 时,返回列表,列表项格式同上文 ``multiple=False`` 时的返回值;若用户没有上传文件,返回空列表。
     """
     item_spec, valid_func = _parse_args(locals())
     item_spec['type'] = 'file'
+    item_spec['max_size'] = _parse_file_size(max_size)
+    item_spec['max_total_size'] = _parse_file_size(max_total_size)
 
-    def read_file(data):  # data: None or {'filename':, 'dataurl'}
-        if data is None:
-            return data
-        header, encoded = data['dataurl'].split(",", 1)
-        data['content'] = b64decode(encoded)
+    def read_file(data):  # data: None or [{'filename':, 'dataurl', 'mime_type', 'last_modified'}, ...]
+        for d in data:
+            header, encoded = d['dataurl'].split(",", 1)
+            d['content'] = b64decode(encoded)
+
+        if not multiple:
+            return data[0] if len(data) >= 1 else None
         return data
 
     return single_input(item_spec, valid_func, read_file)
@@ -514,7 +542,7 @@ def input_group(label='', inputs=None, valid_func=None, cancelable=False):
 
     if all('auto_focus' not in i for i in spec_inputs):  # 每一个输入项都没有设置auto_focus参数
         for i in spec_inputs:
-            text_inputs = {TEXT, NUMBER, PASSWORD, SELECT}  # todo update
+            text_inputs = {TEXT, NUMBER, PASSWORD, SELECT, URL}  # todo update
             if i.get('type') in text_inputs:
                 i['auto_focus'] = True
                 break

+ 2 - 2
test/template.py

@@ -390,7 +390,7 @@ def basic_input():
     put_markdown(f'`{repr(text)}`')
 
     # 文件上传
-    img = yield file_upload("Select a image:", accept="image/*")
+    img = yield file_upload("Select a image:", accept="image/*", max_size=10**7)
     put_image(img['content'], title=img['filename'])
 
     # 输入参数
@@ -525,7 +525,7 @@ def basic_input():
             '标签5,selected',
         ], inline=False, name='radio', value='标签5,selected', valid_func=check_item),
 
-        file_upload('file_upload', name='file_upload'),
+        file_upload('file_upload', name='file_upload', max_size='10m'),
 
         actions('actions', [
             {'label': '提交', 'value': 'submit'},

+ 5 - 0
webiojs/src/handlers/input.ts

@@ -209,6 +209,11 @@ class FormController {
         // 事件绑定
         element.on('submit', 'form', function (e) {
             e.preventDefault(); // avoid to execute the actual submit of the form.
+
+            for(let name in that.name2input)
+                if(!that.name2input[name].check_valid())
+                    return alert('输入项存在错误,请修复错误后再提交');
+
             let data: { [i: string]: any } = {};
             $.each(that.name2input, (name, ctrl) => {
                 data[name] = ctrl.get_value();

+ 6 - 0
webiojs/src/models/input/base.ts

@@ -27,6 +27,11 @@ export class InputItem {
         throw new Error("Not implement!");
     }
 
+    // 检查输入项的有效性,在表单提交时调用
+    check_valid():boolean{
+        return true;
+    }
+
     //在表单加入DOM树后触发
     after_add_to_dom(): any {
 
@@ -75,6 +80,7 @@ export class InputItem {
 
         if ('valid_status' in attributes) {
             let class_name = attributes.valid_status ? 'is-valid' : 'is-invalid';
+            if(attributes.valid_status===0) class_name = '';  // valid_status为0时,表示清空valid_status标志
             input_elem.removeClass('is-valid is-invalid').addClass(class_name);
             delete attributes.valid_status;
         }

+ 51 - 9
webiojs/src/models/input/file.ts

@@ -18,7 +18,8 @@ const file_input_tpl = `
 export class File extends InputItem {
     static accept_input_types: string[] = ["file"];
 
-    data_url_value: { filename: string, dataurl: string } = null; // 待上传文件信息
+    data_url_value: { filename: string, dataurl: string, mime_type: string, last_modified: number, size: number }[] = []; // 待上传文件信息
+    valid = true;
 
     constructor(session: Session, task_id: string, spec: any) {
         super(session, task_id, spec);
@@ -48,25 +49,66 @@ export class File extends InputItem {
         // 文件选中后先不通知后端
         let that = this;
         input_elem.on('change', function () {
-            let file = (input_elem[0] as HTMLInputElement).files[0];
-            let fr = new FileReader();
-            fr.onload = function () {
-                that.data_url_value = {
-                    'filename': file.name,
-                    'dataurl': fr.result as string
+            that.data_url_value = [];
+            let total_size = 0;
+            that.valid = true;
+            let file = (input_elem[0] as HTMLInputElement).files;
+            for (let f of file) {
+                let fr = new FileReader();
+                total_size += f.size;
+
+                if (that.spec.max_size && f.size > that.spec.max_size) {
+                    that.valid = false;
+                    that.update_input_helper(-1, {
+                        'valid_status': false,
+                        'invalid_feedback': `文件"${f.name}"大小超过限制: 单个文件大小不超过${that._formate_size(that.spec.max_size)}`
+                    });
+                } else if (that.spec.max_total_size && total_size > that.spec.max_total_size) {
+                    that.valid = false;
+                    that.update_input_helper(-1, {
+                        'valid_status': false,
+                        'invalid_feedback': `文件总大小超过限制: 文件总大小不超过${that._formate_size(that.spec.max_total_size)}`
+                    });
+                    return
+                }
+                if (!that.valid) return;
+                that.update_input_helper(-1, {'valid_status': 0});
+
+                fr.onload = function () {
+                    that.data_url_value.push({
+                        'filename': f.name,
+                        'size': f.size,
+                        'mime_type': f.type,
+                        'last_modified': f.lastModified / 1000,
+                        'dataurl': fr.result as string
+                    });
                 };
-            };
-            fr.readAsDataURL(file);
+                fr.readAsDataURL(f);
+            }
+
         });
 
         return this.element;
     }
 
+    _formate_size(size: number): string {
+        for (let s of ['Byte', 'Kb', 'Mb']) {
+            if (size / 1024 < 1)
+                return size.toFixed(2) + s;
+            size = size / 1024;
+        }
+        return size.toFixed(2) + 'Gb';
+    }
+
     update_input(spec: any): any {
         let attributes = spec.attributes;
         this.update_input_helper(-1, attributes);
     }
 
+    check_valid(): boolean {
+        return this.valid;
+    }
+
     get_value(): any {
         return this.data_url_value;
     }