用戶登錄功能是一個信息系統(tǒng)必不可少的一部分,作為博客網(wǎng)站司恳,同樣需要管理員登錄管理后臺途乃,游客注冊后登錄評論等
大家好,我是落霞孤鶩
扔傅,上一篇我們已經(jīng)搭建好了前后端的框架的代碼耍共,并調(diào)通了前后端接口烫饼。從這一篇開始,進入到業(yè)務(wù)功能開發(fā)進程中试读。
首先我們需要實現(xiàn)的功能是用戶登錄杠纵,用戶登錄功能雖然在系統(tǒng)開發(fā)中已經(jīng)很成熟,但是當(dāng)我們自己動手做的時候钩骇,會發(fā)現(xiàn)這個功能是那種典型的說起來容易比藻,做起來復(fù)雜的功能,需要考慮和處理的點很多倘屹。
一银亲、需求分析
1.1 完整需求
一個完整的用戶登錄功能,需要考慮的點如下:
- 賬號和密碼的格式
- 支持郵箱纽匙、賬號务蝠、手機號碼登錄
- 手機號碼支持驗證碼登錄
- 密碼錯誤的次數(shù)
- 忘記密碼功能
- 注冊功能
- 新用戶首次登錄自動注冊功能
- 社交平臺賬號鑒權(quán)登錄
- 支持記住賬號
- 7天自動登錄
- 登錄狀態(tài)保持
- 權(quán)限鑒定
- 登出
- 密碼修改
在前后端分離的狀態(tài)下,我們還需要考慮跨域問題等
1.2 博客網(wǎng)站需求
考慮到我們的博客系統(tǒng)是個人博客烛缔,用戶登錄的場景主要集中在游客評論馏段,管理員登錄管理后臺兩個場景,所以登錄功能可以適當(dāng)做刪減力穗。
該博客系統(tǒng)的登錄功能主要實現(xiàn)以下幾個點:
- 賬號和密碼的格式
- 支持郵箱、賬號
- 忘記密碼功能
- 注冊功能
- 登錄狀態(tài)保持
- 權(quán)限鑒定
- 登出
- 密碼修改
以上功能點气嫁,滿足博客網(wǎng)站基本需求
- 未登錄的游客只能留言当窗,不能評論
- 游客登錄后可以評論博客
- 游客登錄后可以修改密碼
- 管理員登錄后可以管理博客后臺
二、后端接口開發(fā)
用戶登錄和鑒權(quán)實際上在 Django
里面已經(jīng)有完整的功能寸宵,但是由于我們使用的是前后端分離架構(gòu)崖面,在 Django
的基礎(chǔ)上使用了 Django Rest Framework
,因此原有的 Django
登錄和鑒權(quán)接口需要做改造和調(diào)整梯影,以適應(yīng)前后端分離功能巫员。
這里需要處理幾個點:
- 用戶登錄,賬號密碼校驗甲棍,Session保持
-
API
鑒權(quán)简识,也即:接口是否是登錄后才能使用,還是不登錄也可以使用) - 密碼修改和重置
2.1 配置鑒權(quán)模式
這里采用 Django Rest Framework
提供的基于 Django
的 Session
方案感猛,如果你想采用 JWT
(介紹)方案七扰,可以按照官網(wǎng)教程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
中會創(chuàng)建 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
颅围,主要是因為修改密碼時需要提供原密碼和新密碼伟葫,所以單獨創(chuàng)建一個 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
類
密碼修改的方式有兩種院促,一種是通過修改密碼功能修改筏养,這個時候需要知道自己的原密碼,然后修改成自己想要的新密碼常拓,一種是通過忘記密碼功能修改渐溶,這個時候不需要知道自己的密碼,但需要知道自己綁定的郵箱弄抬,新密碼發(fā)送到郵箱里面茎辐。
- 在
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))
- 安裝發(fā)送郵件所需要的依賴
pip install django-smtp-ssl==1.0
- 同時在
requirements.txt
文件中增加依賴
django-smtp-ssl==1.0
- 在
project/settings.py
中增加郵箱配置拖陆,這里的EMAIL_HOST
和EMAIL_PORT
是需要依據(jù)填寫的郵箱做出調(diào)整,我這里填寫的是網(wǎng)易的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
語句導(dǎo)入。
將忘記密碼的功能放在
LoginViewSet
類下的原因是登錄接口和忘記密碼的接口均是在不需要登錄的情況下調(diào)用的接口鸯两,因此通過請求方式的不同來區(qū)分兩種接口坏瞄。
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()),
]
至此甩卓,后端接口已經(jīng)編寫完成
三鸠匀、前端頁面開發(fā)
因為用戶登錄的場景有兩個,一個是管理員登錄后臺逾柿,一個是游客登錄博客網(wǎng)站缀棍,所以需要在兩個地方完成用戶的登錄宅此。
作為管理 員登錄后臺系統(tǒng),登錄后需要進入到管理后臺爬范,所以需要單獨的登錄地址父腕,因此提供一個單獨的登錄URL和頁面。
作為游客青瀑,正常情況下璧亮,可以瀏覽博客,然后需要評論時斥难,點擊登錄按鈕枝嘶,完成登錄即可,因此需要一個登錄對話框即可哑诊。
3.1 基于 TypeScript 要求增加 interface 定義
3.1.1 創(chuàng)建 types
文件夾
在 src
下創(chuàng)建文件夾 types
群扶, 并在types
下創(chuàng)建文件 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
下創(chuàng)建文件夾 store
骆莹,并在 store
文件夾下創(chuàng)建文件 index.ts
3.2.2 定義 User
和 Nav
相關(guān)的全局 state
- 首先定義
state
的接口,目前我們需要用到三個state担猛,一個是用戶信息User
幕垦,一個是博客頁面頂部導(dǎo)航的路由數(shù)據(jù)navs
,是一個Nav
的數(shù)組毁习,還有一個是當(dāng)前導(dǎo)航菜單的索引navIndex
智嚷,表示當(dāng)前頁面是在哪一個菜單下卖丸。 - 通過
Symbol
定義一個InjectKey
纺且,用于在Vue3
中通過 useState 獲取到我們定義state
- 定義
state
在dispatch時用到的方法名,這里我們需要用到三個setUser
稍浆,clearUser
载碌,setNavIndex
- 定義初始化
User
信息的方法,在登錄完成后衅枫,我們?yōu)榱吮WC用戶信息在刷新頁面后仍然可以識別用戶是已經(jīng)登錄的狀態(tài)嫁艇,需要sessionStorage中存放登錄后的用戶信息,所以User
的state在初始化的時候弦撩,需要考慮從sessionStorage
中讀取步咪。 - 通過
createStore
方法構(gòu)建store
,在state()
返回初始數(shù)據(jù)益楼,在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: "關(guān)于",
},
],
}
},
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 創(chuàng)建 views
和 components
文件夾
3.3.1 新增 views
文件夾
在src
下新增文件夾views
点晴,用于存放可以被router
定義和管理的頁面上
3.3.2 新增 components
文件夾
在src
下新增文件夾components
,用于存放頁面上的可以復(fù)用的組件悯周,這些組件一般不會出現(xiàn)在 router
中粒督,而是通過 import
的方式使用
3.4 增加后端 API
調(diào)用方法
由于后端我們使用的 Django
和 Django Rest Framework
兩個框架,對接口鑒權(quán)模式我們沿用了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
表示未授權(quán)登錄贮匕,所以需要對Http
的code
做判斷 - 通過工具方法,在請求接口時花枫,帶上
X-CRSFToken
- 在獲得請求結(jié)果后刻盐,判斷狀態(tài)碼,如果不是200相關(guān)的正確碼劳翰,則全局提示異常
- 如果是
401
的狀態(tài)碼敦锌,則跳轉(zhuǎn)到登錄頁面
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
,后面用于普通用戶進入博客網(wǎng)站時看到的第一個頁面生均。代碼如下:
<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 定義路由
現(xiàn)在我們已經(jīng)有了Login.vue
和 Home.vue
兩個頁面了,現(xiàn)在可以定義路由了威彰。
- 我們采用
WebHistory
的方式展示路由出牧,這種方式在瀏覽器的地址欄中展示的URL更優(yōu)美 - 采用
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
中我們需要處理如下邏輯:
- 創(chuàng)建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
在上一篇中我們?yōu)榱藴y試前后端的連通性,將 App.vue
直接處理成了一個表格展示用戶列表的頁面伯复,而實際的項目中盈咳,該頁面是需要處理路由導(dǎo)航等相關(guān)功能的,因此我們先將該頁面改造成直接菜單導(dǎo)航的方式边翼。
在游客需要評論的時候鱼响,需要完成登錄后才可以,所以這里的登錄和管理員的登錄方式是不一樣的组底。
- 我們通過一個模態(tài)框的方式完成登錄
- 在未登錄時需要展示登錄和注冊兩個按鈕
- 登錄后丈积,不需要做頁面跳轉(zhuǎn),只需要在右上角顯示用戶的昵稱或賬號债鸡,表示用戶已經(jīng)登錄
- 登錄后江滨,可以登出
3.8.1 增加 RegisterAndLogin.vue
組件
這里的注冊和登錄復(fù)用同一個組件,通過按鈕點擊的不同厌均,展示不同的內(nèi)容唬滑。
- 需要校驗輸入的內(nèi)容
- 登錄成功后,需要更新全局
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
這個頁面處理頂部導(dǎo)航的功能稻艰,引用了 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
是用來做導(dǎo)航用的侈净,當(dāng)瀏覽器中的地址發(fā)生變化時尊勿,router/index.ts
中定義的路由對應(yīng)的頁面就會渲染到router-view
標簽中 - 通過 Vue 3 的
defineComponent
定義App
組件,并導(dǎo)入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 效果圖
經(jīng)過這么一波調(diào)整后畜侦,運行起來的效果如下圖: