03-react-router

1. 前端路由

1. 什么是前端路由

一个路径 path 对应一个组件 component,当浏览器访问一个 path 时,path 对应的组件会在页面中进行渲染

const routes = [
    {
        path: '/about',
        component: About,
    },
    {
        path: '/article',
        component: Article,
    },
];

2. 创建路由开发环境

# 使用 CRA 创建项目
npm create-react-app react-router-pro

# 安装最新的 ReactRouter 包
npm i react-router-dom

# 启动项目
npm run start




 



3. 快速开始

TllVUzlONmx2R25rd0RKQkZqNUxrL2owTk44RnhubGZNVVhwZ1czL1RBPT0=
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import {createBrowserRouter, RouterProvider} from "react-router-dom";

const router = createBrowserRouter([
  {
    path: '/login',
    element: <div>登录</div>
  },
  {
    path: '/article',
    element: <div>文章</div>
  }
])

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <RouterProvider router={router}/>
);
// root.render(
//   <React.StrictMode>
//     <App />
//   </React.StrictMode>
// );

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();





 

 
 
 
 
 
 
 
 
 
 



 











2. 抽象路由模块

NFZqM1JjeDV1RXhpSHU4QXI2MFF5ZmowTk44RnhubGZNVVhwZ1czL1RBPT0=
Nmplby9XQzZvRWxLd2NtL0pGS1ZrOUtOcmlZUDZiTy83TGJmVUtzPQ==
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import reportWebVitals from './reportWebVitals';
import {RouterProvider} from "react-router-dom";


// 1. 导入路由router
import router from './router'

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
    <React.StrictMode>
      {/* 2. 路由绑定 */}
      <RouterProvider router={router}></RouterProvider>
    </React.StrictMode>
);
// root.render(
//   <React.StrictMode>
//     <App />
//   </React.StrictMode>
// );

reportWebVitals();




 



 





 









1. router.js

import Login from '../page/Login'
import Article from '../page/Article'
import Layout from '../page/Layout'
import Board from '../page/Board'
import About from '../page/About'
import NotFound from '../page/NotFound'

import { createBrowserRouter, createHashRouter } from 'react-router-dom'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      // 设置为默认二级路由,一级路由访问的时候,它也能得到渲染
      {
        index: true,
        element: <Board />
      },
      {
        path: 'about',
        element: <About />
      }
    ]
  },
  {
    path: '/login',
    element: <Login />
  },
  {
    path: '/article/:id/:name',
    element: <Article />
  },
  {
    path: '*',
    element: <NotFound />
  }
])

console.log(router)

export default router







 

 
































2. Layout

import {Link, Outlet} from "react-router-dom"

const Layout = () => {
  return (
      <div>
        我是一级路由layout组件
        <Link to="/">面板</Link>
        <Link to="/about">关于</Link>
        {/* 配置二级路由的出口 */}
        <Outlet/>
      </div>
  )
}

export default Layout






 
 

 





1. Board

const Board = () => {
  return <div>我是面板</div>
}

export default Board

2. About

const About = () => {
  return <div>我是关于页</div>
}

export default About

3. Login

import {Link, useNavigate} from 'react-router-dom'

const Login = () => {
  const navigate = useNavigate()
  return (
      <div>
        我是登录页
        {/* 声明式的写法 */}
        <Link to="/article">跳转到文章页</Link>
        {/* 命令式的写法 */}
        <button onClick={() => navigate('/article')}>跳转到文章页</button>
        <button onClick={() => navigate('/article?id=1001&name=jack')}>searchParams传参</button>
        <button onClick={() => navigate('/article/1001/jack')}>params传参</button>
      </div>
  )
}

export default Login



 




 

 
 
 





1. Article

import {useParams, useSearchParams} from "react-router-dom"

const Article = () => {
  // const [params] = useSearchParams()
  // const id = params.get('id')
  // const name = params.get('name')

  const params = useParams()
  const id = params.id
  const name = params.name
  return <div>我是文章页{id}-{name}</div>
}

export default Article

4. NotFound

const NotFound = () => {
  return <div>页面跑到月球了</div>
}

export default NotFound

3. 路由导航

1. what

路由系统中的多个路由之间需要进行路由跳转,并且在跳转的同时有可能需要传递参数进行通信

TDNSR3VaU1RGZVI3YkN6aTNabHptdmowTk44RnhubGZNVVhwZ1czL1RBPT0=

2. 声明式导航

指在模版中通过 <Link/> 组件描述出要跳转到哪里去

  • eg:后台管理系统的左侧菜单通常使用这种方式进行
  • 语法说明:通过给组件的 to 属性指定要跳转到路由 path,组件会被渲染为浏览器支持的 a 链接,如果需要传参直接通过字符串拼接的方式拼接参数即可
<Link to="/article">跳转到文章页</Link>

3. 编程式导航

指通过 useNavigate 钩子进行导航,通过调用方法以命令式的形式进行路由跳转

  • eg:想在登录请求完毕之后跳转,可以选择这种方式,更加灵活
  • 语法说明:通过调用 navigate() 传入地址 path 实现跳转
<button onClick={() => navigate('/article')}>跳转到文章页</button>

4. 导航传参

YUlWb0Z4Wm9rVHYycS9HWE1UUmEwZmowTk44RnhubGZNVVhwZ1czL1RBPT0=

1. searchParams传参

  {
    path: '/article',
    element: <Article />
  },

 


<button onClick={() => navigate('/article?id=1001&name=jack')}>searchParams传参</button>
import {useParams, useSearchParams} from "react-router-dom"

const Article = () => {
  const [params] = useSearchParams()
  const id = params.get('id')
  const name = params.get('name')

  // const params = useParams()
  // const id = params.id
  // const name = params.name
  return <div>我是文章页{id}-{name}</div>
}

export default Article



 










2. Params传参

  {
    path: '/article/:id/:name',
    element: <Article />
  },

 


import {useParams, useSearchParams} from "react-router-dom"

const Article = () => {
  // const [params] = useSearchParams()
  // const id = params.get('id')
  // const name = params.get('name')

  const params = useParams()
  const id = params.id
  const name = params.name
  return <div>我是文章页{id}-{name}</div>
}

export default Article







 






5. 嵌套路由配置

1. what

在一级路由中又内嵌了其他路由,嵌套至一级路由内的路由,又称作二级路由

RXdoRVBISUlzUllNZTlob3RHL3RML2owTk44RnhubGZNVVhwZ1czL1RBPT0=

2. 嵌套路由配置

  1. 使用 children 属性配置路由嵌套关系
  2. 使用 <Outlet/> 组件配置二级路由渲染位置
  {
    path: '/',
    element: <Layout />,
    children: [
      {
        path: 'board',
        element: <Board />
      },
      {
        path: 'about',
        element: <About />
      }
    ]
  },



 










import {Link, Outlet} from "react-router-dom"

const Layout = () => {
  return (
      <div>
        我是一级路由layout组件
        <Link to="/board">面板</Link>
        <Link to="/about">关于</Link>
        {/* 配置二级路由的出口 */}
        <Outlet/>
      </div>
  )
}

export default Layout






 
 

 





eFFISjdDR1REVGViSURFcVhuV1FGOUtOcmlZUDZiTy83TGJmVUt0K2tRPT0=

3. 默认二级路由

当访问的是一级路由时,默认的二级路由组件可以得到渲染,只需要在二级路由的位置去掉 path,设置 index 属性为 true

  {
    path: '/',
    element: <Layout />,
    children: [
      // 设置为默认二级路由,一级路由访问的时候,它也能得到渲染
      {
        index: true,
        element: <Board />
      },
      {
        path: 'about',
        element: <About />
      }
    ]
  },






 








import {Link, Outlet} from "react-router-dom"

const Layout = () => {
  return (
      <div>
        我是一级路由layout组件
        <Link to="/">面板</Link>
        <Link to="/about">关于</Link>
        {/* 配置二级路由的出口 */}
        <Outlet/>
      </div>
  )
}

export default Layout






 








YWVXc0FPcEhBamExcVQrOFNhM1NzZmowTk44RnhubGZNVVhwZ1czL1RBPT0=

4. 404路由配置

场景:当浏览器输入 url 的路径在整个路由配置中都找不到对应的 path,为了用户体验,可以使用 404 兜底组件进行渲染

  1. 准备一个 NotFound 组件
  2. 在路由表数组的末尾,以 * 号作为路由 path 配置路由
{
    path: '*',
    element: <NotFound />,
}

 


6. 两种路由模式

各个主流框架的路由常用的路由模式有两种

  1. history 模式:ReactRouter 由 createBrowerRouter() 创建
  2. hash 模式:ReactRouter 由 createHashRouter() 创建
路由模式url表现底层原理是否需要后端支持
historyurl/loginhistory 对象 + pushState 事件需要
hashurl/#/login监听 hashChange 事件不需要(浏览器不会将 # 之后的部分发送到服务器)

7. 案例-记账本

使用 CRA 创建项目,并安装必要依赖,包括下列基础包

  1. Redux状态管理:@reduxjs/toolkitreact-redux
  2. 路由:react-router-dom
  3. 时间处理:dayjs
  4. class类名处理:classnames
  5. 移动端组件库:antd-mobile
  6. 请求插件:axios

1. 配置别名路径

1. 背景知识

  1. 路径解析配置(webpack),把 @/ 解析为 src/
  2. 路径联想配置(VsCode),VsCode 在输入 @/ 时,自动联想出来对应的 src/ 下的子级目录
U09nU0pHdjNnbDZsbldIR0dib3lML2owTk44RnhubGZNVVhwZ1czL1RBPT0=

2. 路径解析配置

配置步骤:

  1. 安装 craco。npm i -D @craco/craco
  2. 项目根目录下创建配置文件:craco.config.js
  3. 配置文件中添加路径解析配置
  4. 包文件中配置启动和打包命令
const path = require('path');

module.exports = {
    // webpack 配置
    webpack: {
        // 配置别名
        alias: {
            // 约定:使用 @ 表示 src 文件所在路径
            '@': path.resolve(__dirname, 'src')
        }
    }
};
"scripts": {
    "start": "craco start",
    "build": "craco build"
}

3. 联想路径配置

  1. 根目录下新增配置文件:jsconfig.json
  2. 添加路径提示配置
{
  "compilerOptions":{
    "baseUrl":"./",
    "paths":{
      "@/*":[
        "src/*"
      ]
    }
  }
}

2. 数据Mock实现

在前后端分离的开发模式下,前端可以在没有实际后端接口的支持下先进行接口数据的模拟,进行正常的业务功能开发

1. 常见的Mock方式

OWZWbVM0eTlvYVRoWUp4RE9kQXhDL2owTk44RnhubGZNVVhwZ1cwPQ==

2. json-server实现Mock

  1. 项目中安装 json-server:npm i -D json-server
  2. 准备一个 json 文件(素材里获取)
  3. 添加启动命令
    • "server": "json-server ./server/data.json --port 8888"
  4. 访问接口进行测试

3. 整体路由设计

  1. 两个一级路由 (Layout / new)
  2. 两个二级路由 (Layout - mouth/year)
T3YxZnNyUkRLTWhBMjZ3d3RPWS9BUGowTk44RnhubGZNVVhwZ1czL1RBPT0=

4. antD主题定制

1. 定制方案

  1. 全局定制:整个应用范围内的组件都生效
  2. 局部定制:只在某些元素内部的组件生效
<div className="purple-theme">
    <Button color="primary">局部测试</Button>
</div>
<Button color="primary">测试</Button>

2. 实现方式

/* 全局定制 */
:root {
    --adm-color-primary: #a062d4;
}

/* 局部定制 */
.purple-theme {
    --adm-color-primary: #a062d4;
}

3. 记账本主题色

:root:root {
  --adm-color-primary: rgb(105, 174, 120);
}

5. Redux管理账目列表

THJkaUZQZFA5OEc5ZU1oVFUzVDhHdmowTk44RnhubGZNVVhwZ1czL1RBPT0=
// 账单列表相关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
// 组合子模块 导出store实例

import { configureStore } from '@reduxjs/toolkit'
import billReducer from './modules/billStore'

const store = configureStore({
  reducer: {
    bill: billReducer
  }
})

export default store
import router from './router'
import { Provider } from 'react-redux'

const root = ReactDOM.createRoot(document.getElementById('root'))
root.render(
  <Provider store={store}>
    <RouterProvider router={router} />
  </Provider>
)

6. TabBar功能实现

RlFuM2dqL0hIWjRoVHl1dDhURm1KZmowTk44RnhubGZNVVhwZ1czL1RBPT0=

1. 静态布局实现

配套静态模版和样式文件

import { TabBar } from "antd-mobile"
import { useEffect } from "react"
import { Outlet } 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])
  return (
    <div className="layout">
      <div className="container">
        <Outlet />
      </div>
      <div className="footer">
        <TabBar>
          {tabs.map(item => (
            <TabBar.Item key={item.key} icon={item.icon} title={item.title} />
          ))}
        </TabBar>
      </div>
    </div>
  )
}

export default Layout
.layout {
  .container {
    position: fixed;
    top: 0;
    bottom: 50px;
  }
  .footer {
    position: fixed;
    bottom: 0;
    width: 100%;
  }
}

2. 切换路由实现

监听 change 事件,在事件回调中调用路由跳转方法

 // 切换菜单跳转路由
  const navigate = useNavigate()
  const swithRoute = (path) => {
    console.log(path)
    navigate(path)
  }

  return (
    <div className="layout">
      <div className="footer">
        <TabBar onChange={swithRoute}>
          {/* 省略... */}
        </TabBar>
      </div>
    </div>
  )

7. 月度账单-统计区域

RkkyTVhCTEFHTGx4QUlrbDVFcWQ5UGowTk44RnhubGZNVVhwZ1czL1RBPT0=

1. 准备静态结构

import { NavBar, DatePicker } from 'antd-mobile'
import './index.scss'

const Month = () => {
  return (
    <div className="monthlyBill">
      <NavBar className="nav" backArrow={false}>
        月度收支
      </NavBar>
      <div className="content">
        <div className="header">
          {/* 时间切换区域 */}
          <div className="date">
            <span className="text">
              2023 | 3月账单
            </span>
            <span className='arrow expand'></span>
          </div>
          {/* 统计区域 */}
          <div className='twoLineOverview'>
            <div className="item">
              <span className="money">{100}</span>
              <span className="type">支出</span>
            </div>
            <div className="item">
              <span className="money">{200}</span>
              <span className="type">收入</span>
            </div>
            <div className="item">
              <span className="money">{200}</span>
              <span className="type">结余</span>
            </div>
          </div>
          {/* 时间选择器 */}
          <DatePicker
            className="kaDate"
            title="记账日期"
            precision="month"
            visible={false}
            max={new Date()}
          />
        </div>
      </div>
    </div >
  )
}

export default Month
.monthlyBill {
  --ka-text-color: #191d26;
  height: 100%;
  background: linear-gradient(180deg, #ffffff, #f5f5f5 100%);
  background-size: 100% 240px;
  background-repeat: no-repeat;
  background-color: rgba(245, 245, 245, 0.9);
  color: var(--ka-text-color);

  .nav {
    --adm-font-size-10: 16px;
    color: #121826;
    background-color: transparent;
    .adm-nav-bar-back-arrow {
      font-size: 20px;
    }
  }

  .content {
    height: 573px;
    padding: 0 10px;
    overflow-y: scroll;
    -ms-overflow-style: none; /* Internet Explorer 10+ */
    scrollbar-width: none; /* Firefox */
    &::-webkit-scrollbar {
      display: none; /* Safari and Chrome */
    }

    > .header {
      height: 135px;
      padding: 20px 20px 0px 18.5px;
      margin-bottom: 10px;
      background-image: url(https://zqran.gitee.io/images/ka/month-bg.png);
      background-size: 100% 100%;

      .date {
        display: flex;
        align-items: center;
        margin-bottom: 25px;
        font-size: 16px;

        .arrow {
          display: inline-block;
          width: 7px;
          height: 7px;
          margin-top: -3px;
          margin-left: 9px;
          border-top: 2px solid #121826;
          border-left: 2px solid #121826;
          transform: rotate(225deg);
          transform-origin: center;
          transition: all 0.3s;
        }
        .arrow.expand {
          transform: translate(0, 2px) rotate(45deg);
        }
      }
    }
  }
  .twoLineOverview {
    display: flex;
    justify-content: space-between;
    width: 250px;

    .item {
      display: flex;
      flex-direction: column;

      .money {
        height: 24px;
        line-height: 24px;
        margin-bottom: 5px;
        font-size: 18px;
      }
      .type {
        height: 14px;
        line-height: 14px;
        font-size: 12px;
      }
    }
  }
}

2. 点击切换时间选择框

  1. 准备一个状态数据
  2. 点击切换状态
  3. 根据状态控制弹框打开关闭以及箭头样式
import { NavBar, DatePicker } from 'antd-mobile'
import './index.scss'
import { useState } from "react"
import classNames from "classnames"

const Month = () => {
  // 控制时间选择器打开关闭
  const [dateVisible, setDateVisible] = useState(false)
  // 时间选择框确实事件
  const dateConfirm = (date) => {
    // 关闭弹框
    setDateVisible(false)
  }

  return (
    <div className="monthlyBill">
      <NavBar className="nav" backArrow={false}>
        月度收支
      </NavBar>
      <div className="content">
        <div className="header">
          {/* 时间切换区域 */}
          <div className="date" onClick={() => setDateVisible(true)}>
            {/* 省略.. */}
            <span className={classNames('arrow', dateVisible && 'expand')}></span>
          </div>
          {/* 统计区域 */}

          {/* 时间选择器 */}
          <DatePicker
            className="kaDate"
            title="记账日期"
            precision="month"
            visible={dateVisible}
            max={new Date()}
            onConfirm={dateConfirm}
          />
        </div>
      </div>
    </div >
  )
}

export default Month

3. 切换时间显示

bGRqQWhxWnhya1ZxQ1dGbjVORG5iUGowTk44RnhubGZNVVhwZ1czL1RBPT0=
  1. 以当前时间作为默认值
  2. 在时间切换时完成时间修改
import dayjs from "dayjs"

const [currentMonth, setCurrentMonth] = useState(() => {
    return dayjs().format('YYYY-MM')
})

const dateConfirm = (date) => {
  setDateVisible(false)
  const month = dayjs(date).format('YYYY-MM')
  setCurrentMonth(month)
}

4. 统计功能实现

  1. 按月分组
  2. 根据获取到的时间作为 key 取当月的账单数组
  3. 根据当月的账单数组计算支出、收入、总计
// 按月分组
const billList = useSelector(state => state.bill.billList)
const monthGroup = useMemo(() => {
  return _.groupBy(billList, item => dayjs(item.date).format('YYYY-MM'))
}, [billList])

// 根据获取到的时间作为key取当月的账单数组
const dateConfirm = (date) => {
  const monthKey = dayjs(date).format('YYYY-MM')
  setMonthList(monthGroup[monthKey])
}

// 计算统计
const overview = useMemo(() => {
  const income = currentMonthList.filter(item => item.type === 'income')
    .reduce((a, c) => a + c.money, 0)
  const pay = currentMonthList.filter(item => item.type === 'pay')
    .reduce((a, c) => a + c.money, 0)
  return {
    income,
    pay,
    total: income + pay
  }
}, [currentMonthList])

5. 完整代码

import { useSelector } from "react-redux"
import { NavBar, DatePicker } from 'antd-mobile'
import './index.scss'
import _ from 'lodash'
import dayjs from "dayjs"
import { useMemo, useState } from "react"
import { useEffect } from "react"
import classNames from "classnames"

const Month = () => {
  // 按月分组
  const billList = useSelector(state => state.bill.billList)
  const monthGroup = useMemo(() => {
    return _.groupBy(billList, item => dayjs(item.date).format('YYYY-MM'))
  }, [billList])

  // 控制时间选择器打开关闭
  const [dateVisible, setDateVisible] = useState(false)
  const [currentMonthList, setMonthList] = useState([])
  const [currentMonth, setCurrentMonth] = useState(() => {
    return dayjs().format('YYYY-MM')
  })

  const dateConfirm = (date) => {
    setDateVisible(false)
    const monthKey = dayjs(date).format('YYYY-MM')
    setCurrentMonth(monthKey)
    setMonthList(monthGroup[monthKey])
  }

  // 首次加载
  useEffect(() => {
    const list = monthGroup[dayjs().format('YYYY-MM')]
    if(list){
      setMonthList(list)
    }
  }, [monthGroup])

  // 计算统计
  const overview = useMemo(() => {
    if (!currentMonthList) return { income: 0, pay: 0, total: 0 }
    const income = currentMonthList.filter(item => item.type === 'income')
      .reduce((a, c) => a + c.money, 0)
    const pay = currentMonthList.filter(item => item.type === 'pay')
      .reduce((a, c) => a + c.money, 0)
    return {
      income,
      pay,
      total: income + pay
    }
  }, [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">
              {currentMonth} 账单
            </span>
            <span className={classNames('arrow', dateVisible && 'expand')}></span>
          </div>
          {/* 统计区域 */}
          <div className='twoLineOverview'>
            <div className="item">
              <span className="money">{overview.pay.toFixed(2)}</span>
              <span className="type">支出</span>
            </div>
            <div className="item">
              <span className="money">{overview.income.toFixed(2)}</span>
              <span className="type">收入</span>
            </div>
            <div className="item">
              <span className="money">{(overview.total).toFixed(2)}</span>
              <span className="type">结余</span>
            </div>
          </div>
          {/* 时间选择器 */}
          <DatePicker
            className="kaDate"
            title="记账日期"
            precision="month"
            visible={dateVisible}
            max={new Date()}
            onConfirm={dateConfirm}
          />
        </div>
      </div>
    </div >
  )
}

export default Month

8. 月度账单-单日统计列表实现

K1BRMDN3WEdlVjh4UmVtQmJmOU1Zd0RhVnllUTZHYlh3T3Q5ZXRWVXZrUT0=

1. 准备组件和配套样式

import classNames from 'classnames'
import './index.scss'

const DailyBill = () => {
  return (
    <div className={classNames('dailyBill')}>
      <div className="header">
        <div className="dateIcon">
          <span className="date">{'03月23日'}</span>
          <span className={classNames('arrow')}></span>
        </div>
        <div className="oneLineOverview">
          <div className="pay">
            <span className="type">支出</span>
            <span className="money">{100}</span>
          </div>
          <div className="income">
            <span className="type">收入</span>
            <span className="money">{200}</span>
          </div>
          <div className="balance">
            <span className="money">{100}</span>
            <span className="type">结余</span>
          </div>
        </div>
      </div>
    </div>
  )
}
export default DailyBill

配套样式

.dailyBill {
  margin-bottom: 10px;
  border-radius: 10px;
  background: #ffffff;

  .header {
    --ka-text-color: #888c98;
    padding: 15px 15px 10px 15px;

    .dateIcon {
      display: flex;
      justify-content: space-between;
      align-items: center;
      height: 21px;
      margin-bottom: 9px;
      .arrow {
        display: inline-block;
        width: 5px;
        height: 5px;
        margin-top: -3px;
        margin-left: 9px;
        border-top: 2px solid #888c98;
        border-left: 2px solid #888c98;
        transform: rotate(225deg);
        transform-origin: center;
        transition: all 0.3s;
      }
      .arrow.expand {
        transform: translate(0, 2px) rotate(45deg);
      }

      .date {
        font-size: 14px;
      }
    }
  }
  .oneLineOverview {
    display: flex;
    justify-content: space-between;

    .pay {
      flex: 1;
      .type {
        font-size: 10px;
        margin-right: 2.5px;
        color: #e56a77;
      }
      .money {
        color: var(--ka-text-color);
        font-size: 13px;
      }
    }

    .income {
      flex: 1;
      .type {
        font-size: 10px;
        margin-right: 2.5px;
        color: #4f827c;
      }
      .money {
        color: var(--ka-text-color);
        font-size: 13px;
      }
    }

    .balance {
      flex: 1;
      margin-bottom: 5px;
      text-align: right;

      .money {
        line-height: 17px;
        margin-right: 6px;
        font-size: 17px;
      }
      .type {
        font-size: 10px;
        color: var(--ka-text-color);
      }
    }
  }

  .billList {
    padding: 15px 10px 15px 15px;
    border-top: 1px solid #ececec;
    .bill {
      display: flex;
      justify-content: space-between;
      align-items: center;
      height: 43px;
      margin-bottom: 15px;

      &:last-child {
        margin-bottom: 0;
      }

      .icon {
        margin-right: 10px;
        font-size: 25px;
      }
      .detail {
        flex: 1;
        padding: 4px 0;
        .billType {
          display: flex;
          align-items: center;
          height: 17px;
          line-height: 17px;
          font-size: 14px;
          padding-left: 4px;
        }
      }
      .money {
        font-size: 17px;

        &.pay {
          color: #ff917b;
        }
        &.income {
          color: #4f827c;
        }
      }
    }
  }
}
.dailyBill.expand {
  .header {
    border-bottom: 1px solid #ececec;
  }
  .billList {
    display: block;
  }
}

2. 按日分组账单数据

aXhXMy9Pb1pzRzFUb09mOEpKN2REdmowTk44RnhubGZNVVhwZ1cwPQ==
// 把当前月按日分组账单数据
  const dayGroup = useMemo(() => {
    const group = _.groupBy(currentMonthList, (item) => dayjs(item.date).format('YYYY-MM-DD'))
    return {
      dayKeys: Object.keys(group),
      group
    }
  }, [currentMonthList])
  console.log(dayGroup)

3. 遍历日账单组件并传入参数

 {/* 日账单 */}
{dayGroup.dayKeys.map(dayKey => (
  <DailyBill key={dayKey} date={dayKey} billList={dayGroup.group[dayKey]} />
))}

4. 接收数据计算统计渲染页面

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])
  return (
    <div className={classNames('dailyBill')}>
      <div className="header">
        <div className="dateIcon">
          <span className="date">{date}</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>
  )
}

export default DailyBill

9. 月度账单-单日账单列表展示

d1FKcFlIZXBhWGFmYmcrMmVtZXl1UGowTk44RnhubGZNVVhwZ1czL1RBPT0=

1. 渲染基础列表

{/* 单日列表 */}
<div className="billList">
  {billList.map(item => {
    return (
      <div className="bill" key={item.id}>
        <div className="detail">
          <div className="billType">{item.useFor}</div>
        </div>
        <div className={classNames('money', item.type)}>
          {item.money.toFixed(2)}
        </div>
      </div>
    )
  })}
</div>

2. 适配Type

  1. 准备静态数据
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
}, {})
  1. 适配type
 <div className="billType">{billTypeToName[item.useFor]}</div>

10. 月度账单-切换打开关闭

TTBpc09LU2RzZ2hZNE9mWU9ZMTZYL2owTk44RnhubGZNVVhwZ1czL1RBPT0=
// 声明状态
const [visible, setVisible] = useState(false)

// 控制箭头
 <span
   className={classNames('arrow', !visible && 'expand')}
   onClick={() => setVisible(!visible)}></span>

// 控制列表显示
<div className="billList" style={{ display: !visible && 'none' }}></div>

11. 月度账单-Icon组件封装

OEpRQWxPQlhPSHVjQmpNdjlod3I2UGowTk44RnhubGZNVVhwZ1czL1RBPT0=

1. 准备静态结构

const Icon = () => {
  return (
    <img
      src={`https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/ka/food.svg`}
      alt="icon"
      style={{
        width: 20,
        height: 20,
      }}
      />
  )
}

export default Icon

2. 设计参数

const BASE_URL = 'https://yjy-teach-oss.oss-cn-beijing.aliyuncs.com/reactbase/ka/'

const Icon = ({ type }) => {
  return (
    <img
      src={`${BASE_URL + type}.svg`}
      alt="icon"
      style={{
        width: 20,
        height: 20,
      }}
    />
  )
}

export default Icon

3. 使用组件

<div className="billList" style={{ display: visible ? 'block' : 'none' }}>
    {billList.map(item => {
      return (
        <div className="bill" key={item.id}>
          <Icon type={item.useFor} />
        </div>
      )
    })}
  </div>

11. 记账功能

1. 结构渲染

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'

const New = () => {
  const navigate = useNavigate()
  return (
    <div className="keepAccounts">
      <NavBar className="nav" onBack={() => navigate(-1)}>
        记一笔
      </NavBar>

      <div className="header">
        <div className="kaType">
          <Button
            shape="rounded"
            className={classNames('selected')}
          >
            支出
          </Button>
          <Button
            className={classNames('')}
            shape="rounded"
          >
            收入
          </Button>
        </div>

        <div className="kaFormWrapper">
          <div className="kaForm">
            <div className="date">
              <Icon type="calendar" className="icon" />
              <span className="text">{'今天'}</span>
              <DatePicker
                className="kaDate"
                title="记账日期"
                max={new Date()}
              />
            </div>
            <div className="kaInput">
              <Input
                className="input"
                placeholder="0.00"
                type="number"
              />
              <span className="iconYuan">¥</span>
            </div>
          </div>
        </div>
      </div>

      <div className="kaTypeList">
        {billListData['pay'].map(item => {
          return (
            <div className="kaType" key={item.type}>
              <div className="title">{item.name}</div>
              <div className="list">
                {item.list.map(item => {
                  return (
                    <div
                      className={classNames(
                        'item',
                        ''
                      )}
                      key={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">
          保 存
        </Button>
      </div>
    </div>
  )
}

export default New

配套样式

.keepAccounts {
  --ka-bg-color: #daf2e1;
  --ka-color: #69ae78;
  --ka-border-color: #191d26;

  height: 100%;
  background-color: var(--ka-bg-color);

  .nav {
    --adm-font-size-10: 16px;
    color: #121826;
    background-color: transparent;
    &::after {
      height: 0;
    }

    .adm-nav-bar-back-arrow {
      font-size: 20px;
    }
  }

  .header {
    height: 132px;

    .kaType {
      padding: 9px 0;
      text-align: center;

      .adm-button {
        --adm-font-size-9: 13px;

        &:first-child {
          margin-right: 10px;
        }
      }
      .selected {
        color: #fff;
        --background-color: var(--ka-border-color);
      }
    }

    .kaFormWrapper {
      padding: 10px 22.5px 20px;

      .kaForm {
        display: flex;
        padding: 11px 15px 11px 12px;
        border: 0.5px solid var(--ka-border-color);
        border-radius: 9px;
        background-color: #fff;

        .date {
          display: flex;
          align-items: center;
          height: 28px;
          padding: 5.5px 5px;
          border-radius: 4px;
          // color: #4f825e;
          color: var(--ka-color);
          background-color: var(--ka-bg-color);

          .icon {
            margin-right: 6px;
            font-size: 17px;
          }
          .text {
            font-size: 16px;
          }
        }

        .kaInput {
          flex: 1;
          display: flex;
          align-items: center;

          .input {
            flex: 1;
            margin-right: 10px;
            --text-align: right;
            --font-size: 24px;
            --color: var(--ka-color);
            --placeholder-color: #d1d1d1;
          }

          .iconYuan {
            font-size: 24px;
          }
        }
      }
    }
  }

  .container {
  }
  .kaTypeList {
    height: 490px;
    padding: 20px 11px;
    padding-bottom: 70px;
    overflow-y: scroll;
    background: #ffffff;
    border-radius: 20px 20px 0 0;
    -ms-overflow-style: none; /* Internet Explorer 10+ */
    scrollbar-width: none; /* Firefox */
    &::-webkit-scrollbar {
      display: none; /* Safari and Chrome */
    }

    .kaType {
      margin-bottom: 25px;
      font-size: 12px;
      color: #333;

      .title {
        padding-left: 5px;
        margin-bottom: 5px;
        font-size: 13px;
        color: #808080;
      }
      .list {
        display: flex;

        .item {
          width: 65px;
          height: 65px;
          padding: 9px 0;
          margin-right: 7px;
          text-align: center;
          border: 0.5px solid #fff;
          &:last-child {
            margin-right: 0;
          }

          .icon {
            height: 25px;
            line-height: 25px;
            margin-bottom: 5px;
            font-size: 25px;
          }
        }
        .item.selected {
          border: 0.5px solid var(--ka-border-color);
          border-radius: 5px;
          background: var(--ka-bg-color);
        }
      }
    }
  }

  .btns {
    position: fixed;
    bottom: 15px;
    width: 100%;
    text-align: center;

    .btn {
    width: 200px;
    --border-width: 0;
    --background-color: #fafafa;
    --text-color: #616161;
    &:first-child {
    margin-right: 15px;
    }
    }
    .btn.save {
    --background-color: var(--ka-bg-color);
    --text-color: var(--ka-color);
    }
    }
  }

2. 支出和收入切换

const new = ()=>{
  // 1. 区分账单状态
  const [billType, setBillType] = useState('income')
  return (
     <div className="keepAccounts">
      <div className="kaType">
        {/* 2. 点击切换状态 */}
        <Button
          shape="rounded"
          className={classNames(billType==='pay'?'selected':'')}
          onClick={() => setBillType('pay')}
        >
          支出
        </Button>
        <Button
          className={classNames(billType==='income'?'selected':'')}
          onClick={() => setBillType('income')}
          shape="rounded"
        >
          收入
        </Button>
      </div>
      {/* 2. 适配数据 */}
      <div className="kaTypeList">
          {billListData[billType].map(item => {

          })}
      </div>
    </div>
  )
}

3. 新增一笔

import { useDispatch } from 'react-redux'

const New = () => {
  // 收集金额
  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: new Date(),
      useFor: useFor
    }
    console.log(data)
    dispatch(addBillList(data))
  }
  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">{'今天'}</span>
              <DatePicker
                className="kaDate"
                title="记账日期"
                max={new Date()}
              />
            </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 (
                    <div
                      className={classNames(
                        'item',
                        ''
                      )}
                      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