前言

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

img

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

知识点

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

正文

头部信息展示

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 接口,获取用户信息,添加代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
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

用户信息相关操作

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
...
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>
};

添加样式:

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

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

浏览器展示效果如下:

img

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

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

img

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

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

img

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 对象:

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
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,添加如下代码:

1
2
3
4
const MODE = import.meta.env.MODE; // 环境变量

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

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

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

1
2
3
4
5
6
7
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'), // src 路径
'utils': path.resolve(__dirname, 'src/utils'), // src 路径
'config': path.resolve(__dirname, 'src/config') // src 路径
}
},

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

1
import { baseUrl } from "config";

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

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

img

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

1
2
3
4
5
6
7
8
9
10
11
12
// 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 并如下使用:

1
2
3
4
5
6
7
8
9
10
11
12
// 获取用户信息
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 官方使用的表单组件。

1
npm i rc-form -S

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
// 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);

样式代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.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 内的值,并且可以加以验证。

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

1
2
3
4
5
6
7
8
9
// router/index.js
...
import Account from '@/container/Account'

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

页面展示如下:

img

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

测试账号:admin,密码:111111

退出登录

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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>
);
};

样式如下:

1
2
3
4
5
6
7
.logout {
width: 90%;
position: absolute;
bottom: 70px;
left: 50%;
transform: translateX(-50%);
}

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

img

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

1
2
3
4
5
6
7
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 就无法设置。