react + zarm 实现新增账单弹窗封装

简介: react + zarm 实现新增账单弹窗封装

需要实现的交互效果


大致如下图:

930d51259d344e60849d9c34cc90a54d.png



实现过程


1.先编写新增按钮


先实现点击新增按钮,调出弹窗的功能。

src\container\Home\index.jsx 里面添加下面的代码

import CustomIcon from '@/components/CustomIcon'
import PopupAddBill from '@/components/PopupAddBill'
... 
const Home = () => {
  ... 
  const addRef = useRef(); // 新增账单 ref
  const addToggle = () => {
    addRef.current && addRef.current.show()
  }
  ...
  return <div className={s.home}>
    <div className={s.contentWrap}>
      <PopupAddBill ref={addRef} onReload={refreshData} />
  </div>
    ... 
    <div className={s.add} onClick={addToggle}><CustomIcon type='bianji' /></div>
  </div>
}


src\container\Home\style.module.less 添加样式

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

.home {
  ...
  .add {
    position: fixed;
    bottom: 100px;
    right: 30px;
    z-index: 1000;
    width: 50px;
    height: 50px;
    border-radius: 50%;
    box-shadow: 0 0 10px 0 rgb(0 0 0 / 20%);
    background-color: #fff;
    display: flex;
    justify-content: center;
    align-items: center;
    border: 1PX solid #e9e9e9;
    color: #007fff;
  }
}


我们就能得到下面的效果

cda34dc23d2f427099b69bfecd1e3888.png


2.添加datatime日期选择类型

src\components\PopupDate\index.jsx 添加 datetime 类型

const choseMonth = (item) => {
  setNow(item)
  setShow(false)
  if (mode == 'month') {
    onSelect(dayjs(item).format('YYYY-MM'))
  } else if (mode == 'date') {
    onSelect(dayjs(item).format('YYYY-MM-DD'))
  } else if (mode == 'datetime') {
    onSelect(dayjs(item).format('YYYY-MM-DD HH:mm:ss'))
  }
}


用于实现下面的效果:

3b6b30cc369b486e9276c74d3632a4a3.png


3.实现新增账单弹窗封装


在 components 下新建 PopupAddBill 文件夹,再新建 index.jsx 和 style.module.less,代码如下:

   账单类型和账单时间


   将金额动态化,引入 Zarm 为我们提供的模拟数字键盘组件 Keyboard,这里我们使用 “zarm”: “^2.8.2”,新版本好像有点问题

import React, { forwardRef, useEffect, useRef, useState } from 'react';
import { Popup, Icon, Keyboard, Input, Toast  } from 'zarm';
import CustomIcon from '@/components/CustomIcon'
import PopupDate from '../PopupDate'
import dayjs from 'dayjs';
import PropTypes from 'prop-types';
import { queryTypeList, addBillData } from './api/index.js'
import { typeMap } from '@/utils';
import cx from 'classnames';
import s from './style.module.less';
const PopupAddBill = forwardRef((props, ref) => {
  const dateRef = useRef();
  const [show, setShow] = useState(false) // 内部控制弹窗显示隐藏。
  const [date, setDate] = useState(new Date()); // 日期
  const [payType, setPayType] = useState('expense'); // 支出或收入类型
  const [currentType, setCurrentType] = useState({}); // 当前选中账单类型
  const [amount, setAmount] = useState(''); // 账单金额
  const [expense, setExpense] = useState([]); // 支出类型数组
  const [income, setIncome] = useState([]); // 收入类型数组
  const [remark, setRemark] = useState(''); // 备注
  const [showRemark, setShowRemark] = useState(false); // 备注输入框
  // 通过 forwardRef 拿到外部传入的 ref,并添加属性,使得父组件可以通过 ref 控制子组件。
  if (ref) {
    ref.current = {
      show: () => {
        setShow(true);
      },
      close: () => {
        setShow(false);
      }
    }
  };
  useEffect(async () => {
    const { data } = await queryTypeList({});
    const _expense = data.filter(i => i.type == 1); // 支出类型
    const _income = data.filter(i => i.type == 2); // 收入类型
    setExpense(_expense);
    setIncome(_income);
    setCurrentType(_expense[0]); // 新建账单,类型默认是支出类型数组的第一项
  }, [])
  // 切换收入还是支出
  const changeType = (type) => {
    setPayType(type);
    type == 'expense' ? setCurrentType(expense[0]) : setCurrentType(income[0]);
  };
  // 日期选择回调
  const selectDate = (val) => {
    console.log('日期选择回调', val)
    setDate(val);
  }
  // 监听输入框改变值
  const handleMoney = (value) => {
    console.log('value', value)
    value = String(value)
    // 点击关闭的时候
    if(value == 'close') {
      setShow(false)
      return
    }
    // 点击是删除按钮时
    if (value == 'delete') {
      let _amount = amount.slice(0, amount.length - 1)
      setAmount(_amount)
      return
    }
    // 点击确认按钮时,调用新增账单接口
    if (value == 'ok') {
      addBill()
      return
    }
    // 当输入的值为 '.' 且 已经存在 '.',则不让其继续字符串相加。
    if (value == '.' && amount.includes('.')) return
    // 小数点后保留两位,当超过两位时,不让其字符串继续相加。
    if (value != '.' && amount.includes('.') && amount && amount.split('.')[1].length >= 2) return
    // amount += value
    setAmount(amount + value)
  }
  // 添加账单
  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).format('YYYY-MM-DD HH:mm:ss'),
      pay_type: payType == 'expense' ? 1 : 2,
      remark: remark || ''
    }
    const result = await addBillData(params);
    console.log(result);
    // 重置参数
    setAmount('');
    setPayType('expense');
    setCurrentType(expense[0]);
    setDate(new Date());
    setRemark('');
    Toast.show('添加成功');
    setShow(false);
    // 刷新数据
    if (props.onReload) props.onReload();
  }
  return <Popup
    visible={show}
    direction="bottom"
    onMaskClick={() => setShow(false)}
    destroy={false}
    mountContainer={() => document.body}
  >
    <div className={s.addWrap}>
      {/* 右上角关闭弹窗 */}
      <header className={s.header}>
        <span className={s.close} onClick={() => setShow(false)}><Icon type="wrong" /></span>
      </header>
      {/* 「收入」和「支出」类型切换 */}
      <div className={s.filter}>
        <div className={s.type}>
          <span onClick={() => changeType('expense')} className={cx({ [s.expense]: true, [s.active]: payType == 'expense' })}>支出</span>
          <span onClick={() => changeType('income')} className={cx({ [s.income]: true, [s.active]: payType == 'income' })}>收入</span>
        </div>
        <div
          className={s.time}
          onClick={() => dateRef.current && dateRef.current.show()}
        >{dayjs(date).format('YYYY-MM-DD HH:mm')} <Icon className={s.arrow} type="arrow-bottom" /></div>
      </div>
      <div className={s.money}>
        <span className={s.sufix}>¥</span>
        <span className={cx(s.amount, s.animation)}>{amount}</span>
      </div>
      <div className={s.typeWarp}>
        <div className={s.typeBody}>
          {/* 通过 payType 判断,是展示收入账单类型,还是支出账单类型 */}
          {
            (payType == 'expense' ? expense : income).map(item => <div onClick={() => setCurrentType(item)} key={item.id} className={s.typeItem}>
              {/* 收入和支出的字体颜色,以及背景颜色通过 payType 区分,并且设置高亮 */}
              <span className={cx({[s.iconfontWrap]: true, [s.expense]: payType == 'expense', [s.income]: payType == 'income', [s.active]: currentType.id == item.id})}>                
                <CustomIcon className={s.iconfont} type={typeMap[item.id].icon} />
              </span>
              <span>{item.name}</span>
            </div>)
          }
        </div>
      </div>
      <div className={s.remark}>
        {
          showRemark ? <Input
            autoHeight
            showLength
            maxLength={50}
            type="text"
            rows={3}
            value={remark}
            placeholder="请输入备注信息"
            onChange={(val) => setRemark(val)}
            onBlur={() => setShowRemark(false)}
          /> : <span onClick={() => setShowRemark(true)}>{remark || '添加备注'}</span>
        }
      </div>
      <Keyboard type="price" onKeyClick={(value) => handleMoney(value)} />
      <PopupDate ref={dateRef} mode="datetime" onSelect={selectDate} />
    </div>
  </Popup>
})
export default PopupAddBill


.add-wrap {
  padding-top: 12px;
  background-color: #fff;
  border-top-left-radius: 10px;
  border-top-right-radius: 10px;
  .header {
    padding: 0 16px;
    .close {
      display: flex;
      align-items: center;
      justify-content: flex-end;
    }
  }
  .filter {
    padding: 12px 24px;
    display: flex;
    justify-content: space-between;
    align-items: center;
    .type {
      span {
        display: inline-block;
        background: #f5f5f5;
        color: rgba(0, 0, 0, 0.5);
        padding: 4px 12px;
        font-size: 12px;
        border-radius: 24px;
        border: 1px solid #f5f5f5;
      }
      .expense {
        margin-right: 6px;
        &.active {
          background-color: #eafbf6;
          border-color: #007fff;
          color: #007fff;
        }
      }
      .income {
        &.active {
          background-color: #fbf8f0;
          border-color: rgb(236, 190, 37);
          color: rgb(236, 190, 37);
        }
      }
    }
  }
  .time {
    display: flex;
    justify-content: center;
    align-items: center;
    padding: 4px 12px;
    background-color: #f0f0f0;
    border-radius: 20px;
    color: rgba(0, 0, 0, 0.9);
    .arrow {
      font-size: 12px;
      margin-left: 5px;
    }
  }
  .money {
    padding-bottom: 12px;
    border-bottom: 1px solid #e9e9e9;
    margin: 0 24px;
    .sufix {
      font-size: 36px;
      font-weight: bold;
      vertical-align: top;
    }
    .amount {
      font-size: 40px;
      padding-left: 10px;
    }
  }
  .type-warp {
    display: flex;
    overflow-x: auto;
    margin: 0 24px;
    margin-bottom: 20px;
    * {
      touch-action: pan-x;
    }
    .type-body {
      display: flex;
      white-space: nowrap;
      .type-item {
        display: flex;
        flex-direction: column;
        justify-content: center;
        align-items: center;
        padding: 16px 12px 10px 12px;
        .iconfont-wrap {
          display: flex;
          justify-content: center;
          align-items: center;
          background-color: #f5f5f5;
          border-radius: 50%;
          width: 30px;
          height: 30px;
          margin-bottom: 5px;
          .iconfont {
            color: rgba(0, 0, 0, 0.5);
            font-size: 20px;
          }
        }
        .expense {
          &.active {
            background-color: #007fff;
            .iconfont {
              color: #fff;
            }
          }
        }
        .income {
          &.active {
            background-color: rgb(236, 190, 37);
            .iconfont {
              color: #fff;
            }
          }
        }
      }
    }
  }
  .remark {
    padding: 0 24px;
    padding-bottom: 12px;
    color: #4b67e2;
    :global {
      .za-input--textarea {
        border: 1px solid #e9e9e9;
        padding: 10px;
      }
    }
  }
}



4.测试效果

我们先点新增按钮

43adeb24b7324b7f9e818be0aaf53275.png


然后就会弹出新增账单的窗口

62410262370c45aea7ea39dfb1e50d6a.png


我们测试一下支出的(收入可以自己去测试一下)


8b7d93c2c485423093d7eec9dedbc7d6.png


填写好信息之后,我们点击确定,发现新增支出的账单数据就添加成功了。


2f8eb02ab2a2455894a01c90c4f58a90.png



另外点击日期时间选择:我改成了精确到分钟


ba15730f828f4d9585decd801fa5a3ed.png










目录
相关文章
|
2月前
|
前端开发 JavaScript 网络架构
react对antd中Select组件二次封装
本文介绍了如何在React中对Ant Design(antd)的Select组件进行二次封装,包括创建MSelect组件、定义默认属性、渲染Select组件,并展示了如何使用Less进行样式定义和如何在项目中使用封装后的Select组件。
100 2
react对antd中Select组件二次封装
|
2月前
|
前端开发
React添加路径别名alias、接受props默认值、并二次封装antd中Modal组件与使用
本文介绍了在React项目中如何添加路径别名alias以简化模块引入路径,设置组件props的默认值,以及如何二次封装Ant Design的Modal组件。文章还提供了具体的代码示例,包括配置Webpack的alias、设置defaultProps以及封装Modal组件的步骤和方法。
74 1
React添加路径别名alias、接受props默认值、并二次封装antd中Modal组件与使用
|
1月前
|
前端开发
react 封装防抖
react 封装防抖
32 4
|
2月前
封装react-antd-table组件参数以及方法如rowSelection、pageNum、pageSize、分页方法等等
文章介绍了如何封装React-Antd的Table组件,包括参数和方法,如行选择(rowSelection)、页码(pageNum)、页面大小(pageSize)、分页方法等,以简化在不同表格组件中的重复代码。
67 0
|
5月前
|
前端开发 API
|
4月前
|
前端开发
Vue3 【仿 react 的 hook】封装 useTitle
Vue3 【仿 react 的 hook】封装 useTitle
52 0
|
4月前
|
前端开发 API
Vue3 【仿 react 的 hook】封装 useLocation
Vue3 【仿 react 的 hook】封装 useLocation
41 0
|
6月前
|
SQL 存储 前端开发
React&Nest.js全栈社区平台(五)——👋封装通用分页Service实现文章流与详情
React&Nest.js全栈社区平台(五)——👋封装通用分页Service实现文章流与详情
React&Nest.js全栈社区平台(五)——👋封装通用分页Service实现文章流与详情
|
JavaScript 前端开发
前端学习笔记202306学习笔记第五十四天-react.js & material-ui之编辑表单 封装form组件10
前端学习笔记202306学习笔记第五十四天-react.js & material-ui之编辑表单 封装form组件10
48 0
|
6月前
|
前端开发 JavaScript
React中封装echarts图表组件以及自适应窗口变化
React中封装echarts图表组件以及自适应窗口变化
207 1