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 }
浙公网安备 33010602011771号