智能材料预审扫描进度组件(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. 状态管理与动画控制
组件通过ref和computed管理响应式状态,使用定时器控制扫描过程和步骤切换:
// 开始扫描动画
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>