antd pro table中的文件上传

  • A+
所属分类:Web前端
摘要

项目中经常会遇到在表格中展示图片的需求(比如展示用户信息时, 有一列是用户的头像).

概述

项目中经常会遇到在表格中展示图片的需求(比如展示用户信息时, 有一列是用户的头像).

antd pro table 的功能很强大, 对于常规的信息展示只需参照示例配置 column 就可以了. 但是对于文件(比如图片) 在表格中的展示, 介绍并不多.

下面通过示例来演示 antd pro table 中图片的上传和展示.

示例代码

前端主要包含如下 2 部分:

  1. 列表页面: 通过 antd pro table 显示数据信息
  2. 表单页面: 新建/修改数据的页面, 上传图片的功能就在其中

一个模块主要包含如下几个文件:

  1. teacher.jsx: 显示数据列表信息
  2. teacher-form.jsx: 用于添加/修改数据
  3. model.js: list.jsx 和 form.jsx 之间共享数据
  4. service.js: 访问后端的 API

下面的例子是实际项目中的一个简单的模块, 完成教师信息的 CURD, 教师的头像是图片文件

列表页面

  1  import React, { useState, useRef } from 'react';   2  import { connect } from 'umi';   3  import { PageHeaderWrapper } from '@ant-design/pro-layout';   4  import { Button, Card, Modal, Space, Popconfirm, Form, message } from 'antd';   5  import { PlusOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons';   6  import ProTable from '@ant-design/pro-table';   7  import { queryAllTeacher, addTeacher, updateTeacher, deleteTeacher } from './service';   8  import { getDictDataByCatagory, getDownloadUrl } from '@/utils/common';   9  import TeacherForm from './teacher-form';  10    11  const Teacher = (props) => {  12    const { dicts, form, avatarFid } = props;  13    const [createModalVisible, handleModalVisible] = useState(false);  14    15    // preview state  16    const [previewVisible, handlePreviewVisible] = useState(false);  17    const [previewImageUrl, handlePreviewImageUrl] = useState('');  18    19    const [record, handleRecord] = useState(null);  20    const tableRef = useRef();  21    22    const previewAvatar = (record) => {  23      handlePreviewVisible(true);  24      if (record.avatar) handlePreviewImageUrl(getDownloadUrl(record.avatar));  25      else handlePreviewImageUrl('/nopic.jpg');  26    };  27    28    const teacherColumns = [  29      {  30        title: '头像图片',  31        dataIndex: 'avatar',  32        hideInSearch: true,  33        render: (_, record) => (  34          <a onClick={() => previewAvatar(record)}>  35            {record.avatar ? (  36              <img src={getDownloadUrl(record.avatar)} width={50} height={60} />  37            ) : (  38              <img src={'/nopic.jpg'} width={50} height={60} />  39            )}  40          </a>  41        ),  42      },  43      {  44        title: '姓名',  45        dataIndex: 'login_name',  46      },  47      {  48        title: '性别',  49        dataIndex: 'sex',  50        hideInSearch: true,  51      },  52      {  53        title: '手机号',  54        dataIndex: 'mobile',  55      },  56      {  57        title: '身份证号码',  58        dataIndex: 'identity_card',  59        hideInSearch: true,  60      },  61      {  62        title: '个人简介',  63        dataIndex: 'comment',  64        ellipsis: true,  65        width: 300,  66        hideInSearch: true,  67      },  68      {  69        title: '来源类型',  70        dataIndex: 'teacher_source',  71        hideInSearch: true,  72        valueEnum: getDictDataByCatagory(dicts, 'teacher_source'),  73      },  74      {  75        title: '操作',  76        dataIndex: 'option',  77        valueType: 'option',  78        render: (_, record) => (  79          <Space>  80            <Button  81              type="primary"  82              size="small"  83              onClick={() => {  84                handleRecord(record);  85                // 设置avatar数据  86                let avatarUrl = '/nopic.jpg';  87    88                if (record.avatar) avatarUrl = getDownloadUrl(record.avatar);  89    90                record.avatarFile = [  91                  {  92                    uid: '1',  93                    name: 'avatar',  94                    status: 'done',  95                    url: avatarUrl,  96                  },  97                ];  98                handleModalVisible(true);  99              }} 100            > 101              修改 102            </Button> 103            <Popconfirm 104              placement="topRight" 105              title="是否删除?" 106              okText="Yes" 107              cancelText="No" 108              onConfirm={async () => { 109                const response = await deleteTeacher(record.id); 110                if (response.code === 10000) message.info('教师: [' + record.login_name + '] 已删除'); 111                else 112                  message.warn('教师: [' + record.login_name + '] 有关联的课程和班级信息, 无法删除'); 113                tableRef.current.reload(); 114              }} 115            > 116              <Button danger size="small"> 117                删除 118              </Button> 119            </Popconfirm> 120          </Space> 121        ), 122      }, 123    ]; 124   125    const okHandle = async () => { 126      const fieldsValue = await form.validateFields(); 127      // handleAdd(fieldsValue); 128      console.log(fieldsValue); 129      fieldsValue.avatar = avatarFid; 130      const response = record 131        ? await updateTeacher(record.id, fieldsValue) 132        : await addTeacher(fieldsValue); 133   134      if (response.code !== 10000) { 135        if ( 136          response.message.indexOf('Uniqueness violation') >= 0 && 137          response.message.indexOf('teacher_mobile_key') >= 0 138        ) 139          message.error('教师创建失败, 当前手机号已经存在'); 140      } 141   142      if (response.code === 10000) { 143        handleModalVisible(false); 144        tableRef.current.reload(); 145      } 146    }; 147   148    return ( 149      <PageHeaderWrapper title={false}> 150        <Card> 151          <ProTable 152            headerTitle="教师列表" 153            actionRef={tableRef} 154            rowKey="id" 155            toolBarRender={(action, { selectedRows }) => [ 156              <Button 157                icon={<PlusOutlined />} 158                type="primary" 159                onClick={() => { 160                  handleRecord(null); 161                  handleModalVisible(true); 162                }} 163              > 164                新建 165              </Button>, 166            ]} 167            request={async (params) => { 168              const response = await queryAllTeacher(params); 169              return { 170                data: response.data.teacher, 171                total: response.data.teacher_aggregate.aggregate.count, 172              }; 173            }} 174            columns={teacherColumns} 175          /> 176          <Modal 177            destroyOnClose 178            forceRender 179            title="教师信息" 180            visible={createModalVisible} 181            onOk={okHandle} 182            onCancel={() => handleModalVisible(false)} 183          > 184            <TeacherForm record={record} /> 185          </Modal> 186          <Modal 187            visible={previewVisible} 188            title={'用户头像'} 189            footer={null} 190            onCancel={() => handlePreviewVisible(false)} 191          > 192            <img alt="preview" style={{ width: '100%' }} src={previewImageUrl} /> 193          </Modal> 194        </Card> 195      </PageHeaderWrapper> 196    ); 197  }; 198   199  export default connect(({ dict, teacher }) => ({ 200    dicts: dict.dicts, 201    form: teacher.form, 202    avatarFid: teacher.avatarFid, 203  }))(Teacher); 

form 页面

  1  import React, { useState, useEffect } from 'react';   2  import _ from 'lodash';   3  import { connect } from 'umi';   4  import { formLayout } from '@/utils/common';   5  import { Form, Select, Input, Upload, Modal } from 'antd';   6  import { PlusOutlined, LoadingOutlined } from '@ant-design/icons';   7  import { upload } from '@/services/file';   8     9  const FormItem = Form.Item;  10  const { Option } = Select;  11  const { TextArea } = Input;  12    13  const TeacherForm = (props) => {  14    const { dispatch, dicts, record } = props;  15    const sexes = ['男', '女'];  16    const [fileList, handleFileList] = useState([]);  17    const [loading, handleLoading] = useState(false);  18    const [previewVisible, handlePreviewVisible] = useState(false);  19    const [previewTitle, handlePreviewTitle] = useState('');  20    const [previewImageUrl, handlePreviewImageUrl] = useState('');  21    22    const [form] = Form.useForm();  23    useEffect(() => {  24      if (form) {  25        form.resetFields();  26        dispatch({ type: 'teacher/setForm', payload: form });  27      }  28    29      // 初始化avatar  30      if (record && record.avatarFile) handleFileList(record.avatarFile);  31    32      if (record) dispatch({ type: 'teacher/setAvatarFid', payload: record.avatar });  33      else dispatch({ type: 'teacher/setAvatarFid', payload: '' });  34    }, []);  35    36    const handleChange = async ({ file, fileList }) => {  37      handleFileList(fileList);  38      if (file.status === 'uploading') handleLoading(true);  39      if (file.status === 'done') handleLoading(false);  40    };  41    42    const uploadButton = (  43      <div disabled>  44        {loading ? <LoadingOutlined /> : <PlusOutlined />}  45        <div className="ant-upload-text">上传照片</div>  46      </div>  47    );  48    49    const uploadAvatar = async ({ onSuccess, onError, file }) => {  50      const response = await upload('avatar', file);  51      try {  52        const {  53          code,  54          data: { fid },  55        } = response;  56    57        onSuccess(response, file);  58    59        dispatch({ type: 'teacher/setAvatarFid', payload: fid });  60      } catch (e) {  61        onError(e);  62      }  63    };  64    65    const previewImage = async (file) => {  66      handlePreviewVisible(true);  67      handlePreviewTitle(file.name);  68      let src = file.url;  69      if (!src) {  70        src = await new Promise((resolve) => {  71          const reader = new FileReader();  72          reader.readAsDataURL(file.originFileObj);  73          reader.onload = () => resolve(reader.result);  74        });  75      }  76      handlePreviewImageUrl(src);  77    };  78    79    const removeImage = () => {  80      handleFileList([]);  81      dispatch({ type: 'teacher/setAvatarFid', payload: '' });  82    };  83    84    const normFile = (e) => {  85      if (Array.isArray(e)) {  86        return e;  87      }  88      return e && e.fileList;  89    };  90    91    const uploadProps = {  92      name: 'avatar',  93      listType: 'picture-card',  94      className: 'avatar-uploader',  95      customRequest: uploadAvatar,  96      onPreview: previewImage,  97      onRemove: removeImage,  98      fileList: fileList,  99    }; 100   101    return ( 102      <div> 103        <Form form={form} {...formLayout} initialValues={record ? { ...record } : ''}> 104          <FormItem 105            label="来源类型" 106            name="teacher_source" 107            rules={[ 108              { 109                required: true, 110              }, 111            ]} 112          > 113            <Select 114              style={{ 115                width: '100%', 116              }} 117            > 118              {_.filter(dicts, (d) => d.catagory === 'teacher_source').map((r) => ( 119                <Option key={r.id} value={r.key}> 120                  {r.val} 121                </Option> 122              ))} 123            </Select> 124          </FormItem> 125          <FormItem 126            label="姓名" 127            name="login_name" 128            rules={[ 129              { 130                required: true, 131              }, 132            ]} 133          > 134            <Input placeholder="姓名" /> 135          </FormItem> 136          <FormItem 137            label="性别" 138            name="sex" 139            rules={[ 140              { 141                required: true, 142              }, 143            ]} 144          > 145            <Select 146              style={{ 147                width: '100%', 148              }} 149            > 150              {sexes.map((r) => ( 151                <Option key={r} value={r}> 152                  {r} 153                </Option> 154              ))} 155            </Select> 156          </FormItem> 157          <FormItem 158            label="手机号" 159            name="mobile" 160            rules={[ 161              { 162                pattern: new RegExp(/^1[3-9]d{9}$/, 'g'), 163                message: '手机号格式不正确', 164              }, 165            ]} 166          > 167            <Input placeholder="手机号" /> 168          </FormItem> 169          <FormItem label="身份证号码" name="identity_card"> 170            <Input placeholder="身份证号码" /> 171          </FormItem> 172          <FormItem label="个人简介" name="comment"> 173            <TextArea rows={4} placeholder="个人简介" /> 174          </FormItem> 175          <FormItem 176            label="用户头像" 177            name="avatarFile" 178            valuePropName="fileList" 179            getValueFromEvent={normFile} 180          > 181            <Upload {...uploadProps} onChange={handleChange}> 182              {fileList.length >= 1 ? null : uploadButton} 183            </Upload> 184          </FormItem> 185        </Form> 186        <Modal 187          visible={previewVisible} 188          title={previewTitle} 189          footer={null} 190          onCancel={() => handlePreviewVisible(false)} 191        > 192          <img alt="preview" style={{ width: '100%' }} src={previewImageUrl} /> 193        </Modal> 194      </div> 195    ); 196  }; 197   198  export default connect(({ dict }) => ({ 199    dicts: dict.dicts, 200  }))(TeacherForm); 

model.js

 1  import { message } from 'antd';  2    3  const Model = {  4    namespace: 'teacher',  5    state: {  6      form: null,  7      avatarFid: '',  8    },  9   10    effects: {}, 11    reducers: { 12      setForm(state, { payload }) { 13        return { 14          ...state, 15          form: payload, 16        }; 17      }, 18      setAvatarFid(state, { payload }) { 19        return { 20          ...state, 21          avatarFid: payload, 22        }; 23      }, 24    }, 25  }; 26  export default Model; 

service.js

 1  import { graphql } from '@/services/graphql_client';  2  import md5 from 'md5';  3  import moment from 'moment';  4    5  const gqlQueryAll = `  6  query search_teacher($login_name: String, $mobile: String, $limit: Int!, $offset: Int!) {  7    teacher(order_by: {updated_at: desc}, limit: $limit, offset: $offset, where: {login_name: {_ilike: $login_name}, mobile: {_ilike: $mobile}}) {  8      id  9      avatar 10      comment 11      identity_card 12      login_name 13      mobile 14      sex 15      teacher_source 16    } 17    teacher_aggregate(where: {login_name: {_ilike: $login_name}, mobile: {_ilike: $mobile}}) { 18      aggregate { 19        count 20      } 21    } 22  } 23  `; 24   25  const qplAddTeacher = ` 26  mutation add_teacher($avatar: uuid, $comment: String, $identity_card: String, $login_name: String!, $mobile: String, $sex: String!, $teacher_source: String!, $password: String!){ 27    insert_teacher_one(object: {avatar: $avatar, comment: $comment, identity_card: $identity_card, login_name: $login_name, mobile: $mobile, sex: $sex, teacher_source: $teacher_source, password: $password}) { 28      id 29    } 30  } 31  `; 32   33  const qplUpdateTeacher = ` 34  mutation update_teacher($id: uuid!, $avatar: uuid, $comment: String, $identity_card: String, $login_name: String, $mobile: String, $sex: String, $teacher_source: String) { 35    update_teacher_by_pk(_set: {avatar: $avatar, comment: $comment, identity_card: $identity_card, login_name: $login_name, mobile: $mobile, sex: $sex, teacher_source: $teacher_source}, pk_columns: {id: $id}) { 36      id 37    } 38  } 39  `; 40   41  const qplDeleteTeacher = ` 42  mutation del_teacher($id: uuid!){ 43    delete_teacher_by_pk(id: $id) { 44      id 45    } 46  } 47  `; 48   49  export async function queryAllTeacher(params) { 50    let qplVar = { 51      limit: params.pageSize, 52      offset: (params.current - 1) * params.pageSize, 53    }; 54   55    if (params.login_name) qqlVar.login_name = '%' + params.login_name + '%'; 56    if (params.mobile) qqlVar.mobile = '%' + params.mobile + '%'; 57   58    return graphql(gqlQueryAll, qplVar); 59  } 60   61  export async function addTeacher(params) { 62    const { avatar, comment, identity_card, mobile, sex, login_name, teacher_source } = params; 63   64    let insertVar = { login_name, sex, mobile, teacher_source }; 65    if (avatar !== '') insertVar.avatar = avatar; 66    if (identity_card) insertVar.identity_card = identity_card; 67    if (comment) insertVar.comment = comment; 68    if (mobile) { 69      insertVar.mobile = mobile; 70      insertVar.password = md5(mobile.slice(-6)); 71    } else { 72      // default password 73      insertVar.password = md5('123456'); 74    } 75   76    return graphql(qplAddTeacher, { 77      ...insertVar, 78    }); 79  } 80   81  export async function updateTeacher(id, params) { 82    let { avatar, comment, identity_card, mobile, sex, login_name, teacher_source } = params; 83    if (avatar === '') avatar = null; 84    return graphql(qplUpdateTeacher, { 85      id, 86      avatar, 87      comment, 88      identity_card, 89      mobile, 90      sex, 91      login_name, 92      teacher_source, 93    }); 94  } 95   96  export async function deleteTeacher(id) { 97    return graphql(qplDeleteTeacher, { id }); 98  } 

service.js 中的请求是 graphql api

总结

  1. 这个模块的 增和改 用的同一个页面, 因为是弹出的 modal, 所有实际的提交功能是在 teacher.jsx 中完成的

  2. antd upload 组件的 外围 FormItem 需要加上如下属性(valuePropName 和 getValueFromEvent):

    1  <FormItem 2    label="用户头像" 3    name="avatarFile" 4    valuePropName="fileList" 5    getValueFromEvent={normFile} 6  > 7      <Upload /> 8  </FormItem> 
  3. antd upload 组件虽然有默认的上传事件, 但是如果自定义上传的事件, 可以更方便的和自己的后端 API 进行对接

     1  const uploadAvatar = async ({ onSuccess, onError, file }) => {  2    const response = await upload('avatar', file);  3    try {  4      const {  5        code,  6        data: { fid },  7      } = response;  8    9      onSuccess(response, file); 10   11      dispatch({ type: 'teacher/setAvatarFid', payload: fid }); 12    } catch (e) { 13      onError(e); 14    } 15  };