image.png

弹窗组件实现

先实现点击新增按钮,调出弹窗的功能。首先,在 Home/index.jsx 文件中添加 「新增按钮」,如下所示:

  1. import CustomIcon from '@/components/CustomIcon'
  2. ...
  3. const Home = () => {
  4. ...
  5. const addToggle = () => {
  6. // do something
  7. }
  8. ...
  9. return <div className={s.home}>
  10. ...
  11. <div className={s.add} onClick={addToggle}><CustomIcon type='tianjia' /></div>
  12. </div>
  13. }

样式中,注意给 border 设置的是 1PX,大写的单位,因为这样写的话,postcss-pxtorem 插件就不会将其转化为 rem 单位。

根据之前实现的弹窗组件,我们再实现一套类似的,在弹窗内控制弹窗组件的显示隐藏,在 components 下新建 PopupAddBill 文件夹,再新建 index.jsx 和 style.module.less,代码如下:

  1. // PopupAddBill/index.jsx
  2. import React, { forwardRef, useEffect, useRef, useState } from 'react';
  3. import PropTypes from 'prop-types';
  4. import { Popup } from 'zarm';
  5. const PopupAddBill = forwardRef((props, ref) => {
  6. const [show, setShow] = useState(false) // 内部控制弹窗显示隐藏。
  7. // 通过 forwardRef 拿到外部传入的 ref,并添加属性,使得父组件可以通过 ref 控制子组件。
  8. if (ref) {
  9. ref.current = {
  10. show: () => {
  11. setShow(true);
  12. },
  13. close: () => {
  14. setShow(false);
  15. }
  16. }
  17. };
  18. return <Popup
  19. visible={show}
  20. direction="bottom"
  21. onMaskClick={() => setShow(false)}
  22. destroy={false}
  23. mountContainer={() => document.body}
  24. >
  25. <div style={{ height: 200, background: '#fff' }}>弹窗</div>
  26. </Popup>
  27. })
  28. export default PopupAddBill

去 Home/index.jsx 中调用:

  1. // Home/index.jsx
  2. import PopupAddBill from '@/components/PopupAddBill'
  3. const Home = () => {
  4. ...
  5. const addRef = useRef(); // 添加账单 ref
  6. ...
  7. // 添加账单弹窗
  8. const addToggle = () => {
  9. addRef.current && addRef.current.show()
  10. }
  11. return <div className={s.home}>
  12. ...
  13. <PopupAddBill ref={addRef} />
  14. </div>
  15. }


接下来我们要在这个基础上给新增账单弹窗“添砖加瓦”。

账单类型和账单时间

我们先实现弹窗头部左侧的「支出」和「收入」账单类型切换功能,添加代码如下:

  1. // components/PopupAddBill/index.jsx
  2. ...
  3. import cx from 'classnames';
  4. import { Popup, Icon } from 'zarm';
  5. import s from './style.module.less';
  6. const PopupAddBill = forwardRef((props, ref) => {
  7. ...
  8. const [payType, setPayType] = useState('expense'); // 支出或收入类型
  9. ...
  10. // 切换收入还是支出
  11. const changeType = (type) => {
  12. setPayType(type);
  13. };
  14. return <Popup
  15. visible={show}
  16. direction="bottom"
  17. onMaskClick={() => setShow(false)}
  18. destroy={false}
  19. mountContainer={() => document.body}
  20. >
  21. <div className={s.addWrap}>
  22. {/* 右上角关闭弹窗 */}
  23. <header className={s.header}>
  24. <span className={s.close} onClick={() => setShow(false)}><Icon type="wrong" /></span>
  25. </header>
  26. {/* 「收入」和「支出」类型切换 */}
  27. <div className={s.filter}>
  28. <div className={s.type}>
  29. <span onClick={() => changeType('expense')} className={cx({ [s.expense]: true, [s.active]: payType == 'expense' })}>支出</span>
  30. <span onClick={() => changeType('income')} className={cx({ [s.income]: true, [s.active]: payType == 'income' })}>收入</span>
  31. </div>
  32. </div>
  33. </div>
  34. </Popup>
  35. })
  36. export default PopupAddBill

我们定义 expense 为支出,income 为收入,代码中通过 payType 变量,来控制「收入」和「支出」按钮的切换。

接下来在类型边上添加时间筛选弹窗,此时你将体会到之前提取时间筛选组件是多么的明智。

  1. import React, { forwardRef, useEffect, useRef, useState } from 'react';
  2. ...
  3. import dayjs from 'dayjs';
  4. import PopupDate from '../PopupDate'
  5. ...
  6. const PopupAddBill = forwardRef((props, ref) => {
  7. ...
  8. const dateRef = useRef();
  9. const [date, setDate] = useState(new Date()); // 日期
  10. ...
  11. // 日期选择回调
  12. const selectDate = (val) => {
  13. setDate(val);
  14. }
  15. return <Popup
  16. visible={show}
  17. direction="bottom"
  18. onMaskClick={() => setShow(false)}
  19. destroy={false}
  20. mountContainer={() => document.body}
  21. >
  22. <div className={s.addWrap}>
  23. {/* 「收入」和「支出」类型切换 */}
  24. <div className={s.filter}>
  25. ...
  26. <div
  27. className={s.time}
  28. onClick={() => dateRef.current && dateRef.current.show()}
  29. >{dayjs(date).format('MM-DD')} <Icon className={s.arrow} type="arrow-bottom" /></div>
  30. </div>
  31. <PopupDate ref={dateRef} onSelect={selectDate} />
  32. </div>
  33. </Popup>
  34. })
  35. export default PopupAddBill

我们引入了公共组件 PopupDate,传入 ref 控制弹窗的显示隐藏,传入 onSelect 获取日期组件选择后回调的值,并通过 setDate 重制 date,触发视图的更新,
我们通过上述代码,已经创造出了两个值,分别是「账单类型」和「账单日期」,还差「账单金额」 「账单种类」、「备注」。

账单金额

我们将金额动态化,引入 Zarm 为我们提供的模拟数字键盘组件 Keyboard,代码如下:

  1. ...
  2. // 监听输入框改变值
  3. const handleMoney = (value) => {
  4. value = String(value)
  5. // 点击是删除按钮时
  6. if (value == 'delete') {
  7. let _amount = amount.slice(0, amount.length - 1)
  8. setAmount(_amount)
  9. return
  10. }
  11. // 点击确认按钮时
  12. if (value == 'ok') {
  13. // 这里后续将处理添加账单逻辑
  14. return
  15. }
  16. // 当输入的值为 '.' 且 已经存在 '.',则不让其继续字符串相加。
  17. if (value == '.' && amount.includes('.')) return
  18. // 小数点后保留两位,当超过两位时,不让其字符串继续相加。
  19. if (value != '.' && amount.includes('.') && amount && amount.split('.')[1].length >= 2) return
  20. // amount += value
  21. setAmount(amount + value)
  22. }
  23. ...
  24. <div className={s.money}>
  25. <span className={s.sufix}>¥</span>
  26. <span className={cx(s.amount, s.animation)}>{amount}</span>
  27. </div>
  28. <Keyboard type="price" onKeyClick={(value) => handleMoney(value)} />

账单种类

账单种类的作用是表示该笔账单的大致用途,我们通过接口从数据库回去账单种类列表,以横向滚动的形式,展示在金额的下面,接下来我们看具体的代码实现:

  1. ...
  2. import CustomIcon from '../CustomIcon';
  3. import { get, typeMap } from '@/utils';
  4. ...
  5. const [currentType, setCurrentType] = useState({}); // 当前选中账单类型
  6. const [expense, setExpense] = useState([]); // 支出类型数组
  7. const [income, setIncome] = useState([]); // 收入类型数组
  8. useEffect(async () => {
  9. const { data: { list } } = await get('/api/type/list');
  10. const _expense = list.filter(i => i.type == 1); // 支出类型
  11. const _income = list.filter(i => i.type == 2); // 收入类型
  12. setExpense(_expense);
  13. setIncome(_income);
  14. setCurrentType(_expense[0]); // 新建账单,类型默认是支出类型数组的第一项
  15. }, [])
  16. ...
  17. <div className={s.typeWarp}>
  18. <div className={s.typeBody}>
  19. {/* 通过 payType 判断,是展示收入账单类型,还是支出账单类型 */}
  20. {
  21. (payType == 'expense' ? expense : income).map(item => <div onClick={() => setCurrentType(item)} key={item.id} className={s.typeItem}>
  22. {/* 收入和支出的字体颜色,以及背景颜色通过 payType 区分,并且设置高亮 */}
  23. <span className={cx({[s.iconfontWrap]: true, [s.expense]: payType == 'expense', [s.income]: payType == 'income', [s.active]: currentType.id == item.id})}>
  24. <CustomIcon className={s.iconfont} type={typeMap[item.id].icon} />
  25. </span>
  26. <span>{item.name}</span>
  27. </div>)
  28. }
  29. </div>
  30. </div>

注意,在 h5 界面实现横向滚动,和在网页端相比,多了如下属性:

  1. * {
  2. touch-action: pan-x;
  3. }

CSS属性 touch-action 用于设置触摸屏用户如何操纵元素的区域(例如,浏览器内置的缩放功能)。

如果不设置它,只是通过 overflow-x: auto,无法实现 h5 端的横向滚动的,并且你要在一个 div 容器内设置全局 * 为 touch-action: pan-x;

备注弹窗

备注虽然不起眼,但是别小看它,它可以在账单类型不足以概括账单时,加以一定的文字描述。
我们直接将其放置于「账单种类」的下面,代码如下:

  1. ...
  2. import { Input } from 'zarm';
  3. ...
  4. const [remark, setRemark] = useState(''); // 备注
  5. const [showRemark, setShowRemark] = useState(false); // 备注输入框展示控制
  6. ...
  7. <div className={s.remark}>
  8. {
  9. showRemark ? <Input
  10. autoHeight
  11. showLength
  12. maxLength={50}
  13. type="text"
  14. rows={3}
  15. value={remark}
  16. placeholder="请输入备注信息"
  17. onChange={(val) => setRemark(val)}
  18. onBlur={() => setShowRemark(false)}
  19. /> : <span onClick={() => setShowRemark(true)}>{remark || '添加备注'}</span>
  20. }
  21. </div>

CSS 样式部分

  1. .remark {
  2. padding: 0 24px;
  3. padding-bottom: 12px;
  4. color: #4b67e2;
  5. :global {
  6. .za-input--textarea {
  7. border: 1px solid #e9e9e9;
  8. padding: 10px;
  9. }
  10. }
  11. }

:global 的使用之前已经有描述过,这里再提醒大家一句,目前项目使用的是 css module 的形式,所以样式名都会被打上 hash 值,我们需要修改没有打 hash 值的 zarm 内部样式,需要通过 :global 方法。

调用上传账单接口

此时我们集齐了五大参数:

  • 账单类型:payType
  • 账单金额:amount
  • 账单日期:date
  • 账单种类:currentType
  • 备注:remark

我们给 Keyboard 的「确定」按钮回调添加方法:

  1. import { Toast } from 'zarm';
  2. import { post } from '@/utils';
  3. // 监听输入框改变值
  4. const handleMoney = (value) => {
  5. value = String(value)
  6. // 点击是删除按钮时
  7. if (value == 'delete') {
  8. let _amount = amount.slice(0, amount.length - 1)
  9. setAmount(_amount)
  10. return
  11. }
  12. // 点击确认按钮时
  13. if (value == 'ok') {
  14. addBill()
  15. return
  16. }
  17. // 当输入的值为 '.' 且 已经存在 '.',则不让其继续字符串相加。
  18. if (value == '.' && amount.includes('.')) return
  19. // 小数点后保留两位,当超过两位时,不让其字符串继续相加。
  20. if (value != '.' && amount.includes('.') && amount && amount.split('.')[1].length >= 2) return
  21. // amount += value
  22. setAmount(amount + value)
  23. }
  24. // 添加账单
  25. const addBill = async () => {
  26. if (!amount) {
  27. Toast.show('请输入具体金额')
  28. return
  29. }
  30. const params = {
  31. amount: Number(amount).toFixed(2), // 账单金额小数点后保留两位
  32. type_id: currentType.id, // 账单种类id
  33. type_name: currentType.name, // 账单种类名称
  34. date: dayjs(date).unix() * 1000, // 日期传时间戳
  35. pay_type: payType == 'expense' ? 1 : 2, // 账单类型传 1 或 2
  36. remark: remark || '' // 备注
  37. }
  38. const result = await post('/api/bill/add', params);
  39. // 重制数据
  40. setAmount('');
  41. setPayType('expense');
  42. setCurrentType(expense[0]);
  43. setDate(new Date());
  44. setRemark('');
  45. Toast.show('添加成功');
  46. setShow(false);
  47. if (props.onReload) props.onReload();
  48. }

onReload 方法为首页账单列表传进来的函数,当添加完账单的时候,执行 onReload 重新获取首页列表数据。

  1. <PopupAddBill ref={addRef} onReload={refreshData} />

此时,我们的添加账单的功能已经全部实现。