Explorar el Código

feat:登录新增验证码

zhangchong hace 1 año
padre
commit
a0a7460a3a

+ 85 - 0
src/components/ImgVerify/hooks.ts

@@ -0,0 +1,85 @@
+import { ref, onMounted } from 'vue';
+
+/**
+ * 绘制图形验证码
+ * @param width - 图形宽度
+ * @param height - 图形高度
+ */
+export const useImageVerify = (width = 120, height = 32) => {
+	const domRef = ref<HTMLCanvasElement>();
+	const imgCode = ref('');
+
+	function setImgCode(code: string) {
+		imgCode.value = code;
+	}
+
+	function getImgCode() {
+		if (!domRef.value) return;
+		imgCode.value = draw(domRef.value, width, height);
+	}
+
+	onMounted(() => {
+		getImgCode();
+	});
+
+	return {
+		domRef,
+		imgCode,
+		setImgCode,
+		getImgCode,
+	};
+};
+
+function randomNum(min: number, max: number) {
+	const num = Math.floor(Math.random() * (max - min) + min);
+	return num;
+}
+
+function randomColor(min: number, max: number) {
+	const r = randomNum(min, max);
+	const g = randomNum(min, max);
+	const b = randomNum(min, max);
+	return `rgb(${r},${g},${b})`;
+}
+
+function draw(dom: HTMLCanvasElement, width: number, height: number) {
+	let imgCode = '';
+
+	const Random_STRING: string = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+
+	const ctx = dom.getContext('2d');
+	if (!ctx) return imgCode;
+
+	ctx.fillStyle = randomColor(180, 230);
+	ctx.fillRect(0, 0, width, height);
+	for (let i = 0; i < 4; i += 1) {
+		const text = Random_STRING[randomNum(0, Random_STRING.length)];
+		imgCode += text;
+		const fontSize = randomNum(18, 41);
+		const deg = randomNum(-30, 30);
+		ctx.font = `${fontSize}px Simhei`;
+		ctx.textBaseline = 'top';
+		ctx.fillStyle = randomColor(80, 150);
+		ctx.save();
+		ctx.translate(30 * i + 15, 15);
+		ctx.rotate((deg * Math.PI) / 180);
+		ctx.fillText(text, -15 + 5, -15);
+		ctx.restore();
+	}
+	for (let i = 0; i < 5; i += 1) {
+		ctx.beginPath();
+		ctx.moveTo(randomNum(0, width), randomNum(0, height));
+		ctx.lineTo(randomNum(0, width), randomNum(0, height));
+		ctx.strokeStyle = randomColor(180, 230);
+		ctx.closePath();
+		ctx.stroke();
+	}
+	for (let i = 0; i < 41; i += 1) {
+		ctx.beginPath();
+		ctx.arc(randomNum(0, width), randomNum(0, height), 1, 0, 2 * Math.PI);
+		ctx.closePath();
+		ctx.fillStyle = randomColor(150, 200);
+		ctx.fill();
+	}
+	return imgCode;
+}

+ 35 - 0
src/components/ImgVerify/index.vue

@@ -0,0 +1,35 @@
+<template>
+	<canvas ref="domRef" width="120" height="32" class="cursor-pointer" @click="getImgCode" />
+</template>
+<script setup lang="ts" name="ReImageVerify">
+import { watch } from 'vue';
+import { useImageVerify } from './hooks';
+interface Props {
+	code?: string;
+}
+
+interface Emits {
+	(e: 'update:code', code: string): void;
+}
+
+const props = withDefaults(defineProps<Props>(), {
+	code: '',
+});
+
+const emit = defineEmits<Emits>();
+
+const { domRef, imgCode, setImgCode, getImgCode } = useImageVerify();
+
+watch(
+	() => props.code,
+	(newValue) => {
+		setImgCode(newValue);
+	}
+);
+watch(imgCode, (newValue) => {
+	// 不区分大小写
+	emit('update:code', newValue.toLowerCase());
+});
+
+defineExpose({ getImgCode });
+</script>

+ 3 - 1
src/main.ts

@@ -13,6 +13,8 @@ import Pagination from '@/components/Pagination/index.vue';
 // 空组件
 import Empty from '@/components/Empty/index.vue';
 
+import { MotionPlugin } from '@vueuse/motion';
+
 const app = createApp(App);
 
 // 自定义指令和svg组件
@@ -22,4 +24,4 @@ other.elSvg(app);
 app.component('Pagination', Pagination);
 app.component('Empty', Empty);
 
-app.use(pinia).use(router).use(ElementPlus).mount('#app');
+app.use(pinia).use(router).use(ElementPlus).use(MotionPlugin).mount('#app');

+ 40 - 0
src/utils/motion.ts

@@ -0,0 +1,40 @@
+import { h, defineComponent, withDirectives, resolveDirective } from 'vue';
+
+/** 封装@vueuse/motion动画库中的自定义指令v-motion */
+export default defineComponent({
+	name: 'Motion',
+	props: {
+		delay: {
+			type: Number,
+			default: 50,
+		},
+	},
+	render() {
+		const { delay } = this;
+		const motion = resolveDirective('motion');
+		return withDirectives(
+			h(
+				'div',
+				{},
+				{
+					default: () => [this.$slots.default()],
+				}
+			),
+			[
+				[
+					motion,
+					{
+						initial: { opacity: 0, y: 100 },
+						enter: {
+							opacity: 1,
+							y: 0,
+							transition: {
+								delay,
+							},
+						},
+					},
+				],
+			]
+		);
+	},
+});

+ 97 - 59
src/views/login/component/Account.vue

@@ -1,64 +1,90 @@
 <template>
 	<el-form size="large" class="login-content-form" ref="ruleFormRef" :model="state.ruleForm" @submit.native.prevent>
-		<el-form-item class="login-animation1 mb30" prop="username" :rules="[{ required: true, message: '请输入账号', trigger: 'blur' }]">
-			<el-input
-				type="text"
-				class="inputDeep"
-				placeholder="请输入账号"
-				v-model="state.ruleForm.username"
-				clearable
-				@keyup.enter="onSignIn(ruleFormRef)"
-				autocomplete="off"
-			>
-				<template #prefix>
-					<SvgIcon name="ele-User" class="el-input__icon" />
-				</template>
-			</el-input>
-		</el-form-item>
-		<el-form-item class="login-animation2 mb30" prop="password" :rules="[{ required: true, message: '请输入密码', trigger: 'blur' }]">
-			<el-input
-				class="inputDeep"
-				clearable
-				show-password
-				placeholder="请输入密码"
-				v-model="state.ruleForm.password"
-				@keyup.enter="onSignIn(ruleFormRef)"
-				autocomplete="off"
-			>
-				<template #prefix>
-					<SvgIcon name="ele-Unlock" class="el-input__icon" />
-				</template>
-			</el-input>
-		</el-form-item>
-		<!--		 <el-form-item class="login-animation3">
-			<el-col :span="15">
-				<el-input type="text" maxlength="4" class="inputDeep" placeholder="请输入验证码" v-model="state.ruleForm.code" @keyup.enter="onSignIn(ruleFormRef)" clearable autocomplete="off">
+		<motion :delay="100">
+			<el-form-item class="mb30" prop="username" :rules="[{ required: true, message: '请输入账号', trigger: 'blur' }]">
+				<el-input
+					type="text"
+					class="inputDeep"
+					placeholder="请输入账号"
+					v-model="state.ruleForm.username"
+					clearable
+					@keyup.enter="onSignIn(ruleFormRef)"
+					autocomplete="off"
+				>
+					<template #prefix>
+						<SvgIcon name="ele-User" class="el-input__icon" />
+					</template>
+				</el-input>
+			</el-form-item>
+		</motion>
+		<motion :delay="200">
+			<el-form-item class="mb30" prop="password" :rules="[{ required: true, message: '请输入密码', trigger: 'blur' }]">
+				<el-input
+					class="inputDeep"
+					clearable
+					show-password
+					placeholder="请输入密码"
+					v-model="state.ruleForm.password"
+					@keyup.enter="onSignIn(ruleFormRef)"
+					autocomplete="off"
+				>
 					<template #prefix>
-						<SvgIcon  name="ele-Position" class="el-input__icon"/>
+						<SvgIcon name="ele-Unlock" class="el-input__icon" />
 					</template>
 				</el-input>
-			</el-col>
-			<el-col :span="8" :offset=1>
-				<el-button class="login-content-code">1234</el-button>
-			</el-col>
-		</el-form-item>-->
-		<el-form-item class="login-animation4">
-			<el-button type="primary" class="login-content-submit" round @click="onSignIn(ruleFormRef)" v-waves="'light'" :loading="state.loading"
-				>登录</el-button
+			</el-form-item>
+		</motion>
+		<motion :delay="300">
+			<el-form-item
+				prop="verifyCode"
+				:rules="[
+					{ required: true, message: '请输入验证码', trigger: 'blur' },
+					{
+						validator: validatePass,
+						trigger: 'blur',
+					},
+				]"
 			>
-		</el-form-item>
-		<!-- <div class="font12 mt30 login-animation4 login-msg">
+				<el-col :span="15">
+					<el-input
+						type="text"
+						maxlength="4"
+						class="inputDeep"
+						placeholder="请输入验证码"
+						v-model="state.ruleForm.verifyCode"
+						@keyup.enter="onSignIn(ruleFormRef)"
+						clearable
+						autocomplete="off"
+					>
+						<template #prefix>
+							<SvgIcon name="iconfont icon-quanxian" class="el-input__icon" />
+						</template>
+					</el-input>
+				</el-col>
+				<el-col :span="8" :offset="1" class="flex">
+					<ReImageVerify v-model:code="verifyCode" />
+				</el-col>
+			</el-form-item>
+		</motion>
+		<motion :delay="400">
+			<el-form-item>
+				<el-button type="primary" class="login-content-submit" round @click="onSignIn(ruleFormRef)" :loading="state.loading">登录</el-button>
+			</el-form-item>
+		</motion>
+		<!-- <div class="font12 mt30 login-msg">
 			* 温馨提示:建议使用谷歌、Microsoft Edge,版本 79.0.1072.62 及以上浏览器,360浏览器请使用极速模式
 		</div> -->
-		<div class="login-msg login-animation4">
-			<div>联系管理员<b>重置密码</b></div>
-			<!-- <el-button link type="primary" class="font16" @click="forgetPwd">忘记密码</el-button> -->
-		</div>
+		<motion :delay="500">
+			<div class="login-msg">
+				<div>联系管理员<b>重置密码</b></div>
+				<!-- <el-button link type="primary" class="font16" @click="forgetPwd">忘记密码</el-button> -->
+			</div>
+		</motion>
 	</el-form>
 </template>
 
 <script setup lang="ts" name="loginAccount">
-import { reactive, computed, ref } from 'vue';
+import { reactive, computed, ref, defineAsyncComponent } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { ElNotification } from 'element-plus';
 import { storeToRefs } from 'pinia';
@@ -72,6 +98,10 @@ import type { FormInstance } from 'element-plus';
 import { signIn } from '@/api/login';
 import { JSEncrypt } from 'jsencrypt'; // rsa加密
 import { throttle } from '@/utils/tools';
+import Motion from '@/utils/motion';
+
+const ReImageVerify = defineAsyncComponent(() => import('@/components/ImgVerify/index.vue'));
+
 // 定义变量内容
 const storesThemeConfig = useThemeConfig(); // 主题配置
 const { themeConfig } = storeToRefs(storesThemeConfig); // 主题配置
@@ -81,6 +111,7 @@ const state = reactive<any>({
 	ruleForm: {
 		username: '', // 账号
 		password: '', // 密码
+		verifyCode: '', // 验证码
 	},
 	loading: false, // 加载
 });
@@ -89,6 +120,20 @@ const ruleFormRef = ref<FormInstance>(); // 表单ref
 const currentTime = computed(() => {
 	return formatAxis(new Date());
 });
+const validatePass = (rule: any, value: any, callback: any) => {
+	if (value === '') {
+		callback(new Error('请输入验证码'));
+	} else {
+		if (state.ruleForm.verifyCode !== '') {
+			if (state.ruleForm.verifyCode !== verifyCode.value) {
+				callback(new Error('验证码错误,请重新输入'));
+			}
+		}
+		callback();
+	}
+};
+// 验证码
+const verifyCode = ref<string>(''); // 验证码
 // 登录
 const onSignIn = throttle(async (formEl: FormInstance | undefined) => {
 	if (!formEl) return;
@@ -177,6 +222,9 @@ const signInSuccess = (isNoPower: boolean | undefined) => {
 		border-radius: 0;
 		border-bottom: 1px solid var(--el-input-border-color, var(--el-border-color));
 	}
+	:deep(.el-form-item.is-error .el-input__wrapper.is-focus) {
+		box-shadow: 0 0 0 0 var(--el-input-border-color, var(--el-border-color)) inset;
+	}
 }
 
 .login-content-form {
@@ -191,16 +239,6 @@ const signInSuccess = (isNoPower: boolean | undefined) => {
 		font-size: 14px;
 	}
 
-	@for $i from 1 through 4 {
-		.login-animation#{$i} {
-			opacity: 0;
-			animation-name: error-num;
-			animation-duration: 0.5s;
-			animation-fill-mode: forwards;
-			animation-delay: calc($i/10) + s;
-		}
-	}
-
 	.login-content-password {
 		display: inline-block;
 		width: 20px;

+ 6 - 3
src/views/login/index.vue

@@ -2,7 +2,9 @@
 	<div class="login-container w100 h100">
 		<div class="login-content">
 			<div class="login-content-main">
-				<h4 class="login-content-title">{{ getThemeConfig.globalViceTitle }}</h4>
+				<Motion>
+					<h4 class="login-content-title">{{ getThemeConfig.globalViceTitle }}</h4>
+				</Motion>
 				<div v-if="!state.isScan">
 					<el-tabs v-model="state.tabsActiveName">
 						<!-- <el-tab-pane label="账号密码登录" name="account"> -->
@@ -29,6 +31,7 @@ import { storeToRefs } from 'pinia';
 import { useThemeConfig } from '@/stores/themeConfig';
 import { NextLoading } from '@/utils/loading';
 import { getImageUrl } from '@/utils/tools';
+import Motion from '@/utils/motion';
 // 定义接口来定义对象的类型
 interface LoginState {
 	tabsActiveName: string;
@@ -43,8 +46,8 @@ const Account = defineAsyncComponent(() => import('@/views/login/component/Accou
 const storesThemeConfig = useThemeConfig();
 const { themeConfig } = storeToRefs(storesThemeConfig);
 const state = reactive<LoginState>({
-	tabsActiveName: 'account',  // 默认显示账号密码登录
-	isScan: false,  // 是否显示扫码登录
+	tabsActiveName: 'account', // 默认显示账号密码登录
+	isScan: false, // 是否显示扫码登录
 });
 let bgImg = themeConfig.value.loginImage ?? `url(${getImageUrl('login/bg.png')})`;
 // 获取布局配置信息