uni-app Vue3+TS 微信小程序扫码核销功能实现

基于uniapp camera组件实现微信小程序扫码页面,包含相机权限校验、动态扫描动画、扫码防抖、异常错误处理

1. 实现逻辑

1.1 权限管控逻辑

  1. 页面进入自动检测手机相机权限,区分未申请、申请中、已授权、已拒绝四种状态
  2. 首次进入自动唤起相机权限申请,用户拒绝后弹窗引导跳转系统设置开启权限
  3. 权限状态动态切换页面UI,无权限隐藏相机组件展示权限提示

1.2 页面布局逻辑

  1. 全屏黑色背景铺满页面,扫码相机区域居中固定定位
  2. 自定义扫码框四角高亮边框,替代原生简陋扫码框
  3. 上层叠加遮罩层级,保证扫描动画、提示文字置顶显示
  4. 底部放置扫码引导文案,提升用户使用体验

1.3 扫描动画逻辑

  1. 使用定时器实现扫描线上下往复滑动效果
  2. 动态计算扫描线透明度,上下两端渐隐、中间高亮
  3. 页面显示启动动画,页面隐藏/销毁清空定时器,杜绝内存泄漏

1.4 扫码业务逻辑

  1. 增加扫码防抖锁,防止短时间重复多次触发扫码事件
  2. 扫码成功触发手机短震动反馈,弹出成功提示并跳转业务页面
  3. 扫码识别失败统一错误提示,设置冷却时间自动重置扫码状态
  4. 相机启动失败自动重新校验权限,完善异常兜底处理

1.5 生命周期优化

  1. onShow:页面显示重置扫码状态、重新校验相机权限
  2. onHide:页面隐藏停止扫描动画、清空延时定时器
  3. onUnmounted:页面彻底销毁清除所有定时器,优化性能

在这里插入图片描述

2. 具体代码

2.1 页面完整代码

<template>
    <view class="content">
        <!-- 相机组件 -->
        <camera v-if="!showPopup && hasCameraPermission" mode="scanCode" class="camera" device-position="back"
            flash="auto" @scancode="onScanCode" @error="onCameraError">
        </camera>

        <!-- 相机权限被拒绝时的提示 -->
        <view v-if="!hasCameraPermission && !showPopup && permissionStatus === 'denied'" class="permission-denied">
            <text class="permission-text">需要相机权限才能扫描二维码</text>
            <button class="permission-btn" @click="requestCameraPermission">申请相机权限</button>
        </view>

        <!-- 首次进入页面,正在申请权限的加载状态 -->
        <view v-if="!hasCameraPermission && !showPopup && permissionStatus === 'requesting'" class="permission-loading">
            <text class="permission-text">正在申请相机权限...</text>
        </view>

        <!-- 遮罩层 UI - 修改为使用四个方向的遮罩块 -->
        <view v-if="hasCameraPermission" class="scan-overlay">
            <!-- .中间扫描区域 (绝对定位居中) -->
            <view class="scan-area">
                <!-- 四个角标 -->
                <view class="corner corner-tl"></view>
                <view class="corner corner-tr"></view>
                <view class="corner corner-bl"></view>
                <view class="corner corner-br"></view>

                <!-- 扫描线 - 使用 JS 控制动画 -->
                <view class="scan-line" :style="scanLineStyle"></view>
            </view>
        </view>

        <!-- 提示文本 -->
        <view v-if="hasCameraPermission" class="tip_text">将二维码/条形码放入框内,即可自动扫描</view>


    </view>
</template>

<script setup lang="ts">
import { ref, computed, onUnmounted } from 'vue';
import { onShow, onHide } from "@dcloudio/uni-app";

// 防抖标记
const isScanning = ref(false);
// 用于存储冷却期定时器
const scanCooldownTimer = ref<any>(null);
// 相机权限状态
const hasCameraPermission = ref(false);
// 权限状态:'unknown'未知,'requesting'申请中,'denied'已拒绝,'granted'已授权
const permissionStatus = ref('unknown');

const showPopup = ref(false);

// 扫描线动画相关
const scanLineTop = ref(0);
const scanLineOpacity = ref(0);
const scanAnimationTimer = ref<any>(null);
const scanAreaHeight = 267; // 扫描区域高度,与 CSS 中保持一致

// 扫描线样式
const scanLineStyle = computed(() => {
    return {
        top: `${scanLineTop.value}px`,
        opacity: scanLineOpacity.value
    };
});

// 开始扫描线动画
const startScanAnimation = () => {
    let direction = 1; // 1 向下,-1 向上
    let position = 0;
    const speed = 2; // 每帧移动的像素

    scanAnimationTimer.value = setInterval(() => {
        position += speed * direction;

        // 计算透明度
        if (position < scanAreaHeight * 0.1) {
            scanLineOpacity.value = position / (scanAreaHeight * 0.1);
        } else if (position > scanAreaHeight * 0.9) {
            scanLineOpacity.value = 1 - (position - scanAreaHeight * 0.9) / (scanAreaHeight * 0.1);
        } else {
            scanLineOpacity.value = 1;
        }

        // 边界检测
        if (position >= scanAreaHeight) {
            position = scanAreaHeight;
            direction = -1;
        } else if (position <= 0) {
            position = 0;
            direction = 1;
        }

        scanLineTop.value = position;
    }, 16); 
};

// 停止扫描线动画
const stopScanAnimation = () => {
    if (scanAnimationTimer.value) {
        clearInterval(scanAnimationTimer.value);
        scanAnimationTimer.value = null;
    }
};

// onShow 生命周期,确保页面显示时重置状态
onShow(() => {
    // 页面显示时,确保没有在进行冷却,可以立即开始新的扫描
    clearScanCooldown();
    // 检查相机权限
    checkCameraPermission();
});

// onHide 生命周期,页面隐藏时清理状态
onHide(() => {
    if (scanCooldownTimer.value) {
        clearTimeout(scanCooldownTimer.value);
        scanCooldownTimer.value = null;
    }
    stopScanAnimation();
});

// 页面卸载时清理
onUnmounted(() => {
    stopScanAnimation();
    if (scanCooldownTimer.value) {
        clearTimeout(scanCooldownTimer.value);
        scanCooldownTimer.value = null;
    }
});

// 检查相机权限
function checkCameraPermission() {
    uni.getSystemInfo({
        success: (res) => {
            // 检查平台是否支持权限API
            if (res.platform === 'android' || res.platform === 'ios') {
                uni.getSetting({
                    success: (settingRes) => {
                        // 检查相机权限
                        if (settingRes.authSetting['scope.camera']) {
                            hasCameraPermission.value = true;
                            permissionStatus.value = 'granted';
                            startScanAnimation();
                        } else if (settingRes.authSetting['scope.camera'] === false) {
                            // 用户已经明确拒绝过
                            hasCameraPermission.value = false;
                            permissionStatus.value = 'denied';
                        } else {
                            // 权限未知,首次进入,自动申请权限
                            hasCameraPermission.value = false;
                            permissionStatus.value = 'requesting';
                            requestCameraPermission(true); // 传入true表示是自动申请
                        }
                    },
                    fail: () => {
                        // 获取设置失败,尝试申请权限
                        hasCameraPermission.value = false;
                        permissionStatus.value = 'requesting';
                        requestCameraPermission(true);
                    }
                });
            } else {
                // 非移动平台,默认有权限
                hasCameraPermission.value = true;
                permissionStatus.value = 'granted';
                startScanAnimation();
            }
        },
        fail: () => {
            // 获取系统信息失败,尝试申请权限
            hasCameraPermission.value = false;
            permissionStatus.value = 'requesting';
            requestCameraPermission(true);
        }
    });
}

// 申请相机权限
function requestCameraPermission(isAutoRequest = false) {
    // 如果已经在申请中,避免重复申请
    if (permissionStatus.value === 'requesting' && !isAutoRequest) return;

    permissionStatus.value = 'requesting';

    uni.authorize({
        scope: 'scope.camera',
        success: () => {
            hasCameraPermission.value = true;
            permissionStatus.value = 'granted';
            startScanAnimation();
        },
        fail: () => {
            hasCameraPermission.value = false;
            permissionStatus.value = 'denied';

            // 只有在自动申请失败时才显示弹窗
            if (isAutoRequest) {
                uni.showModal({
                    title: '提示',
                    content: '您已拒绝相机权限,将无法使用扫码功能。是否前往设置开启权限?',
                    success: (modalRes) => {
                        if (modalRes.confirm) {
                            uni.openSetting({
                                success: (settingRes) => {
                                    if (settingRes.authSetting['scope.camera']) {
                                        hasCameraPermission.value = true;
                                        permissionStatus.value = 'granted';
                                        startScanAnimation();
                                    }
                                }
                            });
                        }
                    }
                });
            }
        }
    });
}

// 清除冷却期并重置状态
function clearScanCooldown() {
    if (scanCooldownTimer.value) {
        clearTimeout(scanCooldownTimer.value);
        scanCooldownTimer.value = null;
    }
    isScanning.value = false;
}

/**
 * 扫码成功回调
 */
 function onScanCode(e: any) {
    console.log('扫码结果:', e.detail);

    if (isScanning.value) return;
    isScanning.value = true;

    const result = e.detail.result;

    if (result != null && result.length > 0) {
        try {
            // 触发手机震动反馈
            uni.vibrateShort();

            uni.showToast({
                icon: 'success',
                title: "扫码成功",
                duration: 1500, 
            });


            uni.navigateTo({
                url: '/packageB/scanInfo/index' + `?id=${result}`,
            });


        } catch (e: any) {
            uni.vibrateShort();
            uni.showToast({
                icon: 'error',
                title: e?.description ?? '识别失败',
                duration: 1500
            });

            scanCooldownTimer.value = setTimeout(() => {
                clearScanCooldown();
            }, 1500);
        }
    } else {

        isScanning.value = false;
    }
}

/**
 * 相机错误回调
 */
function onCameraError(e: any) {
    console.error('相机错误:', e.detail);
    // 相机错误时,重新检查权限
    checkCameraPermission();

    if (hasCameraPermission.value) {
        // 有权限但仍然出错,可能是其他问题
        uni.showToast({
            title: '相机开启失败,请重试',
            icon: 'none'
        });
    }
    // 如果没有权限,checkCameraPermission函数会处理权限申请
};


</script>

<style scoped lang="scss">
/* 确保页面全屏 */
.content {
    width: 100vw;
    height: 100vh;
    position: relative;
    background-color: #000;
}

.camera {
    z-index: 1;
    position: absolute;
    width: 266px;
    height: 266px;
    left: 50%;
    top: 25%;
    transform: translateX(-50%);
}

/* 遮罩层容器 */
.scan-overlay {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 2;
    /* 确保遮罩层在上层 */
}

/* 扫描区域容器 - 绝对定位居中 */
.scan-area {
    position: absolute;
    width: 267px;
    height: 267px;
    left: 50%;
    top: 25%;
    transform: translateX(-50%);
    border: 1px solid rgba(255, 255, 255, 0.1);
    background-color: transparent;
    z-index: 4;
    overflow: visible;
}

/* 四个角的样式 */
.corner {
    position: absolute;
    width: 35px;
    height: 35px;
    border-color: #ffc783;
    /* 绿色角标 */
    border-style: solid;
    border-width: 0;
    z-index: 199;
}

.corner-tl {
    top: -3px;
    left: -3px;
    border-top-width: 5px;
    border-left-width: 5px;
    border-radius: 3px;
    border-top-left-radius: 8px;
}

.corner-tr {
    top: -3px;
    right: -3px;
    border-top-width: 5px;
    border-right-width: 5px;
    border-top-right-radius: 8px;
}

.corner-bl {
    bottom: -3px;
    left: -3px;
    border-bottom-width: 5px;
    border-left-width: 5px;
    border-bottom-left-radius: 8px;
}

.corner-br {
    bottom: -3px;
    right: -3px;
    border-bottom-width: 5px;
    border-right-width: 5px;
    border-bottom-right-radius: 8px;
}

/* 扫描线样式 - 动画由 JS 控制 */
.scan-line {
    width: 100%;
    height: 2px;
    background: linear-gradient(90deg,
            rgba(255, 199, 131, 0.1) 0%,
            #ffc783 54.15%,
            #737373 100%);
    position: absolute;
    z-index: 5;
    transition: opacity 0.1s ease;
}

.footer-tip {
    color: #cccccc;
    font-size: 14px;
    text-align: center;
    width: 100%;
}

.tip_text {
    width: 100%;
    position: absolute;
    left: 50%;
    transform: translateX(-50%);
    top: 62%;
    font-family:
        PingFang SC,
        PingFang SC;
    font-weight: bold;
    font-size: 14px;
    color: #ffffff;
    z-index: 18;
    text-align: center;
}

.permission-denied {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    height: 100%;
    padding: 40rpx;
}

.permission-loading {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    height: 100%;
    padding: 40rpx;
}

.camera-icon {
    width: 120rpx;
    height: 120rpx;
    margin-bottom: 30rpx;
}

.permission-text {
    font-size: 32rpx;
    color: #333;
    text-align: center;
    margin-bottom: 40rpx;
    line-height: 1.5;
}

.permission-btn {
    background-color: #c1a37e;
    color: white;
    border-radius: 8rpx;
    padding: 20rpx 40rpx;
    font-size: 30rpx;
}
</style>

更多推荐