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可以定義偽接口。參照:http://www.rzrgm.cn/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號