基于vue3的极简登录架构设计

68 阅读4分钟

1. axios封装:request.ts

作用:统一处理请求,自动携带token,401跳转

// src/api/request.ts
import axios from 'axios'

// 创建axios实例
const request = axios.create({
  baseURL: '/api',
  timeout: 10000
})

// 请求拦截器:自动添加token
request.interceptors.request.use(
  (config) => {
    const token = localStorage.getItem('token')
    if (token) {
      config.headers.Authorization = `Bearer ${token}`
    }
    return config
  },
  (error) => Promise.reject(error)
)

// 响应拦截器:统一处理错误
request.interceptors.response.use(
  (response) => response.data,
  (error) => {
    if (error.response?.status === 401) {
      // token过期,清除token
      localStorage.removeItem('token')
      // 跳转到登录页
      window.location.href = '/login'
    }
    return Promise.reject(error)
  }
)

export default request

2. 接口定义:auth.ts

作用:定义所有登录相关的接口

// src/api/auth.ts
import request from './request'

// 登录接口
export const loginApi = (username: string, password: string) => {
  return request.post('/login', {
    username,
    password
  })
}

// 获取用户信息接口
export const getUserInfoApi = () => {
  return request.get('/user-info')
}

3. Store:auth.ts

作用:管理登录状态,token存localStorage,用户信息只存内存,刷新页面重新调接口获取最新用户信息

// src/store/auth.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { loginApi, getUserInfoApi } from '@/api/auth'

export const useAuthStore = defineStore('auth', () => {
  // token需要持久化,因为请求要用
  const token = ref(localStorage.getItem('token') || '')
  
  // 用户信息只存在内存里,刷新就没了
  const userInfo = ref<any>(null)
  
  // 计算属性
  const isLoggedIn = computed(() => !!token.value)
  const username = computed(() => userInfo.value?.username || '')
  const nickname = computed(() => userInfo.value?.nickname || '')
  
  // 登录
  const login = async (user: string, pwd: string, remember: boolean) => {
    try {
      // 1. 调用登录接口
      const res = await loginApi(user, pwd) as any
      
      // 2. 保存token(必须存,不然刷新就丢了)
      token.value = res.token
      localStorage.setItem('token', res.token)
      
      // 3. 获取用户信息(存内存)
      await getUserInfo()
      
      // 4. 如果记住用户名,保存用户名(不是密码)
      if (remember) {
        localStorage.setItem('savedUsername', user)
      } else {
        localStorage.removeItem('savedUsername')
      }
      
      return true
    } catch (error) {
      console.error('登录失败', error)
      return false
    }
  }
  
  // 获取用户信息(每次刷新页面都要重新获取)
  const getUserInfo = async () => {
    // 如果没有token,不获取
    if (!token.value) {
      return null
    }
    
    try {
      const res = await getUserInfoApi() as any
      userInfo.value = res
      return res
    } catch (error) {
      console.error('获取用户信息失败', error)
      // 如果获取失败(比如token过期),清除登录状态
      logout()
      throw error
    }
  }
  
  // 退出登录
  const logout = () => {
    token.value = ''
    userInfo.value = null
    localStorage.removeItem('token')
    // 注意:记住的用户名不清除,方便下次登录
  }
  
  return {
    token,
    userInfo,
    isLoggedIn,
    username,
    nickname,
    login,
    logout,
    getUserInfo
  }
})

4. 路由:index.ts

作用:控制页面访问权限,刷新页面时重新获取用户信息

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/store/auth'

const routes = [
  {
    path: '/login',
    name: 'login',
    component: () => import('@/views/Login.vue')
  },
  {
    path: '/',
    name: 'home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: true }
  }
]

const router = createRouter({
  history: createWebHistory(),
  routes
})

router.beforeEach(async (to, from, next) => {
  const authStore = useAuthStore()
  
  // 如果已登录且要去登录页,先退出再过去
  if (to.path === '/login' && authStore.isLoggedIn) {
    authStore.logout()
    next()
    return
  }
  
  // 如果需要登录
  if (to.meta.requiresAuth) {
    // 如果有token
    if (authStore.isLoggedIn) {
      // 如果没有用户信息,说明是刷新页面,需要重新获取
      if (!authStore.userInfo) {
        try {
          await authStore.getUserInfo()
          next()
        } catch (error) {
          // 获取用户信息失败(token过期等),已经自动退出
          next('/login')
        }
      } else {
        // 有用户信息,直接放行
        next()
      }
    } else {
      // 没token,去登录
      next('/login')
    }
  } else {
    next()
  }
})

export default router

5. 登录页:Login.vue

作用:用户输入账号密码

<!-- src/views/Login.vue -->
<template>
  <div class="login">
    <h2>用户登录</h2>
    
    <div class="form">
      <div class="field">
        <label>用户名:</label>
        <input 
          v-model="form.username" 
          type="text"
          placeholder="请输入用户名"
        />
      </div>
      
      <div class="field">
        <label>密码:</label>
        <input 
          v-model="form.password" 
          type="password"
          placeholder="请输入密码"
        />
      </div>
      
      <div class="field">
        <label>
          <input type="checkbox" v-model="form.remember" />
          记住用户名
        </label>
      </div>
      
      <div v-if="error" class="error">{{ error }}</div>
      
      <button 
        @click="handleLogin"
        :disabled="!form.username || !form.password || loading"
      >
        {{ loading ? '登录中...' : '登录' }}
      </button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'

const router = useRouter()
const authStore = useAuthStore()

const form = reactive({
  username: '',
  password: '',
  remember: false
})

const loading = ref(false)
const error = ref('')

// 页面加载时,如果有保存的用户名就自动填充
onMounted(() => {
  const savedUsername = localStorage.getItem('savedUsername')
  if (savedUsername) {
    form.username = savedUsername
    form.remember = true
  }
})

const handleLogin = async () => {
  if (!form.username || !form.password) {
    error.value = '请输入用户名和密码'
    return
  }
  
  loading.value = true
  error.value = ''
  
  try {
    const success = await authStore.login(
      form.username, 
      form.password, 
      form.remember
    )
    
    if (success) {
      router.push('/')
    } else {
      error.value = '用户名或密码错误'
    }
  } catch (e: any) {
    error.value = e.response?.data?.message || '登录失败,请稍后重试'
  } finally {
    loading.value = false
  }
}
</script>

<style scoped>
.login {
  max-width: 400px;
  margin: 100px auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 8px;
}
.field {
  margin-bottom: 15px;
}
.field label {
  display: block;
  margin-bottom: 5px;
}
.field input[type="text"],
.field input[type="password"] {
  width: 100%;
  padding: 8px;
  border: 1px solid #ddd;
  border-radius: 4px;
  box-sizing: border-box;
}
.error {
  color: red;
  margin-bottom: 10px;
}
button {
  width: 100%;
  padding: 10px;
  background: #1890ff;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
button:disabled {
  background: #ccc;
  cursor: not-allowed;
}
</style>

6. 首页:Home.vue

作用:展示用户信息,处理加载状态

<!-- src/views/Home.vue -->
<template>
  <div class="home">
    <h1>首页</h1>
    
    <!-- 加载中状态 -->
    <div v-if="loading" class="loading">
      加载用户信息中...
    </div>
    
    <!-- 展示用户信息 -->
    <div v-else-if="authStore.userInfo" class="user-info">
      <h2>用户信息</h2>
      <p><strong>用户名:</strong> {{ authStore.userInfo.username }}</p>
      <p><strong>昵称:</strong> {{ authStore.userInfo.nickname || '未设置' }}</p>
      <p><strong>邮箱:</strong> {{ authStore.userInfo.email || '未设置' }}</p>
      <p><strong>手机:</strong> {{ authStore.userInfo.phone || '未设置' }}</p>
      <p><strong>角色:</strong> {{ authStore.userInfo.roles?.join(', ') || '普通用户' }}</p>
    </div>
    
    <button @click="handleLogout" class="logout-btn">
      退出登录
    </button>
  </div>
</template>

<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/store/auth'

const router = useRouter()
const authStore = useAuthStore()
const loading = ref(false)

// 页面加载时确保用户信息是最新的
onMounted(async () => {
  // 如果store里没有用户信息(比如刷新页面),就获取一下
  if (!authStore.userInfo) {
    loading.value = true
    try {
      await authStore.getUserInfo()
    } catch (error) {
      console.error('获取用户信息失败', error)
    } finally {
      loading.value = false
    }
  }
})

const handleLogout = () => {
  authStore.logout()
  router.push('/login')
}
</script>

<style scoped>
.home {
  max-width: 600px;
  margin: 50px auto;
  padding: 20px;
}
.user-info {
  background: #f5f5f5;
  padding: 20px;
  border-radius: 8px;
  margin: 20px 0;
}
.user-info p {
  margin: 10px 0;
}
.loading {
  color: #999;
  text-align: center;
  padding: 20px;
}
.logout-btn {
  padding: 10px 20px;
  background: #ff4d4f;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
}
</style>

7. 其他必需文件

// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'

const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')
<!-- src/App.vue -->
<template>
  <router-view />
</template>
// src/vite-env.d.ts
/// <reference types="vite/client" />

declare module '*.vue' {
  import type { DefineComponent } from 'vue'
  const component: DefineComponent<{}, {}, any>
  export default component
}