前言 账单模块还剩最后一个小节,账单详情。账单详情页要做的事情有两个,一个是编辑当前账单操作,另一个是删除当前账单操作,我们先来观察完成后页面结构,如下所示:
这里是第一次涉及内页,所以我们需要制作一个公用的头部 Header
,支持传参接收 title
信息。我们在上一章节提取的「添加账单弹窗组件」,在这里派上了用场,新增和编辑是一家,唯一的差别就是编辑的时候,需要传入当前账单的 id
给「添加账单组件」,组件内通过账单详情接口,获取账单详情,并将获取的参数用于各个字段初始化值,这就实现了组件的复用。
知识点
正文开始 公用头部 在 components
目录下新建 Header
目录,老规矩,添加两个文件 index.jsx
和 style.module.less
。
为 Header/index.jsx
添加代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 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 样式代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 .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
下引入这个公用头部,代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 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;
效果如下所示:
账单明细 接下来,我们通过列表页传入的浏览器查询字符串,通俗的将就是浏览器地址栏上的参数,来获取该笔账单的详情,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 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(); 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
如下所示:
可以看到,我们想要的参数在 search
属性中,我想把 ?id=917
转换成 json
键值对的形式,如:
所以我通过 npm install query-string
引入了查询字符串解析的一个插件,通过如下方式:
1 qs.parse(location.search);
可以将浏览器查询参数变成一个对象形式,所以我们在代码中可以通过 const
的解构,将 id
取出。最后通过 get
方法请求详情接口:
接下来,我们给账单明细部分布局,并且将数据接入,代码如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 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(); 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
的原理。
浏览器展示效果如下:
我们还需为底部的两个按钮添加事件。首先,为删除按钮添加删除事件:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 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
组件,该组件提供了调用方法的形式唤起弹窗,我们利用这个属性 为「删除」加一个二次确认的形式,避免误触按钮。
效果如下所示:
最麻烦的编辑事件处理,我们先来明确一下思路。在点击「编辑」按钮之后,我们会唤起之前写好的「添加账单天窗」,然后将账单 detail
参数通过 props
传递给弹窗组件,组件在接收到 detail
时,将信息初始化给弹窗给的相应参数。
我们来看代码的实现,首先在 Detail/index.jsx
内添加代码:
1 2 3 4 5 6 7 8 9 10 11 12 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} />
尝试点击编辑按钮:
紧接着,我们修改 PopupAddBill
组件,如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 const PopupAddBill = forwardRef(({ detail = {}, onReload }, ref ) => { ... const id = detail && detail.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; 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(); } })
首先,通过 setXXX
将 detail
的数据依次设置初始值;其次,账单种类需要判断是否是编辑或是新建;最后,修改添加账单按钮,如果是「编辑」操作,给 params
参数添加一个 id
,并且调用的接口变成 /api/bill/update
。
完成上述操作之后,我们查看浏览器操作情况如下所示:
总结 本小节我们学习了如何封装内页的头部组件,以及如何将之前的「新增」复用给「编辑」操作,可以以这个为一个思考点,用发散的思维去复制这样的模式,是否其他的新增和编辑操作,也可以这么实现。