nprogress插件的安装和使用,登录业务的代码编写,统一设置token,处理页面访问登录状态和登录过期。pinia-plugin-persistedstate插件的安装和使用。

nprogress插件的安装和使用,登录业务的代码编写,统一设置token,处理页面访问登录状态和登录过期。pinia-plugin-persistedstate插件的安装和使用。

npm install nprogress
npm i --save-dev @types/nprogress

新建services/user.ts mkdir src/services && touch src/services/user.ts

/**
 * 用户相关API & 业务逻辑(service层)
 * - 只负责异步API、数据转换、复杂流程
 * - 类型全部解耦,只用import type { UserInfo } from ...
 */

import http from '@/utils/http'
import type { UserInfo } from '@/store/types/user'

/**
 * 用户登录
 * @param username 用户名
 * @param password 密码
 * @returns 用户信息
 */
export function loginApi(username: string, password: string) {
  return http.post<UserInfo>('/api/login', { username, password })
}

/**
 * 获取当前用户信息
 */
export function fetchUserApi() {
  return http.get<UserInfo>('/api/user')
}

/**
 * 用户登出
 */
export function logoutApi() {
  return http.post<void>('/api/logout')
}

对应的store层代码

/**
 * 用户Pinia Store(只做状态与同步变更)
 * - Google标准:全部类型解耦,最大注释
 * - 只做本地状态管理/变更/展示,异步交由service
 */

import { defineStore } from 'pinia'
import type { UserInfo } from '../types/user'
import { loginApi, fetchUserApi, logoutApi } from '@/services/user'

export const useUserStore = defineStore('user', {
  /**
   * 1. State:本地用户信息
   */
  state: (): UserInfo => ({
    id: '',
    name: '访客',
    email: '',
    avatarUrl: '',
    isLoggedIn: false
  }),

  /**
   * 2. Actions:同步变更+异步业务(推荐复杂业务分离出service层)
   */
  actions: {
    /**
     * 本地登录变更(仅存数据,不调接口)
     * @param info 登录成功后的用户信息
     */
    setLogin(info: Omit<UserInfo, 'isLoggedIn'>) {
      this.id = info.id
      this.name = info.name
      this.email = info.email
      this.avatarUrl = info.avatarUrl ?? ''
      this.isLoggedIn = true
    },

    /**
     * 本地登出变更(重置所有状态)
     */
    setLogout() {
      this.id = ''
      this.name = '访客'
      this.email = ''
      this.avatarUrl = ''
      this.isLoggedIn = false
    },

    /**
     * 登录流程(调API + 本地写入)
     */
    async login(username: string, password: string) {
      const user = await loginApi(username, password)
      this.setLogin(user)
      // 可选:本地存储token等
    },

    /**
     * 拉取当前用户信息并写入本地
     */
    async fetchUser() {
      const user = await fetchUserApi()
      this.setLogin(user)
    },

    /**
     * 登出流程(调API + 本地清理)
     */
    async logout() {
      await logoutApi()
      this.setLogout()
      // 可选:清理token
    }
  },

  /**
   * 3. Getters:只做本地展示/状态派生
   */
  getters: {
    /**
     * 用户名首字母大写
     */
    displayName: (state): string =>
      state.name ? state.name.charAt(0).toUpperCase() + state.name.slice(1) : '访客'
  }
    persist: true // 开启自动持久化,所有state字段自动同步到本地

})

layout层代码

mkdir src/layouts && touch src/layouts/MainLayout.vue && touch src/layouts/AuthLayout.vue

<template>
  <!-- 主应用外壳,包含头部、侧边栏、主内容和全局通知等 -->
  <div class="main-layout">
    <!-- 头部导航栏(可插槽自定义) -->
    <AppHeader />
    <div class="layout-body">
      <!-- 侧边栏(如有权限菜单) -->
      <AppSidebar />
      <!-- 主内容区:用slot注入实际页面内容 -->
      <main class="main-content">
        <slot />
      </main>
    </div>
    <!-- 全局底部 -->
    <AppFooter />
  </div>
</template>

<script setup lang="ts">
// 引用复用组件
import AppHeader from '@/components/AppHeader.vue'
import AppSidebar from '@/components/AppSidebar.vue'
import AppFooter from '@/components/AppFooter.vue'
</script>

<style scoped>
.main-layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  background: #f5f6fa;
}
.layout-body {
  flex: 1;
  display: flex;
  flex-direction: row;
}
.main-content {
  flex: 1;
  padding: 24px;
  background: #fff;
  min-height: 0;
  overflow: auto;
}
</style>

<template>
  <!-- 登录/注册/忘记密码专用外壳,居中简约 -->
  <div class="auth-layout">
    <div class="auth-box">
      <slot />
    </div>
  </div>
</template>

<script setup lang="ts">
/**
 * AuthLayout
 * 用于登录/注册等不需要主导航的简约页面外壳
 */
</script>

<style scoped>
.auth-layout {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: linear-gradient(120deg, #409eff 0%, #f5f6fa 100%);
}
.auth-box {
  width: 350px;
  padding: 32px 24px;
  background: #fff;
  border-radius: 18px;
  box-shadow: 0 2px 16px #409eff33;
}
</style>

对应componets层代码

mkdir src/components && touch src/components/AppHeader.vue && touch src/components/AppSidebar.vue && touch src/components/AppFooter.vue && touch src/components/UserAvatar.vue

<template>
  <header class="app-header">
    <!-- 应用LOGO -->
    <div class="logo">MyApp</div>
    <!-- 右侧用户栏 -->
    <div class="header-actions">
      <UserAvatar />
      <!-- 更多操作按钮 -->
    </div>
  </header>
</template>

<script setup lang="ts">
import UserAvatar from './UserAvatar.vue'
</script>

<style scoped>
.app-header {
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 32px;
  background: #409eff;
  color: #fff;
}
.logo {
  font-size: 22px;
  font-weight: bold;
  letter-spacing: 2px;
}
.header-actions {
  display: flex;
  align-items: center;
  gap: 20px;
}
</style>

<template>
  <aside class="app-sidebar">
    <!-- 侧边菜单栏,可根据权限/路由动态生成 -->
    <nav>
      <ul>
        <li><router-link to="/">首页</router-link></li>
        <li><router-link to="/about">关于</router-link></li>
        <!-- 可扩展更多 -->
      </ul>
    </nav>
  </aside>
</template>

<script setup lang="ts">
/**
 * AppSidebar
 * 侧边菜单,可接入动态权限/路由管理
 */
</script>

<style scoped>
.app-sidebar {
  width: 200px;
  background: #fff;
  border-right: 1px solid #eee;
  padding-top: 18px;
  min-height: 100%;
}
.app-sidebar ul {
  list-style: none;
  padding: 0;
}
.app-sidebar li {
  margin: 12px 0;
}
.app-sidebar a {
  color: #333;
  text-decoration: none;
  padding: 8px 16px;
  display: block;
  border-radius: 8px;
}
.app-sidebar a.router-link-active {
  background: #409eff22;
  color: #409eff;
}
</style>

<template>
  <footer class="app-footer">
    © 2025 MyApp. All rights reserved.
  </footer>
</template>

<script setup lang="ts">
/**
 * AppFooter
 * 全局底部信息栏
 */
</script>

<style scoped>
.app-footer {
  height: 48px;
  text-align: center;
  color: #888;
  background: #fafbfc;
  line-height: 48px;
  font-size: 14px;
}
</style>

<template>
  <div class="user-avatar">
    <img :src="avatarUrl" :alt="userName" />
    <span class="user-name">{{ userName }}</span>
  </div>
</template>

<script setup lang="ts">
import { computed } from 'vue'
import { useUserStore } from '@/store/modules/user'

// 读取当前用户信息
const userStore = useUserStore()
const avatarUrl = computed(() => userStore.avatarUrl || 'https://api.dicebear.com/8.x/pixel-art/svg?seed=user')
const userName = computed(() => userStore.displayName)
</script>

<style scoped>
.user-avatar {
  display: flex;
  align-items: center;
  gap: 8px;
}
.user-avatar img {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  object-fit: cover;
}
.user-name {
  font-size: 15px;
  color: #333;
}
</style>

mkdir src/router && mkdir src/router/modules && touch src/router/index.ts && touch src/router/modules/other.ts && touch src/router/modules/base.ts

/**
 * 路由主入口(router/index.ts)
 * - 自动聚合所有模块路由
 * - 全局守卫:nprogress、鉴权、动态layout等
 */

import { createRouter, createWebHistory } from 'vue-router'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { baseRoutes } from './modules/base'
import { otherRoutes } from './modules/other'
import { useUserStore } from '@/store/modules/user'

// 合并所有路由
const routes = [...baseRoutes, ...otherRoutes]

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

// 全局前置守卫:进度条 + 登录拦截
router.beforeEach((to, _from, next) => {
  NProgress.start()
  const userStore = useUserStore()
  if (to.meta.requiresAuth && !userStore.isLoggedIn) {
    next('/login')
  } else {
    next()
  }
})

router.afterEach(() => {
  NProgress.done()
})

export default router

/**
 * 其它业务模块路由(router/modules/other.ts)
 */

import type { RouteRecordRaw } from 'vue-router'

export const otherRoutes: RouteRecordRaw[] = [
  {
    path: '/about',
    name: 'About',
    component: () => import('@/views/About.vue'),
    meta: { layout: 'main', requiresAuth: true, title: '关于我们' }
  }
]

/**
 * 基础路由模块(router/modules/base.ts)
 * - 管理无需权限的公共页面路由
 */

import type { RouteRecordRaw } from 'vue-router'

export const baseRoutes: RouteRecordRaw[] = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { layout: 'auth', public: true, title: '登录' }
  },
  {
    path: '/',
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { layout: 'main', requiresAuth: true, title: '首页' }
  }
  // 可扩展更多公共页面
]

mkdir src/views && touch src/views/AppHome.vue && touch src/views/AppLogin.vue && touch src/views/AppAbout.vue

<template>
  <MainLayout>
    <h1>欢迎,{{ userStore.displayName }}</h1>
    <p>这是首页内容。</p>
  </MainLayout>
</template>

<script setup lang="ts">
// 引入主layout
import MainLayout from '@/layouts/MainLayout.vue'
import { useUserStore } from '@/store/modules/user'

// 读取用户信息
const userStore = useUserStore()
</script>

<!-- 样式可选 -->

<template>
  <AuthLayout>
    <form class="login-form" @submit.prevent="handleLogin">
      <h2>登录</h2>
      <input v-model="username" placeholder="用户名" />
      <input v-model="password" type="password" placeholder="密码" />
      <button type="submit">登录</button>
      <div class="error" v-if="errorMsg">{{ errorMsg }}</div>
    </form>
  </AuthLayout>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import AuthLayout from '@/layouts/AuthLayout.vue'
import { useUserStore } from '@/store/modules/user'

const userStore = useUserStore()
const username = ref('')
const password = ref('')
const errorMsg = ref('')

const handleLogin = async () => {
  try {
    await userStore.login(username.value, password.value)
    window.location.href = '/'
  } catch  {
    errorMsg.value = '用户名或密码错误'
  }
}
</script>

<style scoped>
.login-form {
  display: flex;
  flex-direction: column;
  gap: 18px;
}
.error {
  color: red;
  margin-top: 8px;
}
</style>

<template>
  <MainLayout>
    <h1>关于我们</h1>
    <p>本项目遵循Google级代码分层规范。</p>
  </MainLayout>
</template>

<script setup lang="ts">
import MainLayout from '@/layouts/MainLayout.vue'
</script>

修改src/main.ts

/**
 * 应用主入口(main.ts)
 * - 注册Pinia、Router、ElementPlus等
 * - 最大化注释,团队友好
 */

import { createApp } from 'vue'
import App from './App.vue'
import router from '@/router'
import { pinia } from '@/store'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'


// 创建vue app
const app = createApp(App)

// 注册全局插件
app.use(pinia)
app.use(router)
app.use(ElementPlus)

// 挂载应用
app.mount('#app')

懒得写后端接口,postman可以定义伪接口。参照:https://www.cnblogs.com/surpassme/p/16489009.html

修改.env

VITE_API_BASE_URL=/
VITE_APP_TITLE=My Vite App

看数据变化的插件,Vue.js devtools

安装pinia-plugin-persistedstate

npm install pinia-plugin-persistedstate

修改pinia单例文件

// 引入并创建Pinia实例,便于主入口集成
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persistedstate'

// 导出Pinia实例
export const pinia = createPinia()
pinia.use(piniaPersist)

token过期,修改https

import axios  from 'axios'
import type { AxiosRequestConfig, AxiosResponse, AxiosError } from 'axios'
import router from '@/router' // 若用SPA跳转,否则可用location.href
import { useUserStore } from '@/store/modules/user'

const http = axios.create({
  baseURL: import.meta.env.VITE_API_BASE_URL,
  timeout: 10000,
  headers: { 'Content-Type': 'application/json' }
})

http.interceptors.request.use(
  config => config,
  error => Promise.reject(error)
)

http.interceptors.response.use(
  (response: AxiosResponse) => {
    if (response.data && typeof response.data === 'object' && 'data' in response.data) {
      return response.data.data
    }
    return response.data
  },
  (error: AxiosError) => {
    // === 统一处理token过期/失效 ===
    // 1. 如果是http层401
    if (error.response && error.response.status === 401) {
      // 2. 清理用户store,自动登出
      const userStore = useUserStore()
      userStore.setLogout?.()
      // 3. 跳转登录页,防止死循环/多次重定向用replace
      router.replace('/login')
      // 或 window.location.href = '/login'
    }
    // 4. 也可以根据后端返回自定义错误码处理
    // if (error.response && error.response.data && error.response.data.code === 'TOKEN_EXPIRED') {
    //   ...
    // }
    return Promise.reject(error)
  })

// 重点是这一行的泛型
const get = <T>(url: string, config?: AxiosRequestConfig): Promise<T> => {
  return http.get<unknown, T>(url, config)
}

const post = <T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> => {
  return http.post<unknown, T>(url, data, config)
}

export default { get, post }
 
posted @ 2025-05-27 13:59  $Traitor$  阅读(56)  评论(0)    收藏  举报