- A+
所属分类:Web前端
概述
项目中经常会遇到在表格中展示图片的需求(比如展示用户信息时, 有一列是用户的头像).
antd pro table 的功能很强大, 对于常规的信息展示只需参照示例配置 column 就可以了. 但是对于文件(比如图片) 在表格中的展示, 介绍并不多.
下面通过示例来演示 antd pro table 中图片的上传和展示.
示例代码
前端主要包含如下 2 部分:
- 列表页面: 通过 antd pro table 显示数据信息
- 表单页面: 新建/修改数据的页面, 上传图片的功能就在其中
一个模块主要包含如下几个文件:
- teacher.jsx: 显示数据列表信息
- teacher-form.jsx: 用于添加/修改数据
- model.js: list.jsx 和 form.jsx 之间共享数据
- 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
总结
-
这个模块的 增和改 用的同一个页面, 因为是弹出的 modal, 所有实际的提交功能是在 teacher.jsx 中完成的
-
antd upload 组件的 外围 FormItem 需要加上如下属性(valuePropName 和 getValueFromEvent):
1 <FormItem 2 label="用户头像" 3 name="avatarFile" 4 valuePropName="fileList" 5 getValueFromEvent={normFile} 6 > 7 <Upload /> 8 </FormItem>
-
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 };