前言

# 前言

行文至此,万里长征已经快要走到头了。本章节带同学们来编写最后一个模块 —— 个人中心。

img

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

# 知识点

  • 图片资源上传格式处理。
  • 原生表单插件 rc-form 的使用。
  • 底部导航栏定位。

# 正文

# 头部信息展示

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

import React from 'react';

import s from './style.module.less';

const User = () => {
  return <div className={s.user}>
    <div className={s.head}>
      <div className={s.info}>
        <span>昵称:测试</span>
        <span>
          <img style={{ width: 30, height: 30, verticalAlign: '-10px' }} src="//s.yezgea02.com/1615973630132/geqian.png" alt="" />
          <b>个性签名</b>
        </span>
      </div>
      <img className={s.avatar} style={{ width: 60, height: 60, borderRadius: 8 }} src={'//s.yezgea02.com/1624959897466/avatar.jpeg'} alt="" />
   </div>
  </div>
}

export default User

文末已为同学们提供下本章节 demo 代码,样式部分不再详细说明。

这里给 .head 一个背景图片,介绍一下顶部的布局思路,如下所示:

img

.head 内通过 flex 实现左右布局,在 .info 内通过 flexflex-direction 设置为 column 实现上下布局。

.head 底部留出的位置,用于放置后续的操作。

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

import React, { useState, useEffect } from 'react';
import { get } from '@/utils';

import s from './style.module.less';

const User = () => {
  const [user, setUser] = useState({});
  
  useEffect(() => {
    getUserInfo();
  }, []);

  // 获取用户信息
  const getUserInfo = async () => {
    const { data } = await get('/api/user/get_userinfo');
    setUser(data);
    setAvatar(data.avatar)
  };

  return <div className={s.user}>
    <div className={s.head}>
      <div className={s.info}>
        <span>昵称:{user.username || '--'}</span>
        <span>
          <img style={{ width: 30, height: 30, verticalAlign: '-10px' }} src="//s.yezgea02.com/1615973630132/geqian.png" alt="" />
          <b>{user.signature || '暂无个签'}</b>
        </span>
      </div>
      <img className={s.avatar} style={{ width: 60, height: 60, borderRadius: 8 }} src={user.avatar || ''} alt="" />
   </div>
  </div>
}

export default User

/api/user/get_userinfo 接口返回字段分析:

  • avatar:头像地址,这里要注意,我目前采用的线上接口,如果是本地开发的情况,需要修改你的 host
  • signature:个性签名。
  • username:用户登录名称。

浏览器展示如下所示:

img

# 用户信息相关操作

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

... 
import { useNavigate } from 'react-router-dom';
import { Cell,  } from 'zarm';

const User = () => {
  ...
  const navigateTo = useNavigate();

  return <div className={s.user}>
    ... 
    <div className={s.content}>
      <Cell
        hasArrow
        title="用户信息修改"
        onClick={() => navigateTo('/userinfo')}
        icon={<img style={{ width: 20, verticalAlign: '-7px' }} src="//s.yezgea02.com/1615974766264/gxqm.png" alt="" />}
      />
      <Cell
        hasArrow
        title="重制密码"
        onClick={() => navigateTo('/account')}
        icon={<img style={{ width: 20, verticalAlign: '-7px' }} src="//s.yezgea02.com/1615974766264/zhaq.png" alt="" />}
      />
      <Cell
        hasArrow
        title="关于我们"
        onClick={() => navigateTo('/about')}
        icon={<img style={{ width: 20, verticalAlign: '-7px' }} src="//s.yezgea02.com/1615975178434/lianxi.png" alt="" />}
      />
    </div>
  </div>
};

添加样式:

...
.content {
  width: 90%;
  position: absolute;
  top: 120px;
  left: 50%;
  transform: translateX(-50%);
  box-shadow: 3px 2px 20px 10px rgba(0, 0, 0, .1);
  border-radius: 10px;
  overflow: hidden;
}

代码部分,直接采用 Zarm 组件库提供的 Cell 组件,它适用于列表布局,官方文档 (opens new window)提供了很多列表布局的例子,可以直接在内部拷贝代码进行二次加工。能不用自己写样式,尽量就不要写。用组件库的目的,就是减少工作量,提高布局的效率。

浏览器展示效果如下:

img

这里有三个列表跳转项,分别是 userinfoaccountabout。我们逐一击破。

首先我们在 container 目录下新建一个 UserInfo 目录,如下所示:

img

添加 index.jsstyle.module.less,并且在 router/index.js 内添加相对应的路由配置项。

于是我们尝试点击「修改用户信息」,如下所示:

img

成功之后,我们便可在 UserInfo 中编写编辑用户相关信息的操作,在编写正式代码之前,我们先对 Zarm 的上传组件进行分析,我们尝试编写如下代码:

import React from 'react';
import { FilePicker, Button } from 'zarm';

import s from './style.module.less';

const UserInfo = () => {

  const handleSelect = (file) => {
    console.log('file', file)
  }
  return <div className={s.userinfo}>
    <FilePicker onChange={handleSelect} accept="image/*">
      <Button theme='primary' size='xs'>点击上传</Button>
    </FilePicker>
  </div>
};

export default UserInfo;

点击按钮,上传一张图片,我们查看回调函数 handleSelect 的执行结果:

img

此时,我们需要的是上传资源的原始文件,在上述返回对象中,file 属性为 File 文件类型,它是浏览器返回的原生对象,我们需要通过下列代码,将其改造成一个 form-data 对象:

const handleSelect = (file) => {
  console.log('file', file)
  let formData = new FormData()
  formData.append('file', file.file)
}

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

接下来进行完整代码的编写,如下所示:

import React, { useEffect, useState } from 'react';
import { Button, FilePicker, Input, Toast } from 'zarm';
import { useNavigate } from 'react-router-dom';
import Header from '@/components/Header'; // 由于是内页,使用到公用头部
import axios from 'axios'; // // 由于采用 form-data 传递参数,所以直接只用 axios 进行请求
import { get, post } from '@/utils';
import { baseUrl } from 'config';  // 由于直接使用 axios 进行请求,统一封装了请求 baseUrl
import s from './style.module.less';

const UserInfo = () => {
  const navigateTo = useNavigate(); // 路由实例
  const [user, setUser] = useState({}); // 用户
  const [avatar, setAvatar] = useState(''); // 头像
  const [signature, setSignature] = useState(''); // 个签
  const token = localStorage.getItem('token'); // 登录令牌

  useEffect(() => {
    getUserInfo(); // 初始化请求
  }, []);

  // 获取用户信息
  const getUserInfo = async () => {
    const { data } = await get('/api/user/get_userinfo');
    setUser(data);
    setAvatar(data.avatar)
    setSignature(data.signature)
  };

  // 获取图片回调 
  const handleSelect = (file) => {
    console.log('file.file', file.file)
    if (file && file.file.size > 200 * 1024) {
      Toast.show('上传头像不得超过 200 KB!!')
      return
    }
    let formData = new FormData()
    // 生成 form-data 数据类型
    formData.append('file', file.file)
    // 通过 axios 设置  'Content-Type': 'multipart/form-data', 进行文件上传
    axios({
      method: 'post',
      url: `${baseUrl}/upload`,
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data',
        'Authorization': token
      }
    }).then(res => {
      // 返回图片地址
      setAvatar(res.data)
    })
  }

  // 编辑用户信息方法
  const save = async () => {
    const { data } = await post('/api/user/edit_userinfo', {
      signature,
      avatar
    });

    Toast.show('修改成功')
    // 成功后回到个人中心页面
    navigateTo(-1)
  }

  return <>
    <Header title='用户信息' />
    <div className={s.userinfo}>
      <h1>个人资料</h1>
      <div className={s.item}>
        <div className={s.title}>头像</div>
        <div className={s.avatar}>
          <img className={s.avatarUrl} src={avatar} alt=""/>
          <div className={s.desc}>
            <span>支持 jpg、png、jpeg 格式大小 200KB 以内的图片</span>
            <FilePicker className={s.filePicker} onChange={handleSelect} accept="image/*">
              <Button className={s.upload} theme='primary' size='xs'>点击上传</Button>
            </FilePicker>
          </div>
        </div>
      </div>
      <div className={s.item}>
        <div className={s.title}>个性签名</div>
        <div className={s.signature}>
          <Input
            clearable
            type="text"
            value={signature}
            placeholder="请输入个性签名"
            onChange={(value) => setSignature(value)}
          />
        </div>
      </div>
      <Button onClick={save} style={{ marginTop: 50 }} block theme='primary'>保存</Button>
    </div>
  </>
};

export default UserInfo;

详细的注释信息,已经在上述代码中表明,需要注意的是,本次请求直接使用了 axios 方法,所以我们需要将 baseUrl 单独封装到一个配置文件中,便于后续使用,在 src 目录下新建 config/index.js,添加如下代码:

const MODE = import.meta.env.MODE // 环境变量

export const baseUrl = MODE == 'development' ? '/api' : 'http://api.chennick.wang';

MODE 作为 vite 运行时的环境变量,可以通过它来配置开发环境和生成环境的一些变量差异。

然后需要在 vite.config.js 中修改如下:

resolve: {
    alias: {
      '@': path.resolve(__dirname, 'src'), // src 路径
      'utils': path.resolve(__dirname, 'src/utils'), // src 路径
      'config': path.resolve(__dirname, 'src/config') // src 路径
    }
  },

配置好 config ,便可以直接在代码中通过:

import { baseUrl } from 'config';

上述形式来获取 config 中的变量信息。

重启项目,浏览器展示效果如下:

img

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

// utils/index.js
import { baseUrl } from 'config'
const MODE = import.meta.env.MODE // 环境变量
...
export const imgUrlTrans = (url) => {
  if (url && url.startsWith('http')) {
    return url
  } else {
    url = `${MODE == 'development' ? 'http://api.chennick.wang' : baseUrl}${url}`
    return url
  }
}

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

// 获取用户信息
const getUserInfo = async () => {
  const { data } = await get('/api/user/get_userinfo');
  setUser(data);
  setAvatar(imgUrlTrans(data.avatar))
  setSignature(data.signature)
};

... 

// 返回图片地址
setAvatar(imgUrlTrans(res.data))

再次打开浏览器,点击选择图片如下:

img

保存后,数据成功修改,我们如下所示:

img

# 重置密码

完成用户信息编辑之后,接下来实现重置密码部分,我们在 container 目录下新建 Account 目录,在内部分别新建 index.jsxstyle.module.less

首先我们需要安装 rc-form 作为本次页面的表单组件,因为 Zarm 没有提供表单组件,包括 Antd Mobile 这样的组件,也没有提供表单相关的组件,所以这里我们需要使用 rc-form 自己编写表单相关验证方法,它也是 antd 官方使用的表单组件。

npm i rc-form -S

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

// Account/index.jsx
import React from 'react';
import { Cell, Input, Button, Toast } from 'zarm';
import { createForm  } from 'rc-form';
import Header from '@/components/Header'
import { post } from '@/utils'

import s from './style.module.less'

const Account = (props) => {
  // Account 通过 createForm 高阶组件包裹之后,可以在 props 中获取到 form 属性
  const { getFieldProps, getFieldError } = props.form;

  // 提交修改方法
  const submit = () => {
    // validateFields 获取表单属性元素
    props.form.validateFields(async (error, value) => {
      // error 表单验证全部通过,为 false,否则为 true
      if (!error) {
        console.log(value)
        if (value.newpass != value.newpass2) {
          Toast.show('新密码输入不一致');
          return
        }
        await post('/api/user/modify_pass', {
          old_pass: value.oldpass,
          new_pass: value.newpass,
          new_pass2: value.newpass2
        })
        Toast.show('修改成功')
      }
    });
  }

  return <>
    <Header title="重制密码" />
    <div className={s.account}>
      <div className={s.form}>
        <Cell title="原密码">
          <Input
            clearable
            type="text"
            placeholder="请输入原密码"
            {...getFieldProps('oldpass', { rules: [{ required: true }] })}
          />
        </Cell>
        <Cell title="新密码">
          <Input
            clearable
            type="text"
            placeholder="请输入新密码"
            {...getFieldProps('newpass', { rules: [{ required: true }] })}
          />
        </Cell>
        <Cell title="确认密码">
          <Input
            clearable
            type="text"
            placeholder="请再此输入新密码确认"
            {...getFieldProps('newpass2', { rules: [{ required: true }] })}
          />
        </Cell>
      </div>
      <Button className={s.btn} block theme="primary" onClick={submit}>提交</Button>
    </div>
  </>
};

export default createForm()(Account);

样式代码:

.account {
  padding: 0 12px;
  .form {
    :global {
      .za-cell:after {
        left: unset;
        border-top: unset;
        border-bottom: 1PX solid #e9e9e9;
      }
    }
  }
  .btn {
    margin-top: 50px;
  }
}

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

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

// router/index.js 
... 
import Account from '@/container/Account'

... 
{
  path: "/account",
  component: Account
}

页面展示如下:

img

这里为了方便查看效果,输入框就不以密码的形式隐藏输入了,点击「提交」按钮之后,接口调用成功,但是我为 admin 账户在服务端设置了不能修改密码的权限,这里方便大家测试页面方便,不能随意修改密码。

测试账号:admin,密码:111111

# 退出登录

退出登录操作,我的处理方式是将本地的 token 清除,并且回到登录页面,简单粗暴了一些,但也不失为一个解决方案。

User/index.jsx 下添加代码如下:

const User = () => {
  // 退出登录
  const logout = async () => {
    localStorage.removeItem('token');
    navigateTo('/login');
  };

  return <div className={s.user}>
    ... 
    <Button className={s.logout} block theme="danger" onClick={logout}>退出登录</Button>
  </div>
}

样式如下:

.logout {
  width: 90%;
  position: absolute;
  bottom: 70px;
  left: 50%;
  transform: translateX(-50%);
}

通过绝对定位将按钮定位在底部,我们尝试点击它,如下所示:

img

再次点击登录,发现没有自动前往首页,我们这里对登录页面进行修改,打开 Login/index.jsx,做如下修改:

const { data } = await post('/api/user/login', {
  username,
  password
});
console.log('data', data)
localStorage.setItem('token', data.token);
window.location.href = '/'

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

# 总结

实战部分到此结束,同学们若是在实战中遇到了问题,请前往课程的官方交流群,群里有与你志同道合的同学,以及本人也会在群里为大家排忧解难。