Преглед изворни кода

添加 cl-slide-verify 滑动验证组件,支持转正图片

icssoa пре 7 месеци
родитељ
комит
fe71ae2ce7

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "cool-unix",
-	"version": "8.0.18",
+	"version": "8.0.19",
 	"license": "MIT",
 	"scripts": {
 		"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

+ 6 - 0
pages.json

@@ -429,6 +429,12 @@
 					"style": {
 						"navigationBarTitleText": "SVG 图标"
 					}
+				},
+				{
+					"path": "other/slide-verify",
+					"style": {
+						"navigationBarTitleText": "SlideVerify 滑动验证"
+					}
 				}
 			]
 		},

+ 47 - 0
pages/demo/other/slide-verify.uvue

@@ -0,0 +1,47 @@
+<template>
+	<cl-page>
+		<view class="p-3">
+			<demo-item :label="t('基础用法')">
+				<cl-slide-verify
+					v-model="status"
+					@success="onSuccess"
+					@fail="onFail"
+				></cl-slide-verify>
+			</demo-item>
+
+			<demo-item :label="t('没有错误提示')">
+				<cl-slide-verify :show-fail="false"></cl-slide-verify>
+			</demo-item>
+
+			<demo-item :label="t('转动图片')">
+				<cl-slide-verify
+					mode="image"
+					image-url="https://unix.cool-js.com/images/demo/avatar.jpg"
+				></cl-slide-verify>
+			</demo-item>
+		</view>
+	</cl-page>
+</template>
+
+<script lang="ts" setup>
+import { ref } from "vue";
+import DemoItem from "../components/item.uvue";
+import { t } from "@/locale";
+import { useUi } from "@/uni_modules/cool-ui";
+
+const ui = useUi();
+
+const status = ref(false);
+
+function onSuccess() {
+	ui.showToast({
+		message: t("验证通过")
+	});
+}
+
+function onFail() {
+	ui.showToast({
+		message: t("验证失败")
+	});
+}
+</script>

+ 5 - 0
pages/index/home.uvue

@@ -438,6 +438,11 @@ const data = computed<Item[]>(() => {
 					label: "SVG",
 					icon: "bubble-chart-line",
 					path: "/pages/demo/other/svg"
+				},
+				{
+					label: "SlideVerify",
+					icon: "contract-right-fill",
+					path: "/pages/demo/other/slide-verify"
 				}
 			]
 		}

+ 501 - 0
uni_modules/cool-ui/components/cl-slide-verify/cl-slide-verify.uvue

@@ -0,0 +1,501 @@
+<template>
+	<view
+		class="cl-slide-verify"
+		:class="[
+			{
+				'cl-slide-verify--disabled': disabled,
+				'cl-slide-verify--success': isSuccess,
+				'cl-slide-verify--fail': isFail
+			},
+			pt.className
+		]"
+	>
+		<!-- 背景图片(图片验证模式) -->
+		<image
+			v-if="mode == 'image' && imageUrl != ''"
+			class="cl-slide-verify__image"
+			:class="[pt.image?.className]"
+			:src="imageUrl"
+			:style="{
+				transform: `rotate(${currentAngle}deg)`,
+				height: parseRpx(imageSize!),
+				width: parseRpx(imageSize!)
+			}"
+			mode="aspectFill"
+		></image>
+
+		<!-- 滑动轨道 -->
+		<view
+			class="cl-slide-verify__track"
+			:class="[
+				{
+					'cl-slide-verify__track--success': isSuccess,
+					'cl-slide-verify__track--fail': isFail,
+					'cl-slide-verify__track--dark': isDark
+				},
+				pt.track?.className
+			]"
+			:style="{
+				height: size + 'px'
+			}"
+		>
+			<!-- 滑动进度条 -->
+			<view
+				class="cl-slide-verify__progress"
+				:class="[
+					{
+						'cl-slide-verify__progress--success': isSuccess,
+						'cl-slide-verify__progress--fail': isFail
+					},
+					pt.progress?.className
+				]"
+				:style="progressStyle"
+			></view>
+
+			<!-- 滑动按钮 -->
+			<view
+				class="cl-slide-verify__slider"
+				:class="[
+					{
+						'cl-slide-verify__slider--active': isDragging,
+						'cl-slide-verify__slider--success': isSuccess,
+						'cl-slide-verify__slider--fail': isFail,
+						'cl-slide-verify__slider--dark': isDark
+					},
+					pt.slider?.className
+				]"
+				:style="sliderStyle"
+				@touchstart="onTouchStart"
+				@touchmove.stop.prevent="onTouchMove"
+				@touchend="onTouchEnd"
+				@touchcancel="onTouchEnd"
+			>
+				<cl-icon
+					:name="sliderIcon"
+					:size="44"
+					:color="sliderColor"
+					:pt="{
+						className: parseClass([pt.icon?.className])
+					}"
+				></cl-icon>
+			</view>
+
+			<!-- 文字提示 -->
+			<view class="cl-slide-verify__text" :class="[pt.text?.className]">
+				<cl-text
+					:color="textColor"
+					:pt="{
+						className: parseClass([pt.label?.className])
+					}"
+				>
+					{{ currentText }}
+				</cl-text>
+			</view>
+		</view>
+	</view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch, nextTick, getCurrentInstance, type PropType } from "vue";
+import { isDark, parseClass, parsePt, parseRpx, random } from "@/cool";
+import type { PassThroughProps } from "../../types";
+import { vibrate } from "@/uni_modules/cool-vibrate";
+import { t } from "@/locale";
+
+defineOptions({
+	name: "cl-slide-verify"
+});
+
+// 组件属性定义
+const props = defineProps({
+	// 样式穿透
+	pt: {
+		type: Object,
+		default: () => ({})
+	},
+	// 是否验证成功
+	modelValue: {
+		type: Boolean,
+		default: false
+	},
+	// 验证模式:slide-直接滑动验证, image-图片旋转验证
+	mode: {
+		type: String as PropType<"slide" | "image">,
+		default: "slide"
+	},
+	// 滑块大小
+	size: {
+		type: Number,
+		default: 40
+	},
+	// 是否禁用
+	disabled: {
+		type: Boolean,
+		default: false
+	},
+	// 图片URL(图片模式使用)
+	imageUrl: {
+		type: String,
+		default: ""
+	},
+	// 图片大小(图片模式使用)
+	imageSize: {
+		type: [Number, String],
+		default: 300
+	},
+	// 角度容错范围
+	angleThreshold: {
+		type: Number,
+		default: 10
+	},
+	// 提示文字
+	text: {
+		type: String,
+		default: ""
+	},
+	// 成功文字
+	successText: {
+		type: String,
+		default: () => t("验证成功")
+	},
+	// 是否错误提示
+	showFail: {
+		type: Boolean,
+		default: true
+	},
+	// 错误提示文字
+	failText: {
+		type: String,
+		default: () => t("验证失败")
+	}
+});
+
+// 事件定义
+const emit = defineEmits(["update:modelValue", "success", "fail", "change"]);
+
+const { proxy } = getCurrentInstance()!;
+
+// 样式穿透类型
+type PassThrough = {
+	className?: string;
+	track?: PassThroughProps;
+	image?: PassThroughProps;
+	progress?: PassThroughProps;
+	slider?: PassThroughProps;
+	icon?: PassThroughProps;
+	text?: PassThroughProps;
+	label?: PassThroughProps;
+};
+
+// 样式穿透计算
+const pt = computed(() => parsePt<PassThrough>(props.pt));
+
+// 滑动状态相关变量
+const isDragging = ref(false); // 是否正在拖动
+const isSuccess = ref(false); // 是否验证成功
+const isFail = ref(false); // 是否验证失败
+const sliderLeft = ref(0); // 滑块左侧距离
+const progressWidth = ref(0); // 进度条宽度
+const startX = ref(0); // 触摸起始点X坐标
+const currentAngle = ref(0); // 当前图片角度
+const initialAngle = ref(0); // 初始图片角度
+
+// 轨道宽度
+const trackWidth = ref(0); // 滑动轨道宽度
+
+// 当前显示的提示文字
+const currentText = computed(() => {
+	if (isSuccess.value) {
+		// 成功时显示成功文字
+		return props.successText;
+	}
+	if (isFail.value) {
+		// 失败时显示失败文字
+		return props.failText;
+	}
+	if (props.text != "") {
+		// 有自定义文字时显示自定义文字
+		return props.text;
+	}
+	if (props.mode == "image") {
+		// 图片模式下默认提示
+		return t("向右滑动转动图片");
+	}
+	return t("向右滑动验证"); // 默认提示
+});
+
+// 滑块图标
+const sliderIcon = computed(() => {
+	if (isSuccess.value) {
+		// 成功时显示对勾
+		return "check-line";
+	}
+	return "arrow-right-double-line"; // 其他情况显示双箭头
+});
+
+// 滑块颜色
+const sliderColor = computed(() => {
+	if (isSuccess.value || isFail.value) {
+		// 成功或失败时为白色
+		return "white";
+	}
+	return "primary"; // 其他情况为主题色
+});
+
+// 文字颜色
+const textColor = computed(() => {
+	if (isSuccess.value) {
+		// 成功时为绿色
+		return "success";
+	}
+	if (isFail.value) {
+		// 失败时为红色
+		return "error";
+	}
+	if (isDragging.value) {
+		// 拖动时为主题色
+		return "primary";
+	}
+	return "info"; // 默认为信息色
+});
+
+// 进度条样式
+const progressStyle = computed(() => {
+	const style = {}; // 样式对象
+	let width = progressWidth.value; // 当前进度条宽度
+	if (width > props.size) {
+		// 超过滑块宽度时,增加宽度
+		width += props.size / 2;
+	}
+	style["width"] = width + "px"; // 设置宽度
+	if (!isDragging.value) {
+		// 非拖动时添加过渡动画
+		style["transition-duration"] = "300ms";
+	}
+	return style; // 返回样式对象
+});
+
+// 滑块样式
+const sliderStyle = computed(() => {
+	const style = {
+		left: sliderLeft.value + "px", // 滑块左侧距离
+		height: props.size + "px", // 滑块高度
+		width: props.size + "px" // 滑块宽度
+	};
+	if (!isDragging.value) {
+		// 非拖动时添加过渡动画
+		style["transition-duration"] = "300ms";
+	}
+	return style; // 返回样式对象
+});
+
+// 检查验证是否成功
+function checkVerification(): boolean {
+	if (props.mode == "slide") {
+		// 滑动模式下,滑块到达最右侧即为成功
+		return sliderLeft.value / (trackWidth.value - props.size) == 1;
+	} else if (props.mode == "image") {
+		// 图片模式下,角度在容错范围内即为成功
+		const angle = currentAngle.value % 360;
+		return angle <= props.angleThreshold || angle >= 360 - props.angleThreshold;
+	}
+	return false; // 其他情况返回失败
+}
+
+// 重置组件状态
+function reset() {
+	sliderLeft.value = 0; // 滑块归零
+	progressWidth.value = 0; // 进度条归零
+	isSuccess.value = false; // 清除成功状态
+	isFail.value = false; // 清除失败状态
+	isDragging.value = false; // 清除拖动状态
+	// 图片模式下重新设置随机初始角度
+	if (props.mode == "image") {
+		initialAngle.value = random(100, 180); // 随机初始角度
+		currentAngle.value = initialAngle.value; // 当前角度等于初始角度
+	}
+}
+
+// 初始化组件
+function init() {
+	nextTick(() => {
+		// 等待DOM更新后执行
+		reset(); // 重置组件状态
+		// 获取轨道宽度
+		uni.createSelectorQuery()
+			.in(proxy)
+			.select(".cl-slide-verify")
+			.boundingClientRect()
+			.exec((res) => {
+				trackWidth.value = (res[0] as NodeInfo).width ?? 0; // 设置轨道宽度
+			});
+	});
+}
+
+// 触摸开始事件
+function onTouchStart(e: TouchEvent) {
+	if (props.disabled || isSuccess.value || isFail.value) return; // 禁用或已完成时不处理
+	isDragging.value = true; // 标记为拖动中
+	startX.value = e.touches[0].clientX; // 记录起始X坐标
+	vibrate(1); // 震动反馈
+}
+
+// 触摸移动事件
+function onTouchMove(e: TouchEvent) {
+	if (!isDragging.value || props.disabled || isSuccess.value || isFail.value) return; // 非拖动或禁用/完成时不处理
+	const currentX = e.touches[0].clientX; // 当前X坐标
+	const deltaX = currentX - startX.value; // 计算滑动距离
+	// 限制滑动范围
+	const newLeft = Math.max(0, Math.min(trackWidth.value - props.size, deltaX));
+	sliderLeft.value = newLeft; // 设置滑块位置
+	progressWidth.value = newLeft; // 设置进度条宽度
+	// 图片模式下,根据滑动距离旋转图片
+	if (props.mode == "image") {
+		const progress = newLeft / (trackWidth.value - props.size); // 计算滑动进度
+		// 从初始错误角度线性旋转到正确角度
+		currentAngle.value = initialAngle.value + initialAngle.value * progress * 3;
+	}
+	emit("change", {
+		progress: newLeft / trackWidth.value, // 当前进度
+		angle: currentAngle.value // 当前角度
+	});
+}
+
+// 触摸结束事件
+function onTouchEnd() {
+	if (!isDragging.value || props.disabled || isSuccess.value || isFail.value) return; // 非拖动或禁用/完成时不处理
+	isDragging.value = false; // 结束拖动
+	// 检查验证是否成功
+	const isComplete = checkVerification();
+	if (isComplete) {
+		// 验证成功
+		isSuccess.value = true;
+		emit("update:modelValue", true); // 通知父组件
+		emit("success", {
+			mode: props.mode,
+			progress: sliderLeft.value / trackWidth.value,
+			angle: currentAngle.value
+		});
+	} else {
+		if (props.showFail) {
+			isFail.value = true; // 显示失败状态
+		} else {
+			// 验证失败,重置位置
+			reset();
+		}
+		emit("update:modelValue", false); // 通知父组件
+		emit("fail", {
+			mode: props.mode,
+			progress: sliderLeft.value / trackWidth.value,
+			angle: currentAngle.value
+		});
+	}
+	vibrate(2); // 震动反馈
+}
+
+// 监听模式变化,重新初始化
+watch(
+	computed(() => props.mode),
+	() => {
+		reset();
+		init();
+	},
+	{ immediate: true }
+);
+
+// 监听图片URL变化
+watch(
+	computed(() => props.imageUrl),
+	() => {
+		if (props.mode == "image") {
+			reset();
+		}
+	}
+);
+
+// 暴露方法
+defineExpose({
+	reset
+});
+</script>
+
+<style lang="scss" scoped>
+.cl-slide-verify {
+	@apply relative rounded-lg w-full flex flex-col items-center justify-center;
+
+	&__track {
+		@apply relative w-full h-full;
+		@apply bg-surface-100 rounded-lg;
+
+		&--success {
+			@apply bg-green-50;
+		}
+
+		&--fail {
+			@apply bg-red-50;
+		}
+
+		&--dark {
+			@apply bg-surface-700;
+		}
+	}
+
+	&__image {
+		@apply rounded-full mb-3;
+	}
+
+	&__progress {
+		@apply absolute left-0 top-0 h-full;
+		@apply bg-primary-100;
+
+		&--success {
+			@apply bg-green-200;
+		}
+
+		&--fail {
+			@apply bg-red-200;
+		}
+	}
+
+	&__slider {
+		@apply absolute top-1/2 left-0 z-20;
+		@apply bg-white rounded-lg;
+		@apply flex items-center justify-center;
+		@apply border border-surface-200;
+		transform: translateY(-50%);
+
+		&--active {
+			@apply shadow-lg border-primary-300;
+		}
+
+		&--success {
+			@apply bg-green-500 border-green-500;
+		}
+
+		&--fail {
+			@apply bg-red-500 border-red-500;
+		}
+
+		&--dark {
+			@apply bg-surface-900;
+		}
+	}
+
+	&__text {
+		@apply absolute flex items-center justify-center h-full w-full;
+		@apply pointer-events-none z-10;
+	}
+
+	&--disabled {
+		@apply opacity-50;
+	}
+
+	&--success {
+		@apply border-green-300;
+	}
+
+	&--fail {
+		@apply border-red-300;
+	}
+}
+</style>

+ 28 - 0
uni_modules/cool-ui/components/cl-slide-verify/props.ts

@@ -0,0 +1,28 @@
+import type { PassThroughProps } from "../../types";
+
+export type ClSlideVerifyPassThrough = {
+	className?: string;
+	track?: PassThroughProps;
+	image?: PassThroughProps;
+	progress?: PassThroughProps;
+	slider?: PassThroughProps;
+	icon?: PassThroughProps;
+	text?: PassThroughProps;
+	label?: PassThroughProps;
+};
+
+export type ClSlideVerifyProps = {
+	className?: string;
+	pt?: ClSlideVerifyPassThrough;
+	modelValue?: boolean;
+	mode?: "slide" | "image";
+	size?: number;
+	disabled?: boolean;
+	imageUrl?: string;
+	imageSize?: any;
+	angleThreshold?: number;
+	text?: string;
+	successText?: string;
+	showFail?: boolean;
+	failText?: string;
+};

+ 2 - 0
uni_modules/cool-ui/index.d.ts

@@ -55,6 +55,7 @@ import type { ClSelectTimeProps, ClSelectTimePassThrough } from "./components/cl
 import type { ClSelectTriggerProps, ClSelectTriggerPassThrough } from "./components/cl-select-trigger/props";
 import type { ClSignProps, ClSignPassThrough } from "./components/cl-sign/props";
 import type { ClSkeletonProps, ClSkeletonPassThrough } from "./components/cl-skeleton/props";
+import type { ClSlideVerifyProps, ClSlideVerifyPassThrough } from "./components/cl-slide-verify/props";
 import type { ClSliderProps, ClSliderPassThrough } from "./components/cl-slider/props";
 import type { ClStickyProps } from "./components/cl-sticky/props";
 import type { ClSwitchProps, ClSwitchPassThrough } from "./components/cl-switch/props";
@@ -127,6 +128,7 @@ declare module "vue" {
 		"cl-select-trigger": (typeof import('./components/cl-select-trigger/cl-select-trigger.uvue')['default']) & import('vue').DefineComponent<ClSelectTriggerProps>;
 		"cl-sign": (typeof import('./components/cl-sign/cl-sign.uvue')['default']) & import('vue').DefineComponent<ClSignProps>;
 		"cl-skeleton": (typeof import('./components/cl-skeleton/cl-skeleton.uvue')['default']) & import('vue').DefineComponent<ClSkeletonProps>;
+		"cl-slide-verify": (typeof import('./components/cl-slide-verify/cl-slide-verify.uvue')['default']) & import('vue').DefineComponent<ClSlideVerifyProps>;
 		"cl-slider": (typeof import('./components/cl-slider/cl-slider.uvue')['default']) & import('vue').DefineComponent<ClSliderProps>;
 		"cl-sticky": (typeof import('./components/cl-sticky/cl-sticky.uvue')['default']) & import('vue').DefineComponent<ClStickyProps>;
 		"cl-switch": (typeof import('./components/cl-switch/cl-switch.uvue')['default']) & import('vue').DefineComponent<ClSwitchProps>;