Vue3+TypeScript+Django Rest Framework 搭建個人博客(二):用戶登錄功能

用戶登錄功能是一個信息系統(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)前后端分離功能巫员。

這里需要處理幾個點:

  1. 用戶登錄,賬號密碼校驗甲棍,Session保持
  2. API 鑒權(quán)简识,也即:接口是否是登錄后才能使用,還是不登錄也可以使用)
  3. 密碼修改和重置

2.1 配置鑒權(quán)模式

這里采用 Django Rest Framework 提供的基于 DjangoSession 方案感猛,如果你想采用 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 自帶的 authenticatelogin 咱士,完成用戶的登錄立由,并返回用戶登錄信息轧钓,在這個過程中,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 會自動清理 SessionCookies

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/loginuser/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ā)送到郵箱里面茎辐。

  1. 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))
  1. 安裝發(fā)送郵件所需要的依賴
pip install django-smtp-ssl==1.0
  1. 同時在 requirements.txt 文件中增加依賴
django-smtp-ssl==1.0
  1. project/settings.py 中增加郵箱配置拖陆,這里的 EMAIL_HOSTEMAIL_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
  1. 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 !'
        })

  1. 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/loginuser/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 增加 UserNav 定義

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 定義 UserNav 相關(guān)的全局 state

  1. 首先定義 state的接口,目前我們需要用到三個state担猛,一個是用戶信息 User幕垦,一個是博客頁面頂部導(dǎo)航的路由數(shù)據(jù)navs,是一個Nav的數(shù)組毁习,還有一個是當(dāng)前導(dǎo)航菜單的索引navIndex智嚷,表示當(dāng)前頁面是在哪一個菜單下卖丸。
  2. 通過Symbol定義一個 InjectKey纺且,用于在 Vue3 中通過 useState 獲取到我們定義state
  3. 定義 state 在dispatch時用到的方法名,這里我們需要用到三個setUser稍浆,clearUser载碌,setNavIndex
  4. 定義初始化 User 信息的方法,在登錄完成后衅枫,我們?yōu)榱吮WC用戶信息在刷新頁面后仍然可以識別用戶是已經(jīng)登錄的狀態(tài)嫁艇,需要sessionStorage中存放登錄后的用戶信息,所以 User 的state在初始化的時候弦撩,需要考慮從 sessionStorage中讀取步咪。
  5. 通過 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)建 viewscomponents 文件夾

3.3.1 新增 views 文件夾

src 下新增文件夾views点晴,用于存放可以被router 定義和管理的頁面上

3.3.2 新增 components 文件夾

src 下新增文件夾components,用于存放頁面上的可以復(fù)用的組件悯周,這些組件一般不會出現(xiàn)在 router 中粒督,而是通過 import 的方式使用

3.4 增加后端 API 調(diào)用方法

由于后端我們使用的 DjangoDjango 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,增加登錄和未登錄的處理邏輯解总。

  1. Django Rest Framework 使用標準的Http code表示未授權(quán)登錄贮匕,所以需要對Httpcode做判斷
  2. 通過工具方法,在請求接口時花枫,帶上X-CRSFToken
  3. 在獲得請求結(jié)果后刻盐,判斷狀態(tài)碼,如果不是200相關(guān)的正確碼劳翰,則全局提示異常
  4. 如果是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.vueHome.vue 兩個頁面了,現(xiàn)在可以定義路由了威彰。

  1. 我們采用WebHistory的方式展示路由出牧,這種方式在瀏覽器的地址欄中展示的URL更優(yōu)美
  2. 采用History 的方式后,我們需要在vite.config.ts 中定義base 時用這種:base: '/'歇盼,在/前不能增加.
  3. 對首頁以外的頁面舔痕,采用 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 中我們需要處理如下邏輯:

  1. 創(chuàng)建APP
  2. 加載 Element-Plus 的組件
  3. 加載 Element-Plus 的插件
  4. 加載 Vue-Router 的路由
  5. 加載 Vuexstate

完整代碼:

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)航的方式边翼。

在游客需要評論的時候鱼响,需要完成登錄后才可以,所以這里的登錄和管理員的登錄方式是不一樣的组底。

  1. 我們通過一個模態(tài)框的方式完成登錄
  2. 在未登錄時需要展示登錄和注冊兩個按鈕
  3. 登錄后丈积,不需要做頁面跳轉(zhuǎn),只需要在右上角顯示用戶的昵稱或賬號债鸡,表示用戶已經(jīng)登錄
  4. 登錄后江滨,可以登出

3.8.1 增加 RegisterAndLogin.vue 組件

這里的注冊和登錄復(fù)用同一個組件,通過按鈕點擊的不同厌均,展示不同的內(nèi)容唬滑。

  1. 需要校驗輸入的內(nèi)容
  2. 登錄成功后,需要更新全局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下編寫如下代碼

  1. 其中 Nav 是用來做導(dǎo)航用的侈净,當(dāng)瀏覽器中的地址發(fā)生變化時尊勿,router/index.ts 中定義的路由對應(yīng)的頁面就會渲染到 router-view 標簽中
  2. 通過 Vue 3 的defineComponent 定義 App 組件,并導(dǎo)入 Nav 組件
  3. 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)整后畜侦,運行起來的效果如下圖:

3.9.1 代碼結(jié)構(gòu)

3.9.1.1 前端代碼結(jié)構(gòu)
image-20210807183853314
3.9.1.2 后端代碼結(jié)構(gòu)
image-20210807183646053

3.9.1 首頁效果

image-20210807180624024

3.9.2 管理員登錄頁面

image-20210807180715991

3.9.3 游客注冊頁面

image-20210807180800573

3.9.4 游客登錄頁面

image-20210807180815026
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末元扔,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子旋膳,更是在濱河造成了極大的恐慌澎语,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,378評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件溺忧,死亡現(xiàn)場離奇詭異咏连,居然都是意外死亡盯孙,警方通過查閱死者的電腦和手機鲁森,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,356評論 2 382
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來振惰,“玉大人歌溉,你說我怎么就攤上這事。” “怎么了痛垛?”我有些...
    開封第一講書人閱讀 152,702評論 0 342
  • 文/不壞的土叔 我叫張陵草慧,是天一觀的道長。 經(jīng)常有香客問我匙头,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,259評論 1 279
  • 正文 為了忘掉前任院刁,我火速辦了婚禮借杰,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘电抚。我一直安慰自己惕稻,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 64,263評論 5 371
  • 文/花漫 我一把揭開白布蝙叛。 她就那樣靜靜地躺著俺祠,像睡著了一般。 火紅的嫁衣襯著肌膚如雪借帘。 梳的紋絲不亂的頭發(fā)上蜘渣,一...
    開封第一講書人閱讀 49,036評論 1 285
  • 那天,我揣著相機與錄音肺然,去河邊找鬼宋梧。 笑死,一個胖子當(dāng)著我的面吹牛狰挡,可吹牛的內(nèi)容都是我干的捂龄。 我是一名探鬼主播,決...
    沈念sama閱讀 38,349評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼加叁,長吁一口氣:“原來是場噩夢啊……” “哼倦沧!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起它匕,我...
    開封第一講書人閱讀 36,979評論 0 259
  • 序言:老撾萬榮一對情侶失蹤展融,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后豫柬,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體告希,經(jīng)...
    沈念sama閱讀 43,469評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,938評論 2 323
  • 正文 我和宋清朗相戀三年烧给,在試婚紗的時候發(fā)現(xiàn)自己被綠了燕偶。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 38,059評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡础嫡,死狀恐怖指么,靈堂內(nèi)的尸體忽然破棺而出酝惧,到底是詐尸還是另有隱情,我是刑警寧澤伯诬,帶...
    沈念sama閱讀 33,703評論 4 323
  • 正文 年R本政府宣布晚唇,位于F島的核電站,受9級特大地震影響盗似,放射性物質(zhì)發(fā)生泄漏哩陕。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 39,257評論 3 307
  • 文/蒙蒙 一赫舒、第九天 我趴在偏房一處隱蔽的房頂上張望萌踱。 院中可真熱鬧,春花似錦号阿、人聲如沸并鸵。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,262評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽园担。三九已至,卻和暖如春枯夜,著一層夾襖步出監(jiān)牢的瞬間弯汰,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,485評論 1 262
  • 我被黑心中介騙來泰國打工湖雹, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留咏闪,地道東北人。 一個月前我還...
    沈念sama閱讀 45,501評論 2 354
  • 正文 我出身青樓摔吏,卻偏偏與公主長得像鸽嫂,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子征讲,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,792評論 2 345

推薦閱讀更多精彩內(nèi)容