antd pro table中的文件上传

概述

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

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  };
    
posted @ 2020-09-10 18:03  wang_yb  阅读(2848)  评论(0编辑  收藏  举报