| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501 |
- <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>
|