Vue3+TypeScript+Django Rest Framework 搭建个人博客(二):用户登录功能

用户登录功能是一个信息系统必不可少的一部分,作为博客网站,同样需要管理员登录管理后台,游客注册后登录评论等

大家好,我是落霞孤鹜,上一篇我们已经搭建好了前后端的框架的代码,并调通了前后端接口。从这一篇开始,进入到业务功能开发进程中。

首先我们需要实现的功能是用户登录,用户登录功能虽然在系统开发中已经很成熟,但是当我们自己动手做的时候,会发现这个功能是那种典型的说起来容易,做起来复杂的功能,需要考虑和处理的点很多。

一、需求分析

1.1 完整需求

一个完整的用户登录功能,需要考虑的点如下:

  • 账号和密码的格式
  • 支持邮箱、账号、手机号码登录
  • 手机号码支持验证码登录
  • 密码错误的次数
  • 忘记密码功能
  • 注册功能
  • 新用户首次登录自动注册功能
  • 社交平台账号鉴权登录
  • 支持记住账号
  • 7天自动登录
  • 登录状态保持
  • 权限鉴定
  • 登出
  • 密码修改

在前后端分离的状态下,我们还需要考虑跨域问题

1.2 博客网站需求

考虑到我们的博客系统是个人博客,用户登录的场景主要集中在游客评论,管理员登录管理后台两个场景,所以登录功能可以适当做删减。

该博客系统的登录功能主要实现以下几个点:

  • 账号和密码的格式
  • 支持邮箱、账号
  • 忘记密码功能
  • 注册功能
  • 登录状态保持
  • 权限鉴定
  • 登出
  • 密码修改

以上功能点,满足博客网站基本需求

  • 未登录的游客只能留言,不能评论
  • 游客登录后可以评论博客
  • 游客登录后可以修改密码
  • 管理员登录后可以管理博客后台

二、后端接口开发

用户登录和鉴权实际上在 Django 里面已经有完整的功能,但是由于我们使用的是前后端分离架构,在 Django 的基础上使用了 Django Rest Framework ,因此原有的 Django 登录和鉴权接口需要做改造和调整,以适应前后端分离功能。

这里需要处理几个点:

  1. 用户登录,账号密码校验,Session保持
  2. API 鉴权,也即:接口是否是登录后才能使用,还是不登录也可以使用)
  3. 密码修改和重置

2.1 配置鉴权模式

这里采用 Django Rest Framework 提供的基于 DjangoSession 方案,如果你想采用 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 自带的 authenticatelogin ,完成用户的登录,并返回用户登录信息,在这个过程中,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 会自动清理 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,主要是因为修改密码时需要提供原密码和新密码,所以单独创建一个 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

密码修改的方式有两种,一种是通过修改密码功能修改,这个时候需要知道自己的原密码,然后修改成自己想要的新密码,一种是通过忘记密码功能修改,这个时候不需要知道自己的密码,但需要知道自己绑定的邮箱,新密码发送到邮箱里面。

  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. 安装发送邮件所需要的依赖
pip install django-smtp-ssl==1.0
  1. 同时在 requirements.txt 文件中增加依赖
django-smtp-ssl==1.0
  1. project/settings.py 中增加邮箱配置,这里的 EMAIL_HOSTEMAIL_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
  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 语句导入。

将忘记密码的功能放在 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/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()),
]

至此,后端接口已经编写完成

三、前端页面开发

因为用户登录的场景有两个,一个是管理员登录后台,一个是游客登录博客网站,所以需要在两个地方完成用户的登录。

  • 作为管理 员登录后台系统,登录后需要进入到管理后台,所以需要单独的登录地址,因此提供一个单独的登录URL和页面。

  • 作为游客,正常情况下,可以浏览博客,然后需要评论时,点击登录按钮,完成登录即可,因此需要一个登录对话框即可。

3.1 基于 TypeScript 要求增加 interface 定义

3.1.1 创建 types 文件夹

src 下创建文件夹 types, 并在types 下创建文件 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 下创建文件夹 store,并在 store 文件夹下创建文件 index.ts

3.2.2 定义 UserNav 相关的全局 state

  1. 首先定义 state的接口,目前我们需要用到三个state,一个是用户信息 User,一个是博客页面顶部导航的路由数据navs,是一个Nav的数组,还有一个是当前导航菜单的索引navIndex,表示当前页面是在哪一个菜单下。
  2. 通过Symbol定义一个 InjectKey,用于在 Vue3 中通过 useState 获取到我们定义state
  3. 定义 state 在dispatch时用到的方法名,这里我们需要用到三个setUserclearUsersetNavIndex
  4. 定义初始化 User 信息的方法,在登录完成后,我们为了保证用户信息在刷新页面后仍然可以识别用户是已经登录的状态,需要sessionStorage中存放登录后的用户信息,所以 User 的state在初始化的时候,需要考虑从 sessionStorage中读取。
  5. 通过 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 创建 viewscomponents 文件夹

3.3.1 新增 views 文件夹

src 下新增文件夹views,用于存放可以被router 定义和管理的页面上

3.3.2 新增 components 文件夹

src 下新增文件夹components,用于存放页面上的可以复用的组件,这些组件一般不会出现在 router 中,而是通过 import 的方式使用

3.4 增加后端 API 调用方法

由于后端我们使用的 DjangoDjango 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,增加登录和未登录的处理逻辑。

  1. Django Rest Framework 使用标准的Http code表示未授权登录,所以需要对Httpcode做判断
  2. 通过工具方法,在请求接口时,带上X-CRSFToken
  3. 在获得请求结果后,判断状态码,如果不是200相关的正确码,则全局提示异常
  4. 如果是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.vueHome.vue 两个页面了,现在可以定义路由了。

  1. 我们采用WebHistory的方式展示路由,这种方式在浏览器的地址栏中展示的URL更优美
  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. 创建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

在上一篇中我们为了测试前后端的连通性,将 App.vue 直接处理成了一个表格展示用户列表的页面,而实际的项目中,该页面是需要处理路由导航等相关功能的,因此我们先将该页面改造成直接菜单导航的方式。

在游客需要评论的时候,需要完成登录后才可以,所以这里的登录和管理员的登录方式是不一样的。

  1. 我们通过一个模态框的方式完成登录
  2. 在未登录时需要展示登录和注册两个按钮
  3. 登录后,不需要做页面跳转,只需要在右上角显示用户的昵称或账号,表示用户已经登录
  4. 登录后,可以登出

3.8.1 增加 RegisterAndLogin.vue 组件

这里的注册和登录复用同一个组件,通过按钮点击的不同,展示不同的内容。

  1. 需要校验输入的内容
  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

这个页面处理顶部导航的功能,引用了 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 是用来做导航用的,当浏览器中的地址发生变化时,router/index.ts 中定义的路由对应的页面就会渲染到 router-view 标签中
  2. 通过 Vue 3 的defineComponent 定义 App 组件,并导入 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 效果图

经过这么一波调整后,运行起来的效果如下图:

3.9.1 代码结构

3.9.1.1 前端代码结构
image-20210807183853314
3.9.1.2 后端代码结构
image-20210807183646053

3.9.1 首页效果

image-20210807180624024

3.9.2 管理员登录页面

image-20210807180715991

3.9.3 游客注册页面

image-20210807180800573

3.9.4 游客登录页面

image-20210807180815026
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容