用户登录功能是一个信息系统必不可少的一部分,作为博客网站,同样需要管理员登录管理后台,游客注册后登录评论等
大家好,我是落霞孤鹜
,上一篇我们已经搭建好了前后端的框架的代码,并调通了前后端接口。从这一篇开始,进入到业务功能开发进程中。
首先我们需要实现的功能是用户登录,用户登录功能虽然在系统开发中已经很成熟,但是当我们自己动手做的时候,会发现这个功能是那种典型的说起来容易,做起来复杂的功能,需要考虑和处理的点很多。
一、需求分析
1.1 完整需求
一个完整的用户登录功能,需要考虑的点如下:
- 账号和密码的格式
- 支持邮箱、账号、手机号码登录
- 手机号码支持验证码登录
- 密码错误的次数
- 忘记密码功能
- 注册功能
- 新用户首次登录自动注册功能
- 社交平台账号鉴权登录
- 支持记住账号
- 7天自动登录
- 登录状态保持
- 权限鉴定
- 登出
- 密码修改
在前后端分离的状态下,我们还需要考虑跨域问题等
1.2 博客网站需求
考虑到我们的博客系统是个人博客,用户登录的场景主要集中在游客评论,管理员登录管理后台两个场景,所以登录功能可以适当做删减。
该博客系统的登录功能主要实现以下几个点:
- 账号和密码的格式
- 支持邮箱、账号
- 忘记密码功能
- 注册功能
- 登录状态保持
- 权限鉴定
- 登出
- 密码修改
以上功能点,满足博客网站基本需求
- 未登录的游客只能留言,不能评论
- 游客登录后可以评论博客
- 游客登录后可以修改密码
- 管理员登录后可以管理博客后台
二、后端接口开发
用户登录和鉴权实际上在 Django
里面已经有完整的功能,但是由于我们使用的是前后端分离架构,在 Django
的基础上使用了 Django Rest Framework
,因此原有的 Django
登录和鉴权接口需要做改造和调整,以适应前后端分离功能。
这里需要处理几个点:
- 用户登录,账号密码校验,Session保持
-
API
鉴权,也即:接口是否是登录后才能使用,还是不登录也可以使用) - 密码修改和重置
2.1 配置鉴权模式
这里采用 Django Rest Framework
提供的基于 Django
的 Session
方案,如果你想采用 JWT
(介绍)方案,可以按照官网教程Authentication - Django REST framework进行配置。在 project/settings.py
中的 REST_FRAMWORK
配置项中修改如下:
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.BasicAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
'PAGE_SIZE': 10
}
2.2 编写登录登出接口
2.2.1 增加 UserLoginSerializer
类
在 common/serializers.py
文件中,增加代码,修改后代码如下:
from rest_framework import serializers
from common.models import User
class UserSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'avatar', 'email', 'is_active', 'created_at', 'nickname']
class UserLoginSerializer(serializers.ModelSerializer):
class Meta:
model = User
fields = ['id', 'username', 'password']
extra_kwargs = {
'password': {'write_only': True},
}
2.2.2 增加 UserLoginViewSet
类
在 common/views.py
中增加 UserLoginViewSet
类,使用Django
自带的 authenticate
和 login
,完成用户的登录,并返回用户登录信息,在这个过程中,Response
中会创建 Session
,保存登录后的 user
信息,生成Cookies
一并返回。方法修改后代码如下:
from django.contrib.auth import authenticate, login
from rest_framework import viewsets, permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from common.models import User
from common.serializers import UserSerializer, UserLoginSerializer
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all().order_by('username')
serializer_class = UserSerializer
permission_classes = [permissions.AllowAny]
class UserLoginViewSet(GenericAPIView):
permission_classes = [permissions.AllowAny]
serializer_class = UserLoginSerializer
queryset = User.objects.all()
def post(self, request, *args, **kwargs):
username = request.data.get('username', '')
password = request.data.get('password', '')
user = authenticate(username=username, password=password)
if user is not None and user.is_active:
login(request, user)
serializer = UserSerializer(user)
return Response(serializer.data, status=200)
else:
ret = {'detail': 'Username or password is wrong'}
return Response(ret, status=403)
2.2.3 增加 UserLogoutViewSet
类
在 common/views.py
中增加 UserLogoutViewSet
类,使用Django
自带的 auth_logout
,完成用户的登出,并返回登出成功信息,这个过程中,Django
会自动清理 Session
和Cookies
class UserLogoutViewSet(GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = UserLoginSerializer
def get(self, request, *args, **kwargs):
auth_logout(request)
return Response({'detail': 'logout successful !'})
2.2.4 配置路由
在 common/urls.py
中增加 user/login
和 user/logout
路由,代码如下:
from django.conf.urls import url
from django.urls import include, path
from rest_framework import routers
from common import views
router = routers.DefaultRouter()
router.register('user', views.UserViewSet)
app_name = 'common'
urlpatterns = [
path('', include(router.urls)),
url(r'^user/login', views.UserLoginViewSet.as_view()),
url(r'^user/logout', views.UserLogoutViewSet.as_view()),
]
2.3 编写修改密码接口
2.3.1 增加 UserPasswordSerializer
类
在 common/serializers.py
文件中增加类 UserPasswordSerializer
,主要是因为修改密码时需要提供原密码和新密码,所以单独创建一个 serializer
,代码如下:
class UserPasswordSerializer(serializers.ModelSerializer):
new_password = serializers.SerializerMethodField()
class Meta:
model = User
fields = ['id', 'username', 'password', 'new_password']
@staticmethod
def get_new_password(obj):
return obj.password or ''
2.3.2 增加 PasswordUpdateViewSet
类
密码修改的方式有两种,一种是通过修改密码功能修改,这个时候需要知道自己的原密码,然后修改成自己想要的新密码,一种是通过忘记密码功能修改,这个时候不需要知道自己的密码,但需要知道自己绑定的邮箱,新密码发送到邮箱里面。
- 在
common/views.py
中增加一个方法:get_random_password
,该方法用来生成一个随即的密码,支撑忘记密码功能
def get_random_password():
import random
import string
return ''.join(random.sample(string.ascii_letters + string.digits + string.punctuation, 8))
- 安装发送邮件所需要的依赖
pip install django-smtp-ssl==1.0
- 同时在
requirements.txt
文件中增加依赖
django-smtp-ssl==1.0
- 在
project/settings.py
中增加邮箱配置,这里的EMAIL_HOST
和EMAIL_PORT
是需要依据填写的邮箱做出调整,我这里填写的是网易的163
邮箱
EMAIL_BACKEND = 'django_smtp_ssl.SSLEmailBackend'
MAILER_EMAIL_BACKEND = EMAIL_BACKEND
EMAIL_HOST = 'smtp.163.com'
EMAIL_PORT = 465
EMAIL_HOST_USER = 'zgj0607@163.com'
EMAIL_HOST_PASSWORD = 'xxxx'
EMAIL_SUBJECT_PREFIX = u'[LSS]'
EMAIL_USE_SSL = True
- 在
common/views.py
中增加PasswordUpdateViewSet
,提供请求方式的接口。post
用来完成修改密码功能。
class PasswordUpdateViewSet(GenericAPIView):
permission_classes = [permissions.IsAuthenticated]
serializer_class = UserPasswordSerializer
queryset = User.objects.all()
def post(self, request, *args, **kwargs):
user_id = request.user.id
password = request.data.get('password', '')
new_password = request.data.get('new_password', '')
user = User.objects.get(id=user_id)
if not user.check_password(password):
ret = {'detail': 'old password is wrong !'}
return Response(ret, status=403)
user.set_password(new_password)
user.save()
return Response({
'detail': 'password changed successful !'
})
- 在
UserLoginViewSet
中增加put
方法,用于完成忘记密码功能,send_mail 使用的是from django.core.mail import send_mail
语句导入。
将忘记密码的功能放在
LoginViewSet
类下的原因是登录接口和忘记密码的接口均是在不需要登录的情况下调用的接口,因此通过请求方式的不同来区分两种接口。
class UserLoginViewSet(GenericAPIView):
permission_classes = [permissions.AllowAny]
serializer_class = UserLoginSerializer
queryset = User.objects.all()
def post(self, request, *args, **kwargs):
username = request.data.get('username', '')
password = request.data.get('password', '')
user = authenticate(username=username, password=password)
if user is not None and user.is_active:
login(request, user)
serializer = UserSerializer(user)
return Response(serializer.data, status=200)
else:
ret = {'detail': 'Username or password is wrong'}
return Response(ret, status=403)
def put(self, request, *args, **kwargs):
"""
Parameter: username->user's username who forget old password
"""
username = request.data.get('username', '')
users = User.objects.filter(username=username)
user: User = users[0] if users else None
if user is not None and user.is_active:
password = get_random_password()
try:
send_mail(subject="New password for Library System",
message="Hi: Your new password is: \n{}".format(password),
from_email=django.conf.settings.EMAIL_HOST_USER,
recipient_list=[user.email],
fail_silently=False)
user.password = make_password(password)
user.save()
return Response({
'detail': 'New password will send to your email!'
})
except Exception as e:
print(e)
return Response({
'detail': 'Send New email failed, Please check your email address!'
})
else:
ret = {'detail': 'User does not exist(Account is incorrect !'}
return Response(ret, status=403)
2.3.3 添加路由
在 common/urls.py
中增加 user/login
和 user/logout
路由,代码如下:
from django.conf.urls import url
from django.urls import include, path
from rest_framework import routers
from common import views
router = routers.DefaultRouter()
router.register('user', views.UserViewSet)
app_name = 'common'
urlpatterns = [
path('', include(router.urls)),
url(r'^user/login', views.UserLoginViewSet.as_view()),
url(r'^user/logout', views.UserLogoutViewSet.as_view()),
url(r'^user/pwd', views.PasswordUpdateViewSet.as_view()),
]
至此,后端接口已经编写完成
三、前端页面开发
因为用户登录的场景有两个,一个是管理员登录后台,一个是游客登录博客网站,所以需要在两个地方完成用户的登录。
作为管理 员登录后台系统,登录后需要进入到管理后台,所以需要单独的登录地址,因此提供一个单独的登录URL和页面。
作为游客,正常情况下,可以浏览博客,然后需要评论时,点击登录按钮,完成登录即可,因此需要一个登录对话框即可。
3.1 基于 TypeScript 要求增加 interface 定义
3.1.1 创建 types
文件夹
在 src
下创建文件夹 types
, 并在types
下创建文件 index.ts
3.1.2 增加 User
和 Nav
定义
在 src/types/index.ts
编写如下代码
export interface User {
id: number,
username: string,
email: string,
avatar: string | any,
nickname: string | any,
is_active?: any,
is_superuser?: boolean,
created_at?: string,
}
export interface Nav {
index: string,
path: string,
name: string,
}
其中 ? 表示可选属性,可以为空,| 表示属性值类型的可选项,可以多种类型的属性值,any
表示任何类型都可以。
3.2 基于 Vuex
保存 User
登录信息
3.2.1 新增文件夹 store
在src
下创建文件夹 store
,并在 store
文件夹下创建文件 index.ts
3.2.2 定义 User
和 Nav
相关的全局 state
- 首先定义
state
的接口,目前我们需要用到三个state,一个是用户信息User
,一个是博客页面顶部导航的路由数据navs
,是一个Nav
的数组,还有一个是当前导航菜单的索引navIndex
,表示当前页面是在哪一个菜单下。 - 通过
Symbol
定义一个InjectKey
,用于在Vue3
中通过 useState 获取到我们定义state
- 定义
state
在dispatch时用到的方法名,这里我们需要用到三个setUser
,clearUser
,setNavIndex
- 定义初始化
User
信息的方法,在登录完成后,我们为了保证用户信息在刷新页面后仍然可以识别用户是已经登录的状态,需要sessionStorage中存放登录后的用户信息,所以User
的state在初始化的时候,需要考虑从sessionStorage
中读取。 - 通过
createStore
方法构建store
,在state()
返回初始数据,在mutations
中定义对state
的操作方法。
src/store/index.ts
中代码如下:
import {InjectionKey} from 'vue'
import {createStore, Store} from 'vuex'
import { Nav, User} from "../types";
export interface State {
user: User,
navIndex: string,
navs: Array<Nav>,
}
export const StateKey: InjectionKey<Store<State>> = Symbol();
export const SET_USER = 'setUser';
export const CLEAR_USER = 'clearUser'
export const SET_NAV_INDEX = 'setNavIndex'
export const initDefaultUserInfo = (): User => {
let user: User = {
id: 0,
username: "",
avatar: "",
email: '',
nickname: '',
is_superuser: false,
}
if (window.sessionStorage.userInfo) {
user = JSON.parse(window.sessionStorage.userInfo);
}
return user
}
export const store = createStore<State>({
state() {
return {
user: initDefaultUserInfo(),
navIndex: '1',
navs: [
{
index: "1",
path: "/",
name: "主页",
},
{
index: "2",
path: "/catalog",
name: "分类",
},
{
index: "3",
path: "/archive",
name: "归档",
},
{
index: "4",
path: "/message",
name: "留言",
},
{
index: "5",
path: "/about",
name: "关于",
},
],
}
},
mutations: {
setUser(state: object | any, userInfo: object | any) {
for (const prop in userInfo) {
state[prop] = userInfo[prop];
}
},
clearUser(state: object | any) {
state.user = initDefaultUserInfo();
},
setNavIndex(state: object | any, navIndex: string) {
state.navIndex = navIndex
},
},
})
3.3 创建 views
和 components
文件夹
3.3.1 新增 views
文件夹
在src
下新增文件夹views
,用于存放可以被router
定义和管理的页面上
3.3.2 新增 components
文件夹
在src
下新增文件夹components
,用于存放页面上的可以复用的组件,这些组件一般不会出现在 router
中,而是通过 import
的方式使用
3.4 增加后端 API
调用方法
由于后端我们使用的 Django
和 Django Rest Framework
两个框架,对接口鉴权模式我们沿用了Django的Session
模式,因此我们需要处理好跨域访问。
3.4.1 增加 getCookies
工具方法
在src
下增加 utils
文件夹,在src/utils
下新增文件index.js
文件,编写如下代码:
export function getCookie(cName: string) {
if (document.cookie.length > 0) {
let cStart = document.cookie.indexOf(cName + "=");
if (cStart !== -1) {
cStart = cStart + cName.length + 1;
let cEnd = document.cookie.indexOf(";", cStart);
if (cEnd === -1) cEnd = document.cookie.length;
return unescape(document.cookie.substring(cStart, cEnd));
}
}
return "";
}
3.4.2 增加请求接口时登录和未登录的处理逻辑
在原来请求后端的定义上,改造src/api/index.ts
,增加登录和未登录的处理逻辑。
-
Django Rest Framework
使用标准的Http code
表示未授权登录,所以需要对Http
的code
做判断 - 通过工具方法,在请求接口时,带上
X-CRSFToken
- 在获得请求结果后,判断状态码,如果不是200相关的正确码,则全局提示异常
- 如果是
401
的状态码,则跳转到登录页面
import axios, {AxiosRequestConfig, AxiosResponse} from "axios";
import {ElMessage} from "element-plus";
import router from "../router";
import {getCookie} from "../utils";
const request = axios.create({
baseURL: import.meta.env.MODE !== 'production' ? '/api' : '',
})
request.interceptors.request.use((config: AxiosRequestConfig) => {
// Django SessionAuthentication need csrf token
config.headers['X-CSRFToken'] = getCookie('csrftoken')
return config
})
request.interceptors.response.use(
(response: AxiosResponse) => {
const data = response.data
console.log('response => ', response)
if (data.status === '401') {
localStorage.removeItem('user');
ElMessage({
message: data.error,
type: 'error',
duration: 1.5 * 1000
})
return router.push('/login')
} else if (data.status === 'error') {
ElMessage({
message: data.error || data.status,
type: 'error',
duration: 1.5 * 1000
})
}
if (data.success === false && data.msg) {
ElMessage({
message: data.msg,
type: 'error',
duration: 1.5 * 1000
})
}
return data
},
({message, response}) => {
console.log('err => ', message, response) // for debug
if (response && response.data && response.data.detail) {
ElMessage({
message: response.data.detail,
type: 'error',
duration: 2 * 1000
})
} else {
ElMessage({
message: message,
type: 'error',
duration: 2 * 1000
})
}
if (response && (response.status === 403 || response.status === 401)) {
localStorage.removeItem('user');
return router.push('/login')
}
return Promise.reject(message)
}
)
export default request;
3.4.3 增加后端接口请求方法
在 src/api/service.ts
下编写注册、登录、登出方法,代码如下:
export async function login(data: any) {
return request({
url: '/user/login',
method: 'post',
data
})
}
export function logout() {
return request({
url: '/user/logout',
method: 'get'
})
}
export function register(data: any) {
return request({
url: '/user/',
method: 'post',
data
})
}
3.5 编写主页和后台登录页面
3.5.1 增加 HelloWorld
的主页
编写一个真正意义上的Hello World 页面在src/views
新增文件 Home.vue
,后面用于普通用户进入博客网站时看到的第一个页面。代码如下:
<template>
<h3>HelloWorld</h3>
</template>
<script lang="ts">
import { defineComponent, reactive } from "vue";
export default defineComponent({
name: 'Home',
})
</script>
3.5.2 增加后台登录页面Login.vue
该页面用于管理员登录管理后台,登录成功后进入到后台页面,登录成功后,会通过store
提供的dispatch
方法对全局state
进行修改
3.5.2.1 编写 template
部分
<template>
<div class="login-container">
<el-form
ref="loginForm"
:model="state.loginForm"
:rules="rules"
autocomplete="on"
class="login-form"
label-position="left"
>
<div class="title-container">
<h3 class="title">博客管理后台</h3>
</div>
<el-form-item prop="account">
<el-input
ref="account"
v-model="state.loginForm.account"
autocomplete="on"
name="account"
placeholder="Account"
tabindex="1"
type="text"
/>
</el-form-item>
<el-tooltip
v-model="state.capsTooltip"
content="Caps lock is On"
manual
placement="right"
>
<el-form-item prop="password">
<el-input
:key="state.passwordType"
ref="password"
v-model="state.loginForm.password"
:type="state.passwordType"
autocomplete="on"
name="password"
placeholder="Password"
tabindex="2"
@blur="state.capsTooltip = false"
@keyup="checkCapslock"
@keyup.enter="handleLogin"
/>
</el-form-item>
</el-tooltip>
<p class="fp" @click="startFp">Forget password</p>
<el-button
:loading="state.loading"
style="width: 100%; margin-bottom: 30px"
type="primary"
@click.prevent="handleLogin"
>
Login
</el-button>
</el-form>
</div>
</template>
3.5.2.2 编写 script
部分
由于我们用的是TypeScript
,所以要在script
后面加上 lang=ts
<script lang="ts">
import { defineComponent, reactive } from "vue";
import { forgetPassword, login } from "../api/service";
import { SET_USER } from "../store";
import { User } from "../types";
export default defineComponent({
name: "Login",
setup() {
const validatePassword = (rule: any, value: string, callback: Function) => {
if (value.length < 6) {
callback(new Error("The password can not be less than 6 digits"));
} else {
callback();
}
};
const state = reactive({
loginForm: {
account: "",
password: "",
},
loginRules: {
account: [{ required: true, trigger: "blur" }],
password: [
{
required: true,
trigger: "blur",
validator: validatePassword,
},
],
},
forgetRules: {
account: [{ required: true, trigger: "blur" }],
},
passwordType: "password",
capsTooltip: false,
loading: false,
isFP: false,
});
return {
state,
validatePassword,
};
},
mounted() {
if (this.state.loginForm.account === "") {
this.$refs.account.focus();
} else if (this.state.loginForm.password === "") {
this.$refs.password.focus();
}
},
computed: {
rules() {
return this.state.isFP ? this.state.forgetRules : this.state.loginRules;
},
},
methods: {
checkCapslock(e: KeyboardEvent) {
const { key } = e;
this.state.capsTooltip =
key && key.length === 1 && key >= "A" && key <= "Z";
},
handleLogin() {
this.state.isFP = false;
this.$refs.loginForm.validate(async (valid: Boolean) => {
if (valid) {
this.state.loading = true;
const req = {
username: this.state.loginForm.account,
password: this.state.loginForm.password,
};
try {
const data: any = await login(req);
const user: User = {
id: data.id,
username: data.username,
avatar: data.avatar,
email: data.email,
nickname: data.nickname,
};
this.$store.commit(SET_USER, {
user,
});
window.sessionStorage.userInfo = JSON.stringify(user);
await this.$router.push({
path: "/admin",
});
this.state.loading = false;
} catch (e) {
this.state.loading = false;
}
}
});
},
startFp() {
this.state.isFP = true;
this.$refs.loginForm.clearValidate();
this.$nextTick(() => {
this.$refs.loginForm.validate((valid: Boolean) => {
if (valid) {
this.$confirm(
"We will send a new password to " + this.state.loginForm.account,
"Tip",
{
confirmButtonText: "OK",
cancelButtonText: "Cancel",
type: "warning",
}
).then(() => {
forgetPassword({ account: this.state.loginForm.account }).then(
(data) => {
if (!data.error) {
this.$message({
message: "success!",
type: "success",
duration: 1.5 * 1000,
});
}
}
);
});
}
});
});
},
},
});
</script>
3.5.2.3 编写 CSS
部分
由于我们使用的是less
语法,所以在style
后面需要加上lang="less"
,同时控制css的作用域,添加scoped
<style lang="less" scoped>
.login-container {
min-height: 100%;
width: 100%;
overflow: hidden;
background-repeat: no-repeat;
background-position: center;
display: flex;
align-items: center;
justify-content: center;
filter: hue-rotate(200deg);
}
.login-form {
//position: absolute;
width: 300px;
max-width: 100%;
overflow: hidden;
top: 0;
bottom: 0;
left: 0;
right: 0;
margin: auto;
height: 350px;
}
.tips {
font-size: 14px;
color: #fff;
margin-bottom: 10px;
}
.tips span:first-of-type {
margin-right: 16px;
}
.svg-container {
padding: 6px 5px 6px 15px;
color: #889aa4;
vertical-align: middle;
width: 30px;
display: inline-block;
}
.title-container {
position: relative;
color: #333;
}
.title-container .title {
font-size: 40px;
margin: 0px auto 40px auto;
text-align: center;
font-weight: bold;
}
.show-pwd {
position: absolute;
right: 10px;
top: 7px;
font-size: 16px;
color: #889aa4;
cursor: pointer;
user-select: none;
}
.thirdparty-button {
position: absolute;
right: 0;
bottom: 6px;
}
.fp {
font-size: 12px;
text-align: right;
margin-bottom: 10px;
cursor: pointer;
}
</style>
3.6 定义路由
现在我们已经有了Login.vue
和 Home.vue
两个页面了,现在可以定义路由了。
- 我们采用
WebHistory
的方式展示路由,这种方式在浏览器的地址栏中展示的URL更优美 - 采用
History
的方式后,我们需要在vite.config.ts
中定义base
时用这种:base: '/'
,在/
前不能增加.
- 对首页以外的页面,采用
import
懒加载,需要的时候再加载
在src/router/index.ts
文件中编写如下代码:
import {createRouter, createWebHistory, RouteRecordRaw} from "vue-router";
import Home from "../views/Home.vue";
const routes: Array<RouteRecordRaw> = [
{
path: "/",
name: "Home",
component: Home,
meta: {}
},
{
path: "/login/",
name: "Login",
component: () =>
import(/* webpackChunkName: "login" */ "../views/Login.vue")
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
});
export default router;
3.7 改造 main.ts
在 main.ts
中我们需要处理如下逻辑:
- 创建APP
- 加载
Element-Plus
的组件 - 加载
Element-Plus
的插件 - 加载
Vue-Router
的路由 - 加载
Vuex
的state
完整代码:
import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import { StateKey, store } from "./store";
import 'element-plus/lib/theme-chalk/index.css';
import 'element-plus/lib/theme-chalk/base.css';
import {
ElAffix,
ElButton,
ElCard,
ElCascader,
ElCol,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElLoading,
ElMenu,
ElMenuItem,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElPopconfirm,
ElProgress,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTimeline,
ElTimelineItem,
ElTooltip,
ElTree,
ElUpload,
} from 'element-plus';
const app = createApp(App)
const components = [
ElAffix,
ElButton,
ElCard,
ElCascader,
ElCol,
ElDescriptions,
ElDescriptionsItem,
ElDialog,
ElDrawer,
ElDropdown,
ElDropdownItem,
ElDropdownMenu,
ElForm,
ElFormItem,
ElIcon,
ElInput,
ElLoading,
ElMenu,
ElMenuItem,
ElMessage,
ElMessageBox,
ElOption,
ElPagination,
ElPopconfirm,
ElProgress,
ElRow,
ElSelect,
ElTable,
ElTableColumn,
ElTag,
ElTimeline,
ElTimelineItem,
ElTooltip,
ElTree,
ElUpload,
]
const plugins = [
ElLoading,
ElMessage,
ElMessageBox,
]
components.forEach(component => {
app.component(component.name, component)
})
plugins.forEach(plugin => {
app.use(plugin)
})
app.use(router).use(store, StateKey).mount('#app')
3.8 改造 App.vue
在上一篇中我们为了测试前后端的连通性,将 App.vue
直接处理成了一个表格展示用户列表的页面,而实际的项目中,该页面是需要处理路由导航等相关功能的,因此我们先将该页面改造成直接菜单导航的方式。
在游客需要评论的时候,需要完成登录后才可以,所以这里的登录和管理员的登录方式是不一样的。
- 我们通过一个模态框的方式完成登录
- 在未登录时需要展示登录和注册两个按钮
- 登录后,不需要做页面跳转,只需要在右上角显示用户的昵称或账号,表示用户已经登录
- 登录后,可以登出
3.8.1 增加 RegisterAndLogin.vue
组件
这里的注册和登录复用同一个组件,通过按钮点击的不同,展示不同的内容。
- 需要校验输入的内容
- 登录成功后,需要更新全局
state
中的用户信息
具体代码如下:
<template>
<el-dialog
title="登录"
width="40%"
v-model="state.dialogModal"
@close="cancel"
:show-close="true"
>
<el-form>
<el-formItem label="账号" :label-width="state.formLabelWidth">
<el-input
v-model="state.params.username"
placeholder="请输入有效邮箱"
autocomplete="off"
/>
</el-formItem>
<el-formItem label="密码" :label-width="state.formLabelWidth">
<el-input
type="password"
placeholder="密码"
v-model="state.params.password"
autocomplete="off"
/>
</el-formItem>
<el-formItem
v-if="isRegister"
label="昵称"
:label-width="state.formLabelWidth"
>
<el-input
v-model="state.params.nickname"
placeholder="用户名或昵称"
autocomplete="off"
/>
</el-formItem>
<el-formItem
v-if="isRegister"
label="手机"
:label-width="state.formLabelWidth"
>
<el-input
v-model="state.params.phone"
placeholder="手机号"
autocomplete="off"
/>
</el-formItem>
<el-formItem
v-if="isRegister"
label="简介"
:label-width="state.formLabelWidth"
>
<el-input
v-model="state.params.desc"
placeholder="个人简介"
autocomplete="off"
/>
</el-formItem>
</el-form>
<template v-slot:footer>
<div class="dialog-footer">
<el-button
v-if="isLogin"
:loading="state.btnLoading"
type="primary"
@click="handleOk"
>
登 录
</el-button>
<el-button
v-if="isRegister"
:loading="state.btnLoading"
type="primary"
@click="handleOk"
>注 册
</el-button>
</div>
</template>
</el-dialog>
</template>
<script lang="ts">
import { defineComponent, reactive, watch } from "vue";
import { useStore } from "vuex";
import { ElMessage } from "element-plus";
import { SET_USER, StateKey } from "../store";
import { login, register } from "../api/service";
import { User } from "../types";
export default defineComponent({
name: "RegisterAndLogin",
props: {
visible: {
type: Boolean,
default: false,
},
handleFlag: {
type: String,
default: false,
},
},
computed: {
isLogin(): Boolean {
return this.handleFlag === "login";
},
isRegister(): Boolean {
return this.handleFlag === "register";
},
},
emits: ["ok", "cancel"],
setup(props, context) {
const store = useStore(StateKey);
const state = reactive({
dialogModal: props.visible,
btnLoading: false,
loading: false,
formLabelWidth: "60px",
params: {
email: "",
username: "",
nickname: "",
password: "",
phone: "",
desc: "",
},
});
const submit = async (): Promise<void> => {
let data: any = "";
state.btnLoading = true;
try {
if (props.handleFlag === "register") {
state.params.email = state.params.username;
data = await register(state.params);
} else {
data = await login(state.params);
}
state.btnLoading = false;
const user: User = {
id: data.id,
username: data.username,
avatar: data.avatar,
email: data.email,
nickname: data.nickname,
};
store.commit(SET_USER, {
user,
});
window.sessionStorage.userInfo = JSON.stringify(user);
context.emit("ok", false);
ElMessage({
message: "操作成功",
type: "success",
});
state.dialogModal = false;
} catch (e) {
console.error(e);
state.btnLoading = false;
}
};
const handleOk = (): void => {
const reg = new RegExp(
"^[a-z0-9]+([._\\-]*[a-z0-9])*@([a-z0-9]+[-a-z0-9]*[a-z0-9]+.){1,63}[a-z0-9]+$"
); //正则表达式
if (!state.params.username) {
ElMessage({
message: "账号不能为空!",
type: "warning",
});
return;
} else if (!reg.test(state.params.username)) {
ElMessage({
message: "请输入格式正确的邮箱!",
type: "warning",
});
return;
}
if (props.handleFlag === "register") {
if (!state.params.password) {
ElMessage({
message: "密码不能为空!",
type: "warning",
});
return;
} else if (!state.params.nickname) {
ElMessage({
message: "昵称不能为空!",
type: "warning",
});
return;
}
const re = /^(((13[0-9])|(15[0-9])|(17[0-9])|(18[0-9]))+\d{8})$/;
if (state.params.phone && !re.test(state.params.phone)) {
ElMessage({
message: "请输入正确的手机号!",
type: "warning",
});
return;
}
}
submit();
};
const cancel = (): boolean => {
context.emit("cancel", false);
return false;
};
watch(props, (val, oldVal) => {
state.dialogModal = val.visible;
});
return {
state,
handleOk,
submit,
cancel,
};
},
});
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>
3.8.2 编写 Nav.vue
这个页面处理顶部导航的功能,引用了 RegisterAndLogin.vue
组件。
代码如下:
<template>
<div class="nav">
<div class="nav-content">
<el-row :gutter="20">
<el-col :span="3">
<router-link to="/">
<img class="logo" src="../assets/logo.jpeg" alt="微谈小智" />
</router-link>
</el-col>
<el-col :span="16">
<el-menu
:default-active="navIndex"
:router="true"
active-text-color="#409EFF"
class="el-menu-demo"
mode="horizontal"
>
<el-menuItem
v-for="r in navs"
:key="r.index"
:index="r.index"
:route="r.path"
>
{{ r.name }}
</el-menuItem>
</el-menu>
</el-col>
<el-col v-if="isLogin" :span="5">
<div class="nav-right">
<el-dropdown>
<span class="el-dropdown-link">
{{ userInfo.nickname ? userInfo.nickname : userInfo.username
}}<i class="el-icon-arrow-down el-icon--right"></i>
</span>
<img
v-if="!userInfo.avatar"
alt="微谈小智"
class="user-img"
src="../assets/user.png"
/>
<img
v-if="userInfo.avatar"
:src="userInfo.avatar"
alt="微谈小智"
class="user-img"
/>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleClick"
>登 出</el-dropdown-item
>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-col>
<el-col v-else :span="4">
<div class="nav-right" v-if="!isLogin">
<el-button
size="small"
type="primary"
@click="handleClick('login')"
>
登 录</el-button
>
<el-button
size="small"
type="danger"
@click="handleClick('register')"
>
注 册
</el-button>
</div>
<RegisterAndLogin
:handle-flag="state.handleFlag"
:visible="state.visible"
/>
</el-col>
</el-row>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, reactive } from "vue";
import { User } from "../types";
import { useStore } from "vuex";
import { CLEAR_USER, SET_NAV_INDEX, StateKey } from "../store";
import RegisterAndLogin from "./RegisterAndLogin.vue";
import { logout } from "../api/service";
export default defineComponent({
name: "Nav",
components: { RegisterAndLogin },
computed: {
userInfo(): User {
const store = useStore(StateKey);
return store.state.user;
},
isLogin(): Boolean {
return this.userInfo.id > 0;
},
navs(){
const store = useStore(StateKey);
return store.state.navs;
},
navIndex() {
const store = useStore(StateKey);
return store.state.navIndex;
},
},
watch: {
$route: {
handler(val: any, oldVal: any) {
this.routeChange(val, oldVal);
},
immediate: true,
},
},
setup() {
const state = reactive({
handleFlag: "",
visible: false,
title: "主页",
});
const store = useStore(StateKey);
const routeChange = (newRoute: any, oldRoute: any): void => {
for (let i = 0; i < store.state.navs.length; i++) {
const l = store.state.navs[i];
if (l.path === newRoute.path) {
state.title = l.name;
store.commit(SET_NAV_INDEX, l.index);
return;
}
}
store.commit(SET_NAV_INDEX, "-1");
};
const handleClick = async (route: string) => {
if (["login", "register"].includes(route)) {
state.handleFlag = route;
state.visible = true;
} else {
await logout();
window.sessionStorage.userInfo = "";
store.commit(CLEAR_USER);
}
};
return {
state,
handleClick,
routeChange,
};
},
});
</script>
<style lang="less">
.nav {
position: fixed;
top: 0;
left: 0;
z-index: 1000;
width: 100%;
border-bottom: 1px solid #eee;
background-color: #fff;
.nav-content {
width: 1200px;
margin: 0 auto;
}
.logo {
height: 50px;
margin: 0;
border-radius: 50%;
margin-top: 5px;
}
.el-menu.el-menu--horizontal {
border-bottom: none;
}
.el-menu--horizontal > .el-menu-item {
cursor: pointer;
color: #333;
}
.nav-right {
position: relative;
padding-top: 15px;
text-align: right;
.el-dropdown {
cursor: pointer;
padding-right: 60px;
}
.user-img {
position: absolute;
top: -15px;
right: 0;
width: 50px;
border-radius: 50%;
}
}
}
</style>
3.8.3 修改App.vue
在 src/App.vue
下编写如下代码
- 其中
Nav
是用来做导航用的,当浏览器中的地址发生变化时,router/index.ts
中定义的路由对应的页面就会渲染到router-view
标签中 - 通过 Vue 3 的
defineComponent
定义App
组件,并导入Nav
组件 -
css
部分需要在子组件中生效
<template>
<div class="container">
<Nav/>
<div class="layout">
<router-view class="view-content"/>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import Nav from "./components/Nav.vue";
export default defineComponent({
name: "App",
components: {
Nav
},
});
</script>
<style lang="less">
body {
background-color: #f9f9f9;
}
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
padding-top: 50px;
}
.container {
width: 1200px;
margin: 0 auto;
}
img {
vertical-align: bottom;
}
.layout {
height: auto;
}
.button-container {
display: flex;
justify-content: space-between;
flex: 1;
margin-bottom: 24px;
}
.view-content {
margin-top: 12px;
background-color: #ffffff;
padding: 12px 24px 24px 24px;
border-radius: 8px;
}
</style>
3.9 效果图
经过这么一波调整后,运行起来的效果如下图: