登录注册功能-明文

前端

文件目录介绍

📁 根目录配置文件

文件 作用
index.html 应用的入口 HTML 文件,Vite 会基于它构建
package.json 项目依赖、脚本命令、项目信息
package-lock.json 锁定依赖版本
vite.config.js Vite 构建工具配置文件
jsconfig.json 让 VSCode 等编辑器支持路径别名、智能提示
.gitignore Git 忽略文件配置
README.md 项目说明文档

📁 源码目录 src/

main.js

  • 应用的入口 JS 文件
  • 创建 Vue 应用实例
  • 注册 Pinia、Vue Router、全局组件等

App.vue

  • 根组件,通常包含 <router-view> 用于显示不同页面

📁 src/api/

  • user.js -- 用户相关的 API 请求(如登录、注册、获取用户信息)

📁 src/assets/

  • 静态资源:图片、字体、全局样式等(会被 Vite 处理)

📁 src/components/

  • 可复用的 Vue 组件(非页面级别)

📁 src/router/

  • index.js -- Vue Router 配置 ,定义路由规则(如 /login → Login 组件)

📁 src/stores/

  • Pinia 状态管理
  • counter.js -- 示例/通用计数器 store
  • user.js -- 用户信息、登录状态的 store(如 token、用户名)

📁 src/views/

  • 页面级组件:
    • Login.vue -- 登录页
    • Register.vue -- 注册页
    • Home.vueHomeView.vue -- 可能有一个是首页(可合并或区分权限/展示)
    • AboutView.vue -- 关于页面

📁 其他目录

目录 作用
public/ 完全静态资源(不被 Vite 处理),如 favicon
.idea/ JetBrains IDE(WebStorm/IDEA)项目配置
.vscode/ VSCode 编辑器配置
node_modules/ 所有依赖包(library root)

package.json

javascript 复制代码
{
  "name": "login01",
  "version": "0.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "@element-plus/icons-vue": "^2.3.2",
    "axios": "^1.15.2",
    "element-plus": "^2.13.7",
    "pinia": "^3.0.4",
    "vue": "^3.5.32",
    "vue-router": "^5.0.4"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^6.0.6",
    "vite": "^8.0.8",
    "vite-plugin-vue-devtools": "^8.1.1"
  },
  "engines": {
    "node": "^20.19.0 || >=22.12.0"
  }
}

src/api/user.js

javascript 复制代码
// 导入 axios 库,用于发送 HTTP 请求
import axios from 'axios'

/**
 * 创建 axios 实例
 * 好处:可以配置统一的 baseURL、超时时间等,避免每个请求都重复配置
 */
const request = axios.create({
    baseURL: 'http://localhost:8080/api',  // 所有请求的基础路径,实际请求路径 = baseURL + 具体接口路径
    timeout: 5000                          // 请求超时时间(毫秒),超过5秒没有响应则报错
})

// ==================== 请求拦截器 ====================
// 作用:在发送请求之前,对请求配置做一些处理
request.interceptors.request.use(
    // 第一个参数:成功处理函数,在请求发送前执行
    config => {
        // 从 localStorage(浏览器本地存储)中获取 token
        // token 通常在用户登录成功后由后端返回,并存储在这里
        const token = localStorage.getItem('token')

        // 如果 token 存在,则添加到请求头的 Authorization 字段中
        if (token) {
            // Bearer 是常见的认证方式前缀,表示这是一个 Bearer Token
            // 后端收到后会验证这个 token 的有效性
            config.headers['Authorization'] = `Bearer ${token}`
        }

        // 必须返回 config,否则请求无法继续发送
        return config
    },
    // 第二个参数:错误处理函数,如果请求配置过程出错则执行
    error => {
        // 将错误传递下去,中断请求
        return Promise.reject(error)
    }
)

// ==================== 响应拦截器 ====================
// 作用:在接收到后端响应之后,对响应数据做统一处理
request.interceptors.response.use(
    // 第一个参数:成功处理函数,状态码为 2xx 时执行
    response => {
        // 直接返回 response.data(后端返回的实际数据)
        // 通常后端返回格式如:{ code: 200, data: {...}, message: '成功' }
        // 这么做的好处:调用接口的地方不需要再写 .then(res => res.data),代码更简洁
        return response.data
    },
    // 第二个参数:错误处理函数,状态码不是 2xx 时执行(如 401、404、500 等)
    error => {
        // 在控制台打印错误信息,方便调试
        // 实际项目中可以添加更完善的错误处理,比如:
        // - 401 未授权:跳转到登录页
        // - 500 服务器错误:提示用户稍后重试
        console.error('请求错误:', error)

        // 将错误继续抛出,让调用方也能捕获并处理
        return Promise.reject(error)
    }
)

// ==================== 导出用户相关的 API 方法 ====================
export const userApi = {
    /**
     * 用户注册
     * @param {Object} data - 注册信息,通常包含 username, password, email 等
     * @returns {Promise} 返回 Promise 对象,可通过 .then/.catch 处理结果
     */
    register(data) {
        // POST 请求,路径为 /user/register
        // 完整请求路径:http://localhost:8080/api/user/register
        return request.post('/user/register', data)
    },

    /**
     * 用户登录
     * @param {Object} data - 登录信息,通常包含 username, password
     * @returns {Promise} 返回 Promise 对象,登录成功后后端通常会返回 token
     */
    login(data) {
        // POST 请求,路径为 /user/login
        // 完整请求路径:http://localhost:8080/api/user/login
        return request.post('/user/login', data)
    }
}

src/route/index.js

javascript 复制代码
// 导入 Vue Router 的核心方法
// createRouter: 创建路由实例
// createWebHistory: 使用 HTML5 历史模式(URL 看起来像正常路径,没有 # 号)
import { createRouter, createWebHistory } from 'vue-router'

// 导入用户状态管理 store,用于获取登录状态
import { useUserStore } from '@/stores/user'

/**
 * 路由配置列表
 * 每个路由包含:访问路径、对应组件、元信息等
 */
const routes = [
  {
    path: '/',                    // 根路径
    redirect: '/home'            // 重定向:访问根路径时自动跳转到 /home
  },
  {
    path: '/login',              // 登录页路径
    name: 'Login',               // 路由名称(可用于编程式导航,如 router.push({ name: 'Login' }))
    component: () => import('@/views/Login.vue'),  // 懒加载:只有访问时才加载该组件
    meta: { requiresAuth: false }  // 元信息:该页面不需要登录即可访问
  },
  {
    path: '/register',           // 注册页路径
    name: 'Register',
    component: () => import('@/views/Register.vue'),
    meta: { requiresAuth: false }  // 不需要登录
  },
  {
    path: '/home',               // 首页路径
    name: 'Home',
    component: () => import('@/views/Home.vue'),
    meta: { requiresAuth: true }   // 需要登录才能访问
  }
]

// 创建路由实例
const router = createRouter({
  history: createWebHistory(),   // 使用 HTML5 历史模式(URL 没有 #)
  routes                         // 注册上面定义的路由配置
})

// ==================== 全局前置路由守卫 ====================
// 作用:在每次路由跳转前执行,用于权限控制、登录校验等
// 参数说明:
//   to   - 目标路由(即将要进入的路由)
//   from - 来源路由(即将离开的路由)
//   next - 跳转控制函数
router.beforeEach((to, from, next) => {
  // 获取用户 store 实例
  const userStore = useUserStore()
  // 获取用户是否已登录(从 store 中的 isLoggedIn 计算属性或状态读取)
  const isLoggedIn = userStore.isLoggedIn

  // 场景1:目标路由需要登录验证,但用户未登录
  if (to.meta.requiresAuth && !isLoggedIn) {
    // 跳转到登录页
    next('/login')
  }
  // 场景2:用户已登录,但试图访问登录页或注册页(不应该让已登录用户再次登录)
  else if ((to.path === '/login' || to.path === '/register') && isLoggedIn) {
    // 跳转到首页
    next('/home')
  }
  // 场景3:其他所有正常情况
  else {
    // 允许正常跳转
    next()
  }
})

// 导出路由实例,供 main.js 注册使用
export default router

src/stores/user.js

javascript 复制代码
// 从 Pinia 中导入 defineStore 方法,用于定义状态存储
import { defineStore } from 'pinia'

/**
 * 用户状态管理 Store
 * 
 * defineStore 参数说明:
 * 第一个参数:'user' - Store 的唯一标识符(ID),在 Vue DevTools 中显示
 * 第二个参数:配置对象,包含 state、getters、actions
 */
export const useUserStore = defineStore('user', {
    /**
     * state: 定义状态(数据)
     * 使用箭头函数返回一个对象,确保每个组件使用该 Store 时都有独立的状态(服务端渲染时也安全)
     */
    state: () => ({
        /**
         * token:用户认证令牌
         * 从 localStorage 中读取已有的 token,如果不存在则为 null
         * localStorage.setItem('token', xxx) 存储后,页面刷新不会丢失
         */
        token: localStorage.getItem('token') || null,
        
        /**
         * userInfo:用户信息对象
         * 从 localStorage 中读取存储的用户信息(JSON 字符串),
         * 如果不存在或解析失败则为 null
         * 
         * ⚠️ 注意:JSON.parse('null') 会返回 null,所以用了 || 'null' 作为默认值
         * 更好的写法:localStorage.getItem('userInfo') ? JSON.parse(localStorage.getItem('userInfo')) : null
         */
        userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null')
    }),

    /**
     * getters: 计算属性(类似于 Vue 组件中的 computed)
     * 用于从 state 派生出新的数据,并且会缓存结果
     * 第一个参数是 state 对象
     */
    getters: {
        /**
         * isLoggedIn:判断用户是否已登录
         * !!state.token 是双重取反,将 token 值转换为布尔值
         * - 如果 token 有值(非空字符串):!!'dummy-token' → true
         * - 如果 token 是 null 或 '':!!null → false
         * 
         * 在组件中使用:userStore.isLoggedIn
         */
        isLoggedIn: (state) => !!state.token
        
        // 可以添加更多 getter,比如:
        // userName: (state) => state.userInfo?.username || '游客',
        // userAvatar: (state) => state.userInfo?.avatar || '/default-avatar.png'
    },

    /**
     * actions: 定义方法,用于修改 state 或执行异步操作
     * 类似于 Vue 组件中的 methods
     * 在 actions 中可以使用 this 直接访问 state 和其他 actions
     */
    actions: {
        /**
         * setUserInfo:设置用户信息并自动生成 token
         * @param {Object} userData - 用户数据对象,包含 username、email、avatar 等字段
         * 
         * 使用场景:用户登录成功或注册成功时调用
         */
        setUserInfo(userData) {
            // 存储用户信息(this 指向当前 store 实例)
            this.userInfo = userData
            
            // 生成一个模拟的 token
            // 实际项目中 token 应该由后端返回,这里只是演示
            // 格式:'dummy-token-' + 时间戳,例如:'dummy-token-1699999999999'
            this.token = 'dummy-token-' + Date.now()
            
            // 将 token 持久化到 localStorage(关闭浏览器/刷新页面后依然存在)
            localStorage.setItem('token', this.token)
            
            // 将用户信息转换为 JSON 字符串并存储到 localStorage
            localStorage.setItem('userInfo', JSON.stringify(userData))
        },

        /**
         * logout:用户退出登录
         * 清除所有用户相关的状态和存储
         * 
         * 使用场景:用户点击退出按钮、Token 过期时调用
         */
        logout() {
            // 清空 store 中的状态
            this.userInfo = null
            this.token = null
            
            // 从 localStorage 中删除存储的数据
            localStorage.removeItem('token')       // 删除 token
            localStorage.removeItem('userInfo')    // 删除用户信息
        }
    }
})

/**
 * 使用说明 - 在 Vue 组件中的使用示例:
 * 
 * ```vue
 * <script setup>
 * import { useUserStore } from '@/stores/user'
 * import { storeToRefs } from 'pinia'
 * 
 * // 获取 store 实例
 * const userStore = useUserStore()
 * 
 * // ⭐ 重要:为了保持响应式,需要解构 getters/state 时使用 storeToRefs
 * // 如果直接解构 const { isLoggedIn, userInfo } = userStore 会丢失响应式
 * const { isLoggedIn, userInfo, token } = storeToRefs(userStore)
 * 
 * // actions 可以直接解构(它们本身就是函数,没有响应式问题)
 * const { setUserInfo, logout } = userStore
 * 
 * // 登录成功后的处理
 * const handleLoginSuccess = (userData) => {
 *   setUserInfo(userData)
 *   // 此时已自动存储到 localStorage,并更新了登录状态
 * }
 * 
 * // 退出登录
 * const handleLogout = () => {
 *   logout()
 *   // 路由跳转等操作...
 * }
 * </script>
 * 
 * <template>
 *   <div>
 *     <div v-if="isLoggedIn">
 *       欢迎回来,{{ userInfo?.username }}
 *       <button @click="handleLogout">退出</button>
 *     </div>
 *     <div v-else>
 *       <router-link to="/login">请登录</router-link>
 *     </div>
 *   </div>
 * </template>
 * ```
 * 
 * ⚠️ 注意事项:
 * 1. 实际生产项目中,token 应该由后端登录接口返回,而不是前端生成
 * 2. 需要配合 API 拦截器使用(在请求头中自动添加 token)
 * 3. token 过期时需要自动调用 logout() 并跳转到登录页
 * 4. 建议在路由守卫中配合 isLoggedIn 进行权限控制
 */

src/views/Register.vue

javascript 复制代码
<template>
  <!-- 注册页面容器,用于居中和背景渐变 -->
  <div class="register-container">
    <!-- Element Plus 卡片组件,作为表单容器 -->
    <el-card class="register-card">
      <!-- 卡片头部插槽,显示标题 -->
      <template #header>
        <h2>用户注册</h2>
      </template>

      <!-- Element Plus 表单组件 -->
      <!-- ref="registerFormRef" - 用于获取表单实例,调用验证方法 -->
      <!-- :model="registerForm" - 绑定表单数据对象 -->
      <!-- :rules="registerRules" - 绑定验证规则 -->
      <!-- label-width="80px" - 标签宽度,使表单对齐更美观 -->
      <el-form
          ref="registerFormRef"
          :model="registerForm"
          :rules="registerRules"
          label-width="80px"
      >
        <!-- 用户名表单项 -->
        <!-- 双向绑定到表单数据 -->
        <!-- 输入提示 -->
        <!-- 显示清空按钮 -->
        <el-form-item label="用户名" prop="username">
          <el-input
              v-model="registerForm.username"
          placeholder="请输入用户名(3-20个字符)"
          clearable
          />
        </el-form-item>

        <!-- 邮箱表单项 -->
        <el-form-item label="邮箱" prop="email">
          <el-input
              v-model="registerForm.email"
              placeholder="请输入邮箱"
              clearable
          />
        </el-form-item>

        <!-- 密码表单项 -->
        <!-- 密码输入类型 -->
        <!-- 显示密码切换按钮(眼睛图标) -->
        <el-form-item label="密码" prop="password">
          <el-input
              v-model="registerForm.password"
              type="password"
          placeholder="请输入密码(6-20个字符)"
          show-password
          />
        </el-form-item>

        <!-- 确认密码表单项 -->
        <el-form-item label="确认密码" prop="confirmPassword">
          <el-input
              v-model="registerForm.confirmPassword"
              type="password"
              placeholder="请再次输入密码"
              show-password
          />
        </el-form-item>

        <!-- 按钮表单项,无 label(通过 label-width 空出位置) -->
        <el-form-item>
          <!-- 注册按钮,loading 时禁用并显示加载动画 -->
          <el-button type="primary" @click="handleRegister" :loading="loading">
            注册
          </el-button>
          <!-- 返回登录按钮 -->
          <el-button @click="goToLogin">返回登录</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup>
// ==================== 导入依赖 ====================
import { ref, reactive } from 'vue'        // Vue 响应式 API
import { useRouter } from 'vue-router'     // Vue Router 路由实例
import { ElMessage } from 'element-plus'   // Element Plus 消息提示组件
import { userApi } from '@/api/user'       // 用户 API 接口

// ==================== 响应式数据 ====================
// 获取路由实例,用于页面跳转
const router = useRouter()

// 表单组件引用(用于调用表单的验证方法)
const registerFormRef = ref()

// 注册按钮的加载状态(防止重复提交)
const loading = ref(false)

/**
 * 注册表单数据对象
 * 使用 reactive 使其所有属性都具有响应性
 */
const registerForm = reactive({
  username: '',           // 用户名
  email: '',              // 邮箱
  password: '',           // 密码
  confirmPassword: ''     // 确认密码
})

// ==================== 表单验证规则 ====================
/**
 * 自定义验证函数:确认密码验证
 * @param {Object} rule - 当前验证规则对象
 * @param {any} value - 当前字段的值
 * @param {Function} callback - 验证完成后的回调函数
 *   - 验证通过:callback()
 *   - 验证失败:callback(new Error('错误信息'))
 */
const validateConfirmPassword = (rule, value, callback) => {
  // 检查确认密码是否与密码字段一致
  if (value !== registerForm.password) {
    callback(new Error('两次输入密码不一致'))
  } else {
    callback()  // 验证通过
  }
}

/**
 * 表单验证规则配置
 * 规则说明:
 * - required: 是否必填
 * - message: 验证失败时的提示信息
 * - trigger: 触发时机(blur: 失去焦点, change: 内容改变)
 * - min/max: 最小/最大长度
 * - pattern: 正则表达式验证
 * - type: 内置类型验证(如 email)
 * - validator: 自定义验证函数
 */
const registerRules = {
  // 用户名验证规则
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 3, max: 20, message: '长度在3到20个字符', trigger: 'blur' },
    { pattern: /^[a-zA-Z0-9_]+$/, message: '只能包含字母、数字和下划线', trigger: 'blur' }
  ],
  // 邮箱验证规则
  email: [
    { required: true, message: '请输入邮箱', trigger: 'blur' },
    { type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
  ],
  // 密码验证规则
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '长度在6到20个字符', trigger: 'blur' }
  ],
  // 确认密码验证规则(使用自定义验证器)
  confirmPassword: [
    { required: true, message: '请确认密码', trigger: 'blur' },
    { validator: validateConfirmPassword, trigger: 'blur' }
  ]
}

// ==================== 事件处理函数 ====================
/**
 * 处理注册提交
 * 流程:
 * 1. 验证表单必填项和格式
 * 2. 验证通过后调用注册 API
 * 3. 根据响应结果提示用户并跳转
 */
const handleRegister = async () => {
  // 确保表单实例存在
  if (!registerFormRef.value) return

  // 调用 Element Plus 表单的验证方法
  // 参数 valid: true=验证通过, false=验证失败
  await registerFormRef.value.validate(async (valid) => {
    if (valid) {
      // 开启加载状态,禁用按钮防止重复提交
      loading.value = true

      try {
        // 解构赋值:将 confirmPassword 从表单数据中分离出去
        // 因为 API 不需要 confirmPassword 字段
        const { confirmPassword, ...registerData } = registerForm

        // 调用注册 API 接口
        const res = await userApi.register(registerData)

        // 根据返回的 code 判断注册结果
        if (res.code === 200) {
          // 注册成功提示
          ElMessage.success('注册成功,请登录')
          // 跳转到登录页
          router.push('/login')
        } else {
          // 注册失败,显示后端返回的错误信息
          ElMessage.error(res.message)
        }
      } catch (error) {
        // 网络错误或其他异常
        console.error('注册错误:', error)
        ElMessage.error('注册失败,请稍后重试')
      } finally {
        // 无论成功或失败,都要关闭加载状态
        loading.value = false
      }
    }
  })
}

/**
 * 返回登录页
 * 不保存当前填写的数据
 */
const goToLogin = () => {
  router.push('/login')
}
</script>

<style scoped>
/* scoped: 样式只在当前组件生效,不会影响其他组件 */

/* 注册页面容器样式 */
.register-container {
  display: flex;           /* 使用 Flexbox 布局 */
  justify-content: center; /* 水平居中 */
  align-items: center;     /* 垂直居中 */
  min-height: 100vh;       /* 最小高度为视口高度 */
  /* 渐变背景:135度角,从紫色到紫罗兰色 */
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

/* 注册卡片样式 */
.register-card {
  width: 500px;            /* 固定宽度 500px */
  /* 卡片会自动有圆角和阴影效果(Element Plus 默认样式) */
}
</style>

<!--
  ==================== 组件使用流程 ====================

  1. 用户打开注册页面
  2. 填写用户名、邮箱、密码、确认密码
  3. 点击注册按钮
  4. 触发表单验证:
     - 检查是否为空
     - 检查长度是否符合要求
     - 检查用户名格式(字母数字下划线)
     - 检查邮箱格式
     - 检查两次密码是否一致
  5. 验证失败:在对应字段下方显示错误提示
  6. 验证成功:发送 POST 请求到后端 /api/user/register
  7. 等待后端响应:
     - 成功:提示"注册成功",跳转到登录页
     - 失败:显示后端返回的错误信息
  8. 注册期间按钮显示加载动画,防止重复提交
-->

<!--
  ==================== 改进建议 ====================

  1. 添加表单重置功能:
     const resetForm = () => {
       registerFormRef.value?.resetFields()
     }

  2. 添加回车键提交:
     <el-form @submit.prevent="handleRegister">

  3. 添加更强的密码验证(如必须包含数字和字母):
     { pattern: /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{6,20}$/,
       message: '密码必须包含字母和数字',
       trigger: 'blur' }

  4. 添加防抖处理(避免快速连续点击):
     import { debounce } from 'lodash-es'
     const handleRegisterDebounced = debounce(handleRegister, 300)
-->

src/views/Login.vue

javascript 复制代码
<template>
  <!-- 登录页面容器,用于居中和背景渐变 -->
  <div class="login-container">
    <!-- Element Plus 卡片组件,作为表单容器 -->
    <el-card class="login-card">
      <!-- 卡片头部插槽,显示标题 -->
      <template #header>
        <h2>用户登录</h2>
      </template>

      <!-- Element Plus 表单组件 -->
      <!-- ref="loginFormRef" - 用于获取表单实例,调用验证方法 -->
      <!-- :model="loginForm" - 绑定表单数据对象 -->
      <!-- :rules="loginRules" - 绑定验证规则 -->
      <!-- label-width="80px" - 标签宽度,使表单对齐更美观 -->
      <el-form
          ref="loginFormRef"
          :model="loginForm"
          :rules="loginRules"
          label-width="80px"
      >
        <!-- 用户名字段 -->
        <!-- 双向绑定到表单数据 -->
        <!-- 输入提示文字 -->
        <!-- 显示清空按钮 -->
        <el-form-item label="用户名" prop="username">
          <el-input
              v-model="loginForm.username"
          placeholder="请输入用户名"
          clearable
          />
        </el-form-item>

        <!-- 密码字段 -->
        <!-- 密码输入类型(隐藏输入内容) -->
        <!-- 显示密码切换按钮(眼睛图标) -->
        <el-form-item label="密码" prop="password">
          <el-input
              v-model="loginForm.password"
              type="password"
          placeholder="请输入密码"
          show-password
          />
        </el-form-item>

        <!-- 按钮区域 -->
        <el-form-item>
          <!-- 登录按钮:type="primary" 为蓝色主题,:loading 显示加载状态 -->
          <el-button type="primary" @click="handleLogin" :loading="loading">
            登录
          </el-button>
          <!-- 注册账号按钮:点击跳转到注册页 -->
          <el-button @click="goToRegister">注册账号</el-button>
        </el-form-item>
      </el-form>
    </el-card>
  </div>
</template>

<script setup>
// ==================== 导入依赖 ====================
import { ref, reactive } from 'vue'          // Vue 响应式 API
import { useRouter } from 'vue-router'       // Vue Router 路由实例,用于页面跳转
import { ElMessage } from 'element-plus'     // Element Plus 消息提示组件
import { useUserStore } from '@/stores/user' // 用户状态管理 Store(Pinia)
import { userApi } from '@/api/user'         // 用户 API 接口

// ==================== 响应式数据 ====================
// 获取路由实例
const router = useRouter()

// 获取用户 store 实例(用于存储登录后的用户信息和 token)
const userStore = useUserStore()

// 表单组件引用(用于调用表单的验证方法)
const loginFormRef = ref()

// 登录按钮的加载状态(防止重复提交,提交时按钮显示 loading 动画)
const loading = ref(false)

/**
 * 登录表单数据对象
 * 使用 reactive 使其所有属性都具有响应性
 */
const loginForm = reactive({
  username: '',    // 用户名
  password: ''     // 密码
})

// ==================== 表单验证规则 ====================
/**
 * 登录表单验证规则配置
 *
 * 规则说明:
 * - required: 是否必填,true 表示必须填写
 * - message: 验证失败时显示的错误提示信息
 * - trigger: 触发验证的时机
 *   - blur: 失去焦点时触发验证
 *   - change: 内容改变时触发验证
 * - min/max: 最小/最大长度限制
 */
const loginRules = {
  // 用户名验证规则
  username: [
    { required: true, message: '请输入用户名', trigger: 'blur' },
    { min: 3, max: 20, message: '长度在3到20个字符', trigger: 'blur' }
  ],
  // 密码验证规则
  password: [
    { required: true, message: '请输入密码', trigger: 'blur' },
    { min: 6, max: 20, message: '长度在6到20个字符', trigger: 'blur' }
  ]
}

// ==================== 事件处理函数 ====================
/**
 * 处理登录提交
 *
 * 执行流程:
 * 1. 验证表单必填项和格式是否正确
 * 2. 验证通过后调用登录 API
 * 3. 根据响应结果处理:
 *    - 成功:保存用户信息到 store,提示成功,跳转到首页
 *    - 失败:显示错误信息
 */
const handleLogin = async () => {
  // 确保表单实例存在(DOM 已渲染)
  if (!loginFormRef.value) return

  // 调用 Element Plus 表单的验证方法
  // validate 方法会检查所有字段是否符合规则
  // 参数 valid: true=验证通过, false=验证失败
  await loginFormRef.value.validate(async (valid) => {
    // 只有所有字段验证通过才执行登录逻辑
    if (valid) {
      // 开启加载状态:按钮显示加载动画,并自动禁用点击
      loading.value = true

      try {
        // 调用登录 API 接口
        // 发送 POST 请求到后端,携带用户名和密码
        const res = await userApi.login(loginForm)

        // 根据返回的 code 判断登录结果
        // 通常约定:200 表示成功,其他表示失败
        if (res.code === 200) {
          // ========== 登录成功 ==========
          // 将用户信息保存到 Pinia store 中
          // setUserInfo 会自动:
          // 1. 存储用户信息到 store
          // 2. 生成/存储 token 到 store
          // 3. 持久化到 localStorage(刷新页面不会丢失)
          userStore.setUserInfo(res.data)

          // 显示成功提示(3秒后自动消失)
          ElMessage.success('登录成功')

          // 跳转到首页
          // router.push 会触发路由守卫,验证登录状态
          router.push('/home')
        } else {
          // ========== 登录失败 ==========
          // 显示后端返回的错误信息
          // 例如:"用户名或密码错误"、"账号已被禁用"等
          ElMessage.error(res.message)
        }
      } catch (error) {
        // ========== 请求异常处理 ==========
        // 网络错误、服务器无响应、超时等异常情况
        console.error('登录错误:', error)
        ElMessage.error('登录失败,请稍后重试')
      } finally {
        // ========== 关闭加载状态 ==========
        // finally 确保无论成功或失败,都会关闭加载状态
        loading.value = false
      }
    }
  })
}

/**
 * 跳转到注册页面
 * 点击"注册账号"按钮时调用
 */
const goToRegister = () => {
  router.push('/register')
}
</script>

<style scoped>
/* scoped: 样式只在当前组件生效,不会泄漏到其他组件 */

/* 登录页面容器样式 */
.login-container {
  display: flex;           /* 使用 Flexbox 弹性布局 */
  justify-content: center; /* 水平方向居中对齐 */
  align-items: center;     /* 垂直方向居中对齐 */
  height: 100vh;           /* 视口高度(全屏) */
  /* 渐变背景:135度角,从 #667eea 到 #764ba2 */
  background: linear-gradient(135deg, #cbcfdf 0%, #764ba2 100%);
}

/* 登录卡片样式 */
.login-card {
  width: 450px;            /* 固定宽度 450px,比注册卡稍窄(500px) */
  /* 卡片圆角和阴影由 Element Plus 默认样式提供 */
}
</style>

<!--
  ==================== 完整登录流程 ====================

  1. 用户打开登录页面
  2. 输入用户名和密码
  3. 点击"登录"按钮
  4. 触发表单验证:
     - 用户名不能为空,长度 3-20 字符
     - 密码不能为空,长度 6-20 字符
  5. 验证失败:在对应字段下方显示红色错误提示
  6. 验证成功:调用后端 API
  7. 后端验证凭证:
     - 成功:返回用户信息和 token
     - 失败:返回错误信息
  8. 处理响应:
     - 成功:存储用户信息 → 显示成功提示 → 跳转到首页
     - 失败:显示错误提示
  9. 页面跳转时触发路由守卫,确保后续页面访问权限
-->

<!--
  ==================== 与其他文件的协作关系 ====================

  1. **用户 Store (stores/user.js)**
     - setUserInfo() 保存用户信息到 Pinia
     - 同时存储到 localStorage,刷新页面不丢失
     - getters.isLoggedIn 会变成 true

  2. **API 层 (api/user.js)**
     - 发送 POST 请求到 /user/login
     - 请求拦截器自动添加 token(但登录时还没有 token)
     - 响应拦截器直接返回 response.data

  3. **路由守卫 (router/index.js)**
     - 登录成功后跳转到 /home
     - beforeEach 检查 requiresAuth: true
     - 因为 isLoggedIn 为 true,允许访问

  4. **后续请求**
     - 访问其他需要认证的接口时
     - 请求拦截器会自动从 localStorage 读取 token
     - 添加到请求头 Authorization: Bearer xxx
-->

<!--
  ==================== 改进建议 ====================

  1. **记住密码功能**
     ```js
     const rememberMe = ref(false)
     // 保存时存储到 localStorage
     // 页面加载时读取并自动填充
     -->

src/views/Home.vue

javascript 复制代码
<template>
  <div class="home-container">
    <!-- Element Plus 布局容器 -->
    <el-container>
      <!-- 头部区域 -->
      <el-header>
        <div class="header-content">
          <!-- 页面标题 -->
          <h2>欢迎页面</h2>
          <!-- 退出登录按钮,危险主题(红色) -->
          <el-button type="danger" @click="handleLogout">退出登录</el-button>
        </div>
      </el-header>

      <!-- 主要内容区域 -->
      <el-main>
        <!-- 卡片组件,用于展示用户信息 -->
        <el-card>
          <h3>用户信息</h3>
          <!-- Element Plus 描述列表组件 -->
          <!-- :column="1" 表示单列布局 -->
          <!-- border 添加边框样式 -->
          <el-descriptions :column="1" border>
            <!-- 描述项:用户ID -->
            <el-descriptions-item label="用户ID">
              <!-- 可选链操作符 ?. 防止 userInfo 为 null 时报错 -->
              {{ userInfo?.id }}
            </el-descriptions-item>
            <!-- 描述项:用户名 -->
            <el-descriptions-item label="用户名">
              {{ userInfo?.username }}
            </el-descriptions-item>
            <!-- 描述项:邮箱(未设置时显示占位文字) -->
            <el-descriptions-item label="邮箱">
              {{ userInfo?.email || '未设置' }}
            </el-descriptions-item>
          </el-descriptions>
        </el-card>
      </el-main>
    </el-container>
  </div>
</template>

<script setup>
// ==================== 导入依赖 ====================
import { computed } from 'vue'              // Vue 计算属性 API
import { useRouter } from 'vue-router'     // Vue Router 路由实例
import { ElMessageBox } from 'element-plus' // Element Plus 消息弹窗组件
import { useUserStore } from '@/stores/user' // 用户状态管理 Store

// ==================== 实例化 ====================
// 获取路由实例,用于页面跳转
const router = useRouter()

// 获取用户 store 实例
const userStore = useUserStore()

/**
 * 用户信息(计算属性)
 * computed 的作用:
 * 1. 响应式依赖追踪:当 userStore.userInfo 变化时,自动更新视图
 * 2. 缓存结果:只有依赖变化时才重新计算
 *
 * 为什么用 computed 而不是直接使用 userStore.userInfo?
 * - 直接使用也可以,但 computed 能确保响应式更新更明确
 * - 在模板中使用 userInfo 比 userStore.userInfo 更简洁
 */
const userInfo = computed(() => userStore.userInfo)

// ==================== 事件处理函数 ====================
/**
 * 处理退出登录
 *
 * 执行流程:
 * 1. 弹出确认对话框,询问用户是否确定退出
 * 2. 用户点击"确定":清除用户信息,跳转到登录页
 * 3. 用户点击"取消"或关闭对话框:不做任何操作
 *
 * async/await 说明:
 * - ElMessageBox.confirm() 返回一个 Promise
 * - 用户点击确定时 Promise 变为 resolved(成功)
 * - 用户点击取消或关闭时 Promise 变为 rejected(失败)
 */
const handleLogout = async () => {
  try {
    // 弹出确认对话框
    // 参数1:提示内容
    // 参数2:对话框标题
    // 参数3:配置选项
    await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
      confirmButtonText: '确定',     // 确认按钮文字
      cancelButtonText: '取消',      // 取消按钮文字
      type: 'warning'               // 警告类型(黄色感叹号图标)
    })

    // ========== 用户点击"确定" ==========
    // 调用 store 的 logout 方法清除用户信息
    // logout 会执行:
    // - 清空 store 中的 token 和 userInfo
    // - 从 localStorage 中移除 token 和 userInfo
    userStore.logout()

    // 跳转到登录页
    // 由于路由守卫会检测登录状态,跳转后会显示登录页
    router.push('/login')
  } catch (error) {
    // ========== 用户点击"取消"或关闭对话框 ==========
    // 不做任何操作,保持当前页面
    // 注意:这里不需要显示任何提示,因为用户主动取消
  }
}
</script>

<style scoped>
/* ==================== 页面样式 ==================== */

/* 首页容器:占据整个视口高度 */
.home-container {
  height: 100vh;  /* 100% 视口高度,填满整个屏幕 */
}

/* 头部区域样式 */
.el-header {
  background-color: #409EFF;  /* Element Plus 主题蓝色 */
  color: white;                /* 文字颜色白色 */
  display: flex;               /* 使用 Flexbox 布局 */
  align-items: center;         /* 垂直居中对齐子元素 */
  padding: 0 20px;             /* 左右内边距 20px,上下为 0 */
}

/* 头部内容容器(用于分布标题和按钮) */
.header-content {
  width: 100%;                 /* 占满整个头部宽度 */
  display: flex;               /* 使用 Flexbox 布局 */
  justify-content: space-between; /* 两端对齐:标题在左,按钮在右 */
  align-items: center;         /* 垂直居中对齐 */
}

/* 主要内容区域样式 */
.el-main {
  padding: 20px;               /* 内边距 20px */
  background-color: #f0f2f5;  /* 浅灰色背景,与白色卡片形成对比 */
}
</style>

<!--
  ==================== 页面功能说明 ====================

  这是一个需要认证后才能访问的页面(requiresAuth: true)

  主要功能:
  1. 展示当前登录用户的详细信息(ID、用户名、邮箱)
  2. 提供退出登录功能
  3. 页面头部有蓝色背景,标题在左,退出按钮在右

  访问控制:
  - 未登录用户访问此页面会被路由守卫拦截
  - 拦截后会跳转到 /login 登录页
-->

<!--
  ==================== 完整退出流程 ====================

  1. 用户点击"退出登录"按钮
  2. 弹出确认对话框:"确定要退出登录吗?"
  3. 用户选择:

     【点击"确定"】
     ↓
     调用 userStore.logout()
     ├── 清空 store 中的 token(token = null)
     ├── 清空 store 中的 userInfo(userInfo = null)
     ├── localStorage.removeItem('token')
     └── localStorage.removeItem('userInfo')
     ↓
     路由跳转到 /login
     ↓
     路由守卫检查:
     - /login 的 requiresAuth: false,允许访问
     - 显示登录页面

     【点击"取消"】
     ↓
     不做任何操作
     ↓
     停留在当前首页
-->

<!--
  ==================== 组件协作关系 ====================

  1. **与 userStore 的协作**
     - userInfo: 从 store 中读取用户信息并展示
     - logout(): 调用 store 的退出方法清除状态

  2. **与 router 的协作**
     - router.push('/login'): 退出后跳转到登录页

  3. **与路由守卫的协作**
     - 路由守卫检查 meta.requiresAuth: true
     - 退出后 isLoggedIn 变为 false
     - 下次访问 /home 会被重定向到 /login

  4. **与 API 请求拦截器的协作**
     - 退出后 token 被清除
     - 后续 API 请求不再携带 Authorization 头
     - 后端接口会返回 401 未授权错误
-->

<!--
  ==================== 可选链操作符详解 ====================

  userInfo?.id 等价于:

  userInfo && userInfo.id !== undefined && userInfo.id !== null
    ? userInfo.id
    : undefined

  作用:如果 userInfo 是 null 或 undefined,不继续访问 .id 属性,直接返回 undefined
  好处:避免报错 "Cannot read property 'id' of null"

  示例:
  const userInfo = null
  userInfo?.id        // 返回 undefined,不会报错
  userInfo.id         // 报错:Cannot read property 'id' of null
-->

<!--
  ==================== 改进建议 ====================

  1. **添加用户头像展示**
     ```vue
     <el-avatar :src="userInfo?.avatar" /> -->

App.vue

javascript 复制代码
<template>
  <!-- 
    路由视图占位符
    作用:Vue Router 的核心组件,用于显示当前路由匹配的页面组件
    
    工作原理:
    1. 根据当前 URL 路径(如 '/home', '/login')
    2. 在 router/index.js 中查找匹配的路由配置
    3. 将对应的组件渲染在 <router-view /> 所在位置
    
    示例:
    - 访问 /login → <router-view /> 被替换为 <Login />
    - 访问 /home  → <router-view /> 被替换为 <Home />
    - 访问 /register → <router-view /> 被替换为 <Register />
  -->
  <router-view />
</template>

<script setup>
// 此组件不需要任何逻辑代码
// 仅作为应用的根组件,管理路由视图的切换
// <script setup> 是 Vue 3 的组合式 API 语法糖,更简洁
</script>

<style>
/* ==================== 全局样式 ==================== */
/* 注意:这里没有 scoped 属性,样式会应用到整个应用的所有组件 */

/* 通配符选择器 - 重置所有元素的内外边距 */
* {
  margin: 0;           /* 清除所有元素的外边距 */
  padding: 0;          /* 清除所有元素的内边距 */
  box-sizing: border-box; /* 盒模型:元素的宽高包含 border 和 padding,不向外扩展 */
}

/*
  box-sizing: border-box 详解:
  
  默认情况(content-box):
  width = 内容宽度
  实际占宽 = width + padding + border
  
  使用 border-box:
  width = 内容宽度 + padding + border
  实际占宽 = width(更符合直觉)
  
  例如:设置 width: 100px, padding: 10px, border: 1px
  - content-box:实际占 122px,容易打乱布局
  - border-box:实际占 100px,方便布局计算
*/

/* 全局 body 字体样式 */
body {
  /* 字体优先级:从前往后,前面的字体不存在则使用后面的 */
  font-family: 'Helvetica Neue',     /* macOS/iOS 默认字体(西文) */
               Helvetica,            /* macOS 经典字体 */
               'PingFang SC',        /* macOS/iOS 中文默认字体(苹方) */
               'Hiragino Sans GB',   /* macOS 备用中文字体(冬青黑体) */
               'Microsoft YaHei',    /* Windows 中文字体(微软雅黑) */
               '微软雅黑',           /* 同上,中文写法 */
               Arial,                /* 通用西文字体 */
               sans-serif;           /* 系统默认无衬线字体(兜底) */
  /* 
    字体优先级说明:
    - 不同操作系统会按顺序查找可用的字体
    - macOS 用户会看到苹方字体(更美观)
    - Windows 用户会看到微软雅黑字体
    - 都没有则使用系统默认的无衬线字体
  */
}
</style>

<!-- 
  ==================== App.vue 的作用 ====================
  
  App.vue 是整个 Vue 应用的根组件,类似于所有页面的"外壳"
  
  主要职责:
  1. 提供全局布局结构(如侧边栏、顶部导航等)
  2. 提供 <router-view /> 作为路由出口
  3. 定义全局样式(重置样式、字体等)
  4. 挂载全局组件(如消息提示、对话框等)
  
  当前项目的 App.vue 结构:
  ┌─────────────────────────────────────┐
  │                                     │
  │          <router-view />            │
  │     (当前路由对应的页面组件)        │
  │                                     │
  │     例如:/login 时显示 Login.vue    │
  │           /home 时显示 Home.vue     │
  │                                     │
  └─────────────────────────────────────┘
-->

<!-- 
  ==================== 组件渲染流程 ====================
  
  1. main.js 创建 Vue 应用实例
  2. 将 App.vue 作为根组件挂载到 #app 元素
  3. Vue Router 根据当前 URL 决定显示哪个页面组件
  4. 将匹配的组件渲染到 <router-view /> 中
  
  代码示例(main.js 通常的内容):
  
  import { createApp } from 'vue'
  import App from './App.vue'
  import router from './router'
  
  const app = createApp(App)  // App.vue 作为根组件
  app.use(router)             // 注册路由
  app.mount('#app')           // 挂载到 index.html 中的 <div id="app">
-->

<!-- 
  ==================== 扩展建议 ====================
  
  1️⃣ 添加全局布局组件(如 Header、Sidebar)
  
  <template>
    <div class="app-container">
      <el-container>
        <el-header>全局头部导航</el-header>
        <el-container>
          <el-aside>侧边栏菜单</el-aside>
          <el-main>
            <router-view />  <!-- 页面内容区域 -->
          </el-main>
        </el-container>
      </el-container>
    </div>
  </template>
  
  2️⃣ 添加全局加载动画
  
  <template>
    <div v-loading="globalLoading" element-loading-text="加载中...">
      <router-view />
    </div>
  </template>
  
  3️⃣ 添加全局错误边界
  
  <template>
    <router-view v-if="!hasError" />
    <div v-else class="error-boundary">
      系统出错了,请刷新重试
    </div>
  </template>
  
  4️⃣ 添加全局样式变量(CSS 自定义属性)
  
  <style>
  :root {
    --primary-color: #409EFF;
    --success-color: #67C23A;
    --warning-color: #E6A23C;
    --danger-color: #F56C6C;
    --border-radius: 4px;
  }
  </style>
  
  5️⃣ 添加全局动画(路由切换过渡)
  
  <template>
    <router-view v-slot="{ Component }">
      <transition name="fade" mode="out-in">
        <component :is="Component" />
      </transition>
    </router-view>
  </template>
  
  <style>
  .fade-enter-active,
  .fade-leave-active {
    transition: opacity 0.3s ease;
  }
  
  .fade-enter-from,
  .fade-leave-to {
    opacity: 0;
  }
  </style>
-->

<!-- 
  ==================== 与 main.js 的关系 ====================
  
  App.vue 和 main.js 的分工:
  
  main.js(主入口文件):
  - 创建 Vue 应用实例
  - 注册插件(router, pinia, element-plus 等)
  - 挂载根组件 App.vue
  - 执行全局初始化(如请求拦截器配置)
  
  App.vue(根组件):
  - 定义全局布局
  - 提供路由出口
  - 定义全局样式
  - 可选:监听全局事件
  
  简化版 main.js 示例:
  import { createApp } from 'vue'
  import ElementPlus from 'element-plus'
  import 'element-plus/dist/index.css'
  import App from './App.vue'
  import router from './router'
  import pinia from './stores'
  
  const app = createApp(App)
  app.use(ElementPlus)
  app.use(router)
  app.use(pinia)
  app.mount('#app')
-->

<!-- 
  ==================== 常见问题 ====================
  
  Q1: 为什么要重置 * 的 margin 和 padding?
  A1: 不同浏览器的默认样式不同,重置后可以保证跨浏览器的一致性
  
  Q2: scoped 样式和全局样式的区别?
  A2: 
  - <style scoped>:样式只在当前组件生效(自动添加 data-v-xxx 属性)
  - <style>:全局样式,影响所有组件
  
  Q3: 能在一个组件中同时写多个 <style> 吗?
  A3: 可以,支持写多个,例如:
  <style>/* 全局样式 */</style>
  <style scoped>/* 组件私有样式 */</style>
  
  Q4: 为什么要设置 box-sizing: border-box?
  A4: 使布局更可控,避免因 padding/border 导致元素实际宽度超出预期
  
  Q5: <router-view /> 可以嵌套使用吗?
  A5: 可以,用于嵌套路由(如后台管理系统的多级菜单)
-->

main.js

javascript 复制代码
/**
 * ==================== Vue 3 应用入口文件 ====================
 * 这是整个前端应用的启动入口,负责创建 Vue 实例并注册所有插件
 */

// 从 vue 中导入 createApp 函数
// createApp: 用于创建 Vue 应用实例的核心 API
import { createApp } from 'vue'

// 从 pinia 中导入 createPinia 函数
// Pinia: Vue 的官方状态管理库(类似于 Vuex,但更轻量、更现代)
// createPinia: 创建 Pinia 实例
import { createPinia } from 'pinia'

// 导入 Element Plus 组件库
// Element Plus: 基于 Vue 3 的桌面端 UI 组件库
import ElementPlus from 'element-plus'

// 导入 Element Plus 的全局样式文件
// 必须导入,否则组件样式不生效(按钮、表单等会没有样式)
import 'element-plus/dist/index.css'

// 导入根组件 App
// App.vue 是整个应用的根组件,包含路由视图出口 <router-view />
import App from './App.vue'

// 导入路由配置
// router 是从 /router/index.js 导出的路由实例
import router from './router'

/**
 * ==================== 创建 Vue 应用实例 ====================
 * createApp(App) 创建一个 Vue 应用实例
 * App 作为根组件,所有其他组件都是 App 的子组件
 */
const app = createApp(App)

/**
 * ==================== 注册插件 ====================
 * app.use() 用于注册插件
 * 插件可以是第三方库(如 Pinia、Router、ElementPlus)或自定义插件
 */

// 1. 注册 Pinia(状态管理)
// 必须在注册 router 之前注册(因为 router 中可能使用 store)
// 作用:为整个应用提供全局状态管理能力(如用户登录状态、主题设置等)
app.use(createPinia())

// 2. 注册 Vue Router(路由管理)
// 作用:为应用添加路由功能,支持页面跳转、路由守卫、动态路由等
// 注册后,可以在组件中使用 $router(编程式导航)和 $route(当前路由信息)
app.use(router)

// 3. 注册 Element Plus(UI 组件库)
// 作用:全局注册 Element Plus 的所有组件(el-button、el-form、el-card 等)
// 注册后,无需在每个组件中单独导入,可以直接在模板中使用
app.use(ElementPlus)

/**
 * ==================== 挂载应用 ====================
 * app.mount('#app') 将 Vue 应用挂载到 DOM 元素上
 * '#app' 是 CSS 选择器,对应 index.html 中的 <div id="app"></div>
 * 
 * 挂载过程:
 * 1. Vue 会解析 App.vue 的模板
 * 2. 将模板渲染成真实的 DOM
 * 3. 替换掉 index.html 中的 <div id="app"></div>
 * 
 * 注意:mount 方法必须在所有配置(use、mixin、component 等)之后调用
 */
app.mount('#app')

/**
 * ==================== 执行流程时序图 ====================
 * 
 * 1. 解析 JavaScript 代码
 *    ↓
 * 2. 执行 import 导入语句(加载所有依赖)
 *    ↓
 * 3. 执行 createApp(App) 创建应用实例
 *    ↓
 * 4. 执行 app.use(createPinia()) 注册 Pinia
 *    ↓
 * 5. 执行 app.use(router) 注册路由
 *    ↓
 * 6. 执行 app.use(ElementPlus) 注册 UI 库
 *    ↓
 * 7. 执行 app.mount('#app') 挂载应用
 *    ↓
 * 8. 渲染初始页面(根据当前 URL 显示对应组件)
 *    ↓
 * 9. 应用启动完成,等待用户交互
 */
相关推荐
滕青山5 小时前
在线PDF拆分工具核心JS实现
前端·javascript·vue.js
光影少年12 小时前
前端在页面渲染优化和组件优化经验?
前端·vue.js·react.js·前端框架
李白的天不白14 小时前
VUE依赖配置问题
前端·javascript·vue.js
小智社群15 小时前
获取贝壳新房列表
前端·javascript·vue.js
一 乐15 小时前
茶叶商城|基于springboot + vue茶叶商城系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·论文·毕设·茶叶商城系统
吴声子夜歌16 小时前
Vue3——Pinia状态管理
javascript·vue.js·pinia
追风筝的人er1 天前
SpringBoot+Vue3 企业考勤如何处理法定假期?节假日方案、调休补班与工作日判断链路拆解
前端·vue.js·后端
编程老船长1 天前
解决不同项目需要不同 Node.js 版本的问题
前端·vue.js
xiaogg36781 天前
spring oauth2 单点登录
java·vue.js·spring