react + zarm 实现账单详情页以及编辑删除功能

简介: react + zarm 实现账单详情页以及编辑删除功能

需要实现的交互效果


编辑的功能点击还是弹出新增账单窗口那个模块,不过需要稍微改动一下。

7b958000841540cab06e48ea6fb5d0a4.png



实现过程


1.封装公用的头部 Header

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

a81e1943d2e1423da137574f9d216559.png

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 navigate = useNavigate()
  return <div className={s.headerWarp}>
    <div className={s.block}>
      <NavBar
        className={s.header}
        left={<Icon type="arrow-left" theme="primary" onClick={() => navigate(-1)} />}
        title={title}
      />
    </div>
  </div>
};
Header.propTypes = {
  title: PropTypes.string, // 标题
};
export default Header;
.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;
    }
  }
}


2.安装 query-string

npm i query-string -s


ca1ea6c25a9842f893d45f356f47c5ca.png


用法请参考:https://github.com/sindresorhus/query-string

const queryString = require('query-string');
console.log(location.search);
//=> '?foo=bar'
const parsed = queryString.parse(location.search);
console.log(parsed);
//=> {foo: 'bar'}
console.log(location.hash);
//=> '#token=bada55cafe'
const parsedHash = queryString.parse(location.hash);
console.log(parsedHash);
//=> {token: 'bada55cafe'}
parsed.foo = 'unicorn';
parsed.ilike = 'pizza';
const stringified = queryString.stringify(parsed);
//=> 'foo=unicorn&ilike=pizza'
location.search = stringified;
// note that `location.search` automatically prepends a question mark
console.log(location.search);
//=> '?foo=unicorn&ilike=pizza'


3.添加 Detail 路由

src\router\index.js 里添加路由:

import Login from '@/container/Login'
import Home from '@/container/Home'
import Data from '@/container/Data'
import User from '@/container/User'
import Detail from '@/container/Detail'
const routes = [
  {
    path: "/login",
    component: Login
  },{
    path: "/",
    component: Home
  },{
    path: "/data",
    component: Data
  },{
    path: "/user",
    component: User
  },{
    path: "/detail",
    component: Detail
  }
];
export default routes



4.添加 Detail 模块代码

在 container 目录下新建 Detail 目录,添加文件 index.jsxstyle.module.less,以及 api/index.js 接口配置文件。

2197223ad73e4e58859fe3f9b4549dc6.png

import React, { useEffect, useState, useRef } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Modal, Toast } from 'zarm';
import qs from 'query-string';
import cx from 'classnames';
import Header from '@/components/Header';
import CustomIcon from '@/components/CustomIcon';
import PopupAddBill from '@/components/PopupAddBill';
import { typeMap } from '@/utils';
import { billDetails, billDelete } from './api/index.js';
import s from './style.module.less';
const Detail = () => {
  const editRef = useRef();
  const navigate = useNavigate();
  const location = useLocation(); // 路由 location 实例
  const { id } = qs.parse(location.search); // 查询字符串反序列化
  const [detail, setDetail] = useState({}); // 订单详情数据
  useEffect(() => {
    getDetail()
  }, []);
  const getDetail = async () => {
    const { data } = await billDetails({ id });
    setDetail(data);
  }
  // 删除方法
const deleteDetail = () => {
  Modal.confirm({
    title: '删除',
    content: '确认删除账单?',
    onOk: async () => {
      const { data } = await billDelete({ id })
      Toast.show('删除成功')
      navigate(-1)
    },
  });
}
  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>{detail.date}</span>
        </div>
        <div className={s.remark}>
          <span>备注</span>
          <span>{ detail.remark || '-' }</span>
        </div>
      </div>
      <div className={s.operation}>
        <span onClick={deleteDetail}><CustomIcon type='shanchu' />删除</span>
        <span onClick={() => editRef.current && editRef.current.show()}><CustomIcon type='tianjia' />编辑</span>
      </div>
    </div>
    <PopupAddBill ref={editRef} detail={detail} onReload={getDetail} />
  </div>
}
export default Detail


.detail {
  height: 100%;
  display: flex;
  flex-direction: column;
  background-color: #f5f5f5;
  padding: 12px 24px 0 24px;
}
.card {
  border-radius: 12px;
  background-color: #fff;
  padding: 0 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  .type {
    padding: 24px 0 12px 0;
    span:nth-of-type(1) {
      display: inline-block;
      width: 22px;
      height: 22px;
      color: #fff;
      border-radius: 50%;
      text-align: center;
      line-height: 24px;
      margin-right: 8px;
    }
    .expense {
      background-color: #007fff;
    }
    .income {
      background-color: rgb(236, 190, 37);
    }
    .iconfont {
      font-size: 16px;
    }
  }
  .amount {
    font-size: 24px;
    font-weight: 600;
    margin-bottom: 24px;
  }
  .info {
    width: 100%;
    font-size: 14px;
    text-align: left;
    .time {
      display: flex;
      align-items: center;
      justify-content: flex-start;
      margin-bottom: 12px;
      span:nth-of-type(1) {
        flex: 3;
        color: rgba(0,0,0,0.5)
      }
      span:nth-of-type(2) {
        flex: 9;
      }
    }
    .remark {
      display: flex;
      align-items: center;
      justify-content: flex-start;
      margin-bottom: 12px;
      span:nth-of-type(1) {
        flex: 3;
        color: rgba(0,0,0,0.5)
      }
      span:nth-of-type(2) {
        flex: 9;
        color: rgba(0,0,0,0.9)
      }
    }
  }
  .operation {
    width: 100%;
    height: 50px;
    display: flex;
    align-items: center;
    font-size: 16px;
    .van-icon {
      margin-right: 4px;
    }
    span {
      display: flex;
      align-items: center;
      justify-content: center;
      height: 100%;
      flex: 1
    }
    span:nth-of-type(1) {
      color: red;
    }
  }
}
import { fetchData } from "@/utils/axios.js";
// 获取账单详情
export function billDetails(data) {
  return fetchData('/api/bill/details', 'get', data);
}
// 删除账单
export function billDelete(data) {
  return fetchData('/api/bill/delete', 'post', data);
}


5.更新 PopupAddBill 模块逻辑

里面添加了编辑功能的相关逻辑,以及接口的配置

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, billAdd, billUpdate } from './api/index.js'
import { typeMap } from '@/utils';
import cx from 'classnames';
import s from './style.module.less';
const PopupAddBill = forwardRef(({ detail = {}, onReload }, ref) => {
  const dateRef = useRef();
  const id = detail && detail.id // 外部传进来的账单详情 id
  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); // 备注输入框
  // 详情信息回显
  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(detail.date)
    }
  }, [detail])
  // 通过 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);
    // 没有 id 的情况下,说明是新建账单。
    if(!id) {
      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 || ''
    }
    // 如果有id,调用编辑账单,没有就新增账单
    if(id) {
      const { status, desc, data } = await billUpdate({
        id, ...params
      });
      console.log('编辑账单', status, desc, data);
      if(status === 200) {
        Toast.show('编辑成功');
      }else{
        Toast.show(desc);
        return ;
      }
    }else{
      const { status, desc, data } = await billAdd(params);
      console.log('新增账单', status, desc, data);
      if(status === 200) {
        Toast.show('新增成功');
      }else{
        Toast.show(desc);
        return ;
      }
      // 重置参数
      setAmount('');
      setPayType('expense');
      setCurrentType(expense[0]);
      setDate(new Date());
      setRemark('');
      Toast.show('添加成功');
    }
    setShow(false);
    // 刷新数据
    if (onReload) 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>
})
PopupAddBill.propTypes = {
  detail: PropTypes.object,
  onReload: PropTypes.func
}
export default PopupAddBill


8211be474ecd40c1aef6b4682af5e27b.png


api/index.js 里添加编辑更新账单接口

import { fetchData } from "@/utils/axios.js";
// 获取类型字典列表
export function queryTypeList(data) {
  return fetchData('/api/type/list', 'get', data);
}
// 添加账单
export function billAdd(data) {
  return fetchData('/api/bill/add', 'post', data);
}
// 更新账单信息
export function billUpdate(data) {
  return fetchData('/api/bill/update', 'post', data);
}


6.测试一下效果

我们从列表页随便选择一个账单,点击进去

1635dae985244ad6aee5d162f9401e82.png



详情页面如下


f5175f4f0a524de69832b6d960d25cf1.png


下面我们先测试删除按钮,我们点击删除按钮


1913e1a4d8d64e828659168980edf046.png


点击确定:我们发现删除成功了


78317724d098434dab78a8dfcdaeb311.png


我们在测试一下编辑功能:我们将数据改变一下


67935f4df6fa4655a896570846be36a8.png



点击确认:我们发现数据更新了

2799fb585d984b5abaf70874f8b1dcda.png


回到列表页:我们也发现更新成功了。


360657b264574d0fba66ed912dedbb60.png













目录
相关文章
|
2月前
|
前端开发
React查询、搜索类功能的实现
React查询、搜索类功能的实现
38 0
|
2月前
|
前端开发
React 仿淘宝图片放大镜功能
React 仿淘宝图片放大镜功能
|
2月前
|
存储 前端开发 JavaScript
【亮剑】在React中,处理`onScroll`事件可实现复杂功能如无限滚动和视差效果
【4月更文挑战第30天】在React中,处理`onScroll`事件可实现复杂功能如无限滚动和视差效果。类组件和函数组件都能使用`onScroll`,通过`componentDidMount`和`componentWillUnmount`或`useEffect`添加和移除事件监听器。性能优化需注意节流、防抖、虚拟滚动、避免同步计算和及时移除监听器。实战案例展示了如何用Intersection Observer和`onScroll`实现无限滚动列表,当最后一项进入视口时加载更多内容。合理利用滚动事件能提升用户体验,同时要注意性能优化。
|
2月前
|
JavaScript 前端开发
在React和Vue中实现锚点定位功能
在React和Vue中实现锚点定位功能
50 1
|
11月前
|
前端开发
|
11月前
|
前端开发
React/Umi中实现移动端滑动图片验证功能
React/Umi中实现移动端滑动图片验证功能
194 0
|
11月前
|
前端开发
前端学习笔记202305学习笔记第二十九天-React keep alive原理之2
前端学习笔记202305学习笔记第二十九天-React keep alive原理之2
46 0
|
11月前
|
前端开发
前端学习笔记202306学习笔记第四十八天-react-admin marmelab之8
前端学习笔记202306学习笔记第四十八天-react-admin marmelab之7
33 0
|
2月前
|
前端开发 JavaScript
前端知识笔记(二十六)———React如何像Vue一样将css和js写在同一文件
前端知识笔记(二十六)———React如何像Vue一样将css和js写在同一文件
39 1
|
8月前
|
前端开发
前端笔记:React的form表单全部置空或者某个操作框置空的做法
在React框架前端开发中,经常会有弹出框的开发,涉及到弹出框,难免就会有表单。一般在关闭弹出框或者对表单联动时,往往都需要考虑对表单进行置空操作了。
61 0