本文来编写最后一个模块 —— 个人中心,功能如下图所示:
image.pngimage.pngimage.png
个人中心模块分几个功能点,首先是头部的用户信息展示,包括头像、用户昵称、个人签名。其次是账号相关的操作,如用户信息修改、密码重置等。最后是退出登录,将其放置于页面底部,并且设置二次确认弹窗,避免误触。

头部信息展示

修改 container/User/index.jsx 代码如下:

  1. import React from 'react';
  2. import s from './style.module.less';
  3. const User = () => {
  4. return <div className={s.user}>
  5. <div className={s.head}>
  6. <div className={s.info}>
  7. <span>昵称:测试</span>
  8. <span>
  9. <img style={{ width: 30, height: 30, verticalAlign: '-10px' }} src="//s.yezgea02.com/1615973630132/geqian.png" alt="" />
  10. <b>个性签名</b>
  11. </span>
  12. </div>
  13. <img className={s.avatar} style={{ width: 60, height: 60, borderRadius: 8 }} src={'//s.yezgea02.com/1624959897466/avatar.jpeg'} alt="" />
  14. </div>
  15. </div>
  16. }
  17. export default User

这里给 .head 一个背景图片,介绍一下顶部的布局思路,如下所示:
image.png
在 .head 内通过 flex 实现左右布局,在 .info 内通过 flex 的 flex-direction 设置为 column 实现上下布局。
.head 底部留出的位置,用于放置后续的操作。

完成布局之后,将数据填上,通过 /api/user/get_userinfo 接口,获取用户信息,添加代码如下:

  1. import React, { useState, useEffect } from 'react';
  2. import { get } from '@/utils';
  3. import s from './style.module.less';
  4. const User = () => {
  5. const [user, setUser] = useState({});
  6. useEffect(() => {
  7. getUserInfo();
  8. }, []);
  9. // 获取用户信息
  10. const getUserInfo = async () => {
  11. const { data } = await get('/api/user/get_userinfo');
  12. setUser(data);
  13. setAvatar(data.avatar)
  14. };
  15. return <div className={s.user}>
  16. <div className={s.head}>
  17. <div className={s.info}>
  18. <span>昵称:{user.username || '--'}</span>
  19. <span>
  20. <img style={{ width: 30, height: 30, verticalAlign: '-10px' }} src="//s.yezgea02.com/1615973630132/geqian.png" alt="" />
  21. <b>{user.signature || '暂无个签'}</b>
  22. </span>
  23. </div>
  24. <img className={s.avatar} style={{ width: 60, height: 60, borderRadius: 8 }} src={user.avatar || ''} alt="" />
  25. </div>
  26. </div>
  27. }
  28. export default User

用户信息相关操作

紧接着,我们需要布局用户相关操作的内容,在上述基础上添加如下代码:

  1. ...
  2. import { useHistory } from 'react-router-dom';
  3. import { Cell, } from 'zarm';
  4. const User = () => {
  5. ...
  6. const history = useHistory();
  7. return <div className={s.user}>
  8. ...
  9. <div className={s.content}>
  10. <Cell
  11. hasArrow
  12. title="用户信息修改"
  13. onClick={() => history.push('/userinfo')}
  14. icon={<img style={{ width: 20, verticalAlign: '-7px' }} src="//s.yezgea02.com/1615974766264/gxqm.png" alt="" />}
  15. />
  16. <Cell
  17. hasArrow
  18. title="重制密码"
  19. onClick={() => history.push('/account')}
  20. icon={<img style={{ width: 20, verticalAlign: '-7px' }} src="//s.yezgea02.com/1615974766264/zhaq.png" alt="" />}
  21. />
  22. <Cell
  23. hasArrow
  24. title="关于我们"
  25. onClick={() => history.push('/about')}
  26. icon={<img style={{ width: 20, verticalAlign: '-7px' }} src="//s.yezgea02.com/1615975178434/lianxi.png" alt="" />}
  27. />
  28. </div>
  29. </div>
  30. };

这里有三个列表跳转项,分别是 userinfo、account、about。

用户信息修改

在 container 目录下新建一个 UserInfo 目录。
添加 index.js 和 style.module.less,并且在 router/index.js 内添加相对应的路由配置项。

文件上传组件分析
我们先对 Zarm 的文件上传组件进行分析,我们尝试编写如下代码:

  1. import React from 'react';
  2. import { FilePicker, Button } from 'zarm';
  3. import s from './style.module.less';
  4. const UserInfo = () => {
  5. const handleSelect = (file) => {
  6. console.log('file', file)
  7. }
  8. return <div className={s.userinfo}>
  9. <FilePicker onChange={handleSelect} accept="image/*">
  10. <Button theme='primary' size='xs'>点击上传</Button>
  11. </FilePicker>
  12. </div>
  13. };
  14. export default UserInfo;

点击按钮,上传一张图片,我们查看回调函数 handleSelect 的执行结果:
image.png
此时,我们需要的是上传资源的原始文件,在上述返回对象中,file 属性为 File 文件类型,它是浏览器返回的原生对象,我们需要通过下列代码,将其改造成一个 form-data 对象:

  1. const handleSelect = (file) => {
  2. console.log('file', file)
  3. let formData = new FormData()
  4. formData.append('file', file.file)
  5. }

再将 formData 通过 axios 上传到服务器,服务端通过 ctx.request.files[0] 获取到前端上传的 文件原始对象,并将其读取,存入服务器内部。这样就完成了一套前端上传资源,服务端存储并返回路径的一个过程。
接下来进行完整代码的编写,如下所示:

  1. import React, { useEffect, useState } from 'react';
  2. import { Button, FilePicker, Input, Toast } from 'zarm';
  3. import { useHistory } from 'react-router-dom';
  4. import Header from '@/components/Header'; // 由于是内页,使用到公用头部
  5. import axios from 'axios'; // // 由于采用 form-data 传递参数,所以直接只用 axios 进行请求
  6. import { get, post } from '@/utils';
  7. import { baseUrl } from 'config'; // 由于直接使用 axios 进行请求,统一封装了请求 baseUrl
  8. import s from './style.module.less';
  9. const UserInfo = () => {
  10. const history = useHistory(); // 路由实例
  11. const [user, setUser] = useState({}); // 用户
  12. const [avatar, setAvatar] = useState(''); // 头像
  13. const [signature, setSignature] = useState(''); // 个签
  14. const token = localStorage.getItem('token'); // 登录令牌
  15. useEffect(() => {
  16. getUserInfo(); // 初始化请求
  17. }, []);
  18. // 获取用户信息
  19. const getUserInfo = async () => {
  20. const { data } = await get('/api/user/get_userinfo');
  21. setUser(data);
  22. setAvatar(data.avatar)
  23. setSignature(data.signature)
  24. };
  25. // 获取图片回调
  26. const handleSelect = (file) => {
  27. console.log('file.file', file.file)
  28. if (file && file.file.size > 200 * 1024) {
  29. Toast.show('上传头像不得超过 200 KB!!')
  30. return
  31. }
  32. let formData = new FormData()
  33. // 生成 form-data 数据类型
  34. formData.append('file', file.file)
  35. // 通过 axios 设置 'Content-Type': 'multipart/form-data', 进行文件上传
  36. axios({
  37. method: 'post',
  38. url: `${baseUrl}/upload`,
  39. data: formData,
  40. headers: {
  41. 'Content-Type': 'multipart/form-data',
  42. 'Authorization': token
  43. }
  44. }).then(res => {
  45. // 返回图片地址
  46. setAvatar(res.data)
  47. })
  48. }
  49. // 编辑用户信息方法
  50. const save = async () => {
  51. const { data } = await post('/api/user/edit_userinfo', {
  52. signature,
  53. avatar
  54. });
  55. Toast.show('修改成功')
  56. // 成功后回到个人中心页面
  57. history.goBack()
  58. }
  59. return <>
  60. <Header title='用户信息' />
  61. <div className={s.userinfo}>
  62. <h1>个人资料</h1>
  63. <div className={s.item}>
  64. <div className={s.title}>头像</div>
  65. <div className={s.avatar}>
  66. <img className={s.avatarUrl} src={avatar} alt=""/>
  67. <div className={s.desc}>
  68. <span>支持 jpgpngjpeg 格式大小 200KB 以内的图片</span>
  69. <FilePicker className={s.filePicker} onChange={handleSelect} accept="image/*">
  70. <Button className={s.upload} theme='primary' size='xs'>点击上传</Button>
  71. </FilePicker>
  72. </div>
  73. </div>
  74. </div>
  75. <div className={s.item}>
  76. <div className={s.title}>个性签名</div>
  77. <div className={s.signature}>
  78. <Input
  79. clearable
  80. type="text"
  81. value={signature}
  82. placeholder="请输入个性签名"
  83. onChange={(value) => setSignature(value)}
  84. />
  85. </div>
  86. </div>
  87. <Button onClick={save} style={{ marginTop: 50 }} block theme='primary'>保存</Button>
  88. </div>
  89. </>
  90. };
  91. export default UserInfo;

浏览器展示效果如下:
image.png

通过请求,得到的路径是这样的,因为我们在服务端返回的地址就是一个相对路径,所以我们需要给路径加上 host,要注意如果你是本地启动的服务端代码,这里的 host 就是你的服务端代码启动的 host,如 locahost:7001,而我目前使用的是在线接口,所以我们在 utils/index.js 下新增一个图片地址转换的方法,如下所示:

  1. // utils/index.js
  2. import { baseUrl } from 'config'
  3. const MODE = import.meta.env.MODE // 环境变量
  4. ...
  5. export const imgUrlTrans = (url) => {
  6. if (url && url.startsWith('http')) {
  7. return url
  8. } else {
  9. url = `${MODE == 'development' ? 'http://api.chennick.wang' : baseUrl}${url}`
  10. return url
  11. }
  12. }

然后在 UserInfo/index.jsx 中引入 imgUrlTrans 并如下使用:

  1. // 获取用户信息
  2. const getUserInfo = async () => {
  3. const { data } = await get('/api/user/get_userinfo');
  4. setUser(data);
  5. setAvatar(imgUrlTrans(data.avatar))
  6. setSignature(data.signature)
  7. };
  8. ...
  9. // 返回图片地址
  10. setAvatar(imgUrlTrans(res.data))

现在就就可以上传图片了。

修改密码

在 container 目录下新建 Account 目录,在内部分别新建 index.jsx 和 style.module.less。
首先我们需要安装 rc-form 作为本次页面的表单组件,因为 Zarm 没有提供表单组件,包括 Antd Mobile 这样的组件,也没有提供表单相关的组件,所以这里我们需要使用 rc-form 自己编写表单相关验证方法,它也是 antd 官方使用的表单组件。

  1. npm i rc-form -S

为 Account/index.jsx 添加如下代码:

  1. // Account/index.jsx
  2. import React from 'react';
  3. import { Cell, Input, Button, Toast } from 'zarm';
  4. import { createForm } from 'rc-form';
  5. import Header from '@/components/Header'
  6. import { post } from '@/utils'
  7. import s from './style.module.less'
  8. const Account = (props) => {
  9. // Account 通过 createForm 高阶组件包裹之后,可以在 props 中获取到 form 属性
  10. const { getFieldProps, getFieldError } = props.form;
  11. // 提交修改方法
  12. const submit = () => {
  13. // validateFields 获取表单属性元素
  14. props.form.validateFields(async (error, value) => {
  15. // error 表单验证全部通过,为 false,否则为 true
  16. if (!error) {
  17. console.log(value)
  18. if (value.newpass != value.newpass2) {
  19. Toast.show('新密码输入不一致');
  20. return
  21. }
  22. await post('/api/user/modify_pass', {
  23. old_pass: value.oldpass,
  24. new_pass: value.newpass,
  25. new_pass2: value.newpass2
  26. })
  27. Toast.show('修改成功')
  28. }
  29. });
  30. }
  31. return <>
  32. <Header title="重制密码" />
  33. <div className={s.account}>
  34. <div className={s.form}>
  35. <Cell title="原密码">
  36. <Input
  37. clearable
  38. type="text"
  39. placeholder="请输入原密码"
  40. {...getFieldProps('oldpass', { rules: [{ required: true }] })}
  41. />
  42. </Cell>
  43. <Cell title="新密码">
  44. <Input
  45. clearable
  46. type="text"
  47. placeholder="请输入新密码"
  48. {...getFieldProps('newpass', { rules: [{ required: true }] })}
  49. />
  50. </Cell>
  51. <Cell title="确认密码">
  52. <Input
  53. clearable
  54. type="text"
  55. placeholder="请再此输入新密码确认"
  56. {...getFieldProps('newpass2', { rules: [{ required: true }] })}
  57. />
  58. </Cell>
  59. </div>
  60. <Button className={s.btn} block theme="primary" onClick={submit}>提交</Button>
  61. </div>
  62. </>
  63. };
  64. export default createForm()(Account);

这里要注意,Account 在抛出去的时候,需要用 createForm() 高阶组件进行包裹,这样在 Account 的内部能接收到 form 属性,它的内部提供了 getFieldProps 方法,对 Input 组件进行表单设置,Input 的 onChange 方法会被代理,最终可以通过 form.validateFields 以回到函数的形式拿到 Input 内的值,并且可以加以验证。

在路由配置项中添加相应的路由:

  1. // router/index.js
  2. ...
  3. import Account from '@/container/Account'
  4. ...
  5. {
  6. path: "/account",
  7. component: Account
  8. }

退出登陆

退出登录操作,我的处理方式是将本地的 token 清除,并且回到登录页面
在 User/index.jsx 下添加代码如下:

  1. const User = () => {
  2. // 退出登录
  3. const logout = async () => {
  4. localStorage.removeItem('token');
  5. history.push('/login');
  6. };
  7. return <div className={s.user}>
  8. ...
  9. <Button className={s.logout} block theme="danger" onClick={logout}>退出登录</Button>
  10. </div>
  11. }

当点击退出登陆,再次点击登陆的时候发现没有自动跳转首页,需要修改一下Login/index.jsx中的逻辑:

  1. const { data } = await post('/api/user/login', {
  2. username,
  3. password
  4. });
  5. console.log('data', data)
  6. localStorage.setItem('token', data.token);
  7. window.location.href = '/'

这里之所以用 window.location.href 的原因是,utils/axios.js 内部需要再次被执行,才能通过 localStorage.getItem 拿到最新的 token。如果只是用 history.push 跳转页面的话,页面是不会被刷新,那么 axios.js 的 token 就无法设置。

总结

实战部分到此结束,我们完成了一个基础的记账本所有的基础功能。