news 2026/4/29 8:26:48

Vue3电商项目实战:手把手教你用ElementPlus和Pinia搞定登录页(含Token持久化与失效处理)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Vue3电商项目实战:手把手教你用ElementPlus和Pinia搞定登录页(含Token持久化与失效处理)

Vue3电商项目实战:从零构建企业级登录模块

登录功能作为电商系统的门户,其稳定性和安全性直接影响用户体验。本文将带您从零开始,基于Vue3+ElementPlus+Pinia技术栈,构建一个包含完整闭环流程的企业级登录模块。不同于基础教程,我们将重点探讨工程化实践中的关键问题解决方案。

1. 项目初始化与环境配置

在开始构建登录模块前,需要确保开发环境准备就绪。推荐使用Vite作为构建工具,它能提供极快的冷启动和热更新速度。

npm create vite@latest vue3-ecommerce --template vue cd vue3-ecommerce npm install element-plus pinia pinia-plugin-persistedstate axios

项目结构建议采用模块化组织方式:

src/ ├── apis/ # API接口封装 ├── assets/ # 静态资源 ├── components/ # 公共组件 ├── router/ # 路由配置 ├── stores/ # Pinia状态管理 ├── utils/ # 工具函数 └── views/ # 页面组件

对于ElementPlus的引入,推荐按需导入以优化打包体积。在main.js中配置:

import { createApp } from 'vue' import { createPinia } from 'pinia' import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' import App from './App.vue' import router from './router' // ElementPlus按需导入 import { ElButton, ElForm, ElInput } from 'element-plus' import 'element-plus/dist/index.css' const app = createApp(App) const pinia = createPinia() pinia.use(piniaPluginPersistedstate) app.use(pinia) app.use(router) app.use(ElButton).use(ElForm).use(ElInput) app.mount('#app')

2. 登录页面架构设计

现代电商登录页需要兼顾美观与功能。我们采用经典的左右布局:左侧展示品牌形象,右侧放置登录表单。

2.1 页面基础结构

<template> <div class="login-container"> <div class="brand-section"> <img src="@/assets/logo.png" alt="品牌Logo"> <h2>欢迎来到我们的电商平台</h2> <p>新用户?<router-link to="/register">立即注册</router-link></p> </div> <div class="form-section"> <el-form :model="form" :rules="rules" ref="formRef"> <!-- 表单内容将在下一节完善 --> </el-form> <div class="third-party-login"> <p>快速登录</p> <div class="oauth-buttons"> <el-button><i class="icon-wechat"></i>微信</el-button> <el-button><i class="icon-qq"></i>QQ</el-button> </div> </div> </div> </div> </template> <style scoped> .login-container { display: flex; height: 100vh; background: #f5f7fa; } .brand-section { flex: 1; display: flex; flex-direction: column; justify-content: center; align-items: center; background: linear-gradient(135deg, #1e88e5, #0d47a1); color: white; } .form-section { width: 400px; padding: 40px; background: white; display: flex; flex-direction: column; justify-content: center; } </style>

2.2 响应式适配

为确保在不同设备上都有良好体验,需要添加响应式处理:

@media (max-width: 768px) { .login-container { flex-direction: column; } .brand-section { padding: 20px; text-align: center; } .form-section { width: 100%; padding: 20px; } }

3. 表单验证与交互优化

登录表单的验证是保障系统安全的第一道防线。我们将实现多层次的验证策略。

3.1 基础表单验证

<script setup> import { ref } from 'vue' const form = ref({ account: '', password: '', remember: false }) const rules = { account: [ { required: true, message: '请输入用户名', trigger: 'blur' }, { validator: (rule, value, callback) => { if (!/^[a-zA-Z0-9_-]{4,16}$/.test(value)) { callback(new Error('用户名应为4-16位字母数字组合')) } else { callback() } }, trigger: 'blur' } ], password: [ { required: true, message: '请输入密码', trigger: 'blur' }, { min: 8, max: 20, message: '密码长度应为8-20位', trigger: 'blur' }, { validator: (rule, value, callback) => { if (!/(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/.test(value)) { callback(new Error('密码需包含大小写字母和数字')) } else { callback() } }, trigger: 'blur' } ] } </script> <template> <el-form-item prop="account" label="用户名"> <el-input v-model="form.account" placeholder="请输入用户名" prefix-icon="User" /> </el-form-item> <el-form-item prop="password" label="密码"> <el-input v-model="form.password" type="password" placeholder="请输入密码" prefix-icon="Lock" show-password /> </el-form-item> <el-form-item> <el-checkbox v-model="form.remember">记住我</el-checkbox> <router-link to="/forgot-password" class="forgot-password"> 忘记密码? </router-link> </el-form-item> </template>

3.2 高级验证技巧

防抖处理:防止频繁触发验证

import { debounce } from 'lodash-es' const validateAccount = debounce((rule, value, callback) => { // 验证逻辑 }, 500)

密码强度实时显示

<template> <el-form-item prop="password" label="密码"> <el-input ... /> <div class="password-strength"> <div :class="['strength-bar', strengthClass]" :style="{ width: strength + '%' }" ></div> <span>{{ strengthText }}</span> </div> </el-form-item> </template> <script setup> import { computed } from 'vue' const strength = computed(() => { let score = 0 const password = form.value.password if (!password) return 0 // 长度加分 if (password.length > 10) score += 25 else if (password.length > 6) score += 10 // 复杂度加分 if (/[a-z]/.test(password)) score += 10 if (/[A-Z]/.test(password)) score += 10 if (/\d/.test(password)) score += 10 if (/[^a-zA-Z0-9]/.test(password)) score += 15 // 重复字符减分 if (/(.)\1/.test(password)) score -= 10 return Math.min(100, Math.max(0, score)) }) const strengthClass = computed(() => { if (strength.value < 30) return 'weak' if (strength.value < 70) return 'medium' return 'strong' }) const strengthText = computed(() => { if (strength.value < 30) return '弱' if (strength.value < 70) return '中' return '强' }) </script> <style> .password-strength { margin-top: 8px; height: 6px; background: #eee; border-radius: 3px; position: relative; } .strength-bar { height: 100%; border-radius: 3px; transition: width 0.3s, background 0.3s; } .weak { background: #ff4d4f; } .medium { background: #faad14; } .strong { background: #52c41a; } </style>

4. 状态管理与持久化方案

4.1 Pinia状态设计

创建用户状态管理store:

// stores/user.js import { defineStore } from 'pinia' import { ref } from 'vue' import { loginAPI, getUserInfoAPI } from '@/apis/user' export const useUserStore = defineStore('user', () => { const token = ref('') const userInfo = ref({}) const permissions = ref([]) // 登录动作 const login = async (credentials) => { try { const res = await loginAPI(credentials) token.value = res.token await fetchUserInfo() return res } catch (error) { token.value = '' throw error } } // 获取用户信息 const fetchUserInfo = async () => { if (!token.value) return userInfo.value = await getUserInfoAPI() } // 退出登录 const logout = () => { token.value = '' userInfo.value = {} permissions.value = [] } return { token, userInfo, permissions, login, logout, fetchUserInfo } }, { persist: { key: 'ecommerce-user', paths: ['token'], storage: localStorage, beforeRestore: (ctx) => { console.log('即将恢复状态', ctx) }, afterRestore: (ctx) => { console.log('状态恢复完成', ctx) } } })

4.2 持久化高级配置

对于敏感信息,建议进行加密存储:

import CryptoJS from 'crypto-js' const SECRET_KEY = 'your-secret-key' export const useUserStore = defineStore('user', () => { // ...其他代码 }, { persist: { serializer: { deserialize: (value) => { const bytes = CryptoJS.AES.decrypt(value, SECRET_KEY) return JSON.parse(bytes.toString(CryptoJS.enc.Utf8)) }, serialize: (value) => { return CryptoJS.AES.encrypt( JSON.stringify(value), SECRET_KEY ).toString() } } } })

4.3 登录状态集成

在路由守卫中检查登录状态:

// router/index.js import { createRouter } from 'vue-router' import { useUserStore } from '@/stores/user' const router = createRouter({ // ...路由配置 }) router.beforeEach(async (to) => { const userStore = useUserStore() if (to.meta.requiresAuth && !userStore.token) { return { path: '/login', query: { redirect: to.fullPath } } } // 如果已登录但用户信息未加载,则加载用户信息 if (userStore.token && !userStore.userInfo.id) { try { await userStore.fetchUserInfo() } catch (error) { userStore.logout() return { path: '/login' } } } })

5. 请求拦截与Token处理

5.1 Axios实例封装

// utils/http.js import axios from 'axios' import { useUserStore } from '@/stores/user' import router from '@/router' const http = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, headers: { 'Content-Type': 'application/json' } }) // 请求拦截器 http.interceptors.request.use(config => { const userStore = useUserStore() if (userStore.token) { config.headers.Authorization = `Bearer ${userStore.token}` } // 特殊接口处理 if (config.url.includes('/upload')) { config.headers['Content-Type'] = 'multipart/form-data' } return config }, error => { return Promise.reject(error) }) // 响应拦截器 http.interceptors.response.use(response => { // 处理二进制响应 if (response.config.responseType === 'blob') { return response.data } // 常规JSON响应 const { code, data, message } = response.data if (code === 200) { return data } return Promise.reject(new Error(message || '请求失败')) }, error => { if (!error.response) { return Promise.reject(new Error('网络错误,请检查网络连接')) } const { status, data } = error.response const userStore = useUserStore() switch (status) { case 401: userStore.logout() router.push({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) break case 403: router.push('/403') break case 500: router.push('/500') break } return Promise.reject(new Error(data.message || '请求出错')) }) export default http

5.2 Token刷新机制

处理Token过期自动刷新:

// utils/http.js let isRefreshing = false let requests = [] http.interceptors.response.use(response => { return response }, async error => { const { config, response } = error const userStore = useUserStore() if (response.status === 401 && !config.url.includes('/refresh')) { if (!isRefreshing) { isRefreshing = true try { const newToken = await refreshToken() userStore.token = newToken requests.forEach(cb => cb(newToken)) requests = [] return http(config) } catch (e) { userStore.logout() router.push('/login') return Promise.reject(e) } finally { isRefreshing = false } } else { return new Promise(resolve => { requests.push(token => { config.headers.Authorization = `Bearer ${token}` resolve(http(config)) }) }) } } return Promise.reject(error) }) async function refreshToken() { const { data } = await http.post('/auth/refresh') return data.token }

6. 安全防护与异常处理

6.1 常见攻击防护

CSRF防护

// 在http.js的请求拦截器中添加 const getCSRFToken = () => { const cookieValue = document.cookie.match(/X-CSRF-TOKEN=([^;]+)/) return cookieValue ? cookieValue[1] : '' } http.interceptors.request.use(config => { config.headers['X-CSRF-TOKEN'] = getCSRFToken() return config })

XSS防护

<template> <div v-html="sanitizedContent"></div> </template> <script setup> import DOMPurify from 'dompurify' const props = defineProps({ content: String }) const sanitizedContent = computed(() => { return DOMPurify.sanitize(props.content) }) </script>

6.2 登录限流策略

// stores/user.js const loginAttempts = ref(0) const lastAttemptTime = ref(0) const login = async (credentials) => { const now = Date.now() // 检查尝试次数 if (loginAttempts.value >= 5 && now - lastAttemptTime.value < 30 * 60 * 1000) { throw new Error('尝试次数过多,请30分钟后再试') } try { const res = await loginAPI(credentials) // 重置计数器 loginAttempts.value = 0 lastAttemptTime.value = 0 return res } catch (error) { loginAttempts.value++ lastAttemptTime.value = now throw error } }

6.3 错误边界处理

创建全局错误处理组件:

<!-- components/ErrorBoundary.vue --> <template> <slot v-if="!hasError"></slot> <div v-else class="error-boundary"> <h3>抱歉,出了点问题</h3> <p>{{ errorMessage }}</p> <el-button @click="handleRetry">重试</el-button> </div> </template> <script setup> import { ref, onErrorCaptured } from 'vue' const hasError = ref(false) const errorMessage = ref('') onErrorCaptured((err) => { hasError.value = true errorMessage.value = err.message return false }) const handleRetry = () => { hasError.value = false errorMessage.value = '' } </script>

7. 性能优化与用户体验

7.1 登录流程优化

预加载关键资源

// 在登录页面挂载时预加载首页资源 onMounted(() => { if (isAuthenticated.value) { import('@/views/Home.vue') import('@/views/Cart.vue') } })

骨架屏加载效果

<template> <div v-if="loading" class="skeleton-loader"> <div class="skeleton-header"></div> <div class="skeleton-form"> <div class="skeleton-input"></div> <div class="skeleton-input"></div> <div class="skeleton-button"></div> </div> </div> <el-form v-else> <!-- 实际表单内容 --> </el-form> </template> <style> .skeleton-loader { padding: 20px; } .skeleton-header { height: 40px; background: #f0f2f5; margin-bottom: 30px; border-radius: 4px; } .skeleton-input { height: 40px; background: #f0f2f5; margin-bottom: 20px; border-radius: 4px; } .skeleton-button { height: 40px; background: #f0f2f5; border-radius: 4px; } </style>

7.2 缓存策略

路由组件缓存

<template> <router-view v-slot="{ Component }"> <keep-alive :include="cachedViews"> <component :is="Component" /> </keep-alive> </router-view> </template> <script setup> import { ref, watch } from 'vue' import { useRoute } from 'vue-router' const cachedViews = ref([]) const route = useRoute() watch(route, (to) => { if (to.meta.keepAlive && !cachedViews.value.includes(to.name)) { cachedViews.value.push(to.name) } }, { immediate: true }) </script>

API请求缓存

// utils/http.js const cache = new Map() http.interceptors.request.use(config => { if (config.cacheKey && cache.has(config.cacheKey)) { return Promise.resolve(cache.get(config.cacheKey)) } return config }) http.interceptors.response.use(response => { if (response.config.cacheKey) { cache.set(response.config.cacheKey, response.data) } return response.data })

8. 测试与调试技巧

8.1 单元测试示例

// tests/unit/login.spec.js import { mount } from '@vue/test-utils' import Login from '@/views/Login.vue' import { createPinia } from 'pinia' import { useUserStore } from '@/stores/user' describe('Login.vue', () => { let wrapper let userStore beforeEach(() => { const pinia = createPinia() wrapper = mount(Login, { global: { plugins: [pinia] } }) userStore = useUserStore() }) it('验证表单校验规则', async () => { const form = wrapper.vm.form const accountInput = wrapper.find('input[type="text"]') const passwordInput = wrapper.find('input[type="password"]') // 测试空值验证 await accountInput.setValue('') await wrapper.find('form').trigger('submit') expect(wrapper.text()).toContain('请输入用户名') // 测试用户名格式验证 await accountInput.setValue('a') expect(wrapper.text()).toContain('用户名应为4-16位字母数字组合') // 测试密码强度验证 await passwordInput.setValue('123') expect(wrapper.text()).toContain('密码长度应为8-20位') }) it('测试登录成功流程', async () => { // Mock登录API userStore.login = jest.fn().mockResolvedValue({ success: true }) // 填写正确表单 await wrapper.find('input[type="text"]').setValue('validuser') await wrapper.find('input[type="password"]').setValue('ValidPass123') await wrapper.find('form').trigger('submit') expect(userStore.login).toHaveBeenCalledWith({ account: 'validuser', password: 'ValidPass123' }) }) })

8.2 E2E测试配置

// tests/e2e/login.spec.js describe('登录流程', () => { it('成功登录', () => { cy.visit('/login') cy.get('input[type="text"]').type('testuser') cy.get('input[type="password"]').type('Test1234') cy.get('form').submit() cy.url().should('include', '/dashboard') cy.getCookie('token').should('exist') }) it('失败登录显示错误信息', () => { cy.intercept('POST', '/api/login', { statusCode: 401, body: { message: '用户名或密码错误' } }) cy.visit('/login') cy.get('input[type="text"]').type('wronguser') cy.get('input[type="password"]').type('wrongpass') cy.get('form').submit() cy.contains('用户名或密码错误').should('be.visible') }) })

8.3 性能监控

集成Sentry进行错误监控:

// main.js import * as Sentry from '@sentry/vue' import { BrowserTracing } from '@sentry/tracing' if (import.meta.env.PROD) { Sentry.init({ app, dsn: 'your-dsn', integrations: [ new BrowserTracing({ routingInstrumentation: Sentry.vueRouterInstrumentation(router), tracingOrigins: ['your-domain.com'] }) ], tracesSampleRate: 0.2 }) }

9. 国际化与多主题支持

9.1 多语言实现

// locales/index.js import { createI18n } from 'vue-i18n' const messages = { en: { login: { title: 'Login', username: 'Username', password: 'Password', remember: 'Remember me', forgot: 'Forgot password?', submit: 'Sign In' } }, zh: { login: { title: '登录', username: '用户名', password: '密码', remember: '记住我', forgot: '忘记密码?', submit: '登录' } } } const i18n = createI18n({ locale: localStorage.getItem('locale') || 'zh', fallbackLocale: 'en', messages }) export default i18n

9.2 动态主题切换

<template> <el-config-provider :locale="currentLocale"> <router-view /> </el-config-provider> </template> <script setup> import { computed } from 'vue' import { useI18n } from 'vue-i18n' import zhCn from 'element-plus/lib/locale/lang/zh-cn' import en from 'element-plus/lib/locale/lang/en' const { locale } = useI18n() const currentLocale = computed(() => { return locale.value === 'zh' ? zhCn : en }) </script>

10. 部署与持续集成

10.1 Docker部署配置

# Dockerfile FROM node:16 as builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html COPY nginx.conf /etc/nginx/conf.d/default.conf EXPOSE 80 CMD ["nginx", "-g", "daemon off;"]

10.2 Nginx配置优化

# nginx.conf server { listen 80; server_name your-domain.com; gzip on; gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; # 缓存静态资源 location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 1y; add_header Cache-Control "public, no-transform"; } } # API代理 location /api { proxy_pass http://backend:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

10.3 CI/CD配置示例

# .github/workflows/deploy.yml name: Deploy on: push: branches: [ main ] jobs: build-and-deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Node uses: actions/setup-node@v2 with: node-version: '16' - name: Install dependencies run: npm install - name: Run tests run: npm test - name: Build run: npm run build - name: Deploy to server uses: appleboy/ssh-action@master with: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} script: | cd /var/www/ecommerce git pull origin main npm install --production pm2 restart ecommerce
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/29 8:26:04

关于linux命令相关的沉淀

我们知道linux是一个操作系统。 安装了centos7和centos8 通过shell&#xff0c;链接到linux操纵系统 本文章的核心就是&#xff0c;对linux的操作领域和命令做一个集锦。 我们有一个centos7操作系统了。 应该关注哪些领域的事情&#xff0c;了解哪些命令。 这里基于我自己的理解…

作者头像 李华
网站建设 2026/4/29 8:25:02

怎么把Markdown转成PPT?三种实用方法,技术人值得收藏

很多技术人习惯用Markdown写文档、记笔记&#xff0c;但遇到汇报场景时往往需要转成PPT。手动复制粘贴调整格式&#xff0c;动辄一两个小时。本文分享三种方法&#xff0c;以7牛AI PPT为例&#xff0c;借助AI工具快速完成转换&#xff0c;实测最慢30分钟搞定。一、为什么Markdo…

作者头像 李华
网站建设 2026/4/29 8:21:24

如何快速实现网盘不限速下载:LinkSwift完整使用指南

如何快速实现网盘不限速下载&#xff1a;LinkSwift完整使用指南 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 &#xff0c;支持 百度网盘 / 阿里云盘 / 中国移动云盘 / 天翼云…

作者头像 李华
网站建设 2026/4/29 8:21:22

GLM-4v-9b图文理解实战:电商主图缺陷检测+文案优化建议生成

GLM-4v-9b图文理解实战&#xff1a;电商主图缺陷检测文案优化建议生成 1. 引言&#xff1a;当AI质检员遇上电商美工 如果你是电商运营、美工或者产品经理&#xff0c;下面这些场景你一定不陌生&#xff1a; 商品主图上传前&#xff0c;需要反复检查有没有瑕疵、水印、背景杂…

作者头像 李华
网站建设 2026/4/29 8:19:26

百元键盘怎么选?这三款闭眼入不踩雷

百元价位的“真香”时刻 曾几何时&#xff0c;提到百元以内的键盘&#xff0c;大家脑海里浮现的往往是手感生涩、按键松垮甚至用几天就失灵的“电子垃圾”。但这两年&#xff0c;国产外设供应链的成熟彻底改变了这一局面。尤其是国产轴体的崛起&#xff0c;让机械键盘的成本大幅…

作者头像 李华