vue3+node后台管理系统实战——1.利用小程序实现web登录

171 阅读12分钟

有任何问题,都可以私信博主,共同探讨学习。

项目示例地址:中二少年学编程的示例项目

本项目适合熟悉vue3、nodejs基础,并希望了解实战应用的同学;适合想要学习web全栈开发的同学;适合大学生作业、毕业设计参考,有任何问题,请随时联系博主。


一、技术选型

前端:vue3+viewui 后端:nodejs+midwayjs+typeorm 数据库:mysql

二、设计方案

大部分网站的登录模块包含两个功能:注册和登录。

注册主要目的是在系统中申请一个账户和密码,作为后续登录的凭证。但是出于防止恶意注册、便于找回密码、防脚本批量注册等多方面考虑,大部分注册行为还会考虑邮箱验证、手机号验证等功能。

如果同学们开发的网站是面向普通用户(to C),并且有巨大市场潜力的项目,那么可以按照常规注册模式开发项目。

但是很多后台管理系统其实面向的是特定的企业用户(to B),并不需要开发注册功能,只需要超级管理员创建并管理用户即可。

我开发的demo网站,属于后台管理系统,是解决面向企业用户的场景,但是我做出这个demo又是为了让所有同学都可以体验参考。所以我为demo网站设计了两种登录模式:一是便捷的注册登录功能;二是通过超管创建用户后,常规的登录功能。

注册功能想要便捷,就不能沿用手机号验证那套规则,那样可能会吓跑大部分嫌麻烦的同学,利用微信登录又需要每年支付300元认证费用,所以我就设计实现了利用微信扫码跳转小程序,通过小程序验证后,实现登录的功能。

这套方案非常适合想要低成本、便捷地通过微信实现扫码登录的中小企业、学生、个人开发者等群体。

至于登录的逻辑就很简单了,不管利用哪种方式实现,本质都是将前端采集的用户和密码发送到后端,后端与数据库中保存的用户信息匹配,如果匹配成功,则登录成功,如果匹配失败,则返回登录失败。

三、小程序扫码登录

小程序的实现方式略微复杂,而且这是博主自己思考的方案,网上应该是没有太多参考资料。如果仅仅是学习基础的登录技术原理,来应付企业管理系统开发,并不需要学习本章节。

3.1 后端调用小程序官方api生成小程序码

后端调用小程序的官方api生成带参数的小程序码,参数的key值我们设定为cId,后面要用。生成小程序码,可以指定扫描后进入的页面,我们设置为扫码进入“我的”页面。

在这里插入图片描述

代码实现:

 /**
   * 根据token获取微信小程序码
   * @Param access_token - 微信token
   * @Param env_version - 环境版本:develop,release
   * */
  async getWXACodeUnlimited(access_token: string, env_version: string) {
    const url = 'https://api.weixin.qq.com/wxa/getwxacodeunlimit';
    const cId = nanoid()
    const data = {
      scene: 'cId=' + cId,
      page: 'pages/about/about',
      env_version
    }
    const params = {
      access_token
    }
    const result = await this.httpService.request({
      url,
      method: 'POST',
      responseType: 'arraybuffer', // 指定响应类型为二进制数据
      data,
      params
    })
    return {
      imgData: result.data,
      cId
    }
  }

代码讲解:

1.api.weixin.qq.com/wxa/getwxac…

2.const cId = nanoid():使用nanoid插件生成随机的参数

3.await this.httpService.request:使用内置的服务调用接口,同学们不论使用什么工具都是可以的。

3.2 前端请求接口,获取小程序码

3.1章节返回的是小程序码的图片数据和cId参数,前端请求对应的后端接口,获取小程序码图片并显示。

在这里插入图片描述转存失败,建议直接上传图片文件

代码实现:

  /**
     * 获取小程序码
     * 返回值-小程序码参数*/
    const miniAppSceneCodeUrl = ref('') //带参数的二维码
    async function getWXACodeUnlimited() {
        miniAppSceneCodeUrl.value = ''
        let imgRs = {}
        try {
            imgRs = await getWXACodeUnlimitedApi({
                env_version: 'release',
            });
        } catch (e) {
            imgRs = {
                success: false
            }
        }
        // 将 Buffer 数据转换为图片 URL
        // 1. 将数组转换为 Uint8Array
        const uint8Array = new Uint8Array(imgRs.data.imgData.data);

        // 2. 将 Uint8Array 转换为 Blob
        const blob = new Blob([uint8Array], {type: 'image/png'}); // 根据实际图片类型设置 MIME 类型

        // 3. 生成图片 URL
        miniAppSceneCodeUrl.value = URL.createObjectURL(blob);
        return imgRs.data.cId
    }

对应的html代码:

           <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
            </div>

代码解释:

1.getWXACodeUnlimitedApi:调用后端接口,获取图片信息和cId参数值。

2.后面操作buffer的三行代码可以省略,我的后端返回的图片数据是buffer二进制,如果同学们直接返回base64数据,会更简单,直接在前端src中赋值即可显示。之所以返回buffer,是因为我的服务器带宽很差,只能尽量压缩前后端交互数据的大小。

3.miniAppSceneCodeUrl:小程序码的url,获取后直接在前端html中显示。

3.3 小程序监听扫码登录行为

用户扫描小程序码时,会进入小程序并直接跳转“我的”页面,小程序的“我的”页面监听当页面渲染后,是否携带了参数cId。

如果小程序监听用户跳转行为携带了cId,说明用户在触发扫码登录行为,则保存cId到用户信息,如果没有携带cId,属于普通跳转行为,不做任何操作。

代码示例:

	onLoad(async (option) => {
		if (option.scene) {
			const scene = decodeURIComponent(option.scene)
			const params = parseScene(scene); // 解析为键值对
			saveCId(params.cId)
		}

	})

代码解释:

1.onLoad:小程序端是用uniapp开发的,onLoad是页面加载的生命周期。

2.option.scene :监听页面加载后,页面携带的参数。

3.saveCId:保存cId到数据库中的用户信息表中。这部分代码涉及小程序部分功能,略微复杂,涉及很多复杂的判断。比如如果用户第一次登录小程序,还需要先等待小程序创建新用户后,再保存cId到数据库中的用户信息。这些属于小程序的功能开发了,不在本次登录功能的介绍中,所以不再赘述。同学们如果想要实现类似效果,只要能做到在这一步将cId保存到数据库中用户信息即可。

3.4 以cId为筛选条件轮询用户信息

前端在3.2章节中获取小程序码时,getWXACodeUnlimited方法同时返回了cId,详见3.2章节中的代码。

获取到cId后,前端即可以cId为筛选条件,对用户信息表执行轮询操作。因为每一个cId都是随机生成的,当发现用户信息表中出现符合的数据时,说明用户已经扫码登录成功,前端页面就可以放行,显示登录成功了。

代码实现:

    const showRefreshCode = ref(false)
 /**
     *根据小程序码参数轮询用户信息 */
    function getUserInfoBySceneCode(sceneCode) {
        if (!sceneCode) return
        let getApiCount = 0 //当前轮询次数
        let maxApiCount = 3  //最大的轮询次数
        let getApiSuccess = false //轮询是否成功

        getUserInfoInterval.value = setInterval(async () => {
            if (getApiCount >= maxApiCount) {
                // 超过10次,停止轮询
                clearInterval(getUserInfoInterval.value);
                showRefreshCode.value = true
                if (getApiCount === maxApiCount && !getApiSuccess) {
                    // 十次轮询都未成功,显示错误信息
                    console.error('十次轮询均失败:', getApiSuccess);
                    Message.error('扫码登录失败,请重试');
                    showRefreshCode.value = true;
                }
            }
            getApiCount++
            try {
                const userInfoRs = await getUserInfoBySceneCodeApi({
                    sceneCode: sceneCode
                })
                if (userInfoRs.success) {
                    getApiSuccess = true
                    Message.success('扫码登录成功')
                    // 登录成功后,维护全局变量
                    await handleLogIn(userInfoRs)
                    router.push({name: 'home'})
                    // 成功后停止轮询
                    clearInterval(getUserInfoInterval.value);
                }
            } catch (error) {
                console.error('根据小程序码参数获取用户信息失败:', error);
            }
        }, 3000); // 每3秒轮询一次
    }

上面代码就是简单的轮询代码,并没有什么技术点需要讲解。

3.5增加小程序码过期机制

前端无限制地轮询请求,可能会影响性能,所以应该设置一个机制,轮询一定次数后,则认定本次登录行为过期。停止轮询,并隐藏过期小程序码,用户点击刷新后,重新开启新的轮询。

在这里插入图片描述

3.4章节中的showRefreshCode变量就是显示刷新图标的开关。当轮询次数超出限制,则显示刷新图标,并且阻止用户扫码。因为已经不再轮询小程序码中携带的cId参数,再扫码已经没有意义。

当用户点击刷新图标,则重新获取小程序码,并重新开启轮询。

代码实现:

在前面显示小程序码的html代码中,增加刷新图标的判断:

            <div v-if="miniAppSceneCodeUrl"
                 style="width: 100%;display: flex;justify-content: center;position: relative">
              <img :src="miniAppSceneCodeUrl" style="width: 120px;height: 120px"/>
              <div v-if="showRefreshCode" class="refresh-icon">

                <Icon style="cursor: pointer;opacity: 0.8" @click="loginBySceneCode" type="md-refresh" size="80"/>
              </div>
            </div>

代码解释:

1.showRefreshCode:是否显示刷新图标

2.loginBySceneCode:点击刷新图标的方法,包含请求小程序码、刷新图标隐藏、开启轮询用户信息等操作。

四、常规的用户+密码登录

除了小程序扫码登录,示例项目还提供了普通的用户+密码的登录方式。用户由超级管理员创建,创建后的用户使用默认密码登录。现在的示例项目角色管理功能尚未完善,所以所有用户均可创建用户。

4.1 账户登录前端实现

账户登录页面的前端主要由一个Form表单构成,表单包含用户名和密码的输入,登录按钮的实现。并且为账户和密码的输入框增加不能为空的规则。

效果如下:

在这里插入图片描述

代码实现:

<Form ref="loginForm" :model="form" :rules="rules" @keydown.enter.native="handleSubmit">
    <FormItem prop="userCode">
      <Input v-model="form.userCode" placeholder="请输入用户名">
        <span slot="prepend">
          <Icon :size="16" type="ios-person"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem prop="password">
      <Input type="password" v-model="form.password" password  placeholder="请输入密码">
        <span slot="prepend">
          <Icon :size="14" type="md-lock"></Icon>
        </span>
      </Input>
    </FormItem>
    <FormItem>
      <Button @click="handleSubmit" class="lz-btn-primary" long>登录</Button>
    </FormItem>
  </Form>

大部分ui框架都提供了表单校验规则的功能,viewui的表单校验通过rules定义。示例项目仅仅设置了必填验证,实际项目中还应该增加长度验证。

登录按钮功能代码实现:

async function handleSubmit() {
  const validRs=await loginForm.value.validate()
  if (validRs) {
    const data = {
      userCode: form.value.userCode,
      password: form.value.password
    };
    const res = await login(data)
    if (res.success) {
      // 登录成功后,维护全局变量
      await handleLogIn(res)
      // 路由跳转
      router.push({
        name: '_home'
      })
    }

  }
}

代码解释:

1.validRs:判断表单校验是否通过,通过校验后,才能执行登录逻辑

2.await login(data):调用后端接口,判断用户名和密码是否合法,后端通过检查数据库信息,返回判断结果。

3.await handleLogIn(res):如果成功后,维护全局变量。这个方法是pinia中定义的,主要将一些后续会经常使用的关键信息维护在全局状态管理。下文详细讲解。

4.router.push:上面所有方法都实现后,则跳转到网站首页。

handleLogin方法代码实现:

       /**
         * 登录时维护全局变量
         * @param loginRs 登录成功后的返回值
         * @param loginRs.accessToken token
         * @param loginRs.refreshTokenId refreshTokenId
         * @param loginRs.data 用户信息*/
        async handleLogIn(loginRs) {
            // debugger
            const {accessToken, refreshTokenId, data} = loginRs
            console.log('userInfo', data)
            setToken(accessToken, refreshTokenId)
            this.token = accessToken
            // 维护全局用户信息
            this.userInfo = data
            setUserInfoLocal(data)
            //     维护路由信息
            await this.setRouters()
        },

如果同学的项目也采用了token鉴权,那么就在这里维护token信息,我的demo项目采用了双token,但是这部分内容对于前端初学者来说过于复杂,学习曲线过陡不利于长期学习,所以不打算在此赘述。

如果只是简单的项目,不涉及token,则采用下面的代码:

        async handleLogIn(loginRs) {
            // debugger
            const { data} = loginRs
            console.log('userInfo', data)
            // 维护全局用户信息
            this.userInfo = data
            localStorage.setItem('userInfo', JSON.stringify(userInfoData))
            //     维护路由信息
            await this.setRouters()
        },

代码解释:

1.userInfo :保存到全局状态管理的用户信息。userInfo必须要同步保存到缓存中,因为全局状态管理中的userInfo在刷新页面后会失效,很多场景仍然需要缓存中的userInfo。

2.setRouters:从远端获取路由,并维护路由信息到全局状态管理时,才需要此方法。路由信息只需要保存到全局状态管理,并不需要localstorage缓存,因为如果路由是从远端获取的,则说明在做权限和路由的管理,路由与权限有关,属于变化相对频繁的数据,就不能从缓存中简单获取。而应该当监控到失去路由信息时,均重新获取路由。如果项目路由全部由前端维护,不需要做权限管理,则不需要此方法。

4.2 账户登录后端实现

如果不考虑token鉴权,那么最简单的用户密码验证,就是简单的增删改查。后端接口根据前端发送的用户名,查询数据库用户信息表中是否存在该用户,如果存在,再比对密码,如果用户和密码都合法,则用户成功登录。

代码实现:

async login(entity: { userCode: string, password: string }): Promise<any> {
    let userData = await this.baseUserModel.findOne({
      where: {userCode: entity.userCode}
    });
    if(!userData){
      return {
        success: false,
        msg: '用户不存在',
      }
    }
    let hasAuth = false
    if(userData && userData.userCode.trim()==='test'){
      // 判断游客用户-test登录
      hasAuth =  entity.password===this.commonService.getPassCode()
    }else{
      hasAuth = bcrypt.compareSync(entity.password, userData?.password)
    }

    if (!hasAuth) {
      return {
        success: false,
        msg: '用户名或密码错误',
        data:userData
      }
    }
    return {
      success: true,
      data: userData,
    }
  }

代码解释:

1.this.baseUserModel.findOne:这是typeorm的语法,根据userCode查找用户信息。如果不存在,则返回前端结果,如果存在,则继续。

2.我的项目里存在test特殊账户,同学们查看项目示例中二少年学编程的示例项目就能看到,test账户的密码就是个我自己加盐加密的随机数,没有什么知识点。在同学们的实战项目中,这部分可以省略。

3.bcrypt.compareSync:判断传入的密码和用户信息中保存的密码是否相同。bcrypt是一个加解密的插件,如果我们保存到用户信息表中的密码为123456,那么一旦被人攻破服务器,造成的损失就会非常大,所以在创建用户的时候,一般都会使用加密工具进行加密后,再保存。加密后的密码是一串看不出意义的字符串,只要再用bcrypt比对这个字符串和123456,就能确定它俩是否一致。


总结

项目示例地址:中二少年学编程的示例项目。戳链接,查看示例效果。如果链接失效,请手动输入地址:lizetoolbox.top:8080/#/

本文知识点总结:

1.注册和登录的原理

2.小程序扫码登录实现

3.普通账户密码登录实现

有任何前端项目、demo、教程需求,都可以联系博主,博主会视精力更新,免费的羊毛,不薅白不薅!~