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 提供的基于 Django 的 Session 方案,如果你想采用 JWT (介绍)方案,可以按照官网教程Authentication - Django REST framework进行配置。在 project/settings.py 中的 REST_FRAMWORK 配置项中修改如下:

REST_FRAMEWORK = {
  'DEFAULT_AUTHENTICATION_CLASSES': [
        'rest_framework.authentication.BasicAuthentication',
        'rest_framework.authentication.SessionAuthentication',
    ],
  'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
    'PAGE_SIZE': 10
}

2.2 编写登录登出接口

2.2.1 增加 UserLoginSerializer 类

在 common/serializers.py 文件中,增加代码,修改后代码如下:

from rest_framework import serializers

from common.models import User


class UserSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'avatar', 'email', 'is_active', 'created_at', 'nickname']


class UserLoginSerializer(serializers.ModelSerializer):
    class Meta:
        model = User
        fields = ['id', 'username', 'password']
        extra_kwargs = {
            'password': {'write_only': True},
        }

2.2.2 增加 UserLoginViewSet 类

在 common/views.py 中增加 UserLoginViewSet 类,使用Django 自带的 authenticate 和 login ,完成用户的登录,并返回用户登录信息,在这个过程中,Response 中会创建 Session,保存登录后的 user 信息,生成Cookies 一并返回。方法修改后代码如下:

from django.contrib.auth import authenticate, login
from rest_framework import viewsets, permissions
from rest_framework.generics import GenericAPIView
from rest_framework.response import Response

from common.models import User
from common.serializers import UserSerializer, UserLoginSerializer


class UserViewSet(viewsets.ModelViewSet):
    queryset = User.objects.all().order_by('username')
    serializer_class = UserSerializer
    permission_classes = [permissions.AllowAny]


class UserLoginViewSet(GenericAPIView):
    permission_classes = [permissions.AllowAny]
    serializer_class = UserLoginSerializer
    queryset = User.objects.all()

    def post(self, request, *args, **kwargs):
        username = request.data.get('username', '')
        password = request.data.get('password', '')

        user = authenticate(username=username, password=password)
        if user is not None and user.is_active:
            login(request, user)
            serializer = UserSerializer(user)
            return Response(serializer.data, status=200)
        else:
            ret = {'detail': 'Username or password is wrong'}
            return Response(ret, status=403)

2.2.3 增加 UserLogoutViewSet 类

在 common/views.py 中增加 UserLogoutViewSet 类,使用Django 自带的 auth_logout ,完成用户的登出,并返回登出成功信息,这个过程中,Django 会自动清理 Session 和Cookies

class UserLogoutViewSet(GenericAPIView):
    permission_classes = [permissions.IsAuthenticated]
    serializer_class = UserLoginSerializer

    def get(self, request, *args, **kwargs):
        auth_logout(request)
        return Response({'detail': 'logout successful !'})

2.2.4 配置路由

在 common/urls.py 中增加 user/login 和 user/logout 路由,代码如下:

from django.conf.urls import url
from django.urls import include, path
from rest_framework import routers

from common import views

router = routers.DefaultRouter()
router.register('user', views.UserViewSet)

app_name = 'common'

urlpatterns = [
    path('', include(router.urls)),
    url(r'^user/login', views.UserLoginViewSet.as_view()),
    url(r'^user/logout', views.UserLogoutViewSet.as_view()),
]

2.3 编写修改密码接口

2.3.1 增加 UserPasswordSerializer 类

在 common/serializers.py 文件中增加类 UserPasswordSerializer,主要是因为修改密码时需要提供原密码和新密码,所以单独创建一个 serializer ,代码如下:

class UserPasswordSerializer(serializers.ModelSerializer):
    new_password = serializers.SerializerMethodField()

    class Meta:
        model = User
        fields = ['id', 'username', 'password', 'new_password']

    @staticmethod
    def get_new_password(obj):
        return obj.password or ''

2.3.2 增加 PasswordUpdateViewSet 类

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

  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_HOST 和 EMAIL_PORT 是需要依据填写的邮箱做出调整,我这里填写的是网易的 163 邮箱
EMAIL_BACKEND = 'django_smtp_ssl.SSLEmailBackend'
MAILER_EMAIL_BACKEND = EMAIL_BACKEND
EMAIL_HOST = 'smtp.163.com'
EMAIL_PORT = 465
EMAIL_HOST_USER = 'zgj0607@163.com'
EMAIL_HOST_PASSWORD = 'xxxx'
EMAIL_SUBJECT_PREFIX = u'[LSS]'
EMAIL_USE_SSL = True
  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/login 和 user/logout 路由,代码如下:

from django.conf.urls import url
from django.urls import include, path
from rest_framework import routers

from common import views

router = routers.DefaultRouter()
router.register('user', views.UserViewSet)

app_name = 'common'

urlpatterns = [
    path('', include(router.urls)),
    url(r'^user/login', views.UserLoginViewSet.as_view()),
    url(r'^user/logout', views.UserLogoutViewSet.as_view()),
    url(r'^user/pwd', views.PasswordUpdateViewSet.as_view()),
]

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

三、前端页面开发

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

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

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

3.1 基于 TypeScript 要求增加 interface 定义

3.1.1 创建 types 文件夹

在 src 下创建文件夹 types, 并在types 下创建文件 index.ts

3.1.2 增加 User 和 Nav 定义

在 src/types/index.ts 编写如下代码

export interface User {
    id: number,
    username: string,
    email: string,
    avatar: string | any,
    nickname: string | any,
    is_active?: any,
    is_superuser?: boolean,
    created_at?: string,
}
    
export interface Nav {
    index: string,
    path: string,
    name: string,
}

其中 ? 表示可选属性,可以为空,| 表示属性值类型的可选项,可以多种类型的属性值,any 表示任何类型都可以。

3.2 基于 Vuex 保存 User 登录信息

3.2.1 新增文件夹 store

在src 下创建文件夹 store,并在 store 文件夹下创建文件 index.ts

3.2.2 定义 User 和 Nav 相关的全局 state

  1. 首先定义 state的接口,目前我们需要用到三个state,一个是用户信息 User,一个是博客页面顶部导航的路由数据navs,是一个Nav的数组,还有一个是当前导航菜单的索引navIndex,表示当前页面是在哪一个菜单下。
  2. 通过Symbol定义一个 InjectKey,用于在 Vue3 中通过 useState 获取到我们定义state
  3. 定义 state 在dispatch时用到的方法名,这里我们需要用到三个setUser,clearUser,setNavIndex
  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 创建 views 和 components 文件夹

3.3.1 新增 views 文件夹

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

3.3.2 新增 components 文件夹

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

3.4 增加后端 API 调用方法

由于后端我们使用的 Django 和 Django Rest Framework 两个框架,对接口鉴权模式我们沿用了Django的Session模式,因此我们需要处理好跨域访问。

3.4.1 增加 getCookies 工具方法

在src下增加 utils文件夹,在src/utils下新增文件index.js 文件,编写如下代码:

export function getCookie(cName: string) {
    if (document.cookie.length > 0) {
        let cStart = document.cookie.indexOf(cName + "=");
        if (cStart !== -1) {
            cStart = cStart + cName.length + 1;
            let cEnd = document.cookie.indexOf(";", cStart);
            if (cEnd === -1) cEnd = document.cookie.length;
            return unescape(document.cookie.substring(cStart, cEnd));
        }
    }
    return "";
}

3.4.2 增加请求接口时登录和未登录的处理逻辑

在原来请求后端的定义上,改造src/api/index.ts,增加登录和未登录的处理逻辑。

  1. Django Rest Framework 使用标准的Http code表示未授权登录,所以需要对Http的code做判断
  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.vue 和 Home.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. 加载 Vuex 的state

完整代码:

import { createApp } from 'vue'
import App from './App.vue'
import router from "./router";
import { StateKey, store } from "./store";
import 'element-plus/lib/theme-chalk/index.css';
import 'element-plus/lib/theme-chalk/base.css';

import {
    ElAffix,
    ElButton,
    ElCard,
    ElCascader,
    ElCol,
    ElDescriptions,
    ElDescriptionsItem,
    ElDialog,
    ElDrawer,
    ElDropdown,
    ElDropdownItem,
    ElDropdownMenu,
    ElForm,
    ElFormItem,
    ElIcon,
    ElInput,
    ElLoading,
    ElMenu,
    ElMenuItem,
    ElMessage,
    ElMessageBox,
    ElOption,
    ElPagination,
    ElPopconfirm,
    ElProgress,
    ElRow,
    ElSelect,
    ElTable,
    ElTableColumn,
    ElTag,
    ElTimeline,
    ElTimelineItem,
    ElTooltip,
    ElTree,
    ElUpload,
} from 'element-plus';

const app = createApp(App)


const components = [
    ElAffix,
    ElButton,
    ElCard,
    ElCascader,
    ElCol,
    ElDescriptions,
    ElDescriptionsItem,
    ElDialog,
    ElDrawer,
    ElDropdown,
    ElDropdownItem,
    ElDropdownMenu,
    ElForm,
    ElFormItem,
    ElIcon,
    ElInput,
    ElLoading,
    ElMenu,
    ElMenuItem,
    ElMessage,
    ElMessageBox,
    ElOption,
    ElPagination,
    ElPopconfirm,
    ElProgress,
    ElRow,
    ElSelect,
    ElTable,
    ElTableColumn,
    ElTag,
    ElTimeline,
    ElTimelineItem,
    ElTooltip,
    ElTree,
    ElUpload,
]

const plugins = [
    ElLoading,
    ElMessage,
    ElMessageBox,
]

components.forEach(component => {
    app.component(component.name, component)
})

plugins.forEach(plugin => {
    app.use(plugin)
})

app.use(router).use(store, StateKey).mount('#app')

3.8 改造 App.vue

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

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

  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
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容