账单详情页要做的事情有两个,一个是编辑当前账单操作,另一个是删除当前账单操作,如下图所示:
image.png
这里是第一次涉及内页,所以我们需要制作一个公用的头部 Header,支持传参接收 title 信息。我们在上一章节提取的「添加账单弹窗组件」,在这里派上了用场,新增和编辑可以复用,唯一的差别就是编辑的时候,需要传入当前账单的 id 给「添加账单组件」,组件内通过账单详情接口,获取账单详情,并将获取的参数用于各个字段初始化值,这就实现了组件的复用。

下面我们来具体实现一下。

公用头部

在 components 目录下新建 Header 目录,老规矩,添加两个文件 index.jsx 和 style.module.less。
为 Header/index.jsx 添加代码如下:

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { useHistory } from 'react-router-dom'
  4. import { NavBar, Icon } from 'zarm';
  5. import s from './style.module.less'
  6. const Header = ({ title = '' }) => {
  7. const history = useHistory()
  8. return <div className={s.headerWarp}>
  9. <div className={s.block}>
  10. <NavBar
  11. className={s.header}
  12. left={<Icon type="arrow-left" theme="primary" onClick={() => history.goBack()} />}
  13. title={title}
  14. />
  15. </div>
  16. </div>
  17. };
  18. Header.propTypes = {
  19. title: PropTypes.string, // 标题
  20. };
  21. export default Header;

在container/Detail/index.jsx中使用

  1. import React from 'react';
  2. import Header from '@/components/Header';
  3. import s from './style.module.less';
  4. const Detail = () => {
  5. return <div className={s.detail}>
  6. <Header title='账单详情' />
  7. </div>
  8. }
  9. export default Detail

账单明细

接下来,我们通过浏览器地址栏上的参数,来获取该笔账单的详情,如下所示:

  1. // container/Detail/index.jsx
  2. import React, { useEffect, useState } from 'react';
  3. import { useLocation } from 'react-router-dom';
  4. import qs from 'query-string';
  5. import Header from '@/components/Header';
  6. import { get } from '@/utils';
  7. import s from './style.module.less';
  8. const Detail = () => {
  9. const location = useLocation(); // 获取 locaton 实例,我们可以通过打印查看内部都有些什么内容。
  10. const { id } = qs.parse(location.search);
  11. const [detail, setDetail] = useState({});
  12. console.log('location', location);
  13. useEffect(() => {
  14. getDetail()
  15. }, []);
  16. const getDetail = async () => {
  17. const { data } = await get(`/api/bill/detail?id=${id}`);
  18. setDetail(data);
  19. }
  20. return <div className={s.detail}>
  21. <Header title='账单详情' />
  22. </div>
  23. }
  24. export default Detail

我们先来看看,浏览器控制台打印出的 location 如下所示:
image.png
可以看到,我们想要的参数在 search 属性中,我想把 ?id=917 转换成 json 键值对的形式,如:

  1. {
  2. id: 917
  3. }

所以我通过 npm install query-string 引入了查询字符串解析的一个插件,通过如下方式:

  1. qs.parse(location.search)

可以将浏览器查询参数变成一个对象形式,所以我们在代码中可以通过 const 的解构,将 id 取出。最后通过 get 方法请求详情接口。接下来,我们给账单明细部分布局,并且将数据接入,代码如下所示:

  1. import React, { useEffect, useState } from 'react';
  2. import { useLocation } from 'react-router-dom';
  3. import qs from 'query-string';
  4. import dayjs from 'dayjs';
  5. import cx from 'classnames';
  6. import Header from '@/components/Header';
  7. import CustomIcon from '@/components/CustomIcon';
  8. import { get, typeMap } from '@/utils';
  9. import s from './style.module.less';
  10. const Detail = () => {
  11. const location = useLocation(); // 路由 location 实例
  12. const { id } = qs.parse(location.search); // 查询字符串反序列化
  13. const [detail, setDetail] = useState({}); // 订单详情数据
  14. useEffect(() => {
  15. getDetail()
  16. }, []);
  17. const getDetail = async () => {
  18. const { data } = await get(`/api/bill/detail?id=${id}`);
  19. setDetail(data);
  20. }
  21. return <div className={s.detail}>
  22. <Header title='账单详情' />
  23. <div className={s.card}>
  24. <div className={s.type}>
  25. {/* 通过 pay_type 属性,判断是收入或指出,给出不同的颜色*/}
  26. <span className={cx({ [s.expense]: detail.pay_type == 1, [s.income]: detail.pay_type == 2 })}>
  27. {/* typeMap 是我们事先约定好的 icon 列表 */}
  28. <CustomIcon className={s.iconfont} type={detail.type_id ? typeMap[detail.type_id].icon : 1} />
  29. </span>
  30. <span>{ detail.type_name || '' }</span>
  31. </div>
  32. {
  33. detail.pay_type == 1
  34. ? <div className={cx(s.amount, s.expense)}>-{ detail.amount }</div>
  35. : <div className={cx(s.amount, s.incom)}>+{ detail.amount }</div>
  36. }
  37. <div className={s.info}>
  38. <div className={s.time}>
  39. <span>记录时间</span>
  40. <span>{dayjs(Number(detail.date)).format('YYYY-MM-DD HH:mm')}</span>
  41. </div>
  42. <div className={s.remark}>
  43. <span>备注</span>
  44. <span>{ detail.remark || '-' }</span>
  45. </div>
  46. </div>
  47. <div className={s.operation}>
  48. <span><CustomIcon type='shanchu' />删除</span>
  49. <span><CustomIcon type='tianjia' />编辑</span>
  50. </div>
  51. </div>
  52. </div>
  53. }
  54. export default Detail


需为底部的两个按钮添加事件。首先,为删除按钮添加删除事件:

  1. import { useLocation, useHistory } from 'react-router-dom';
  2. import { get, post, typeMap } from '@/utils';
  3. import { Modal, Toast } from 'zarm';
  4. ...
  5. const history = useHistory();
  6. // 删除方法
  7. const deleteDetail = () => {
  8. Modal.confirm({
  9. title: '删除',
  10. content: '确认删除账单?',
  11. onOk: async () => {
  12. const { data } = await post('/api/bill/delete', { id })
  13. Toast.show('删除成功')
  14. history.goBack()
  15. },
  16. });
  17. }

这里我们利用 Zarm 组件提供的 Modal 组件,该组件提供了调用方法的形式唤起弹窗,我们利用这个属性 为「删除」加一个二次确认的形式,避免误触按钮。

最麻烦的编辑事件处理,我们先来明确一下思路。在点击「编辑」按钮之后,我们会唤起之前写好的「添加账单弹窗」,然后将账单 detail 参数通过 props 传递给弹窗组件,组件在接收到 detail 时,将信息初始化给弹窗给的相应参数。

我们来看代码的实现,首先在 Detail/index.jsx 内添加代码:

  1. import React, { useEffect, useState, useRef } from 'react';
  2. import PopupAddBill from '@/components/PopupAddBill';
  3. ...
  4. const editRef = useRef();
  5. ...
  6. <div className={s.operation}>
  7. <span onClick={deleteDetail}><CustomIcon type='shanchu' />删除</span>
  8. <span onClick={() => editRef.current && editRef.current.show()}><CustomIcon type='tianjia' />编辑</span>
  9. </div>
  10. ...
  11. <PopupAddBill ref={editRef} detail={detail} onReload={getDetail} />

紧接着,我们修改 PopupAddBill 组件,如下所示:

  1. const PopupAddBill = forwardRef(({ detail = {}, onReload }, ref) => {
  2. ...
  3. const id = detail && detail.id // 外部传进来的账单详情 id
  4. useEffect(() => {
  5. if (detail.id) {
  6. setPayType(detail.pay_type == 1 ? 'expense' : 'income')
  7. setCurrentType({
  8. id: detail.type_id,
  9. name: detail.type_name
  10. })
  11. setRemark(detail.remark)
  12. setAmount(detail.amount)
  13. setDate(dayjs(Number(detail.date)).$d)
  14. }
  15. }, [detail])
  16. ...
  17. useEffect(async () => {
  18. const { data: { list } } = await get('/api/type/list');
  19. const _expense = list.filter(i => i.type == 1); // 支出类型
  20. const _income = list.filter(i => i.type == 2); // 收入类型
  21. setExpense(_expense);
  22. setIncome(_income);
  23. // 没有 id 的情况下,说明是新建账单。
  24. if (!id) {
  25. setCurrentType(_expense[0]);
  26. };
  27. }, []);
  28. ...
  29. // 添加账单
  30. const addBill = async () => {
  31. if (!amount) {
  32. Toast.show('请输入具体金额')
  33. return
  34. }
  35. const params = {
  36. amount: Number(amount).toFixed(2),
  37. type_id: currentType.id,
  38. type_name: currentType.name,
  39. date: dayjs(date).unix() * 1000,
  40. pay_type: payType == 'expense' ? 1 : 2,
  41. remark: remark || ''
  42. }
  43. if (id) {
  44. params.id = id;
  45. // 如果有 id 需要调用详情更新接口
  46. const result = await post('/api/bill/update', params);
  47. Toast.show('修改成功');
  48. } else {
  49. const result = await post('/api/bill/add', params);
  50. setAmount('');
  51. setPayType('expense');
  52. setCurrentType(expense[0]);
  53. setDate(new Date());
  54. setRemark('');
  55. Toast.show('添加成功');
  56. }
  57. setShow(false);
  58. if (onReload) onReload();
  59. }
  60. })

首先,通过 setXXX 将 detail 的数据依次设置初始值;其次,账单种类需要判断是否是编辑或是新建;最后,修改添加账单按钮,如果是「编辑」操作,给 params 参数添加一个 id,并且调用的接口变成 /api/bill/update。

以上,就是账单详情的实现。