1. 初识 Vue
1.1 前端三大件与框架
前端的基础是三大件:HTML 标记语言、CSS 样式布局与 JavaScript 控制语言,有关介绍可见文章 前端基础。
但就如 Python 的底层是 C++、MyBatis 底层是 JDBC 一样,开发者们为了追求开发效率,对这些底层逻辑不断进行封装,提供了更简单易用的框架,前端框架之一就是 Vue。
1.2 Vue 是什么
Vue 是一种构建用户界面的、渐进式框架。
构建用户界面的 :核心是数据驱动视图,数据变更自动更新页面;
渐进式 :可仅引入 Vue 做局部交互,也可完整搭建大型单页应用,按需使用、分段学习;
框架:提供语法糖、内置 API、生命周期、指令等,提升开发效率。
使用该框架可以极大减少代码量,开发效率高,免去了操作 DOM 的繁琐,只操作数据。
1.3 计数器:原生 JS vs Vue
假如要实现点击增加页面数值大小的功能:

使用原生三大件的代码:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>原生JS计数器</title>
<style>
.count-box { font-size: 24px; margin: 20px 0; }
button { padding: 6px 16px; font-size: 18px; margin: 0 8px; }
</style>
</head>
<body>
<div class="count-box">当前数值:<span id="num">0</span></div>
<button id="addBtn">+</button>
<button id="subBtn">-</button>
<script>
// 1. 手动获取 DOM 元素
const numDom = document.getElementById('num');
const addBtn = document.getElementById('addBtn');
const subBtn = document.getElementById('subBtn');
// 2. 定义数据
let count = 0;
// 3. 加按钮事件
addBtn.addEventListener('click', () => {
count++;
// 4. 手动更新 DOM
numDom.innerText = count;
})
// 减按钮事件
subBtn.addEventListener('click', () => {
count--;
// 手动更新 DOM
numDom.innerText = count;
})
</script>
</body>
</html>
使用 Vue 仅需如下代码:
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Vue计数器</title>
<style>
.count-box { font-size: 24px; margin: 20px 0; }
button { padding: 6px 16px; font-size: 18px; margin: 0 8px; }
</style>
<!-- 引入 Vue 3 完整版(含开发提示,学习阶段用这个即可) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<!--
id="app":Vue 的挂载点
只有在这个 div 内部的模板语法({{ }}、@click 等)才由 Vue 管理
-->
<div id="app">
<!-- {{ count }}:插值表达式,把 data 里的 count 显示到页面上,Vue数据驱动,操作数据前端页面自动变化 -->
<div class="count-box">当前数值:{{ count }}</div>
<!-- @click 是 v-on:click 的简写,绑定点击事件,执行 count++ / count-- -->
<button @click="count++">+</button>
<button @click="count--">-</button>
</div>
<script>
// 从全局 Vue 对象中解构 createApp,用于创建应用实例
const { createApp } = Vue
createApp({
// data 返回响应式数据;count 变化时,模板中 {{ count }} 会自动更新
data() {
return {
count: 0 // 计数器初始值
}
}
}).mount('#app') // 挂载到 id 为 app 的 DOM 元素上,Vue 开始接管该区域
</script>
</body>
</html>
1.4 Vue 的优势
上面的代码看着好像差不多,但结构逻辑相差巨大,主要有以下几点优势:
- 无手动 DOM 操作 :只定义数据
count,页面通过{``{ }}自动渲染; - 内置事件指令
@click,不用写addEventListener; - 数据驱动 :直接修改
count变量,页面自动同步,不用操作 DOM; - 代码量大幅缩减,结构、数据、逻辑分层清晰;
- 页面模板和业务数据绑定在一起,可读性更高。
2. 简单使用------创建 Vue 应用
2.1 Vue 官网与版本
Vue 官方文档:https://cn.vuejs.org/
| 版本 | 说明 |
|---|---|
| Vue 2 | 旧项目常见,使用 new Vue() |
| Vue 3 | 当前主流,使用 createApp(),性能更好,Composition API 更灵活 |
新项目请选择 Vue 3。
2.2 CDN 引入与创建实例
创建 Vue 应用的流程如下:
-
准备容器 :指定 Vue 的管理范围,通常在一个
div内,通过id绑定:html<div id="app">{{ message }}</div> -
引包:在 HTML 中引入 Vue。生产环境用压缩包(体积小),开发调试用完整包(含警告信息):
javascript<!-- 生产环境 --> <script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script> <!-- 开发环境 --> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> -
创建实例并挂载:
javascript<script> const { createApp } = Vue createApp({ // 创建实例 data() { // 响应式数据,同级别还有methods 方法、computed计算属性、watch监听和mounted生命周期 return { message: 'Hello Vue!' } // 返回消息,键值对 } }).mount('#app') // 挂载到 id 为 app 的 DOM 元素 </script>
3. 基础语法
本章用一个通用的最小登录表单 Demo 串联语法。
3.1 模板与插值 {``{ }}
html
<h2>{{ title }}</h2>
<button>{{ loading ? '登录中...' : '登录' }}</button>
把 JavaScript 中的变量 插入 到 HTML 中显示。数据变化时,页面自动更新,无需 innerText。
能写成表达式的方式都支持,如三元表达式 ? :、toUpperCase() 转换大写等,但插值表达式不可在标签属性中使用 (需使用 v-bind)
3.2 Vue指令
Vue指令是v-前缀的特殊标签属性,常用指令如下:
-
显示元素
v-show=表达式,表达式为true时显示,false隐藏。原理为通过切换元素 CSS 的 display 属性控制显隐,隐藏时元素仅不展示,DOM 节点依旧存在页面中,不会被删除。适用于频繁切换显隐的场景。
html<div v-show="isShow">我能显示或隐藏</div> -
事件绑定
v-on 事件=方法名/内联语句html<button v-on:click="handleLogin">登录</button> <input @keyup.enter="handleLogin" />v-on:click可简写为@click,即v-on:=@@keyup.enter:在输入框按 Enter 键时触发登录
-
动态设置标签属性
v-bind:属性名=表达式用于动态绑定 HTML 标签原生属性,让属性值不再写死固定字符串,而是由 JS 变量、表达式控制,可简写为
:。html<div id="app"> <!-- 动态图片地址 --> <img v-bind:src="imgUrl" :title="tipText">不写死图片路径,通过变量动态设置图片地址。
-
双向绑定
v-model双向绑定数据和视图,一方变化另一方自动更新,方便获取与设置内容。
html<input v-model="form.username" placeholder="用户名" /> <input v-model="form.password" type="password" placeholder="密码" />用户在输入框中打字
form.username/form.password自动更新;反过来修改变量也会同步到输入框。登录页收集用户名和密码,最常用这一语法。本质是属性绑定 + 事件绑定合并,即:value + @input
-
条件渲染
v-if根据条件控制组件的创建和移除,与v-show的应用场景不同,适用于要么a要么b,不常切换、条件互斥的场景
html<p v-if="errorMsg" class="error">{{ errorMsg }}</p>仅当
errorMsg非空时显示错误提示,登录失败时使用。可搭配
v-else、v-else if判断渲染条件。 -
基于数据的循环
v-for根据数组 / 对象数据批量渲染相同结构的 DOM 元素,底层会自动遍历数据并生成对应节点,通常写为
v-for = (item,index) in 数组,以遍历对象和下标。html<!-- item 代表数组每一项,list 是响应式数组 --> <li v-for="item in list">{{ item }}</li> const list = ref(['苹果', '香蕉', '橙子'])循环通常需指定唯一标识
key,便于 Vue 进行列表项的正确排序与复用,不加key的默认行为会原地修改元素,就地复用,该key必须有唯一性,推荐使用id而非index,指定方法如下:html<div v-for="user in userList" :key="user.id">删除元素的方法可写为:
javascriptdelUser = (delId) => { userList.value = userList.value.filter(item => item.id !== delId) }用过滤方法去除删除的id,不改变原数组。
3.3 简易登录注册 demo
下面是一个可运行的 Vue 3 登录表单,后文将逐段讲解其中的语法。
效果如下图:

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录表单 Demo</title>
<style>
.login-box { max-width: 320px; margin: 40px auto; }
.error { color: red; font-size: 14px; }
.btn-loading { opacity: 0.6; }
input { display: block; width: 100%; margin: 8px 0; padding: 8px; }
button { padding: 8px 16px; }
</style>
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
</head>
<body>
<div id="app">
<div class="login-box">
<h2>{{ title }}</h2>
<input v-model="form.username" placeholder="用户名" @keyup.enter="handleLogin" />
<input v-model="form.password" type="password" placeholder="密码" @keyup.enter="handleLogin" />
<p v-if="errorMsg" class="error">{{ errorMsg }}</p>
<button
:disabled="!isFormValid || loading"
:class="{ 'btn-loading': loading }"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</div>
</div>
<script>
const { createApp, ref, reactive, computed } = Vue
createApp({
setup() {
const title = ref('用户登录')
const loading = ref(false)
const errorMsg = ref('')
const form = reactive({
username: '',
password: '',
})
const isFormValid = computed(() => {
return form.username.trim() !== '' && form.password.length >= 6
})
async function handleLogin() {
if (!isFormValid.value) return
loading.value = true
errorMsg.value = ''
try {
await new Promise(r => setTimeout(r, 800)) // 模拟网络请求
if (form.password === '123456') {
alert('登录成功!')
} else {
errorMsg.value = '用户名或密码错误'
}
} finally {
loading.value = false
}
}
return { title, loading, errorMsg, form, isFormValid, handleLogin }
}
}).mount('#app')
</script>
</body>
</html>
4. 组件核心机制
当页面变复杂(多个页面、共享状态、请求后端),单个 HTML 文件难以维护。工程化 Vue 项目将代码拆分为多个 单文件组件 (.vue)。
4.1 单文件组件与 .vue 三段结构
单页应用程序(SPA,Single Page Application)是指整个项目只有一个 html 文件,路径变化时只更新特定组件,实现按需更新,性能高,用户体验好,但首屏加载慢,SEO搜索引擎优化差,多用于文档类网站和移动端。
使用 Vue 可以方便地编写 单文件组件(.vue),每个 .vue 文件由三段(template结构、script逻辑和style样式)组成,对应三大件结构如下:
html
<template>
<!-- HTML:页面结构 -->
<!-- 可导入其他vue文件,便于复用 -->
<div class="login-box">
<input v-model="form.username" />
<button @click="handleLogin">登录</button>
</div>
</template>
<script setup>
// JavaScript:数据与逻辑
import { ref, reactive } from 'vue'
const form = reactive({ username: '', password: '' })
const loading = ref(false)
async function handleLogin() {
loading.value = true
// ...
loading.value = false
}
</script>
<style scoped>
/* CSS:仅作用于本组件,不影响其他页面 */
.login-box { max-width: 320px; margin: 0 auto; }
</style>
4.2 项目工作流程------项目目录解析
新建 Vue 项目的工作目录及相关职责如下:
my-vue-app/
├── index.html # 整个网站唯一的 HTML 壳,内有 <div id="app">
├── package.json # 项目依赖与 npm 脚本
├── vite.config.js # Vite 构建配置
└── src/
├── main.js # 程序入口:创建 Vue 应用、注册插件
├── App.vue # 根组件
├── views/ # 页面级组件(登录页、注册页等)
├── components/ # 可复用小组件
├── router/ # 路由配置(URL → 页面)
├── stores/ # 跨页面共享的数据,如登录 token,通常使用 Pinia 全局状态
└── api/ # 封装 HTTP 请求的函数,与 UI 分离
项目整体流程为:

4.3 响应式API
-
响应式基础
ref与reactiveVue3 的两大响应式 API,核心作用:让数据具备响应式特性,数据修改后页面视图自动同步更新,无需手动操作 DOM。
响应式特性,即数据变化时视图自动更新,可在控制台中操作实例数据,检查页面显示。
实际开发中根据业务逻辑修改数据即可,Vue封装了相关DOM操作。
- ref:适用于基础类型,字符串、数字、布尔值;脚本内部读写需加
.value,template模板会自动解包。 - reactive:仅用于引用类型数据:普通对象、数组、表单对象。
javascriptconst loading = ref(false) // 基本类型:用 ref const form = reactive({ // 对象:用 reactive username: '', password: '', }) - ref:适用于基础类型,字符串、数字、布尔值;脚本内部读写需加
-
异步函数
async/await网络请求等通信过程默认异步,需获取并处理结果的方法需使用
async/await标记,async标记方法,await等待方法执行返回结果后再继续执行。javascriptasync function handleLogin() { loading.value = true try { await fakeLoginApi() // 等待接口返回 } catch (err) { errorMsg.value = err.message } finally { loading.value = false // 无论成功失败都关闭 loading } } -
计算属性
computed根据已有数据派生 新值。比如当
form.username或form.password变化时,isFormValid自动重新计算,用于控制按钮是否可点。javascriptconst isFormValid = computed(() => { return form.username.trim() !== '' && form.password.length >= 6 })相比methods方法有缓存特性,只有依赖项变化时才重新计算,效率更高。
-
侦听器
watch用于监听指定响应式数据的变化,数据变更后执行自定义业务逻辑;无缓存,每次数据修改都会触发回调,适合处理异步、复杂副作用操作。
javascriptwatch( () => form, // 监听整个表单 // () => form.username, // 监听一个变量 // [() => form, () => form.username] // 监听多个对象 (newVal, oldVal) => { // 触发函数,参数是旧值和新值 console.log('表单任意字段发生修改') // 可结合clearTimeout和setTimeout计时器实现延迟执行,防止页面抖动 }, { immediate: true, // 组件初始化完成立刻执行一次回调 deep: true // 开启深度监听,监听对象内部属性变化,任意属性变化触发 } )可简写为
javascriptwatch(msg, (newVal, oldVal) => { console.log('新值', newVal, '旧值', oldVal) })
4.4 组件生命周期
Vue实例的生命周期分为:创建=>挂载=>更新=>销毁,完成的功能如下:
- 创建:组件实例初始化(DOM 还未生成),
setup()相当于beforeCreate + created:实例创建完成,所有响应式数据、方法初始化完毕; - 挂载:虚拟 DOM 生成并渲染到真实页面,
onBeforeMount:DOM 挂载前,模板编译完成但页面无真实 DOM;onMounted:DOM 挂载完成,页面渲染完毕 - 更新:监听响应式数据变更,对比新旧虚拟 DOM,
onBeforeUpdate:数据变化后、页面重新渲染前执行;onUpdated:DOM 更新渲染完成 - 销毁:组件实例卸载、资源释放,
onBeforeUnmount:组件销毁前,DOM 仍存在,可手动清除定时器、全局事件;onUnmounted:组件完全销毁,DOM 已移除。
使用示例如下:
javascript
// 1. 创建阶段:实例创建完成
<script setup>
创建阶段操作
</script>
// 2. 挂载阶段
onBeforeMount(() => {
console.log('onBeforeMount:即将渲染DOM')
})
onMounted(() => {
console.log('onMounted:页面挂载完成,可以获取DOM', box.value)
})
// 3. 更新阶段(修改count才会触发)
onBeforeUpdate(() => {
console.log('onBeforeUpdate:数据变更,页面即将刷新')
})
onUpdated(() => {
console.log('onUpdated:页面DOM更新完毕')
})
// 4. 销毁阶段(切换路由/销毁组件触发)
onBeforeUnmount(() => {
console.log('onBeforeUnmount:组件即将销毁,可手动清理定时器')
})
onUnmounted(() => {
console.log('onUnmounted:组件完全销毁')
})
5. 工程配套(Router / Pinia / UI 库 / HTTP)
5.1 Vue Router------页面跳转
路由负责明确访问路径和组件的对应关系,用于实现页面跳转,跳转逻辑都写到main.js内会变得难以维护,故通常都写在router/index.js下,使用export default router导出,跳转逻辑中导入使用。
详细介绍可见官方文档:https://router.vuejs.org/zh/
最小示例如下:
javascript
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import LoginPage from './views/LoginPage.vue'
import HomePage from './views/HomePage.vue'
const routes = [
{ path: '/login', component: LoginPage, name: 'Login' },
// meta元数据,是否需要授权登录,用于后续导航守卫
{ path: '/home', component: HomePage, name: 'Home',meta: { requiresAuth: true } },
]
const router = createRouter({
// 路由地址无#,更简洁
history: createWebHistory(),
// 路由映射
routes,
})
// 导出路由,主容器允许导入
export default router
在组件中跳转:
javascript
import { useRouter } from 'vue-router'
const router = useRouter()
router.push('/register') // 去注册页
router.push({ name: 'Home', query: { id: 1001 } }) // 按路由名称跳转,携带参数id: 1001,route.query.id接收
// app.vue中需如下配置
<template>
<RouterView /> // 路由页面的渲染容器
</template>
跳转有两种方式,声明式导航和编程式导航,分别用于点击超链接触发和逻辑自动跳转:
- 声明式导航 :
<router-link to="/home">首页</router-link>实际生成<a href="/home">首页</a>,点击跳转; - 编程式导航 :
router.push({name:'Home'})随代码逻辑触发,可匹配定时器、监听变化实现自动跳转。
导航守卫 :导航守卫就是路由跳转全过程的拦截钩子函数,路径都先经过全局前置守卫放行才能跳转,用于访问拦截,最常用做全局登录鉴权,简单示例如下:
javascript
// 全局前置导航守卫:每次路由跳转执行,统一做登录权限拦截、页面重定向
router.beforeEach((to, from, next) => {
// to:目标路由对象,存放要跳转页面的路径、名称、meta元信息、参数等
// 逻辑1:目标页面标记了需要登录权限,但用户无token未登录 → 拦截跳转登录页
if (to.meta.requiresAuth ) {
// 直接return路由对象等价next({name:'Login'}),重定向到登录页面
return { name: 'Login' }
}
// 逻辑2:用户已有登录token(已登录),但要去登录/注册页 → 自动重定向到首页Generate
if (!to.meta.requiresAuth && (to.name === 'Login' || to.name === 'Register')) {
// 已登录用户禁止重复进入登录、注册页面,直接跳转业务首页
return { name: 'Main' }
}
// 无匹配拦截条件,默认放行,无需额外return,路由正常跳转目标页面
})
5.2 Pinia------全局状态
Pinia 是 Vue 里的全局状态管理库(Store) ,负责把多个页面都要用到的数据(比如登录状态、用户信息)放在一个统一的地方,任何页面都能读、都能改。
适用于某个状态在多个组件中使用,或多个组件共同维护一份数据的场景。
Store 存放跨页面的公共数据及配套逻辑,如登录注册操作(token、用户信息、登录标识),utils 只放无状态纯工具函数
详细介绍可见官方文档:https://pinia.vuejs.org/zh/
最小示例:
javascript
// 项目路径 stores/user.js
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
// 定义仓库名称,唯一标识user
export const useUserStore = defineStore('user', () => {
// 响应式属性
const token = ref('')
const username = ref('')
const isLoggedIn = computed(() => !!token.value)
// 方法可指定为异步
function login(name, jwt) {
token.value = jwt
username.value = name
}
function logout() {
token.value = ''
username.value = ''
}
// 暴露相关属性和方法
return { token, username, isLoggedIn, login, logout }
})
在组件中使用:
javascript
import { useUserStore } from '@/stores/user'
// 工厂函数会给标识包围useXXXXStore,用于获取指定仓库
const userStore = useUserStore()
// 调用仓库方法或属性
await userStore.login('alice', 'eyJhbG...')
console.log(userStore.isLoggedIn) // true
登录场景 :登录页调用 store.login() 写入 token;其他页面读取 store.isLoggedIn 和 store.username。
5.3 Element Plus------UI 组件库
Element Plus 是一套基于 Vue 3 的 UI 组件库,提供了按钮、输入框、表单、弹窗、分页、标签等功能,提高开发效率。
详细介绍可见官方文档:https://element-plus.org/zh-CN/
该组件库可通过 vite.config.js 如下方式按需引入:
javascript
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
使用方式为原标签前加el,如button=>el-button,使用示例如下:
html
<template>
<el-form :model="form" :rules="rules" ref="formRef">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" />
</el-form-item>
<el-button type="primary" @click="handleSubmit">登录</el-button>
</el-form>
</template>
<script setup>
import { ElMessage } from 'element-plus'
function handleSubmit() {
ElMessage.success('登录成功')
}
</script>
常用组件:el-form、el-input、el-button;消息提示:ElMessage.success() / ElMessage.error()。
登录场景 :项目登录页用 Element Plus 替代原生 <input>,并自带表单校验能力。
5.4 fetch 与 HTTP 请求
前端消息需与后端交互,常用方法有fetch、axios等,相比axios,fetch为浏览器原生,简单需求更轻量化。
通过配置文件配置前端与后端对应端口:
javascript
// 访问前端/api时对应本地8083接口
proxy: {
'/api': { target: 'http://localhost:8083', changeOrigin: true }
}
使用fetch交互的示例如下:
javascript
/**
* 登录请求接口
* @param {string} username - 用户账号
* @param {string} passwordMd5 - MD5加密后的用户密码(前端加密,不传输明文)
* @returns {Promise<Object>} 后端返回完整JSON数据,格式 { code: 0, data: { token, id, username } }
* @throws {Error} HTTP请求异常 / 后端返回错误信息时抛出错误,上层try/catch捕获
*/
async function loginApi(username, passwordMd5) {
// 发起fetch POST登录请求,携带JSON格式账号密码参数
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, passwordMd5 }),
})
// 一次性解析响应JSON,避免重复读取请求流报错
const resData = await response.json()
// HTTP状态码异常则抛出错误
if (!response.ok) {
throw new Error(resData.message || '请求失败')
}
return resData
// 典型返回结构:{ code: 0, data: { token, id, username } }
}
工程化场景需解耦,将通用 fetch 逻辑抽成独立 request 工具文件,auth 登录等接口层负责具体业务逻辑,而不关心底层网络格式,故通常业务逻辑结构如下:
src
├─ api
│ ├─ request.js // 通用fetch底层封装(所有接口统一调用)
│ └─ auth.js // 登录、注册业务接口,只传路径和参数
└─ stores
└─ auth.js // pinia仓库,调用api/auth里的loginApi
6. 简单实战------登录注册实战
6.1 登录页
实现 Login.vue 三段式接口的 template 主要组织页面布局、组件和格式等,其中用到 Element Plus 可简化开发过程,如登录表单、密码输入框等,代码如下:
javascript
<template>
<!-- 登录页面最外层深色背景容器 -->
<div class="auth-bg">
<!-- 三层渐变模糊光晕装饰背景 -->
<div class="bg-orb orb-1" />
<div class="bg-orb orb-2" />
<div class="bg-orb orb-3" />
<!-- 登录玻璃态卡片主体 -->
<div class="auth-card">
<!-- 项目品牌LOGO、名称、特性标签区域 -->
<div class="brand">
<div class="brand-icon">
<el-icon size="32"><Lock /></el-icon>
</div>
<h1 class="brand-name">Login</h1>
<p class="brand-en">Login test</p>
<p class="brand-sub">登录项目实战</p>
</div>
<!-- Element Plus 登录表单,回车直接触发登录 -->
<el-form
ref="formRef"
:model="form"
:rules="rules"
class="auth-form"
size="large"
@keyup.enter="handleLogin"
>
<!-- 用户名输入框 -->
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名 / 邮箱"
:prefix-icon="User"
clearable
class="auth-input"
/>
</el-form-item>
<!-- 密码输入框,支持显示/隐藏密码 -->
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码"
:prefix-icon="Lock"
show-password
class="auth-input"
/>
</el-form-item>
<!-- 登录提交按钮,加载态控制 -->
<el-form-item>
<el-button
type="primary"
class="auth-btn"
:loading="loading"
@click="handleLogin"
>
{{ loading ? '登录中...' : '登 录' }}
</el-button>
</el-form-item>
</el-form>
<!-- 底部跳转注册入口 -->
<div class="auth-footer">
<span class="footer-text">还没有账号?</span>
<el-button link type="primary" class="footer-link" @click="goRegister">
立即注册
</el-button>
</div>
</div>
</div>
</template>
控制部分逻辑为登录表单绑定数据,点击登录后调用 Pinia 仓库的登录方法,返回成功后跳转到后续页面,具体代码如下:
javascript
<script setup>
// Vue 基础响应式API
import { ref, reactive } from 'vue'
// 路由
import { useRouter } from 'vue-router'
// Element Plus 内置图标
import { User, Lock } from '@element-plus/icons-vue'
// Pinia 全局登录状态仓库
import { useAuthStore } from '@/stores/auth'
// 路由实例
const router = useRouter()
// 全局认证仓库实例
const authStore = useAuthStore()
// 表单DOM实例,用于调用表单校验方法
const formRef = ref(null)
// 登录按钮加载状态
const loading = ref(false)
// 登录表单双向绑定数据
const form = reactive({
username: '',
password: '',
})
// 表单校验规则
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' },
],
}
/**
* 登录提交处理函数
* 1. 先执行全局表单校验
* 2. 校验通过调用Pinia登录接口
* 3. 成功跳转生成页面,失败弹出错误提示
*/
async function handleLogin() {
// 表单校验,校验失败捕获异常返回false
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
// 调用仓库登录方法,内部封装fetch请求
await authStore.login({ username: form.username, password: form.password })
ElMessage.success('登录成功,欢迎回来!')
// 跳转到隐写生成主页
router.push({ name: 'Generate' })
} catch (err) {
// 接口异常弹窗提示
ElMessage.error(err.message || '登录失败,请重试')
} finally {
// 无论成功失败都关闭loading
loading.value = false
}
}
/**
* 跳转到注册页面
*/
function goRegister() {
router.push({ name: 'Register' })
}
</script>
样式页面,完全AI生成,美感还行,这对后端来说基本无从下手了,幸亏不影响逻辑,其实也无所谓。
javascript
<style scoped>
/* 页面根容器:全屏垂直水平居中 */
.auth-bg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0d0d1a;
position: relative;
overflow: hidden;
}
/* 背景模糊光晕通用样式 */
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.35;
pointer-events: none;
}
/* 左上紫色光晕 */
.orb-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, #7c3aed, transparent 70%);
top: -120px;
left: -100px;
}
/* 右下蓝色光晕 */
.orb-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #2563eb, transparent 70%);
bottom: -80px;
right: -80px;
}
/* 中间粉色光晕 */
.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, #db2777, transparent 70%);
top: 50%;
left: 60%;
transform: translate(-50%, -50%);
}
/* 玻璃态登录卡片主容器 */
.auth-card {
position: relative;
z-index: 10;
width: 400px;
padding: 48px 40px 36px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
backdrop-filter: blur(20px);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}
/* 品牌标题区域整体居中 */
.brand {
text-align: center;
margin-bottom: 36px;
}
/* 品牌渐变图标容器 */
.brand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #7c3aed, #2563eb);
margin-bottom: 16px;
color: #fff;
}
.brand-name {
font-size: 26px;
font-weight: 700;
color: #f1f5f9;
margin: 0 0 6px;
letter-spacing: 1px;
}
.brand-sub {
font-size: 13px;
color: #94a3b8;
margin: 0 0 12px;
letter-spacing: 1px;
}
.brand-en {
font-size: 11px;
color: #64748b;
margin: 0 0 6px;
line-height: 1.4;
}
/* 功能标签弹性布局,自动换行居中 */
.brand-features {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 8px;
}
/* 表单整体间距 */
.auth-form {
margin-bottom: 8px;
}
/* 深度修改Element Plus原生表单项间距 */
.auth-form :deep(.el-form-item) {
margin-bottom: 18px;
}
/* 自定义输入框外壳:半透明深色玻璃风格 */
.auth-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: none !important;
padding: 0 14px;
}
/* hover/聚焦输入框边框变色 */
.auth-form :deep(.el-input__wrapper:hover),
.auth-form :deep(.el-input__wrapper.is-focus) {
border-color: #7c3aed;
background: rgba(124, 58, 237, 0.08);
}
/* 输入文字颜色 */
.auth-form :deep(.el-input__inner) {
color: #e2e8f0;
height: 44px;
font-size: 14px;
}
/* 占位符灰色 */
.auth-form :deep(.el-input__inner::placeholder) {
color: #475569;
}
/* 前缀图标浅灰色 */
.auth-form :deep(.el-input__prefix-icon) {
color: #64748b;
}
/* 登录渐变提交按钮 */
.auth-btn {
width: 100%;
height: 48px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
letter-spacing: 4px;
background: linear-gradient(135deg, #7c3aed, #2563eb);
border: none;
transition: opacity 0.2s, transform 0.1s;
}
.auth-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.auth-btn:active {
transform: translateY(0);
}
/* 底部注册跳转区域 */
.auth-footer {
text-align: center;
padding-top: 8px;
}
.footer-text {
font-size: 13px;
color: #64748b;
}
.footer-link {
font-size: 13px;
font-weight: 600;
padding: 0 4px;
}
</style>
6.2 注册页
注册相关代码如下:
javascript
<template>
<div class="auth-bg">
<div class="bg-orb orb-1" />
<div class="bg-orb orb-2" />
<div class="bg-orb orb-3" />
<div class="auth-card">
<div class="brand">
<div class="brand-icon">
<el-icon size="32"><UserFilled /></el-icon>
</div>
<h1 class="brand-name">创建账号</h1>
<p class="brand-sub">Join AGS Studio</p>
</div>
<el-form
ref="formRef"
:model="form"
:rules="rules"
class="auth-form"
size="large"
>
<el-form-item prop="username">
<el-input
v-model="form.username"
placeholder="用户名"
:prefix-icon="User"
clearable
/>
</el-form-item>
<el-form-item prop="email">
<el-input
v-model="form.email"
placeholder="邮箱(可选)"
:prefix-icon="Message"
clearable
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="密码(至少 6 位)"
:prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item prop="confirmPassword">
<el-input
v-model="form.confirmPassword"
type="password"
placeholder="确认密码"
:prefix-icon="Lock"
show-password
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
class="auth-btn"
:loading="loading"
@click="handleRegister"
>
{{ loading ? '注册中...' : '注 册' }}
</el-button>
</el-form-item>
</el-form>
<div class="auth-footer">
<span class="footer-text">已有账号?</span>
<el-button link type="primary" class="footer-link" @click="goLogin">
返回登录
</el-button>
</div>
</div>
</div>
</template>
控制逻辑为:校验两次输入密码是否一致,注册按钮事件为仓库的注册方法,后端交互,注册成功后跳转到登录界面,代码如下:
javascript
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { User, Lock, Message, UserFilled } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const authStore = useAuthStore()
const formRef = ref(null)
const loading = ref(false)
const form = reactive({
username: '',
email: '',
password: '',
confirmPassword: '',
})
const validateConfirmPassword = (rule, value, callback) => {
if (value !== form.password) {
callback(new Error('两次密码输入不一致'))
} else {
callback()
}
}
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' },
{ min: 2, max: 20, message: '用户名长度 2~20 位', trigger: 'blur' },
],
email: [{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' }],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码至少 6 位', trigger: 'blur' },
],
confirmPassword: [
{ required: true, message: '请再次输入密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' },
],
}
async function handleRegister() {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.register({
username: form.username,
email: form.email,
password: form.password,
})
ElMessage.success('注册成功!请登录')
router.push({ name: 'Login' })
} catch (err) {
ElMessage.error(err.message || '注册失败,请重试')
} finally {
loading.value = false
}
}
function goLogin() {
router.push({ name: 'Login' })
}
</script>
<style scoped>
.auth-bg {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #0d0d1a;
position: relative;
overflow: hidden;
}
.bg-orb {
position: absolute;
border-radius: 50%;
filter: blur(80px);
opacity: 0.35;
pointer-events: none;
}
.orb-1 {
width: 500px;
height: 500px;
background: radial-gradient(circle, #2563eb, transparent 70%);
top: -120px;
right: -100px;
}
.orb-2 {
width: 400px;
height: 400px;
background: radial-gradient(circle, #7c3aed, transparent 70%);
bottom: -80px;
left: -80px;
}
.orb-3 {
width: 300px;
height: 300px;
background: radial-gradient(circle, #0ea5e9, transparent 70%);
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
}
.auth-card {
position: relative;
z-index: 10;
width: 420px;
padding: 44px 40px 32px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 20px;
backdrop-filter: blur(20px);
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5);
}
.brand {
text-align: center;
margin-bottom: 32px;
}
.brand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
border-radius: 16px;
background: linear-gradient(135deg, #2563eb, #0ea5e9);
margin-bottom: 16px;
color: #fff;
}
.brand-name {
font-size: 24px;
font-weight: 700;
color: #f1f5f9;
margin: 0 0 6px;
}
.brand-sub {
font-size: 12px;
color: #64748b;
margin: 0;
letter-spacing: 2px;
text-transform: uppercase;
}
.auth-form {
margin-bottom: 8px;
}
.auth-form :deep(.el-form-item) {
margin-bottom: 16px;
}
.auth-form :deep(.el-input__wrapper) {
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: none !important;
padding: 0 14px;
}
.auth-form :deep(.el-input__wrapper:hover),
.auth-form :deep(.el-input__wrapper.is-focus) {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.08);
}
.auth-form :deep(.el-input__inner) {
color: #e2e8f0;
height: 44px;
font-size: 14px;
}
.auth-form :deep(.el-input__inner::placeholder) {
color: #475569;
}
.auth-form :deep(.el-input__prefix-icon) {
color: #64748b;
}
.auth-btn {
width: 100%;
height: 48px;
border-radius: 10px;
font-size: 15px;
font-weight: 600;
letter-spacing: 4px;
background: linear-gradient(135deg, #2563eb, #0ea5e9);
border: none;
transition: opacity 0.2s, transform 0.1s;
}
.auth-btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
.auth-btn:active {
transform: translateY(0);
}
.auth-footer {
text-align: center;
padding-top: 8px;
}
.footer-text {
font-size: 13px;
color: #64748b;
}
.footer-link {
font-size: 13px;
font-weight: 600;
padding: 0 4px;
}
</style>
6.3 路由配置
访问页面/时跳转到login,绑定组件LoginView.vue,对应register绑定组件RegisterView,配置元数据requiresAuth用于路由守卫校验规则,示例代码如下:
javascript
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
redirect: '/login',
},
{
path: '/login',
name: 'Login',
// @标识src根目录,避免相对路径
component: () => import('@/views/LoginView.vue'),
meta: { requiresAuth: false },
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/RegisterView.vue'),
meta: { requiresAuth: false },
},
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
})
// 全局前置路由守卫:每次路由跳转前都会执行
router.beforeEach((to) => {
// 1. 从本地存储读取登录凭证token,相关知识下次介绍
const token = localStorage.getItem('token')
// 场景1:当前页面需要登录权限,但用户无token(未登录)
if (to.meta.requiresAuth && !token) {
// 直接返回路由对象,强制重定向到登录页Login
return { name: 'Login' }
}
// 场景2:用户已登录(有token),却访问登录/注册页面(无需权限页面)
if (!to.meta.requiresAuth && token && (to.name === 'Login' || to.name === 'Register')) {
// 直接返回路由对象,强制跳转到首页Generate
return { name: 'Generate' }
}
// 场景3:不满足以上两种拦截规则,无return语句,默认放行当前路由
})
export default router
6.4 fetch网络请求
使用二级策略,request封装基本请求方法,auth实现业务逻辑,网络请求方法如下:
javascript
// 读取Vite环境变量配置的后端基础地址,无配置则默认兜底 /api
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api'
/**
* 全局通用网络请求封装(原生fetch)
* @param {string} url 接口相对路径,如 /auth/login
* @param {Object} options fetch原生配置:method、body、headers等
* @returns {Promise<Object>} 后端返回的JSON数据
* @throws {Error} HTTP状态异常/后端返回错误时抛出异常,上层try/catch捕获
*/
export async function request(url, options = {}) {
// 从本地存储取出登录凭证token,用于接口鉴权
const token = localStorage.getItem('token')
// 请求头合并逻辑
const headers = {
// 默认请求头:告诉后端当前请求体是JSON格式
'Content-Type': 'application/json',
// ... 展开运算符:如果token存在,把 { Authorization: 'Bearer xxx' } 合并进headers对象
...(token && { Authorization: `Bearer ${token}` }),
// ... 展开运算符:把外部传入的自定义headers全部合并,可覆盖默认头
...options.headers,
}
// 发起fetch请求
const response = await fetch(`${BASE_URL}${url}`, {
// ... 展开运算符:将外部传入的method、body等配置全部合并到fetch参数
...options,
headers,
})
// 判断HTTP响应状态码非2xx(404/401/500等),进入错误处理
if (!response.ok) {
// 解析后端返回的错误JSON,解析失败则兜底默认错误对象
const error = await response.json().catch(() => ({ message: '请求失败' }))
// 抛出错误,交给调用方捕获弹窗提示
throw new Error(error.message || '请求失败')
}
// 请求正常,解析并返回后端JSON数据
return response.json()
}
具体业务逻辑如下:
javascript
import { request } from './request'
/**
* 登录接口
* @param {{ username: string, passwordMd5: string }} credentials
*/
export function loginApi(credentials) {
return request('/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
})
}
6.5 Pinia 状态管理
具体登录逻辑由 Pinia 实现,登录页 Vue 调用的即此处,实现代码如下:
javascript
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import md5 from 'js-md5'
import { loginApi, registerApi } from '@/api/auth'
/**
* 密码MD5加密工具
* @param {string} password 原始明文密码
* @returns {string} MD5哈希字符串
*/
function hashPassword(password) {
return md5(password)
}
export const useAuthStore = defineStore('auth', () => {
// 仅存储用户基础信息,移除token相关状态
const user = ref(JSON.parse(localStorage.getItem('user') || 'null'))
// 根据是否存在用户信息判断登录状态
const isLoggedIn = computed(() => !!user.value)
/**
* 登录方法:调用接口、保存用户信息到本地
* @param {Object} credentials 账号密码表单
*/
async function login(credentials) {
const res = await loginApi({
username: credentials.username,
passwordMd5: hashPassword(credentials.password),
})
// 后端返回 { code: 0, data: { id, username } }
user.value = { id: res.data.id, username: res.data.username }
// 只持久化用户信息,移除token存储
localStorage.setItem('user', JSON.stringify(user.value))
return res
}
/**
* 注册方法,无状态存储,仅返回接口结果
* @param {Object} data 注册账号密码
*/
async function register(data) {
const res = await registerApi({
username: data.username,
passwordMd5: hashPassword(data.password),
})
return res
}
/**
* 退出登录:清空用户信息本地缓存
*/
function logout() {
user.value = null
localStorage.removeItem('user')
}
return { user, isLoggedIn, login, register, logout }
})
整体调用链路如下:

总结
第一篇让ai先实现效果,边学技术栈边写的文档,而不是系统学习后输出文档再实战的常规流程,可能这也是以后学习的常态。
到这里对AI还是有了更新的认识,起码不能完全依赖AI,它较难把握用户的真实需求,用户在认识不达到一定深度也很难准确说出自己的想法和需求;另外他会针对需求只采用最简单的实现手段,忽略后续扩展性及相关架构设计,开发者必须对基础知识有一定了解才能驾驭AI。
历时三天左右,学了Vue相关基础语法,结合AI能够阅读基本源码,不至于被他忽悠了,以后要是真要搞全栈,黑马的课还是要全看完才踏实。