一、简述
该登录界面使用 Vue 3 的 Composition API 构建,并使用了 Vant UI 组件库。
核心功能与原理
-
模式切换
通过state.type控制显示登录或注册表单,点击 "还没有账号?立即注册" 或 "已有账号?立即登录" 可切换模式。 -
表单验证
- 使用 Vant 的
van-form和van-field组件实现表单布局。 - 通过
rules属性配置必填项验证(用户名、密码、验证码)。
- 使用 Vant 的
-
验证码机制
- 使用
vue-img-verify组件生成图形验证码。 - 用户输入后,通过
state.verify.toLowerCase() !== state.imgCode.toLowerCase()验证,忽略大小写。
- 使用
-
登录逻辑
-
点击登录按钮时,触发
onSubmit方法。 -
验证通过后,模拟登录成功:
- 生成临时 token(
mock_token_时间戳)。 - 使用
setLocal存储 token 到本地。 - 显示成功提示并跳转至首页。
- 生成临时 token(
-
-
注册逻辑
-
点击注册按钮时,同样触发
onSubmit方法。 -
验证通过后,调用
registerAPI(假设为真实接口):- 密码使用 MD5 加密。
- 注册成功后切换到登录模式。
-
代码结构与交互流程
-
模板部分
- 使用
v-if和v-else根据state.type切换显示登录 / 注册表单。 - 表单字段通过
v-model绑定到state中的响应式数据。
- 使用
-
脚本部分
- 使用
reactive创建响应式状态state,存储表单数据和模式。 toggle方法切换登录 / 注册模式,并清空验证码。onSubmit方法处理表单提交,验证验证码并执行登录 / 注册逻辑。
- 使用
-
样式部分
- 使用 Less 编写样式,包含响应式设计(
@media)。 - 自定义 Vant 组件样式(如
::v-deep选择器)。
- 使用 Less 编写样式,包含响应式设计(
核心技术点
-
响应式原理
- 通过 Vue 3 的
reactive和ref实现数据响应式。 - 表单数据变化自动更新 UI。
- 通过 Vue 3 的
-
组件通信
- 通过
ref引用vue-img-verify组件,获取验证码值。
- 通过
-
状态管理
- 使用
setLocal(可能是封装的 localStorage 工具)存储认证状态。
- 使用
-
UI 交互优化
- 表单验证错误提示(如 "验证码错误")。
- 按钮悬停效果和点击反馈。
- 移动端适配(通过媒体查询)。
二、npm install
在终端输入指令安装相关的库
npm install vant
npm install axios qs
npm install -D less
npm install js-md5
三、目录结构
login.vue、TopBar.vue和VueImageVerify.vue放在components文件夹下。
四、代码
login.vue
<template>
<div class="login">
<!-- 头部导航栏 -->
<topbar
:name="state.type === 'login' ? '登录' : '注册'"
:back="'/home'"
></topbar>
<!-- 登录模块 -->
<div v-if="state.type === 'login'" class="login-body login">
<van-form @submit="onSubmit">
<!-- 用户名输入框 -->
<van-field
v-model="state.username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '用户名不能为空' }]"
/>
<!-- 密码输入框 -->
<van-field
v-model="state.password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '密码不能为空' }]"
/>
<!-- 验证码模块 -->
<van-field
center
clearable
label="验证码"
placeholder="请输入验证码"
v-model="state.verify"
>
<template #button>
<vue-img-verify ref="verifyRef" />
</template>
</van-field>
<!-- 操作按钮区域 -->
<div class="btn-area">
<div class="link" @click="toggle('register')">
还没有账号?立即注册
</div>
<van-button
round
block
color="#1baeae"
native-type="submit"
>
登录
</van-button>
</div>
</van-form>
</div>
<!-- 注册模块 -->
<div v-else class="login-body register">
<van-form @submit="onSubmit">
<van-field
v-model="state.username1"
name="username1"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '用户名不能为空' }]"
/>
<van-field
v-model="state.password1"
type="password"
name="password1"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '密码不能为空' }]"
/>
<van-field
center
clearable
label="验证码"
placeholder="请输入验证码"
v-model="state.verify"
>
<template #button>
<vue-img-verify ref="verifyRef" />
</template>
</van-field>
<div class="btn-area">
<div class="link" @click="toggle('login')">
已有账号?立即登录
</div>
<van-button
round
block
color="#1baeae"
native-type="submit"
>
注册
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup>
import { reactive, ref } from 'vue';
import topbar from '@/components/TopBar.vue';
import vueImgVerify from '@/components/VueImageVerify.vue';
import { setLocal } from '@/common/js/utils';
import { login, register } from '@/api/user';
import md5 from 'js-md5';
import { showSuccessToast, showFailToast } from 'vant';
const verifyRef = ref(null);
const state = reactive({
username: '',
password: '',
username1: '',
password1: '',
type: 'login', // 初始为登录模式
imgCode: '',
verify: ''
});
// 切换登录/注册模式
const toggle = (mode) => {
state.type = mode;
state.verify = ''; // 切换时清空验证码
};
// 表单提交处理
const onSubmit = async (values) => {
// 获取验证码
state.imgCode = verifyRef.value?.state?.imgCode || '';
// 验证验证码
if (state.verify.toLowerCase() !== state.imgCode.toLowerCase()) {
showFailToast('验证码错误');
return;
}
try {
if (state.type === 'login') {
// 登录逻辑
const { data } = await login({
loginName: values.username,
passwordMd5: md5(values.password)
});
// 检查响应结构
console.log('登录响应:', response);
// 根据实际响应结构获取 token
const token = response.data || response; // 假设直接返回 token 或 { data: token }
setLocal('token', token);
showSuccessToast('登录成功');
window.location.href = '/'; // 跳转首页
} else {
// 注册逻辑
await register({
loginName: values.username1,
password: md5(values.password1)
});
showSuccessToast('注册成功,请登录');
toggle('login'); // 注册成功后切换到登录模式
}
} catch (error) {
console.error('请求失败:', error);
showFailToast('操作失败,请重试');
}
};
</script>
<style lang="less" scoped>
.login {
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
background-color: #f5f7fa;
padding: 20px 15px;
font-family: 'PingFang SC', sans-serif;
}
.topbar {
width: 100%;
max-width: 400px;
margin-bottom: 30px;
}
.login-body {
width: 100%;
max-width: 400px;
background-color: #fff;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
padding: 32px 24px;
margin-top: 20px;
}
.van-form {
width: 100%;
}
.van-field {
margin-bottom: 20px;
::v-deep .van-field__label {
font-size: 14px;
color: #333;
font-weight: 500;
margin-bottom: 8px;
}
::v-deep .van-field__control {
height: 48px;
padding: 0 16px;
font-size: 14px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background-color: #fff;
&:focus {
border-color: #1baeae;
box-shadow: 0 0 0 2px rgba(27, 174, 174, 0.2);
}
}
}
.van-field--center {
display: flex;
align-items: center;
gap: 12px;
.vue-img-verify {
width: 180px;
height: 48px;
}
}
.btn-area {
margin-top: 32px;
text-align: center;
}
.link {
font-size: 14px;
color: #1989fa;
margin-bottom: 16px;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.van-button {
height: 52px;
font-size: 16px;
background-color: #1baeae;
border: none;
border-radius: 26px;
transition: background-color 0.2s ease;
&:hover {
background-color: #169c9c;
}
}
// 移动端适配
@media (max-width: 480px) {
.login-body {
padding: 24px 16px;
}
.van-field--center {
.vue-img-verify {
width: 140px;
}
}
}
</style>
TopBar.vue
<template>
<header class="simple-header van-hairline--bottom">
<i v-if="!isback" class="nbicon nbfanhui" @click="goBack"></i>
<i v-else> </i>
<div class="simple-header-name">{{ name }}</div>
<i class="nbicon nbmore"></i>
</header>
<div class="block" />
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const props = defineProps({
name: String,
back: String,
noback: Boolean
});
const isback = ref(props.noback)
const router = useRouter()
const goBack = () => {
if (!props.back) {
router.go(-1)
} else {
router.push({ path: props.back })
}
}
</script>
<style lang="less" scoped>
@import '../common/style/mixin';
.simple-header {
position: fixed;
top: 0;
left: 0;
z-index: 10000;
.fj();
.wh(100%, 44px);
line-height: 44px;
padding: 0 10px;
.boxSizing();
color: #252525;
background: #fff;
.simple-header-name {
font-size: 14px;
}
}
.block {
height: 44px;
}
</style>
VueImageVerify.vue
<template>
<div class="img-verify">
<canvas ref="verify" :width="state.width" :height="state.height" @click="handleDraw"></canvas>
</div>
</template>
<script setup>
import { reactive, onMounted, ref } from 'vue'
const verify = ref(null)
const state = reactive({
pool: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890', // 字符串
width: 120,
height: 40,
imgCode: ''
})
defineExpose({ state })
onMounted(() => {
// 初始化绘制图片验证码
state.imgCode = draw()
})
// 点击图片重新绘制
const handleDraw = () => {
state.imgCode = draw()
}
// 随机数
const randomNum = (min, max) => {
return parseInt(Math.random() * (max - min) + min)
}
// 随机颜色
const randomColor = (min, max) => {
const r = randomNum(min, max)
const g = randomNum(min, max)
const b = randomNum(min, max)
return `rgb(${r},${g},${b})`
}
// 绘制图片
const draw = () => {
// 3.填充背景颜色,背景颜色要浅一点
const ctx = verify.value.getContext('2d')
// 填充颜色
ctx.fillStyle = randomColor(180, 230)
// 填充的位置
ctx.fillRect(0, 0, state.width, state.height)
// 定义paramText
let imgCode = ''
// 4.随机产生字符串,并且随机旋转
for (let i = 0; i < 4; i++) {
// 随机的四个字
const text = state.pool[randomNum(0, state.pool.length)]
imgCode += text
// 随机的字体大小
const fontSize = randomNum(18, 40)
// 字体随机的旋转角度
const deg = randomNum(-30, 30)
/*
* 绘制文字并让四个文字在不同的位置显示的思路 :
* 1、定义字体
* 2、定义对齐方式
* 3、填充不同的颜色
* 4、保存当前的状态(以防止以上的状态受影响)
* 5、平移translate()
* 6、旋转 rotate()
* 7、填充文字
* 8、restore出栈
* */
ctx.font = fontSize + 'px Simhei'
ctx.textBaseline = 'top'
ctx.fillStyle = randomColor(80, 150)
/*
* save() 方法把当前状态的一份拷贝压入到一个保存图像状态的栈中。
* 这就允许您临时地改变图像状态,
* 然后,通过调用 restore() 来恢复以前的值。
* save是入栈,restore是出栈。
* 用来保存Canvas的状态。save之后,可以调用Canvas的平移、放缩、旋转、错切、裁剪等操作。 restore:用来恢复Canvas之前保存的状态。防止save后对Canvas执行的操作对后续的绘制有影响。
*
* */
ctx.save()
ctx.translate(30 * i + 15, 15)
ctx.rotate((deg * Math.PI) / 180)
// fillText() 方法在画布上绘制填色的文本。文本的默认颜色是黑色。
// 请使用 font 属性来定义字体和字号,并使用 fillStyle 属性以另一种颜色/渐变来渲染文本。
// context.fillText(text,x,y,maxWidth);
ctx.fillText(text, -15 + 5, -15)
ctx.restore()
}
// 5.随机产生5条干扰线,干扰线的颜色要浅一点
for (let i = 0; i < 5; i++) {
ctx.beginPath()
ctx.moveTo(randomNum(0, state.width), randomNum(0, state.height))
ctx.lineTo(randomNum(0, state.width), randomNum(0, state.height))
ctx.strokeStyle = randomColor(180, 230)
ctx.closePath()
ctx.stroke()
}
// 6.随机产生40个干扰的小点
for (let i = 0; i < 40; i++) {
ctx.beginPath()
ctx.arc(randomNum(0, state.width), randomNum(0, state.height), 1, 0, 2 * Math.PI)
ctx.closePath()
ctx.fillStyle = randomColor(150, 200)
ctx.fill()
}
return imgCode
}
</script>
<style>
.img-verify canvas {
cursor: pointer;
}
</style>
index.js
import { http } from './http';
import { config } from './config';
const { default_headers } = config;
const request = (option) => {
const { url, method, params, data, headersType, responseType } = option;
return http({
url: url,
method,
params,
data,
responseType: responseType,
headers: {
'Content-Type': headersType || default_headers
}
});
};
export default {
get: (option) => {
return request({ method: 'get', ...option });
},
post: (option) => {
return request({ method: 'post', ...option });
},
delete: (option) => {
return request({ method: 'delete', ...option });
},
put: (option) => {
return request({ method: 'put', ...option });
}
};
http.js
import axios from 'axios';
import qs from 'qs';
import { config } from './config';
import { showFailToast } from 'vant';
const { result_code, base_url } = config;
export const PATH_URL = base_url[import.meta.env.VITE_API_BASEPATH];
// 创建axios实例
const http = axios.create({
baseURL: PATH_URL, // 设置基本URL,用于所有请求的前缀
timeout: config.request_timeout // 请求超时时间
});
http.interceptors.request.use(
(config) => {
if (
config.method === 'post' &&
config.headers['Content-Type'] === 'application/x-www-form-urlencoded'
) {
config.data = qs.stringify(config.data);
}
config.headers['Token'] = localStorage.getItem('token') || '';
// Encode query parameters
if (config.method === 'get' && config.params) {
let url = config.url;
url += '?';
const keys = Object.keys(config.params);
for (const key of keys) {
if (config.params[key] !== undefined && config.params[key] !== null) {
url += `${key}=${encodeURIComponent(config.params[key])}&`;
}
}
url = url.substring(0, url.length - 1);
config.params = {};
config.url = url;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
http.interceptors.response.use(
(response) => {
if (response.config.responseType === 'blob') {
return response;
} else if (response.data.resultCode === result_code) {
return response.data;
} else {
showFailToast(response.data.message);
if(response.data.resultCode === 416) {
localStorage.clear()
window.location.href = '/login'
}
}
},
(error) => {
showFailToast(error.message)
return Promise.reject(error);
}
);
export { http };
config.js
const config = {
/**
* api请求基础路径
*/
base_url: {
// 开发环境接口前缀
base: 'http://vue3shopapi.liangdaye.cn/api/v1',
// 打包开发环境接口前缀
dev: 'http://vue3shopapi.liangdaye.cn/api/v1',
// 打包生产环境接口前缀
pro: 'http://vue3shopapi.liangdaye.cn/api/v1',
// 打包测试环境接口前缀
test: 'http://vue3shopapi.liangdaye.cn/api/v1'
},
/**
* 接口成功返回状态码
*/
result_code: 200,
/**
* 接口请求超时时间
*/
request_timeout: 60000,
/**
* 默认接口请求类型
* 可选值:application/x-www-form-urlencoded multipart/form-data
*/
default_headers: 'application/json'
};
export { config };
user.js
import axios from '../config/axios/index'
export function login(params) {
console.log("parmas...:", params)
return axios.post({ url: '/user/login', data: params});
}
export function register(params) {
return axios.post({ url: '/user/register', data: params});
}
export function getUserInfo() {
return axios.get({ url: '/user/info'});
}
在main.js中加入以下代码,用于注册相关组件。
.use(Field) // 注册 Field 组件
.use(Form) // 注册 Form 组件
.use(Button); // 注册 Button 组件