智能材料预审扫描进度组件(ScanProgress)开发实践

智能材料预审扫描进度组件(ScanProgress)开发实践

在现代移动应用和Web开发中,用户体验是至关重要的一环。特别是在处理需要一定时间的操作时,一个直观、美观的进度展示组件能够有效提升用户体验。本文将介绍我开发的一款智能材料预审扫描进度组件(ScanProgress),该组件基于Vue 3 + TypeScript + SCSS开发,用于在材料上传后展示智能扫描和审核的进度过程。

组件概述

ScanProgress组件是一个用于展示多步骤扫描过程的可视化组件,主要应用于医疗支付、文档审核等需要材料预审的场景。组件通过动态动画效果,直观地展示当前扫描进度、页码信息和各个检测步骤的状态,为用户提供清晰的操作反馈。

核心功能与特点

1. 可视化扫描效果

组件实现了一个动态的扫描线动画,通过上下移动的蓝色线条模拟真实的扫描过程,增强用户的视觉体验:

@keyframes scan-animation {
    0% {
        top: 0;
        opacity: 0;
    }
    10% {
        opacity: 1;
    }
    90% {
        opacity: 1;
    }
    100% {
        top: calc(100% - 8rpx);
        opacity: 0;
    }
}

2. 多步骤状态管理

组件支持三种状态的步骤管理:

  • 等待中(waiting):步骤尚未开始,显示空心圆圈
  • 检测中(detecting):步骤正在进行,显示旋转动画
  • 已完成(completed):步骤已完成,显示绿色对勾

3. 灵活的配置参数

组件提供了丰富的配置选项,支持自定义:

  • 当前页码和总页数
  • 扫描步骤列表
  • 是否正在扫描状态
  • 页码切换间隔时间
  • 单个步骤检测时长
  • 图片URL列表

4. 响应式设计

组件使用rpx单位和弹性布局,确保在不同尺寸的设备上都能良好展示。同时,通过CSS变量和动态计算,实现了界面元素的自适应调整。

技术实现细节

1. 组件结构设计

组件采用Vue 3的Composition API进行开发,主要结构包括:

  • 模板部分:包含标题、扫描图片区域、进度信息、进度条和步骤列表
  • 脚本部分:实现组件的核心逻辑,包括状态管理、动画控制和生命周期管理
  • 样式部分:使用SCSS编写组件的样式,实现响应式设计和动画效果

2. 状态管理与动画控制

组件通过refcomputed管理响应式状态,使用定时器控制扫描过程和步骤切换:

// 开始扫描动画
const startScanAnimation = async () => {
    clearAllTimers()

    const max = totalPages.value

    // 逐页扫描
    for (let page = 1; page <= max; page++) {
        // 更新当前页码
        currentPage.value = page

        // 重置当前页的步骤状态
        resetPageSteps()

        // 按顺序执行所有步骤
        for (let i = 0; i < displaySteps.value.length; i++) {
            // 步骤开始:等待中 -> 检测中
            displaySteps.value[i].status = 'detecting'

            // 等待步骤完成
            await new Promise((resolve) => {
                stepTimer = setTimeout(resolve, props.stepIntervalMs)
            })

            // 步骤完成:检测中 -> 已完成
            displaySteps.value[i].status = 'completed'
        }

        // 如果不是最后一页,等待后进入下一页扫描
        if (page < max) {
            await new Promise((resolve) => {
                scanTimer = setTimeout(resolve, props.pageIntervalMs)
            })
        }
    }

    // 扫描完成
    isScanning.value = false
    clearAllTimers()
}

3. 类型定义

使用TypeScript定义组件的接口和类型,提高代码的可维护性和类型安全性:

/** 扫描步骤状态枚举 */
export type ScanStepStatus = 'waiting' | 'detecting' | 'completed'

/** 扫描步骤接口 */
export interface ScanProgressStep {
    /** 步骤名称 */
    name: string
    /** 步骤状态 */
    status: ScanStepStatus
}

/** 图片数组接口 */
export interface imageItem {
    /** 图片名称 */
    url: string
}

使用方法

1. 基本使用

<template>
    <ScanProgress />
</template>

<script setup lang="ts">
import ScanProgress from './components/ScanProgress.vue'
</script>

2. 自定义配置

<template>
    <ScanProgress
        :current-page="1"
        :total-pages="5"
        :steps="customSteps"
        :image-url-list="imageList"
        :page-interval-ms="3000"
        :step-interval-ms="1500"
    />
</template>

<script setup lang="ts">
import ScanProgress from './components/ScanProgress.vue'

const customSteps = [
    { name: '图像质量检测', status: 'waiting' },
    { name: '关键信息识别', status: 'waiting' },
    { name: '数据完整性验证', status: 'waiting' },
    { name: '合规性检查', status: 'waiting' },
]

const imageList = [
    { url: 'https://example.com/image1.jpg' },
    { url: 'https://example.com/image2.jpg' },
    { url: 'https://example.com/image3.jpg' },
]
</script>

组件参数说明

参数名 类型 默认值 说明
currentPage number 1 当前页码
totalPages number 3 总页数
steps ScanProgressStep[] 默认3个步骤 扫描步骤列表
isScanning boolean true 是否正在扫描
pageIntervalMs number 2000 页码切换间隔时间(毫秒)
stepIntervalMs number 1000 单个步骤检测时长(毫秒)
imageUrlList imageItem[] [] 图片URL列表

总结与展望

ScanProgress组件通过直观的视觉效果和流畅的动画,为用户提供了清晰的扫描进度反馈。组件采用模块化设计,具有良好的可扩展性和可维护性,可以方便地集成到各种需要进度展示的场景中。

未来,我们可以考虑为组件添加更多功能,如:

  • 支持自定义主题颜色
  • 提供更多动画效果选项
  • 支持触摸交互,允许用户手动切换页码
  • 增加无障碍访问支持

希望本文的介绍能够帮助大家了解这个组件的设计思路和实现细节,也欢迎大家提出宝贵的意见和建议。

组件完整代码如下:

<template>
    <view class="scan-progress-container">
        <!-- 标题 -->
        <view class="title-container">
            <text class="title">智能材料预审</text>
        </view>

        <!-- 扫描图片区域 -->
        <view class="scan-image-container">
            <view class="image-frame">
                <!-- 四个蓝色拐角 -->
                <view class="corner top-left"></view>
                <view class="corner top-right"></view>
                <view class="corner bottom-left"></view>
                <view class="corner bottom-right"></view>

                <!-- 模拟上下扫描效果的动画线 -->
                <view class="scan-line" :class="{ 'scan-active': isScanning }"></view>
                <!-- 图片内容 -->
                <view v-if="imageUrlList.length > 0" class="image-content">
                    <image class="image-item" :src="imageUrlList[currentPage - 1]?.url"></image>
                </view>
            </view>
        </view>

        <!-- 扫描进度信息 -->
        <view class="progress-info">
            <text class="scanning-text">正在扫描</text>
            <text class="page-info">{{ currentPage }}/{{ totalPages }}</text>
        </view>

        <!-- 进度条 -->
        <view class="progress-bar-container">
            <view class="progress-bar" :style="{ width: progressPercent + '%' }"></view>
        </view>

        <!-- 扫描步骤列表 -->
        <view class="steps-list">
            <view v-for="(step, index) in scanSteps" :key="step.name || index" class="step-item">
                <text class="step-text">{{ step.name }}</text>
                <view class="step-status">
                    <!-- 已完成状态 -->
                    <template v-if="step.status === 'completed'">
                        <text class="status-text completed">已完成</text>
                        <text class="status-check">✓</text>
                    </template>
                    <!-- 检测中状态 -->
                    <template v-else-if="step.status === 'detecting'">
                        <text class="status-text detecting">检测中</text>
                        <view class="status-spinner"></view>
                    </template>
                    <!-- 等待中状态 -->
                    <template v-else>
                        <text class="status-text waiting">等待中</text>
                        <view class="status-circle"></view>
                    </template>
                </view>
            </view>
        </view>
    </view>
</template>

<script lang="ts" setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'

/** 扫描步骤状态枚举 */
export type ScanStepStatus = 'waiting' | 'detecting' | 'completed'

/** 扫描步骤接口 */
export interface ScanProgressStep {
    /** 步骤名称 */
    name: string
    /** 步骤状态 */
    status: ScanStepStatus
}

/** 图片数组接口 */
export interface imageItem {
    /** 图片名称 */
    url: string
}

const props = withDefaults(
    defineProps<{
        /** 当前页码 */
        currentPage?: number
        /** 总页数 */
        totalPages?: number
        /** 扫描步骤列表 */
        steps?: ScanProgressStep[]
        /** 是否正在扫描 */
        isScanning?: boolean
        /** 每页扫描完成后等待时间(毫秒)- 对应页码切换间隔动效 */
        pageIntervalMs?: number
        /** 单个步骤检测中状态持续时间(毫秒)- 对应步骤旋转动画时长 */
        stepIntervalMs?: number
        /** 图片URL列表 */
        imageUrlList?: imageItem[]
    }>(),
    {
        currentPage: 1,
        totalPages: 3,
        steps: () => [
            { name: '图像清晰度检测', status: 'waiting' },
            { name: '关键信息提取', status: 'waiting' },
            { name: '单据类型分类', status: 'waiting' },
        ],
        imageUrlList: () => [],
        isScanning: true,
        pageIntervalMs: 2000, // 页码切换间隔时间
        stepIntervalMs: 1000, // 单个步骤检测中状态持续时间
    },
)

/** 当前页码响应式变量 */
const currentPage = ref(props.currentPage)
/** 总页数响应式变量 - 动态根据图片列表长度更新 */
const totalPages = ref(props.imageUrlList.length > 0 ? props.imageUrlList.length : props.totalPages)
/** 是否正在扫描响应式变量 */
const isScanning = ref(props.isScanning)

/** 本地步骤列表(深拷贝props.steps以便修改状态) */
const displaySteps = ref(props.steps.map((step) => ({ ...step })))

/** 扫描步骤计算属性 */
const scanSteps = computed(() => displaySteps.value)

/** 进度百分比计算属性 */
const progressPercent = computed(() => {
    const total = totalPages.value
    if (total <= 0) return 0
    const ratio = currentPage.value / total
    return Math.min(100, Math.max(0, ratio * 100))
})

/** 扫描定时器 */
let scanTimer: ReturnType<typeof setInterval> | null = null
/** 步骤切换定时器 */
let stepTimer: ReturnType<typeof setTimeout> | null = null

/** 清除所有定时器 */
const clearAllTimers = () => {
    if (scanTimer != null) {
        clearInterval(scanTimer)
        scanTimer = null
    }
    if (stepTimer != null) {
        clearTimeout(stepTimer)
        stepTimer = null
    }
}

/** 重置当前页的步骤状态为初始状态 */
const resetPageSteps = () => {
    displaySteps.value.forEach((step) => {
        step.status = 'waiting'
    })
}

/** 开始扫描动画 */
const startScanAnimation = async () => {
    clearAllTimers()

    const max = totalPages.value

    // 逐页扫描
    for (let page = 1; page <= max; page++) {
        // 更新当前页码
        currentPage.value = page

        // 重置当前页的步骤状态
        resetPageSteps()

        // 按顺序执行所有步骤
        for (let i = 0; i < displaySteps.value.length; i++) {
            // 步骤开始:等待中 -> 检测中
            displaySteps.value[i].status = 'detecting'

            // 等待步骤完成
            await new Promise((resolve) => {
                stepTimer = setTimeout(resolve, props.stepIntervalMs)
            })

            // 步骤完成:检测中 -> 已完成
            displaySteps.value[i].status = 'completed'

            // 如果不是最后一步,短暂等待后进入下一步
            // 300ms - 步骤之间状态切换的过渡时间(提升视觉流畅度)
            if (i < displaySteps.value.length - 1) {
                await new Promise((resolve) => {
                    stepTimer = setTimeout(resolve, 300) // 步骤切换过渡时间
                })
            }
        }

        // 如果不是最后一页,等待后进入下一页扫描
        if (page < max) {
            await new Promise((resolve) => {
                scanTimer = setTimeout(resolve, props.pageIntervalMs)
            })
        }
    }

    // 扫描完成
    isScanning.value = false
    clearAllTimers()
}

/** 监听图片列表变化,动态更新总页数 */
watch(
    () => props.imageUrlList,
    (newList) => {
        if (newList.length > 0) {
            totalPages.value = newList.length
        }
    },
    { deep: true },
)

/** 组件挂载时启动扫描动画 */
onMounted(() => {
    startScanAnimation()
})

/** 组件卸载时清除所有定时器 */
onUnmounted(() => {
    clearAllTimers()
})
</script>

<style lang="scss" scoped>
.scan-progress-container {
    width: 100%;
    padding: 32rpx;
    box-sizing: border-box;
    background-color: #ffffff;
    border-radius: 16rpx;

    .title-container {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 32rpx;

        .title {
            font-size: 36rpx;
            font-weight: bold;
            color: #242436;
        }
    }

    .scan-image-container {
        margin-bottom: 32rpx;
        background-color: #f5f5f5;
        padding: 40rpx 44rpx;
        border-radius: 20rpx;
        box-sizing: border-box;

        .image-frame {
            position: relative;
            overflow: hidden;
            padding: 28rpx 30rpx;
            box-sizing: border-box;
            // background-color: #ffffff;

            .corner {
                position: absolute;
                width: 48rpx;
                height: 48rpx;
                border: 10rpx solid #0062ff;
                box-sizing: border-box;
            }

            .corner.top-left {
                top: 0;
                left: 0;
                border-right: none;
                border-bottom: none;
                border-top-left-radius: 12rpx;
            }

            .corner.top-right {
                top: 0;
                right: 0;
                border-left: none;
                border-bottom: none;
                border-top-right-radius: 12rpx;
            }

            .corner.bottom-left {
                bottom: 0;
                left: 0;
                border-right: none;
                border-top: none;
                border-bottom-left-radius: 12rpx;
            }

            .corner.bottom-right {
                bottom: 0;
                right: 0;
                border-left: none;
                border-top: none;
                border-bottom-right-radius: 12rpx;
            }

            .scan-line {
                position: absolute;
                left: 0;
                right: 0;
                height: 8rpx;
                background-color: #0062ff;
                opacity: 0;
                transition: top 0.1s linear; /* 扫描线位置变化的过渡时间 */
                box-shadow: 0 0 20rpx rgba(0, 98, 255, 0.6);
                z-index: 10;
            }

            .scan-active {
                /* 应用扫描线动画 - 2秒一个周期,无限循环 */
                animation: scan-animation 2s ease-in-out infinite;
            }

            .image-content {
                width: 420rpx;
                height: 420rpx;
                margin: auto;
                box-sizing: border-box;
                overflow: hidden;
                border-radius: 20rpx;
                // background-color: #ffffff;
                .image-item {
                    width: 100%;
                    height: 100%;
                }
            }
        }
    }

    .progress-info {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 20rpx;

        .scanning-text {
            font-size: 32rpx;
            font-weight: 500;
            color: #242436;
        }

        .page-info {
            font-size: 28rpx;
            color: #0062ff;
            font-weight: bold;
        }
    }

    .progress-bar-container {
        width: 100%;
        height: 8rpx;
        background-color: #e8e8e8;
        border-radius: 4rpx;
        margin-bottom: 32rpx;
        overflow: hidden;

        .progress-bar {
            height: 100%;
            background-color: #0062ff;
            border-radius: 4rpx;
            transition: width 0.5s ease;
        }
    }

    .steps-list {
        .step-item {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-bottom: 20rpx;

            &:last-child {
                margin-bottom: 0;
            }

            .step-text {
                font-size: 28rpx;
                color: #242436;
            }

            .step-status {
                display: flex;
                align-items: center;
                min-height: 28rpx;

                .status-text {
                    font-size: 24rpx;
                    margin-right: 8rpx;
                }

                // 已完成状态文本
                .status-text.completed {
                    color: #19be6b;
                }

                // 检测中状态文本
                .status-text.detecting {
                    color: #0062ff;
                }

                // 等待中状态文本
                .status-text.waiting {
                    color: #999999;
                }

                // 已完成对勾
                .status-check {
                    width: 28rpx;
                    height: 28rpx;
                    background-color: #19be6b;
                    color: #ffffff;
                    border-radius: 14rpx;
                    font-size: 20rpx;
                    text-align: center;
                    line-height: 28rpx;
                }

                // 检测中旋转动画
                .status-spinner {
                    width: 28rpx;
                    height: 28rpx;
                    border: 2rpx solid #0062ff;
                    border-top-color: transparent;
                    border-radius: 50%;
                    animation: spin 1s linear infinite;
                }

                // 等待中空心圆
                .status-circle {
                    width: 28rpx;
                    height: 28rpx;
                    border: 2rpx solid #999999;
                    border-radius: 50%;
                    background-color: transparent;
                }
            }
        }
    }
}

/* 扫描线动画 - 2秒一个周期,实现上下扫描效果 */
@keyframes scan-animation {
    0% {
        top: 0;
        opacity: 0;
    }
    10% {
        opacity: 1;
    }
    90% {
        opacity: 1;
    }
    100% {
        top: calc(100% - 8rpx);
        opacity: 0;
    }
}

/* 旋转动画 - 1秒一个周期,用于检测中状态的旋转指示器 */
@keyframes spin {
    0% {
        transform: rotate(0deg);
    }
    100% {
        transform: rotate(360deg);
    }
}
</style>

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容