前言

# 前言

账单模块还剩最后一个小节,账单详情。账单详情页要做的事情有两个,一个是编辑当前账单操作,另一个是删除当前账单操作,我们先来观察完成后页面结构,如下所示:

img

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

# 知识点

  • 封装公用头部组件
  • 复用添加账单弹窗组件

# 正文开始

# 公用头部

components 目录下新建 Header 目录,老规矩,添加两个文件 index.jsxstyle.module.less

Header/index.jsx 添加代码如下:

import React from 'react';
import PropTypes from 'prop-types';
import { useNavigate } from 'react-router-dom'
import { NavBar, Icon } from 'zarm';

import s from './style.module.less'

const Header = ({ title = '' }) => {
  const navigateTo = useNavigate()
  return <div className={s.headerWarp}>
    <div className={s.block}>
      <NavBar
        className={s.header}
        left={<Icon type="arrow-left" theme="primary" onClick={() => navigateTo(-1)} />}
        title={title}
      />
    </div>
  </div>
};

Header.propTypes = {
  title: PropTypes.string, // 标题
};

export default Header;

我们采用 Zarm 组件库为我们提供的 NavBar 组件,实现头部的组件布局。为左边的返回箭头添加一个事件,navigateTo(-1) 的作用是路由返回事件,它不会触发浏览器的刷新,而是改变浏览器的地址栏,让组件匹配地址栏对应的地址组件。

还有一点要提醒大家,写公用组件一定要写 PropTypes,这是让其他使用该组件的同事知道,你这个组件接受的参数有哪些,以及每个参数的作用是什么,都要注释清楚,这才是一个好的公用组件。我见过很多同事写公用组件都不写 PropTypes,这让使用者非常头大。

CSS 样式代码

.header-warp {
  border-bottom: 1px solid #e9e9e9;
  .block {
    width: 100%;
    height: 46px;
    :global {
      .za-nav-bar__title {
        font-size: 14px;
        color: rgba(0, 0, 0, 0.9);
      }
      .za-icon--arrow-left {
        font-size: 20px;
      }
    }
  }
  .header {
    position: fixed;
    top: 0;
    left: 0;
    width: 100%;
    .more {
      font-size: 20px;
    }
  }
}

完成上述代码之后,我们需要在 container/Detail/index.jsx 下引入这个公用头部,代码如下:

import React from 'react';
import Header from '@/components/Header';

import s from './style.module.less';

const Detail = () => {
  return <div className={s.detail}>
    <Header title='账单详情' />
  </div>
}

export default Detail

效果如下所示:

img

# 账单明细

接下来,我们通过列表页传入的浏览器查询字符串,通俗的将就是浏览器地址栏上的参数,来获取该笔账单的详情,如下所示:

// container/Detail/index.jsx
import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import qs from 'query-string';
import Header from '@/components/Header';
import { get } from '@/utils';

import s from './style.module.less';

const Detail = () => {
  const location = useLocation(); // 获取 locaton 实例,我们可以通过打印查看内部都有些什么内容。
  const { id } = qs.parse(location.search);

  const [detail, setDetail] = useState({});

  console.log('location', location);

  useEffect(() => {
    getDetail()
  }, []);

  const getDetail = async () => {
    const { data } = await get(`/api/bill/detail?id=${id}`);
    setDetail(data);
  }
  return <div className={s.detail}>
    <Header title='账单详情' />
  </div>
}

export default Detail

我们先来看看,浏览器控制台打印出的 location 如下所示:

img

可以看到,我们想要的参数在 search 属性中,我想把 ?id=917 转换成 json 键值对的形式,如:

{
  id: 917
}

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

qs.parse(location.search)

可以将浏览器查询参数变成一个对象形式,所以我们在代码中可以通过 const 的解构,将 id 取出。最后通过 get 方法请求详情接口:

img

接下来,我们给账单明细部分布局,并且将数据接入,代码如下所示:

import React, { useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import qs from 'query-string';
import dayjs from 'dayjs';
import cx from 'classnames';
import Header from '@/components/Header';
import CustomIcon from '@/components/CustomIcon';
import { get, typeMap } from '@/utils';


import s from './style.module.less';

const Detail = () => {
  const location = useLocation(); // 路由 location 实例
  const { id } = qs.parse(location.search); // 查询字符串反序列化

  const [detail, setDetail] = useState({}); // 订单详情数据

  useEffect(() => {
    getDetail()
  }, []);

  const getDetail = async () => {
    const { data } = await get(`/api/bill/detail?id=${id}`);
    setDetail(data);
  }
  return <div className={s.detail}>
    <Header title='账单详情' />
    <div className={s.card}>
      <div className={s.type}>
        {/* 通过 pay_type 属性,判断是收入或指出,给出不同的颜色*/}
        <span className={cx({ [s.expense]: detail.pay_type == 1, [s.income]: detail.pay_type == 2 })}>
          {/* typeMap 是我们事先约定好的 icon 列表 */}
          <CustomIcon className={s.iconfont} type={detail.type_id ? typeMap[detail.type_id].icon : 1} />
        </span>
        <span>{ detail.type_name || '' }</span>
      </div>
      {
        detail.pay_type == 1
          ? <div className={cx(s.amount, s.expense)}>-{ detail.amount }</div>
          : <div className={cx(s.amount, s.incom)}>+{ detail.amount }</div>
      }
      <div className={s.info}>
        <div className={s.time}>
          <span>记录时间</span>
          <span>{dayjs(Number(detail.date)).format('YYYY-MM-DD HH:mm')}</span>
        </div>
        <div className={s.remark}>
          <span>备注</span>
          <span>{ detail.remark || '-' }</span>
        </div>
      </div>
      <div className={s.operation}>
        <span><CustomIcon type='shanchu' />删除</span>
        <span><CustomIcon type='tianjia' />编辑</span>
      </div>
    </div>
  </div>
}

export default Detail

文末已为同学们提供下本章节 demo 代码,样式部分不再详细说明。

布局部分我都已经在代码中给了注释,还有不明白的同学可以在群里提问,再次强调一点,flex 布局请务必要掌握熟练,在日后的开发过程中,无论是小册还是公司的项目,都会大量的运用到它。甚至 Flutter 的布局也借鉴了 flex 的原理。

浏览器展示效果如下:

img

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

import { useLocation, useNavigate } from 'react-router-dom';
import { get, post, typeMap } from '@/utils';
import { Modal, Toast } from 'zarm';
... 

const navigateTo = useNavigate();

// 删除方法
const deleteDetail = () => {
  Modal.confirm({
    title: '删除',
    content: '确认删除账单?',
    onOk: async () => {
      const { data } = await post('/api/bill/delete', { id })
      Toast.show('删除成功')
      navigateTo(-1)
    },
  });
}

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

效果如下所示:

img

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

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

import React, { useEffect, useState, useRef } from 'react';
import PopupAddBill from '@/components/PopupAddBill';
...

const editRef = useRef();
...
<div className={s.operation}>
  <span onClick={deleteDetail}><CustomIcon type='shanchu' />删除</span>
  <span onClick={() => editRef.current && editRef.current.show()}><CustomIcon type='tianjia' />编辑</span>
</div>
...
<PopupAddBill ref={editRef} detail={detail} onReload={getDetail} />

尝试点击编辑按钮:

img

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

const PopupAddBill = forwardRef(({ detail = {}, onReload }, ref) => {
  ...
  const id = detail && detail.id // 外部传进来的账单详情 id

  useEffect(() => {
    if (detail.id) {
      setPayType(detail.pay_type == 1 ? 'expense' : 'income')
      setCurrentType({
        id: detail.type_id,
        name: detail.type_name
      })
      setRemark(detail.remark)
      setAmount(detail.amount)
      setDate(dayjs(Number(detail.date)).$d)
    }
  }, [detail])

  ... 

  useEffect(async () => {
    const { data: { list } } = await get('/api/type/list');
    const _expense = list.filter(i => i.type == 1); // 支出类型
    const _income = list.filter(i => i.type == 2); // 收入类型
    setExpense(_expense);
    setIncome(_income);
      // 没有 id 的情况下,说明是新建账单。
    if (!id) {
      setCurrentType(_expense[0]);
    };
  }, []);

  ... 
  
  // 添加账单
  const addBill = async () => {
    if (!amount) {
      Toast.show('请输入具体金额')
      return
    }
    const params = {
      amount: Number(amount).toFixed(2),
      type_id: currentType.id,
      type_name: currentType.name,
      date: dayjs(date).unix() * 1000,
      pay_type: payType == 'expense' ? 1 : 2,
      remark: remark || ''
    }
    if (id) {
      params.id = id;
      // 如果有 id 需要调用详情更新接口
      const result = await post('/api/bill/update', params);
      Toast.show('修改成功');
    } else {
      const result = await post('/api/bill/add', params);
      setAmount('');
      setPayType('expense');
      setCurrentType(expense[0]);
      setDate(new Date());
      setRemark('');
      Toast.show('添加成功');
    }
    setShow(false);
    if (onReload) onReload();
  }
})

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

完成上述操作之后,我们查看浏览器操作情况如下所示:

img

# 总结

本小节我们学习了如何封装内页的头部组件,以及如何将之前的「新增」复用给「编辑」操作,可以以这个为一个思考点,用发散的思维去复制这样的模式,是否其他的新增和编辑操作,也可以这么实现。