前端
文件目录介绍
📁 根目录配置文件
| 文件 |
作用 |
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/
📁 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.vue 与 HomeView.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. 应用启动完成,等待用户交互
*/