1.环境搭建
//使用CRA创建项目,并安装必要依赖,包括下列基础包
//1. Redux状态管理 - @reduxjs/toolkit 、 react-redux
//2. 路由 - react-router-dom
//3. 时间处理 - dayjs
//4. class类名处理 - classnames
//5. 移动端组件库 - antd-mobile
//6. 请求插件 - axios
npx create-react-app react-bill-test
npm i @reduxjs/toolkit react-redux react-router-dom dayjs classnames antd-mobile axios
npm run start
2.配置别名路径@
const path = require('path')
module.exports = {
devServer: {
port: 3006
},
webpack: {
alias: {
'@': path.resolve(__dirname, 'src')
}
}
}
"start": "craco start",
"build": "craco build",
{
"compilerOptions": {
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
}
}
}
3.json-server实现mock
npm i -D json-server
4.整体路由设计
俩个一级路由 (Layout / new)2. 俩个二级路由 (Layout - mouth/year)
//router下index.js
// 创建路由实例 绑定path element
import Layout from '@/pages/Layout'
import Month from '@/pages/Month'
import New from '@/pages/New'
import Year from '@/pages/Year'
import { createBrowserRouter } from 'react-router-dom'
const router = createBrowserRouter([
{
path: '/',
element: <Layout />,
children: [
{
path: 'month',
element: <Month />
},
{
path: 'year',
element: <Year />
}
]
},
{
path: '/new',
element: <New />
}
])
export default router
//index.js
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import { RouterProvider } from 'react-router-dom'
import sum from '@/test'
import router from './router'
import { Provider } from 'react-redux'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<Provider>
<RouterProvider router={router} />
</Provider>
)
5.antD-mobile主题定制
//theme.css
:root:root {
--adm-color-primary: rgb(105, 174, 120);
}
/* .puple {
--adm-color-primary: #a062d4;
} */
//index.js
// 导入定制主题文件
import './theme.css'
6.redux管理帐目列表
1.
// 账单列表相关store
import { createSlice } from '@reduxjs/toolkit'
import axios from 'axios'
const billStore = createSlice({
name: 'bill',
// 数据状态state
initialState: {
billList: []
},
reducers: {
// 同步修改方法
setBillList (state, action) {
state.billList = action.payload
}
}
})
// 解构actionCreater函数
const { setBillList } = billStore.actions
// 编写异步
const getBillList = () => {
return async (dispatch) => {
// 编写异步请求
const res = await axios.get('http://localhost:8888/ka')
// 触发同步reducer
dispatch(setBillList(res.data))
}
}
export { getBillList }
// 导出reducer
const reducer = billStore.reducer
export default reducer
2.store下index.js
// 组合子模块 导出store实例
import { configureStore } from '@reduxjs/toolkit'
import billReducer from './modules/billStore'
const store = configureStore({
reducer: {
bill: billReducer
}
})
export default store
3.index.js
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.css'
import { RouterProvider } from 'react-router-dom'
import router from './router'
import { Provider } from 'react-redux'
import store from './store'
// 导入定制主题文件
import './theme.css'
const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
<Provider store={store}>
<RouterProvider router={router} />
</Provider>
)
4.前端项目+服务一起启动
"start": "craco start & npm run server",
7.tabBar功能实现
1.layout下index.js文件
import { TabBar } from "antd-mobile"
import { useEffect } from "react"
import { Outlet, useNavigate } from "react-router-dom"
import { useDispatch } from 'react-redux'
import { getBillList } from "@/store/modules/billStore"
import './index.scss'
import {
BillOutline,
CalculatorOutline,
AddCircleOutline
} from 'antd-mobile-icons'
const tabs = [
{
key: '/month',
title: '月度账单',
icon: <BillOutline />,
},
{
key: '/new',
title: '记账',
icon: <AddCircleOutline />,
},
{
key: '/year',
title: '年度账单',
icon: <CalculatorOutline />,
},
]
const Layout = () => {
const dispatch = useDispatch()
useEffect(() => {
dispatch(getBillList())
}, [dispatch])
// 切换菜单跳转路由
const navigate = useNavigate()
const swithRoute = (path) => {
console.log(path)
navigate(path)
}
return (
<div className="layout">
<div className="container">
<Outlet />
</div>
<div className="footer">
<TabBar onChange={swithRoute}>
{tabs.map(item => (
<TabBar.Item key={item.key} icon={item.icon} title={item.title} />
))}
</TabBar>
</div>
</div>
)
}
export default Layout
2.安装scss
npm i -D scss
8.月度账单—统计区域
//date.json
{
"ka": [
{
"type": "pay",
"money": -99,
"date": "2022-10-24 10:36:42",
"useFor": "drinks",
"id": 1
},
{
"type": "pay",
"money": -88,
"date": "2022-10-24 10:37:51",
"useFor": "longdistance",
"id": 2
},
{
"type": "income",
"money": 100,
"date": "2022-10-22 00:00:00",
"useFor": "bonus",
"id": 3
},
{
"type": "pay",
"money": -33,
"date": "2022-09-24 16:15:41",
"useFor": "dessert",
"id": 4
},
{
"type": "pay",
"money": -56,
"date": "2022-10-22T05:37:06.000Z",
"useFor": "drinks",
"id": 5
},
{
"type": "pay",
"money": -888,
"date": "2022-10-28T08:21:42.135Z",
"useFor": "travel",
"id": 6
},
{
"type": "income",
"money": 10000,
"date": "2023-03-20T06:45:54.004Z",
"useFor": "salary",
"id": 7
},
{
"type": "pay",
"money": -10,
"date": "2023-03-22T07:17:12.531Z",
"useFor": "drinks",
"id": 8
},
{
"type": "pay",
"money": -20,
"date": "2023-03-22T07:51:20.421Z",
"useFor": "dessert",
"id": 9
},
{
"type": "pay",
"money": -100,
"date": "2023-03-22T09:18:12.898Z",
"useFor": "drinks",
"id": 17
},
{
"type": "pay",
"money": -50,
"date": "2023-03-23T09:11:23.312Z",
"useFor": "food",
"id": 18
},
{
"type": "pay",
"money": -100,
"date": "2023-04-04T03:03:15.617Z",
"useFor": "drinks",
"id": 19
},
{
"type": "pay",
"money": -100,
"date": "2023-04-02T16:00:00.000Z",
"useFor": "food",
"id": 20
},
{
"type": "income",
"money": 10000,
"date": "2023-02-28T16:00:00.000Z",
"useFor": "salary",
"id": 21
}
]
}
//Month下index文件
import { NavBar, DatePicker } from 'antd-mobile'
import { useEffect, useState } from 'react'
import './index.scss'
import classNames from 'classnames'
import dayjs from 'dayjs'
import { useSelector } from 'react-redux'
import { useMemo } from 'react'
import _ from 'lodash'
import DailyBill from './components/DayBill'
const Month = () => {
// 按月做数据的分组
const billList = useSelector(state => state.bill.billList)
const monthGroup = useMemo(() => {
// return出去计算之后的值
return _.groupBy(billList, (item) => dayjs(item.date).format('YYYY-MM'))
}, [billList])
console.log(monthGroup)
// 控制弹框的打开和关闭
const [dateVisible, setDateVisible] = useState(false)
// 控制时间显示
const [currentDate, setCurrentDate] = useState(() => {
return dayjs(new Date()).format('YYYY-MM')
})
const [currentMonthList, setMonthList] = useState([])
const monthResult = useMemo(() => {
// 支出 / 收入 / 结余
const pay = currentMonthList.filter(item => item.type === 'pay').reduce((a, c) => a + c.money, 0)
const income = currentMonthList.filter(item => item.type === 'income').reduce((a, c) => a + c.money, 0)
return {
pay,
income,
total: pay + income
}
}, [currentMonthList])
// 初始化的时候把当前月的统计数据显示出来
useEffect(() => {
const nowDate = dayjs().format('YYYY-MM')
// 边界值控制
if (monthGroup[nowDate]) {
setMonthList(monthGroup[nowDate])
}
}, [monthGroup])
// 确认回调
const onConfirm = (date) => {
setDateVisible(false)
// 其他逻辑
console.log(date)
const formatDate = dayjs(date).format('YYYY-MM')
console.log(formatDate)
setMonthList(monthGroup[formatDate])
setCurrentDate(formatDate)
}
return (
<div className="monthlyBill">
<NavBar className="nav" backArrow={false}>
月度收支
</NavBar>
<div className="content">
<div className="header">
{/* 时间切换区域 */}
<div className="date" onClick={() => setDateVisible(true)}>
<span className="text">
{currentDate + ''}月账单
</span>
{/* 思路:根据当前弹框打开的状态控制expand类名是否存在 */}
<span className={classNames('arrow', dateVisible && 'expand')}></span>
</div>
{/* 统计区域 */}
<div className='twoLineOverview'>
<div className="item">
<span className="money">{monthResult.pay.toFixed(2)}</span>
<span className="type">支出</span>
</div>
<div className="item">
<span className="money">{monthResult.income.toFixed(2)}</span>
<span className="type">收入</span>
</div>
<div className="item">
<span className="money">{monthResult.total.toFixed(2)}</span>
<span className="type">结余</span>
</div>
</div>
{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
precision="month"
visible={dateVisible}
onCancel={() => setDateVisible(false)}
onConfirm={onConfirm}
onClose={() => setDateVisible(false)}
max={new Date()}
/>
</div>
</div>
</div >
)
}
export default Month
9.月度账单—列表区域
//daybill下index.js. 子组件
import classNames from 'classnames'
import './index.scss'
import { useMemo } from 'react'
import { billTypeToName } from '@/contants/index'
import { useState } from 'react'
import Icon from '@/components/Icon'
const DailyBill = ({ date, billList }) => {
const dayResult = useMemo(() => {
// 计算单日统计
// 支出 / 收入 / 结余
const pay = billList.filter(item => item.type === 'pay').reduce((a, c) => a + c.money, 0)
const income = billList.filter(item => item.type === 'income').reduce((a, c) => a + c.money, 0)
return {
pay,
income,
total: pay + income
}
}, [billList])
// 控制展开收起
const [visible, setVisible] = useState(false)
return (
<div className={classNames('dailyBill')}>
<div className="header">
<div className="dateIcon">
<span className="date">{date}</span>
{/* expand 有这个类名 展开的箭头朝上的样子 */}
<span className={classNames('arrow', visible && 'expand')} onClick={() => setVisible(!visible)}></span>
</div>
<div className="oneLineOverview">
<div className="pay">
<span className="type">支出</span>
<span className="money">{dayResult.pay.toFixed(2)}</span>
</div>
<div className="income">
<span className="type">收入</span>
<span className="money">{dayResult.income.toFixed(2)}</span>
</div>
<div className="balance">
<span className="money">{dayResult.total.toFixed(2)}</span>
<span className="type">结余</span>
</div>
</div>
</div>
{/* 单日列表 */}
<div className="billList" style={{ display: visible ? 'block' : 'none' }}>
{billList.map(item => {
return (
<div className="bill" key={item.id}>
{/* 图标 */}
<Icon type={item.useFor} />
<div className="detail">
<div className="billType">{billTypeToName[item.useFor]}</div>
</div>
<div className={classNames('money', item.type)}>
{item.money.toFixed(2)}
</div>
</div>
)
})}
</div>
</div>
)
}
export default DailyBill
//icon下idnex
const Icon = ({ type }) => {
return (
<img
src={`https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/ka/${type}.svg`}
alt="icon"
style={{
width: 20,
height: 20,
}}
/>
)
}
export default Icon
//contants下index.js
export const billListData = {
pay: [
{
type: 'foods',
name: '餐饮',
list: [
{ type: 'food', name: '餐费' },
{ type: 'drinks', name: '酒水饮料' },
{ type: 'dessert', name: '甜品零食' },
],
},
{
type: 'taxi',
name: '出行交通',
list: [
{ type: 'taxi', name: '打车租车' },
{ type: 'longdistance', name: '旅行票费' },
],
},
{
type: 'recreation',
name: '休闲娱乐',
list: [
{ type: 'bodybuilding', name: '运动健身' },
{ type: 'game', name: '休闲玩乐' },
{ type: 'audio', name: '媒体影音' },
{ type: 'travel', name: '旅游度假' },
],
},
{
type: 'daily',
name: '日常支出',
list: [
{ type: 'clothes', name: '衣服裤子' },
{ type: 'bag', name: '鞋帽包包' },
{ type: 'book', name: '知识学习' },
{ type: 'promote', name: '能力提升' },
{ type: 'home', name: '家装布置' },
],
},
{
type: 'other',
name: '其他支出',
list: [{ type: 'community', name: '社区缴费' }],
},
],
income: [
{
type: 'professional',
name: '其他支出',
list: [
{ type: 'salary', name: '工资' },
{ type: 'overtimepay', name: '加班' },
{ type: 'bonus', name: '奖金' },
],
},
{
type: 'other',
name: '其他收入',
list: [
{ type: 'financial', name: '理财收入' },
{ type: 'cashgift', name: '礼金收入' },
],
},
],
}
export const billTypeToName = Object.keys(billListData).reduce((prev, key) => {
billListData[key].forEach(bill => {
bill.list.forEach(item => {
prev[item.type] = item.name
})
})
return prev
}, {})
//month下index.js文件 父组件
import { NavBar, DatePicker } from 'antd-mobile'
import { useEffect, useState } from 'react'
import './index.scss'
import classNames from 'classnames'
import dayjs from 'dayjs'
import { useSelector } from 'react-redux'
import { useMemo } from 'react'
import _ from 'lodash'
import DailyBill from './components/DayBill'
const Month = () => {
// 按月做数据的分组
const billList = useSelector(state => state.bill.billList)
const monthGroup = useMemo(() => {
// return出去计算之后的值
return _.groupBy(billList, (item) => dayjs(item.date).format('YYYY-MM'))
}, [billList])
console.log(monthGroup)
// 控制弹框的打开和关闭
const [dateVisible, setDateVisible] = useState(false)
// 控制时间显示
const [currentDate, setCurrentDate] = useState(() => {
return dayjs(new Date()).format('YYYY-MM')
})
const [currentMonthList, setMonthList] = useState([])
const monthResult = useMemo(() => {
// 支出 / 收入 / 结余
const pay = currentMonthList.filter(item => item.type === 'pay').reduce((a, c) => a + c.money, 0)
const income = currentMonthList.filter(item => item.type === 'income').reduce((a, c) => a + c.money, 0)
return {
pay,
income,
total: pay + income
}
}, [currentMonthList])
// 初始化的时候把当前月的统计数据显示出来
useEffect(() => {
const nowDate = dayjs().format('YYYY-MM')
// 边界值控制
if (monthGroup[nowDate]) {
setMonthList(monthGroup[nowDate])
}
}, [monthGroup])
// 确认回调
const onConfirm = (date) => {
setDateVisible(false)
// 其他逻辑
console.log(date)
const formatDate = dayjs(date).format('YYYY-MM')
console.log(formatDate)
setMonthList(monthGroup[formatDate])
setCurrentDate(formatDate)
}
// 当前月按照日来做分组
const dayGroup = useMemo(() => {
// return出去计算之后的值
const groupData = _.groupBy(currentMonthList, (item) => dayjs(item.date).format('YYYY-MM-DD'))
const keys = Object.keys(groupData)
return {
groupData,
keys
}
}, [currentMonthList])
return (
<div className="monthlyBill">
<NavBar className="nav" backArrow={false}>
月度收支
</NavBar>
<div className="content">
<div className="header">
{/* 时间切换区域 */}
<div className="date" onClick={() => setDateVisible(true)}>
<span className="text">
{currentDate + ''}月账单
</span>
{/* 思路:根据当前弹框打开的状态控制expand类名是否存在 */}
<span className={classNames('arrow', dateVisible && 'expand')}></span>
</div>
{/* 统计区域 */}
<div className='twoLineOverview'>
<div className="item">
<span className="money">{monthResult.pay.toFixed(2)}</span>
<span className="type">支出</span>
</div>
<div className="item">
<span className="money">{monthResult.income.toFixed(2)}</span>
<span className="type">收入</span>
</div>
<div className="item">
<span className="money">{monthResult.total.toFixed(2)}</span>
<span className="type">结余</span>
</div>
</div>
{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
precision="month"
visible={dateVisible}
onCancel={() => setDateVisible(false)}
onConfirm={onConfirm}
onClose={() => setDateVisible(false)}
max={new Date()}
/>
</div>
{/* 单日列表统计 */}
{
dayGroup.keys.map(key => {
return <DailyBill key={key} date={key} billList={dayGroup.groupData[key]} />
})
}
</div>
</div >
)
}
export default Month
10.记账本—新增账单
//new下index.js
import { Button, DatePicker, Input, NavBar } from 'antd-mobile'
import Icon from '@/components/Icon'
import './index.scss'
import classNames from 'classnames'
import { billListData } from '@/contants'
import { useNavigate } from 'react-router-dom'
import { useState } from 'react'
import { addBillList } from '@/store/modules/billStore'
import { useDispatch } from 'react-redux'
import dayjs from 'dayjs'
const New = () => {
const navigate = useNavigate()
// 1. 准备一个控制收入支出的状态
const [billType, setBillType] = useState('pay') // pay-支出 income-收入
// 收集金额
const [money, setMoney] = useState(0)
const moneyChange = (value) => {
setMoney(value)
}
// 收集账单类型
const [useFor, setUseFor] = useState('')
const dispatch = useDispatch()
// 保存账单
const saveBill = () => {
// 收集表单数据
const data = {
type: billType,
money: billType === 'pay' ? -money : +money,
date: date,
useFor: useFor
}
console.log(data)
dispatch(addBillList(data))
}
// 存储选择的时间
const [date, setDate] = useState()
// 控制时间打开关闭
const [dateVisible, setDateVisible] = useState(false)
// 确认选择时间
const dateConfirm = (value) => {
console.log(value)
setDate(value)
setDateVisible(false)
}
return (
<div className="keepAccounts">
<NavBar className="nav" onBack={() => navigate(-1)}>
记一笔
</NavBar>
<div className="header">
<div className="kaType">
<Button
shape="rounded"
className={classNames(billType === 'pay' ? 'selected' : '')}
onClick={() => setBillType('pay')}
>
支出
</Button>
<Button
className={classNames(billType === 'income' ? 'selected' : '')}
shape="rounded"
onClick={() => setBillType('income')}
>
收入
</Button>
</div>
<div className="kaFormWrapper">
<div className="kaForm">
<div className="date">
<Icon type="calendar" className="icon" />
<span className="text" onClick={() => setDateVisible(true)}>{dayjs(date).format('YYYY-MM-DD')}</span>
{/* 时间选择器 */}
<DatePicker
className="kaDate"
title="记账日期"
max={new Date()}
visible={dateVisible}
onConfirm={dateConfirm}
/>
</div>
<div className="kaInput">
<Input
className="input"
placeholder="0.00"
type="number"
value={money}
onChange={moneyChange}
/>
<span className="iconYuan">¥</span>
</div>
</div>
</div>
</div>
<div className="kaTypeList">
{/* 数据区域 */}
{billListData[billType].map(item => {
return (
<div className="kaType" key={item.type}>
<div className="title">{item.name}</div>
<div className="list">
{item.list.map(item => {
return (
// selected
<div
className={classNames(
'item',
useFor === item.type ? 'selected' : ''
)}
key={item.type}
onClick={() => setUseFor(item.type)}
>
<div className="icon">
<Icon type={item.type} />
</div>
<div className="text">{item.name}</div>
</div>
)
})}
</div>
</div>
)
})}
</div>
<div className="btns">
<Button className="btn save" onClick={saveBill}>
保 存
</Button>
</div>
</div>
)
}
export default New
//store下index.js
// 账单列表相关store
import { createSlice } from '@reduxjs/toolkit'
import axios from 'axios'
const billStore = createSlice({
name: 'bill',
// 数据状态state
initialState: {
billList: []
},
reducers: {
// 同步修改方法
setBillList (state, action) {
state.billList = action.payload
},
// 同步添加账单方法
addBill (state, action) {
state.billList.push(action.payload)
}
}
})
// 解构actionCreater函数
const { setBillList, addBill } = billStore.actions
// 编写异步
const getBillList = () => {
return async (dispatch) => {
// 编写异步请求
const res = await axios.get('http://localhost:8888/ka')
// 触发同步reducer
dispatch(setBillList(res.data))
}
}
const addBillList = (data) => {
return async (dispatch) => {
// 编写异步请求
const res = await axios.post('http://localhost:8888/ka', data)
// 触发同步reducer
dispatch(addBill(res.data))
}
}
export { getBillList, addBillList }
// 导出reducer
const reducer = billStore.reducer
export default reducer