1. 实现布局交互
首先确定新增和编辑的按钮布局, 这里我们采用新增弹窗的交互形式来实现:
创建文件: src\pages\content\Article\components\ArticleEdit.tsx
import React, { useEffect,useState } from 'react';
import {Modal, Button, Form , Input, Switch} from 'antd';
import type {SetStateAction} from 'react';
const {TextArea} = Input;
type PropsType = {
isShow: boolean;
setIsShow: any;
editId?: string;
}
const ArticleEdit: React.FC<PropsType> = props=>{
const {isShow,setIsShow,editId} = props;
const handleOk = ()=>{
setIsShow(false);
}
const handleCancel = ()=>{
setIsShow(false);
}
const [form] = Form.useForm();
useEffect(()=>{
if(isShow&&editId){
alert('编辑请求...')
}else if(isShow&&!editId){
alert('新增请求...')
}
},[isShow])
/**
* 提交保存
*/
const handleFinish = v=>{
console.log(v);
}
return (
<Modal width="60%" title="编辑文章" visible={isShow} onOk={handleOk} onCancel={handleCancel}>
<Form
form={form}
onFinish={handleFinish}
labelCol={
{
span: 4
}
}
wrapperCol={
{
span:20
}
}
>
<Form.Item
label="标题"
rules={
[{required: true, message: '标题不能为空!'}]
}
name="title"
>
<Input placeholder="请输入文章标题"></Input>
</Form.Item>
<Form.Item
label="作者"
rules={
[{required: true, message: '作者不能为空!'}]
}
name="author"
>
<Input placeholder="请输入作者"></Input>
</Form.Item>
<Form.Item
label="文章概要"
name="summary"
>
<TextArea></TextArea>
</Form.Item>
<Form.Item
label="文章显示"
name="isShow"
>
<Switch checkedChildren="显示" unCheckedChildren="隐藏" defaultChecked></Switch>
</Form.Item>
</Form>
</Modal>
)
}
export default ArticleEdit;
src\pages\content\Article\index.tsx
import React, { useEffect, useState } from 'react';
// # https://procomponents.ant.design/components/page-container
import { PageContainer } from '@ant-design/pro-layout';
import type { ArticleType } from './model';
import { Table, Form, Input, Button, Card, Row, Col,Space } from 'antd';
import type {TableColumnType} from 'antd';
import { connect } from 'umi';
import type { Dispatch } from 'umi';
import styles from './index.less';
import {pickBy} from 'lodash';
// 引入明细组件
import ArticleEdit from './components/ArticleEdit';
// 定义model命名空间
const namespace = 'article';
type StateType = {
articleList: ArticleType[];
articleTotalCount: number;
dispatch: Dispatch;
loading: any;
};
type PropsType = {
articleList: ArticleType[];
articleTotalCount: number;
dispatch: Dispatch;
loading: boolean;
};
type SearchType = {
author?: string;
title?: string;
}
// type ColumnType = {
// title: string;
// dataIndex?: string;
// render: any
// }
const ArticleList: React.FC<PropsType> = (props) => {
const { articleList, articleTotalCount, dispatch, loading } = props;
// 条件查询,可以根据title,author查询
const [params,setParams] = useState<SearchType>({});
// 分页起始页肯定是 1
const [current, setCurrent] = useState(1);
// 数据量不大 为了体现分页 暂时控制每页2条数据
const [pageSize, setPageSize] = useState(2);
// 是否打开编辑
const [isEdit, setIsEdit] = useState<boolean>(false);
// 编辑id
const [editId,setEditId] = useState<string>('');
// 获取form组件
const [form] = Form.useForm();
// 监听current和pageSize的变化重新发送请求
useEffect(() => {
dispatch({
type: `${namespace}/findArticleList`,
payload: {
start: current,
limit: pageSize,
params,
},
});
}, [current, pageSize,params]);
/**
* 去新增页面
* @param id
*/
const goAdd = ()=>{
setEditId('');
setIsEdit(true);
}
/**
* 去编辑页
* @param id
*/
const goEdit = (id: string)=>{
// alert(`id${id}`)
setEditId(id);
setIsEdit(true);
}
/**
* 删除行数据
* @param id
*/
const goDel = (id: string)=>{
alert(`del${id}`)
}
type ColumnType = {
title: string;
dataIndex?: string;
}
// 放到组件外部将无法调用组件内部的 state ; 所以移到组件内部
const colums: TableColumnType<ColumnType>[] = [
{
title: '#',
// 如果定义形参未使用 建议添加_
render(_text,_record,index){
return `${index+1}`;
}
},
{
title: 'ID',
dataIndex: 'id',
},
{
title: '标题',
dataIndex: 'title',
},
{
title: '作者',
dataIndex: 'author',
},
{
title: '封面',
render: ({coverImg}: ArticleType) => <img className={styles.coverImg} src={coverImg} alt="" />,
},
{
title: '阅读量',
dataIndex: 'viewCount',
},
{
title: '创建时间',
dataIndex: 'createTime',
},
{
title: '操作',
render:({id})=>(
<Space>
<a onClick={()=>goEdit(id)}>编辑</a>
<a onClick={()=>goDel(id)} className={styles.delBtn}>删除</a>
</Space>
)
}
];
/**
* 提交表单触发事件 v为表单name对应的对象
* @param v
*/
const onFinish = (v: SearchType)=>{
// console.log(v);
// 对于空对象 可能会出现 undefined 的情况,需要去除该属性
const useParams = pickBy(v,(item: any)=>item);
// 我们只需要改变了 params 就可以触发 useEffect
setParams(useParams);
setCurrent(1);
}
const onReset = ()=>{
setParams({});
form.resetFields();
}
return (
<PageContainer className={styles.main}>
<Card className={styles.searchBar}>
<Form
form={form}
labelCol={{
span: 4,
}}
wrapperCol={{
span: 20,
}}
layout="inline"
onFinish={onFinish}
>
<Row gutter={[20,30]} style={{width:'100%'}}>
<Col span={6}>
<Form.Item label="标题" name="title">
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item label="作者" name="author">
<Input />
</Form.Item>
</Col>
<Col span={6} offset={18}>
<Form.Item>
<Button type="ghost" onClick={onReset}>重置</Button>
<Button type="primary" htmlType="submit" style={{marginLeft: '20px'}}>
查询
</Button>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
{/* rowKey 表格行 key 的取值,可以是字符串或一个函数 标识唯一性,这里我们用每一条数据的id作为key */}
<Button onClick={goAdd} type="primary" className={styles.addBtn}>新增</Button>
<Table
loading={loading}
rowKey="id"
columns={colums}
dataSource={articleList}
pagination={{
showQuickJumper: true,
current,
pageSize,
total: articleTotalCount,
showSizeChanger: true,
onShowSizeChange(_curr, size) {
// console.log(v)
setPageSize(size);
},
onChange(v) {
setCurrent(v);
},
}}
/>
{/* 弹窗部分 */}
<ArticleEdit editId={editId} isShow={isEdit} setIsShow={setIsEdit} />
</PageContainer>
);
};
/**
* 提取需要的属性
* dva-loading
* @param param0
*/
const mapStateToProps = (state: StateType) => {
return {
articleList: state[namespace].articleList,
articleTotalCount: state[namespace].articleTotalCount,
dispatch: state.dispatch,
loading: state.loading.effects[`${namespace}/findArticleList`] as boolean,
};
};
export default connect(mapStateToProps)(ArticleList);
2. 接口封装和modal实现
新增和编辑是两个需要大量复用组件的功能,通过在乐居商城的后台实现中总结的经验,完全可以考虑把这两个功能共享同样的组件来实现, 减少代码量,提高复用性. src\services\content\article.ts
import type {ArticleType} from '@/pages/content/Article/model';
...
/**
* 新增文章
* @param params
*/
export async function addArticle(params: ArticleType) {
return request(`/lejuAdmin/productArticle/addArticle`, {
method: 'POST',
data: params,
});
}
/**
* 编辑文章
* @param params
*/
export async function updateArticle(params: ArticleType) {
return request(`/lejuAdmin/productArticle/updateArticle`, {
method: 'POST',
data: params,
});
}
/**
* 删除文章
* @param id
*/
export async function delArticle({id}: {id: string}) {
return request(`/lejuAdmin/productArticle/del/${id}`, {
method: 'DELETE'
});
}
src\pages\content\Article\components\model.ts
这里先添加新增和编辑功能, 同样的,我们根据提交的数据对象是否有 id(id为后台生成) 来判断当前请求是新增还是编辑.
疑问: callback
回调函数的意义?
- 在vuex中的action中,可以通过返回一个promise对象来响应处理, 在dva的effects中,我们可以通过传入回调函数的形式来通知结果. ```typescript import {addArticle as addArticleApi,updateArticle as updateArticleApi} from ‘@/services/content/article’; import {message} from ‘antd’; import type {Effect,Reducer} from ‘umi’;
export type MType = { namespace: string; state: {
}; effects: { saveArticle: Effect; }, reducers: {
}
}
const M: MType = { namespace: ‘articleEdit’, state: {
}, effects: { *saveArticle({payload}, {call,put}){ // 成功后为了通知组件 这里添加了callback的回调函数 const {article,callback} = payload; // 如果有id则为编辑,否则为新增. 这个和vue乐居商城逻辑一样. const saveOrUpdateApi = article.id ? updateArticleApi:addArticleApi; const {success,message:errMsg} = yield call(saveOrUpdateApi,article); if(success){ message.success(‘保存成功!’); if(callback && typeof callback === ‘function’){ callback(); } }else{ message.error(errMsg); } }
}, reducers: {
} }
export default M;
<a name="1l6CX"></a>
### 3. 富文本
从antd官方推荐的 [社区精选推荐](https://ant.design/docs/react/recommendation-cn) 中找到 [braft-editor](https://github.com/margox/braft-editor) .<br />**注意事项: **
- braft富文本的`value `类型为 `EditorState`, 获取html需要调用 `value.toHTML()` 方法
- `value` ` onChange` `onSave`
- 防抖提高性能
```shell
# 安装依赖
npm install braft-editor --save
// 给富文本添加关联值
const [editorState,setEditorState] = useState<EditorState>(null);
// 初始化富文本内容
setEditorState(BraftEditor.createEditorState(article.content1))
// 获取富文本转换的html
const htmlStr = editorState.toHTML();
// 监听变化
const handleEditorChange = (v: EditorState) => {
setEditorState(v);
};
<BraftEditor
style={{ border: '1px solid #e5e5e5' }}
value={editorState}
onChange={debounce(handleEditorChange, 500)}
onSave={submitContent}
/>
4. 上传图片
接口使用通用上传接口,上传成功之后不会保存数据库, 后台对接阿里云OSS对象存储, 成功后返回阿里云资源地址.
注意事项:
- 上传接口也需要token,通过自定义headers实现
listType="picture"
可以使用默认样式fileList
属性必须添加,否则会造成 status 无法变成 done的bug, 请参考 #2423- 在
4.13.0
版本之前受控状态存在 bug。 Form.Item
包含Upload
组件, 如何实现校验?
onChange#
上传中、完成、失败都会调用这个函数。
文件状态改变的回调,返回为:
{
file: { /* ... */ },
fileList: [ /* ... */ ],
event: { /* ... */ },
}
file
当前操作的文件对象。{
uid: 'uid', // 文件唯一标识,建议设置为负数,防止和内部产生的 id 冲突
name: 'xx.png' // 文件名
status: 'done', // 状态有:uploading done error removed,被 beforeUpload 拦截的文件没有 status 属性
response: '{"status": "success"}', // 服务端响应内容
linkProps: '{"download": "image"}', // 下载链接额外的 HTML 属性
}
fileList
当前的文件列表。event
上传中的服务端响应内容,包含了上传进度等信息,高级浏览器支持。
5. 表单验证
注意事项:
- 因为不是通过
Form.onFinish
触发,需要手动触发表单校验: validateFields Form.Item
默认会通过name
属性和包含子组件的value
属性进行匹配校验,如果子组件没有value属性:
Form.Item 包含多个子元素,会报错. 我们需要对多个子元素 进行封装,变成一个自定义组件, 自定义或第三方的表单控件,也可以与 Form 组件一起使用。只要该组件遵循以下的约定:
- 提供受控属性
value
或其它与valuePropName
的值同名的属性。 - 提供
onChange
事件或trigger
的值同名的事件。
- 提供受控属性
对于
Form.Item
包含Upload
组件, 无法触发表单验证的问题处理, 我们把Upload组件封装为自定义组件, 该组件接收value
和onChange
,遵循以上约定即可.src\pages\content\Article\components\ArticleEdit.tsx
关于
UploadFile
的类型推导,通过提示找到源码位置,直接引入// 根据提示找到文件类型的位置 手动引入
import type { UploadFile } from 'antd/lib/upload/interface';
...
// 局部组件 自定义formItem组件
// # https://ant.design/components/form-cn/#components-form-demo-customized-form-controls
const FormUploadFC: React.FC<UploadPropsType> = ({ onChange }) => {
const [fileList, setFileList] = useState<UploadFile[]>([]);
return (
<Upload
action="/lejuAdmin/material/uploadFileOss"
listType="picture"
fileList={fileList}
headers={{
token: getToken() || '',
}}
// 用于移除文件后 对表单的校验同步
onRemove={() => {
onChange?.('');
return true;
}}
// # https://ant.design/components/upload-cn/#API
onChange={({ file, fileList: files }: { file: UploadFile; fileList: UploadFile[] }) => {
// clone数组,然后只需要一个
let nowFiles = [...files];
nowFiles = nowFiles.slice(-1);
const { status, response } = file;
if (status === 'done') {
// 获取上传成功的回调结果
const { success, data, message: err } = response;
if (success) {
message.success(`${file.name} 上传成功!`);
// 避免因直接修改实参造成的报错
if (nowFiles.length > 0) {
nowFiles[0].url = data.fileUrl;
onChange?.(data.fileUrl);
}
} else {
message.error(err);
}
} else if (status === 'error') {
message.error(`${file.name} file upload failed.`);
}
// # https://github.com/ant-design/ant-design/issues/2423
setFileList(nowFiles);
}}
>
<Button icon={<UploadOutlined />}>Upload</Button>
</Upload>
);
};
// ... 使用
<Form.Item
label="上传封面"
name="coverImg"
rules={[{ required: true, message: '上传封面图片' }]}
>
<FormUploadFC />
</Form.Item>
6. 提交保存
提交保存,需要对数据进行格式化,使之符合接口数据要求.
注意事项:coverImg字段已经通过自定义表单组件添加到了form
- cb是回调函数,需要关闭窗口,重置表单,刷新父类列表.
src\pages\content\Article\components\ArticleEdit.tsx
// 默认到处组件
const ArticleEdit: React.FC<PropsType> = (props) => {
// refresh 为通过父级组件传入的参数 为函数类型
const { isShow, setIsShow, editId, dispatch, refresh } = props;
...
const cb = () => {
// 1.关闭弹窗 清除校验
setIsShow(false);
form.resetFields();
// 2.刷新父类列表
refresh();
};
const handleOk = () => {
form
.validateFields()
.then(() => {
// 获取表单数据
const v = form.getFieldsValue();
// 获取富文本转换的html
const htmlStr = editorState.toHTML();
// 封装提交数据
const articleData = {
...v,
isShow: v.isShow ? 1 : 0,
content1: htmlStr,
content2: htmlStr,
editorType: 0, // 编辑器类型 默认富文本,写死
};
if (dispatch) {
// // 提交
dispatch({
type: `${namespace}/saveArticle`,
payload: {
article: articleData,
callback: cb,
},
});
}
})
.catch(() => {
message.error('请注意表单验证!');
});
};
...
src\pages\content\Article\index.tsx
// 新增 refreshList 函数 为了抽离发送请求的dispatch
// 另外需要在 子组件 <ArticleEdit> 中调用当前组件,需要传递 refresh 函数进去
/**
* 刷新数据列表
*/
const refreshList = ()=>{
dispatch({
type: `${namespace}/findArticleList`,
payload: {
start: current,
limit: pageSize,
params,
},
});
}
// 监听current和pageSize的变化重新发送请求
useEffect(() => {
refreshList();
}, [current, pageSize,params]);
...
{/* 弹窗部分 */}
<ArticleEdit editId={editId} isShow={isEdit} setIsShow={setIsEdit} refresh={refreshList}/>