用户登录页面

添加登录接口

// src\api\common.ts
// 公共基础接口封装
import request from '@/utils/request'
import { DemoData, LoginResponse } from '@/api/types/common'

export const demo = () => {
  return request<DemoData>({
    method: 'GET',
    url: '/demo'
  })
}

export const login = (data: {
  account: string
  pwd: string
  imgcode: string
}) => {
  return request<LoginResponse>({
    method: 'POST',
    url: '/login',
    data
  })
}

添加 TS 类型

// src\api\types\common.ts
export interface DemoData {
  title: string
  date: number
}

export interface Menu {
  id: number,
  routePath: string
  routeName: string
  name: string
  icon: string
  children?: Menu[]
}

export interface UserInfo {
  id: number
  account: string
  realName: string
}

export interface LoginResponse {
  token: string
  userInfo: UserInfo
  menus: Menu[]
  uniqueAuth: string[]
}

页面编写

<!-- src\views\login\index.vue -->
<template>
  <div class="login-container">
    <el-form
      ref="formRef"
      class="login-form"
      :rules="rules"
      :model="user"
      size="large"
      @submit.prevent="handleSubmit"
    >
      <div class="login-form__header">
        SHOP ADMIN
      </div>
      <el-form-item prop="account">
        <el-input
          v-model="user.account"
          placeholder="请输入用户名"
          prefix-icon="user"
        />
      </el-form-item>
      <el-form-item prop="pwd">
        <el-input
          v-model="user.pwd"
          type="password"
          placeholder="请输入密码"
          prefix-icon="lock"
        />
      </el-form-item>
      <el-form-item prop="imgcode">
        <div class="imgcode-wrap">
          <el-input
            v-model="user.imgcode"
            placeholder="请输入验证码"
            prefix-icon="key"
          />
          <img
            class="imgcode"
            alt="验证码"
            :src="captchaSrc"
            @click="loadCaptcha"
          >
        </div>
      </el-form-item>
      <el-form-item>
        <el-button
          class="submit-button"
          type="primary"
          :loading="loading"
          native-type="submit"
        >
          登录
        </el-button>
      </el-form-item>
    </el-form>
  </div>
</template>

<script lang="ts" setup>
import type { FormInstance, FormItemRule } from 'element-plus'
import { login } from '@/api/common'

const router = useRouter()
// 获取表单组件实例
const formRef = ref<FormInstance>()
// 表单数据
const user = reactive({
  account: 'admin',
  pwd: '123456',
  imgcode: ''
})
// 表单提交状态
const loading = ref(false)
// 验证码图片地址
const captchaSrc = ref('')
// 表单验证规则
const rules = ref<Record<string, FormItemRule[]>>({
  account: [
    { required: true, message: '请输入账号', trigger: 'change' }
  ],
  pwd: [
    { required: true, message: '请输入密码', trigger: 'change' }
  ],
  imgcode: [
    { required: true, message: '请输入验证码', trigger: 'change' }
  ]
})

// 获取验证码图片地址
const loadCaptcha = () => {
  captchaSrc.value = import.meta.env.VITE_API_BASEURL + '/captcha?' + Date.now()
}

// 表单提交
const handleSubmit = async () => {
  if (!formRef.value) return

  // 表单验证
  const valid = await formRef.value.validate()

  if (!valid) return false

  // 验证通过 展示 loading
  loading.value = true

  // 请求提交
  const data = await login(user).finally(() => {
    loading.value = false
  })
  console.log(data)

  // 处理响应
  router.replace({
    name: 'home'
  })
}

onMounted(() => {
  loadCaptcha()
})

</script>

<style lang="scss" scoped>
.login-container {
  min-width: 400px;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: #2d3a4b;
}

.login-form {
  padding: 30px;
  border-radius: 6px;
  background: #fff;
  min-width: 350px;
  .login-form__header {
    display: flex;
    justify-content: center;
    align-items: center;
    padding-bottom: 30px;
  }

  .el-form-item:last-child {
    margin-bottom: 0;
  }

  .submit-button {
    width: 100%;
  }

  .imgcode-wrap {
    display: flex;
    align-items: center;
    .imgcode {
      width: 130px;
      height: 40px;
      cursor: pointer;
    }
  }
}
</style>

localStorage 模块封装

登录后要将用户信息等数据存储到本地,需要操作 localStorage,将其封装到单独的模块中,统一处理 JSON 转换逻辑:

// src\utils\storage.ts
export const getItem = <T>(key: string) => {
  const data = window.localStorage.getItem(key)
  if (!data) return null
  try {
    return JSON.parse(data) as T
  } catch (error) {
    return null
  }
}

export const setItem = (key: string, value: Object | string | null) => {
  if (typeof value !== 'string') {
    value = JSON.stringify(value)
  }
  window.localStorage.setItem(key, value as string)
}

export const removeItem = (key: string) => {
  window.localStorage.removeItem(key)
}

建议将本地存储的 key 设置为静态常量,防止写错:

// src\utils\constants.ts
/**
 * 静态常量
 */

export const USER = 'USER'

Header 展示用户信息和退出登录

登录用户信息持久化

登录后将用户信息和 token 整合在一起(或者分别)存储到浏览器和 store:

<!-- src\views\login\index.vue -->
// 表单提交
const handleSubmit = async () => {
  ...

  // 请求提交
  const data = await login(user).finally(() => {
    loading.value = false
  })

  store.setUser({
    ...data.userInfo,
    token: data.token
  })

  ...
}
// src\store\index.ts
// import { defineStore } from 'pinia'
import { UserInfo } from '@/api/types/common'
import { getItem, setItem } from '@/utils/storage'
import { USER } from '@/utils/constants'

type User = ({ token: string } & UserInfo) | null

const useStore = defineStore('main', {
  state: () => ({
    count: 0,
    isCollapse: false,
    user: getItem<User>(USER)
  }),
  getters: {
    doubleCount(state) {
      return state.count * 2
    }
  },
  actions: {
    increment() {
      this.count++
    },
    setUser(payload: User) {
      this.user = payload
      setItem(USER, this.user)
    }
  }
})

export default useStore

创建用户信息组件

<!-- src\layout\AppHeader\UserInfo.vue -->
<template>
  <el-dropdown>
    <span class="el-dropdown-link">
      {{ store.user?.realName }}
      <el-icon class="el-icon--right">
        <arrow-down />
      </el-icon>
    </span>
    <template #dropdown>
      <el-dropdown-menu>
        <el-dropdown-item>个人中心</el-dropdown-item>
        <el-dropdown-item @click="handleLogout">
          退出
        </el-dropdown-item>
      </el-dropdown-menu>
    </template>
  </el-dropdown>
</template>

<!-- 由于要使用全局变量 $msgBox 所以使用选项式 API -->
<script lang="ts">
import useStore from '@/store'

export default defineComponent({
  setup() {
    const store = useStore()
    return {
      store
    }
  },
  methods: {
    handleLogout() {
      this.$msgBox.confirm('确认退出吗?', '退出提示', {
        confirmButtonText: '确定',
        cancelButtonText: '取消',
        type: 'warning'
      })
        .then(async () => {
          // 清除用户登录信息
          this.store.setUser(null)

          this.$message.success('退出成功')

          this.$router.push({
            name: 'login'
          })
        })
        .catch(() => {
          this.$message.info('已取消退出')
        })
    }
  }
})
</script>

<style scoped>
.el-dropdown-link {
  cursor: pointer;
  display: flex;
  align-items: center;
}
</style>

当前使用的后端服务并没有存储登录状态,所以退出登录直接删除本地存储的 token 即可。

页面访问校验登录状态

添加路由元信息

给路由添加元信息 requiresAuth 用来标识页面是否需要校验登录状态。

// src\router\index.ts
...

const routes:RouteRecordRaw[] = [
  {
    path: '/',
    component: AppLayout,
    meta: {
      requiresAuth: true
    },
    ...
  },
  ...
]
...

添加 TS 类型:

// src\types\vue-router.d.ts
import 'vue-router'

declare module 'vue-router' {
  // eslint-disable-next-line no-unused-vars
  interface RouteMeta {
    title?: string
    requiresAuth?: boolean
  }
}

设置路由守卫

以前的版本要想获取匹配到的全部路由的 meta 数据,需要遍历匹配的每个路由 $route.matched。Vue Router v4 新提供了一个 $route.meta 对象,它合并了所有的 meta 字段(相同字段取子路由的)。

// src\router\index.ts
...
import useStore from '@/store'

...

router.beforeEach(to => {
  const store = useStore()

  // 校验登录状态
  if (to.meta.requiresAuth && !store.user) {
    return {
      name: 'login',
      query: { redirect: to.fullPath }
    }
  }

  // 开始加载进度条
  nprogress.start()
})

...

修改登录后跳转操作

<!-- src\views\login\index.vue -->
const route = useRoute()

// 表单提交
const handleSubmit = async () => {
  ...

  // 处理响应
  const redirect = route.query.redirect

  if (typeof redirect !== 'string') {
    router.replace({
      name: 'home'
    })
  } else {
    router.replace(redirect)
  }
}

统一处理登录过期

添加 Authorization 请求头

// src\utils\request.ts
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios'
import { ElMessage } from 'element-plus'
import useStore from '@/store'

...

const store = useStore()

// 请求拦截器
request.interceptors.request.use(function (config: AxiosRequestConfig) {
  // 统一设置用户身份 token
  if (store.user && store.user.token) {
    (config.headers as AxiosRequestHeaders).Authorization = `Bearer ${store.user.token}`
  }
  return config
}, function (error) {
  return Promise.reject(error)
})

...

修改响应拦截器

// src\utils\request.ts
import axios, { AxiosRequestConfig, AxiosRequestHeaders } from 'axios'
import { ElMessage, ElMessageBox } from 'element-plus'
import useStore from '@/store'
import router from '@/router'

...

// 控制登录过期的锁(是否正在处理登录过期)
let isRefreshing = false

// 响应拦截器
request.interceptors.response.use(function (response) {
  // 统一处理接口响应错误,如 token 过期无效、服务端异常等
  const status = response.data.status

  /* 正确响应 */
  if (!status || status === 200) {
    return response
  }

  /* 错误响应 */

  // 登录过期处理
  if (status === 401) {
    if (isRefreshing) return Promise.reject(response)

    isRefreshing = true

    ElMessageBox.confirm('登录已过期,您可以取消停留在此页面,或确认重新登录', '登录过期', {
      confirmButtonText: '确认',
      cancelButtonText: '取消'
    })
      .then(() => {
      // 清除本地登录状态
        store.setUser(null)

        // 跳转到登录页
        router.push({
          name: 'login',
          query: {
            redirect: router.currentRoute.value.fullPath
          }
        })
      })
      .catch(() => {})
      .finally(() => {
        isRefreshing = false
      })

    // 抛出异常
    return Promise.reject(response)
  }

  // 其他错误响应
  ElMessage.error(response.data.msg || '请求失败,请稍后重试')
  return Promise.reject(response)
}, function (error) {
  return Promise.reject(error)
})

...
Logo

前往低代码交流专区

更多推荐