1. 实现布局交互

首先确定新增和编辑的按钮布局, 这里我们采用新增弹窗的交互形式来实现:
创建文件: src\pages\content\Article\components\ArticleEdit.tsx

  1. import React, { useEffect,useState } from 'react';
  2. import {Modal, Button, Form , Input, Switch} from 'antd';
  3. import type {SetStateAction} from 'react';
  4. const {TextArea} = Input;
  5. type PropsType = {
  6. isShow: boolean;
  7. setIsShow: any;
  8. editId?: string;
  9. }
  10. const ArticleEdit: React.FC<PropsType> = props=>{
  11. const {isShow,setIsShow,editId} = props;
  12. const handleOk = ()=>{
  13. setIsShow(false);
  14. }
  15. const handleCancel = ()=>{
  16. setIsShow(false);
  17. }
  18. const [form] = Form.useForm();
  19. useEffect(()=>{
  20. if(isShow&&editId){
  21. alert('编辑请求...')
  22. }else if(isShow&&!editId){
  23. alert('新增请求...')
  24. }
  25. },[isShow])
  26. /**
  27. * 提交保存
  28. */
  29. const handleFinish = v=>{
  30. console.log(v);
  31. }
  32. return (
  33. <Modal width="60%" title="编辑文章" visible={isShow} onOk={handleOk} onCancel={handleCancel}>
  34. <Form
  35. form={form}
  36. onFinish={handleFinish}
  37. labelCol={
  38. {
  39. span: 4
  40. }
  41. }
  42. wrapperCol={
  43. {
  44. span:20
  45. }
  46. }
  47. >
  48. <Form.Item
  49. label="标题"
  50. rules={
  51. [{required: true, message: '标题不能为空!'}]
  52. }
  53. name="title"
  54. >
  55. <Input placeholder="请输入文章标题"></Input>
  56. </Form.Item>
  57. <Form.Item
  58. label="作者"
  59. rules={
  60. [{required: true, message: '作者不能为空!'}]
  61. }
  62. name="author"
  63. >
  64. <Input placeholder="请输入作者"></Input>
  65. </Form.Item>
  66. <Form.Item
  67. label="文章概要"
  68. name="summary"
  69. >
  70. <TextArea></TextArea>
  71. </Form.Item>
  72. <Form.Item
  73. label="文章显示"
  74. name="isShow"
  75. >
  76. <Switch checkedChildren="显示" unCheckedChildren="隐藏" defaultChecked></Switch>
  77. </Form.Item>
  78. </Form>
  79. </Modal>
  80. )
  81. }
  82. export default ArticleEdit;

src\pages\content\Article\index.tsx

  1. import React, { useEffect, useState } from 'react';
  2. // # https://procomponents.ant.design/components/page-container
  3. import { PageContainer } from '@ant-design/pro-layout';
  4. import type { ArticleType } from './model';
  5. import { Table, Form, Input, Button, Card, Row, Col,Space } from 'antd';
  6. import type {TableColumnType} from 'antd';
  7. import { connect } from 'umi';
  8. import type { Dispatch } from 'umi';
  9. import styles from './index.less';
  10. import {pickBy} from 'lodash';
  11. // 引入明细组件
  12. import ArticleEdit from './components/ArticleEdit';
  13. // 定义model命名空间
  14. const namespace = 'article';
  15. type StateType = {
  16. articleList: ArticleType[];
  17. articleTotalCount: number;
  18. dispatch: Dispatch;
  19. loading: any;
  20. };
  21. type PropsType = {
  22. articleList: ArticleType[];
  23. articleTotalCount: number;
  24. dispatch: Dispatch;
  25. loading: boolean;
  26. };
  27. type SearchType = {
  28. author?: string;
  29. title?: string;
  30. }
  31. // type ColumnType = {
  32. // title: string;
  33. // dataIndex?: string;
  34. // render: any
  35. // }
  36. const ArticleList: React.FC<PropsType> = (props) => {
  37. const { articleList, articleTotalCount, dispatch, loading } = props;
  38. // 条件查询,可以根据title,author查询
  39. const [params,setParams] = useState<SearchType>({});
  40. // 分页起始页肯定是 1
  41. const [current, setCurrent] = useState(1);
  42. // 数据量不大 为了体现分页 暂时控制每页2条数据
  43. const [pageSize, setPageSize] = useState(2);
  44. // 是否打开编辑
  45. const [isEdit, setIsEdit] = useState<boolean>(false);
  46. // 编辑id
  47. const [editId,setEditId] = useState<string>('');
  48. // 获取form组件
  49. const [form] = Form.useForm();
  50. // 监听current和pageSize的变化重新发送请求
  51. useEffect(() => {
  52. dispatch({
  53. type: `${namespace}/findArticleList`,
  54. payload: {
  55. start: current,
  56. limit: pageSize,
  57. params,
  58. },
  59. });
  60. }, [current, pageSize,params]);
  61. /**
  62. * 去新增页面
  63. * @param id
  64. */
  65. const goAdd = ()=>{
  66. setEditId('');
  67. setIsEdit(true);
  68. }
  69. /**
  70. * 去编辑页
  71. * @param id
  72. */
  73. const goEdit = (id: string)=>{
  74. // alert(`id${id}`)
  75. setEditId(id);
  76. setIsEdit(true);
  77. }
  78. /**
  79. * 删除行数据
  80. * @param id
  81. */
  82. const goDel = (id: string)=>{
  83. alert(`del${id}`)
  84. }
  85. type ColumnType = {
  86. title: string;
  87. dataIndex?: string;
  88. }
  89. // 放到组件外部将无法调用组件内部的 state ; 所以移到组件内部
  90. const colums: TableColumnType<ColumnType>[] = [
  91. {
  92. title: '#',
  93. // 如果定义形参未使用 建议添加_
  94. render(_text,_record,index){
  95. return `${index+1}`;
  96. }
  97. },
  98. {
  99. title: 'ID',
  100. dataIndex: 'id',
  101. },
  102. {
  103. title: '标题',
  104. dataIndex: 'title',
  105. },
  106. {
  107. title: '作者',
  108. dataIndex: 'author',
  109. },
  110. {
  111. title: '封面',
  112. render: ({coverImg}: ArticleType) => <img className={styles.coverImg} src={coverImg} alt="" />,
  113. },
  114. {
  115. title: '阅读量',
  116. dataIndex: 'viewCount',
  117. },
  118. {
  119. title: '创建时间',
  120. dataIndex: 'createTime',
  121. },
  122. {
  123. title: '操作',
  124. render:({id})=>(
  125. <Space>
  126. <a onClick={()=>goEdit(id)}>编辑</a>
  127. <a onClick={()=>goDel(id)} className={styles.delBtn}>删除</a>
  128. </Space>
  129. )
  130. }
  131. ];
  132. /**
  133. * 提交表单触发事件 v为表单name对应的对象
  134. * @param v
  135. */
  136. const onFinish = (v: SearchType)=>{
  137. // console.log(v);
  138. // 对于空对象 可能会出现 undefined 的情况,需要去除该属性
  139. const useParams = pickBy(v,(item: any)=>item);
  140. // 我们只需要改变了 params 就可以触发 useEffect
  141. setParams(useParams);
  142. setCurrent(1);
  143. }
  144. const onReset = ()=>{
  145. setParams({});
  146. form.resetFields();
  147. }
  148. return (
  149. <PageContainer className={styles.main}>
  150. <Card className={styles.searchBar}>
  151. <Form
  152. form={form}
  153. labelCol={{
  154. span: 4,
  155. }}
  156. wrapperCol={{
  157. span: 20,
  158. }}
  159. layout="inline"
  160. onFinish={onFinish}
  161. >
  162. <Row gutter={[20,30]} style={{width:'100%'}}>
  163. <Col span={6}>
  164. <Form.Item label="标题" name="title">
  165. <Input />
  166. </Form.Item>
  167. </Col>
  168. <Col span={6}>
  169. <Form.Item label="作者" name="author">
  170. <Input />
  171. </Form.Item>
  172. </Col>
  173. <Col span={6} offset={18}>
  174. <Form.Item>
  175. <Button type="ghost" onClick={onReset}>重置</Button>
  176. <Button type="primary" htmlType="submit" style={{marginLeft: '20px'}}>
  177. 查询
  178. </Button>
  179. </Form.Item>
  180. </Col>
  181. </Row>
  182. </Form>
  183. </Card>
  184. {/* rowKey 表格行 key 的取值,可以是字符串或一个函数 标识唯一性,这里我们用每一条数据的id作为key */}
  185. <Button onClick={goAdd} type="primary" className={styles.addBtn}>新增</Button>
  186. <Table
  187. loading={loading}
  188. rowKey="id"
  189. columns={colums}
  190. dataSource={articleList}
  191. pagination={{
  192. showQuickJumper: true,
  193. current,
  194. pageSize,
  195. total: articleTotalCount,
  196. showSizeChanger: true,
  197. onShowSizeChange(_curr, size) {
  198. // console.log(v)
  199. setPageSize(size);
  200. },
  201. onChange(v) {
  202. setCurrent(v);
  203. },
  204. }}
  205. />
  206. {/* 弹窗部分 */}
  207. <ArticleEdit editId={editId} isShow={isEdit} setIsShow={setIsEdit} />
  208. </PageContainer>
  209. );
  210. };
  211. /**
  212. * 提取需要的属性
  213. * dva-loading
  214. * @param param0
  215. */
  216. const mapStateToProps = (state: StateType) => {
  217. return {
  218. articleList: state[namespace].articleList,
  219. articleTotalCount: state[namespace].articleTotalCount,
  220. dispatch: state.dispatch,
  221. loading: state.loading.effects[`${namespace}/findArticleList`] as boolean,
  222. };
  223. };
  224. export default connect(mapStateToProps)(ArticleList);

image.png

2. 接口封装和modal实现

新增和编辑是两个需要大量复用组件的功能,通过在乐居商城的后台实现中总结的经验,完全可以考虑把这两个功能共享同样的组件来实现, 减少代码量,提高复用性.
src\services\content\article.ts

  1. import type {ArticleType} from '@/pages/content/Article/model';
  2. ...
  3. /**
  4. * 新增文章
  5. * @param params
  6. */
  7. export async function addArticle(params: ArticleType) {
  8. return request(`/lejuAdmin/productArticle/addArticle`, {
  9. method: 'POST',
  10. data: params,
  11. });
  12. }
  13. /**
  14. * 编辑文章
  15. * @param params
  16. */
  17. export async function updateArticle(params: ArticleType) {
  18. return request(`/lejuAdmin/productArticle/updateArticle`, {
  19. method: 'POST',
  20. data: params,
  21. });
  22. }
  23. /**
  24. * 删除文章
  25. * @param id
  26. */
  27. export async function delArticle({id}: {id: string}) {
  28. return request(`/lejuAdmin/productArticle/del/${id}`, {
  29. method: 'DELETE'
  30. });
  31. }

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;

  1. <a name="1l6CX"></a>
  2. ### 3. 富文本
  3. 从antd官方推荐的 [社区精选推荐](https://ant.design/docs/react/recommendation-cn) 中找到 [braft-editor](https://github.com/margox/braft-editor) .<br />**注意事项: **
  4. - braft富文本的`value `类型为 `EditorState`, 获取html需要调用 `value.toHTML()` 方法
  5. - `value` ` onChange` `onSave`
  6. - 防抖提高性能
  7. ```shell
  8. # 安装依赖
  9. npm install braft-editor --save
  1. // 给富文本添加关联值
  2. const [editorState,setEditorState] = useState<EditorState>(null);
  3. // 初始化富文本内容
  4. setEditorState(BraftEditor.createEditorState(article.content1))
  5. // 获取富文本转换的html
  6. const htmlStr = editorState.toHTML();
  7. // 监听变化
  8. const handleEditorChange = (v: EditorState) => {
  9. setEditorState(v);
  10. };
  11. <BraftEditor
  12. style={{ border: '1px solid #e5e5e5' }}
  13. value={editorState}
  14. onChange={debounce(handleEditorChange, 500)}
  15. onSave={submitContent}
  16. />

4. 上传图片

接口使用通用上传接口,上传成功之后不会保存数据库, 后台对接阿里云OSS对象存储, 成功后返回阿里云资源地址.
image.png
注意事项:

  • 上传接口也需要token,通过自定义headers实现
  • listType="picture" 可以使用默认样式
  • fileList 属性必须添加,否则会造成 status 无法变成 done的bug, 请参考 #2423
  • 4.13.0 版本之前受控状态存在 bug。
  • Form.Item包含 Upload组件, 如何实现校验?

image.pngonChange#

上传中、完成、失败都会调用这个函数。

文件状态改变的回调,返回为:

  1. {
  2. file: { /* ... */ },
  3. fileList: [ /* ... */ ],
  4. event: { /* ... */ },
  5. }
  1. file 当前操作的文件对象。

    1. {
    2. uid: 'uid', // 文件唯一标识,建议设置为负数,防止和内部产生的 id 冲突
    3. name: 'xx.png' // 文件名
    4. status: 'done', // 状态有:uploading done error removed,被 beforeUpload 拦截的文件没有 status 属性
    5. response: '{"status": "success"}', // 服务端响应内容
    6. linkProps: '{"download": "image"}', // 下载链接额外的 HTML 属性
    7. }
  2. fileList 当前的文件列表。

  3. event 上传中的服务端响应内容,包含了上传进度等信息,高级浏览器支持。

    5. 表单验证

    注意事项:
  • 因为不是通过 Form.onFinish 触发,需要手动触发表单校验: validateFields
  • Form.Item 默认会通过name属性和包含子组件的value属性进行匹配校验,如果子组件没有value属性:

image.png

  • Form.Item 包含多个子元素,会报错. 我们需要对多个子元素 进行封装,变成一个自定义组件, 自定义或第三方的表单控件,也可以与 Form 组件一起使用。只要该组件遵循以下的约定:

    • 提供受控属性 value 或其它与 valuePropName 的值同名的属性。
    • 提供 onChange 事件或 trigger 的值同名的事件。
  • 对于 Form.Item 包含Upload 组件, 无法触发表单验证的问题处理, 我们把Upload组件封装为自定义组件, 该组件接收 valueonChange ,遵循以上约定即可.

  • src\pages\content\Article\components\ArticleEdit.tsx
  • 关于 UploadFile 的类型推导,通过提示找到源码位置,直接引入

    1. // 根据提示找到文件类型的位置 手动引入
    2. import type { UploadFile } from 'antd/lib/upload/interface';
    3. ...
    4. // 局部组件 自定义formItem组件
    5. // # https://ant.design/components/form-cn/#components-form-demo-customized-form-controls
    6. const FormUploadFC: React.FC<UploadPropsType> = ({ onChange }) => {
    7. const [fileList, setFileList] = useState<UploadFile[]>([]);
    8. return (
    9. <Upload
    10. action="/lejuAdmin/material/uploadFileOss"
    11. listType="picture"
    12. fileList={fileList}
    13. headers={{
    14. token: getToken() || '',
    15. }}
    16. // 用于移除文件后 对表单的校验同步
    17. onRemove={() => {
    18. onChange?.('');
    19. return true;
    20. }}
    21. // # https://ant.design/components/upload-cn/#API
    22. onChange={({ file, fileList: files }: { file: UploadFile; fileList: UploadFile[] }) => {
    23. // clone数组,然后只需要一个
    24. let nowFiles = [...files];
    25. nowFiles = nowFiles.slice(-1);
    26. const { status, response } = file;
    27. if (status === 'done') {
    28. // 获取上传成功的回调结果
    29. const { success, data, message: err } = response;
    30. if (success) {
    31. message.success(`${file.name} 上传成功!`);
    32. // 避免因直接修改实参造成的报错
    33. if (nowFiles.length > 0) {
    34. nowFiles[0].url = data.fileUrl;
    35. onChange?.(data.fileUrl);
    36. }
    37. } else {
    38. message.error(err);
    39. }
    40. } else if (status === 'error') {
    41. message.error(`${file.name} file upload failed.`);
    42. }
    43. // # https://github.com/ant-design/ant-design/issues/2423
    44. setFileList(nowFiles);
    45. }}
    46. >
    47. <Button icon={<UploadOutlined />}>Upload</Button>
    48. </Upload>
    49. );
    50. };
    51. // ... 使用
    52. <Form.Item
    53. label="上传封面"
    54. name="coverImg"
    55. rules={[{ required: true, message: '上传封面图片' }]}
    56. >
    57. <FormUploadFC />
    58. </Form.Item>

    6. 提交保存

    提交保存,需要对数据进行格式化,使之符合接口数据要求.
    注意事项:

  • coverImg字段已经通过自定义表单组件添加到了form

  • cb是回调函数,需要关闭窗口,重置表单,刷新父类列表.

src\pages\content\Article\components\ArticleEdit.tsx

  1. // 默认到处组件
  2. const ArticleEdit: React.FC<PropsType> = (props) => {
  3. // refresh 为通过父级组件传入的参数 为函数类型
  4. const { isShow, setIsShow, editId, dispatch, refresh } = props;
  5. ...
  6. const cb = () => {
  7. // 1.关闭弹窗 清除校验
  8. setIsShow(false);
  9. form.resetFields();
  10. // 2.刷新父类列表
  11. refresh();
  12. };
  13. const handleOk = () => {
  14. form
  15. .validateFields()
  16. .then(() => {
  17. // 获取表单数据
  18. const v = form.getFieldsValue();
  19. // 获取富文本转换的html
  20. const htmlStr = editorState.toHTML();
  21. // 封装提交数据
  22. const articleData = {
  23. ...v,
  24. isShow: v.isShow ? 1 : 0,
  25. content1: htmlStr,
  26. content2: htmlStr,
  27. editorType: 0, // 编辑器类型 默认富文本,写死
  28. };
  29. if (dispatch) {
  30. // // 提交
  31. dispatch({
  32. type: `${namespace}/saveArticle`,
  33. payload: {
  34. article: articleData,
  35. callback: cb,
  36. },
  37. });
  38. }
  39. })
  40. .catch(() => {
  41. message.error('请注意表单验证!');
  42. });
  43. };
  44. ...

src\pages\content\Article\index.tsx

  1. // 新增 refreshList 函数 为了抽离发送请求的dispatch
  2. // 另外需要在 子组件 <ArticleEdit> 中调用当前组件,需要传递 refresh 函数进去
  3. /**
  4. * 刷新数据列表
  5. */
  6. const refreshList = ()=>{
  7. dispatch({
  8. type: `${namespace}/findArticleList`,
  9. payload: {
  10. start: current,
  11. limit: pageSize,
  12. params,
  13. },
  14. });
  15. }
  16. // 监听current和pageSize的变化重新发送请求
  17. useEffect(() => {
  18. refreshList();
  19. }, [current, pageSize,params]);
  20. ...
  21. {/* 弹窗部分 */}
  22. <ArticleEdit editId={editId} isShow={isEdit} setIsShow={setIsEdit} refresh={refreshList}/>

image.png