refactor(inspection): 区域选择器改用 Tab 级联模式,蓝牙验证 UI 优化

区域选择器:
- 面包屑改为 Tab 导航(参考 TDesign Cascader),支持回退任意层级
- hasChildren 改为 childrenMap(Set),O(1) 查找
- v-for key 从 index 改为唯一递增 key,修复二次打开不渲染问题
- 新增 close 事件,支持 FAB 联动

蓝牙验证:
- 扫描和成功合并为同一视图,进度条连贯过渡
- 成功时进度条平滑跳到 100% 并变绿
- 按钮样式统一复用 .ai-btn-primary

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
lzh
2026-03-24 23:09:25 +08:00
parent 0341ba5c10
commit 223a7e9d9c
2 changed files with 576 additions and 302 deletions

View File

@@ -1,59 +1,74 @@
<template>
<wd-popup v-model="visible" position="bottom" :safe-area-inset-bottom="true" custom-style="border-radius: 24rpx 24rpx 0 0;">
<view class="picker-container">
<!-- 标题栏 -->
<view class="picker-header">
<text class="picker-title">选择巡检区域</text>
<view class="picker-close" @click="visible = false">
<wd-icon name="close" size="20px" color="#9CA3AF" />
<wd-popup v-model="visible" position="bottom" :safe-area-inset-bottom="true" custom-style="border-radius: 32rpx 32rpx 0 0;">
<view class="cascader">
<!-- 头部标题 + 关闭 -->
<view class="cascader__header">
<text class="cascader__title">选择巡检区域</text>
<view class="cascader__close" @click="close">
<view class="i-carbon-close text-18px text-[#9CA3AF]" />
</view>
</view>
<!-- 面包屑导航 -->
<view class="breadcrumb">
<view
v-for="(crumb, idx) in breadcrumbs"
:key="crumb.id"
class="breadcrumb-item"
@click="navigateTo(idx)"
>
<text class="breadcrumb-text" :class="{ 'breadcrumb-text--active': idx === breadcrumbs.length - 1 }">
{{ crumb.areaName }}
</text>
<text v-if="idx < breadcrumbs.length - 1" class="breadcrumb-sep">/</text>
</view>
</view>
<!-- 区域列表 -->
<scroll-view scroll-y :style="{ height: `${scrollHeight}px` }">
<view v-if="loading" class="picker-empty">
<wd-loading />
</view>
<view v-else-if="currentList.length === 0" class="picker-empty">
<text class="text-26rpx text-[#9CA3AF]">暂无子区域</text>
</view>
<view
v-for="item in currentList"
v-else
:key="item.id"
class="picker-item"
:class="{ 'picker-item--function': item.areaType === 'FUNCTION' }"
@click="handleItemClick(item)"
>
<view class="picker-item-left">
<view class="picker-item-icon" :class="`picker-item-icon--${item.areaType?.toLowerCase() || 'default'}`">
<view :class="getTypeIcon(item.areaType)" class="text-16px" />
</view>
<view class="picker-item-info">
<text class="picker-item-name">{{ item.areaName }}</text>
<text class="picker-item-type">{{ getTypeLabel(item.areaType) }}</text>
<!-- Tab 导航 -->
<view v-if="visible" class="cascader__tabs">
<scroll-view scroll-x :scroll-left="tabScrollLeft" class="cascader__tabs-scroll">
<view class="cascader__tabs-inner">
<view
v-for="tab in tabsData"
:key="tab.key"
class="cascader__tab"
:class="{ 'cascader__tab--active': tab.key === activeTabKey }"
@click="switchTab(tab.key)"
>
<text class="cascader__tab-text">{{ tab.label }}</text>
<view class="cascader__tab-line" />
</view>
</view>
<view class="picker-item-right">
<view v-if="item.areaType === 'FUNCTION' && !hasChildren(item.id)" class="picker-item-select">
选择
</scroll-view>
</view>
<!-- 选项列表 -->
<scroll-view
scroll-y
class="cascader__options"
:style="{ height: `${scrollHeight}px` }"
>
<view v-if="loading" class="cascader__empty">
<wd-loading />
</view>
<view v-else-if="currentOptions.length === 0" class="cascader__empty">
<text class="text-26rpx text-[#9CA3AF]">暂无子区域</text>
</view>
<view v-else class="cascader__list">
<view
v-for="item in currentOptions"
:key="item.id"
class="cascader__item"
:class="{
'cascader__item--selected': isSelected(item.id),
'cascader__item--selectable': item.areaType === 'FUNCTION' && !childrenMap.has(item.id),
}"
@click="handleItemClick(item)"
>
<view class="cascader__item-icon" :class="`cascader__item-icon--${item.areaType?.toLowerCase() || 'default'}`">
<view :class="getTypeIcon(item.areaType)" class="text-16px" />
</view>
<view class="cascader__item-content">
<text class="cascader__item-name">{{ item.areaName }}</text>
<text class="cascader__item-desc">{{ getTypeLabel(item.areaType) }}</text>
</view>
<!-- 已选中对勾 -->
<view v-if="isSelected(item.id)" class="cascader__item-check">
<view class="i-carbon-checkmark text-16px text-[#F97316]" />
</view>
<!-- 功能区叶子节点可选择 -->
<view v-else-if="item.areaType === 'FUNCTION' && !childrenMap.has(item.id)" class="cascader__item-action">
开始巡检
</view>
<!-- 有子级箭头 -->
<view v-else-if="childrenMap.has(item.id)" class="cascader__item-arrow">
<view class="i-carbon-chevron-right text-14px text-[#D1D5DB]" />
</view>
<wd-icon v-else name="arrow-right" size="16px" color="#D1D5DB" />
</view>
</view>
</scroll-view>
@@ -63,33 +78,71 @@
<script lang="ts" setup>
import type { OpsBusAreaVO } from '@/api/ops/area'
import { computed, ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { getOpsAreaTree } from '@/api/ops/area'
const emit = defineEmits<{
(e: 'select', payload: { areaId: number, areaName: string }): void
(e: 'close'): void
}>()
const visible = ref(false)
/** scroll-view 高度:屏幕 60% 减去标题栏+面包屑(约 80px */
const scrollHeight = computed(() => {
const { windowHeight } = uni.getSystemInfoSync()
return Math.floor(windowHeight * 0.6 - 80)
})
const loading = ref(false)
const areaList = ref<OpsBusAreaVO[]>([])
const loaded = ref(false)
const tabScrollLeft = ref(0)
/** 面包屑路径 */
const breadcrumbs = ref<{ id: number | null, areaName: string }[]>([
{ id: null, areaName: '全部' },
])
/** scroll-view 高度:只计算一次 */
const scrollHeight = (() => {
const { windowHeight } = uni.getSystemInfoSync()
return Math.floor(windowHeight * 0.6 - 100)
})()
/** 当前展示的列表 */
const currentList = ref<OpsBusAreaVO[]>([])
/** 子节点查找表parentId → trueO(1) 判断是否有子级 */
const childrenMap = computed(() => {
const map = new Set<number>()
for (const item of areaList.value) {
if (item.parentId) {
map.add(item.parentId)
}
}
return map
})
// ---- Tab 数据 ----
let tabKeySeq = 0
interface TabItem {
key: number // 唯一标识,避免 v-for 用 index
label: string
parentId: number | null
selectedId: number | null
}
const tabsData = ref<TabItem[]>([])
const activeTabKey = ref(0)
function createTab(label: string, parentId: number | null, selectedId: number | null): TabItem {
return { key: ++tabKeySeq, label, parentId, selectedId }
}
/** 当前激活 tab */
const activeTab = computed(() => tabsData.value.find(t => t.key === activeTabKey.value))
/** 当前选项列表 */
const currentOptions = computed(() => {
if (!activeTab.value)
return []
return getChildren(activeTab.value.parentId)
})
function isSelected(id: number): boolean {
return activeTab.value?.selectedId === id
}
// ---- 数据操作 ----
/** 加载区域数据 */
async function loadAreaList() {
if (loaded.value)
return
@@ -104,59 +157,64 @@ async function loadAreaList() {
}
}
/** 获取某节点的子节点 */
function getChildren(parentId: number | null): OpsBusAreaVO[] {
return areaList.value
.filter(item => parentId === null ? !item.parentId : item.parentId === parentId)
.sort((a, b) => a.sort - b.sort)
}
/** 判断节点是否有子节点 */
function hasChildren(id: number): boolean {
return areaList.value.some(item => item.parentId === id)
}
// ---- 交互逻辑 ----
/** 刷新当前列表 */
function refreshList() {
const currentParentId = breadcrumbs.value[breadcrumbs.value.length - 1].id
const children = getChildren(currentParentId)
// 如果只有一个非 FUNCTION 节点(如唯一的园区),自动展开
if (children.length === 1 && children[0].areaType !== 'FUNCTION') {
breadcrumbs.value.push({ id: children[0].id, areaName: children[0].areaName })
refreshList()
return
}
currentList.value = children
}
/** 点击区域项 */
function handleItemClick(item: OpsBusAreaVO) {
const children = hasChildren(item.id)
const hasChild = childrenMap.value.has(item.id)
if (item.areaType === 'FUNCTION' && !children) {
// 功能区域且无子级 → 直接选中
if (item.areaType === 'FUNCTION' && !hasChild) {
selectArea(item)
} else if (children) {
// 有子级 → 展开下一层
breadcrumbs.value.push({ id: item.id, areaName: item.areaName })
refreshList()
} else {
// 无子级的非 FUNCTION 节点 → 不可选,提示
uni.showToast({ title: '该区域下暂无巡检点', icon: 'none' })
}
}
/** 面包屑跳转 */
function navigateTo(index: number) {
if (index === breadcrumbs.value.length - 1)
return
breadcrumbs.value = breadcrumbs.value.slice(0, index + 1)
refreshList()
}
if (!hasChild) {
uni.showToast({ title: '该区域下暂无巡检点', icon: 'none' })
return
}
// 标记当前 tab 已选
const tab = activeTab.value
if (tab) {
tab.selectedId = item.id
tab.label = item.areaName
}
// 截断后续 tab回退重选场景
const activeIdx = tabsData.value.findIndex(t => t.key === activeTabKey.value)
tabsData.value = tabsData.value.slice(0, activeIdx + 1)
// 自动展开单一非 FUNCTION 子节点
const childList = getChildren(item.id)
if (childList.length === 1 && childList[0].areaType !== 'FUNCTION') {
const autoItem = childList[0]
const autoTab = createTab(autoItem.areaName, item.id, autoItem.id)
const nextTab = createTab('请选择', autoItem.id, null)
tabsData.value.push(autoTab, nextTab)
activeTabKey.value = nextTab.key
} else {
const nextTab = createTab('请选择', item.id, null)
tabsData.value.push(nextTab)
activeTabKey.value = nextTab.key
}
// 滚动 tab 到最右
nextTick(() => {
tabScrollLeft.value = 9999
})
}
function switchTab(key: number) {
if (key === activeTabKey.value)
return
activeTabKey.value = key
}
/** 选中区域 */
function selectArea(item: OpsBusAreaVO) {
const pathNames = buildPathNames(item.id)
visible.value = false
@@ -166,7 +224,6 @@ function selectArea(item: OpsBusAreaVO) {
})
}
/** 构建从根到目标节点的路径名(跳过单根园区) */
function buildPathNames(targetId: number): string[] {
const path: string[] = []
let currentId: number | null = targetId
@@ -180,59 +237,248 @@ function buildPathNames(targetId: number): string[] {
return path
}
/** 区域类型图标 */
function getTypeIcon(type?: string) {
const map: Record<string, string> = {
PARK: 'i-carbon-location',
BUILDING: 'i-carbon-building',
FLOOR: 'i-carbon-layer-0',
FLOOR: 'i-carbon-layers',
FUNCTION: 'i-carbon-clean',
}
return map[type || ''] || 'i-carbon-location'
}
/** 区域类型标签 */
function getTypeLabel(type?: string) {
const map: Record<string, string> = {
PARK: '园区',
BUILDING: '楼栋',
FLOOR: '楼层',
FUNCTION: '功能区',
FUNCTION: '功能区',
}
return map[type || ''] || '区域'
}
/** 打开选择器 */
function close() {
visible.value = false
}
/** visible 变为 false 时统一触发 close */
watch(visible, (val) => {
if (!val) {
emit('close')
}
})
async function open() {
// 重置 tab 数据tabKeySeq 持续递增,不重置,避免 key 复用)
const initialTab = createTab('请选择', null, null)
tabsData.value = [initialTab]
activeTabKey.value = initialTab.key
tabScrollLeft.value = 0
visible.value = true
await loadAreaList()
// 重置到根级
breadcrumbs.value = [{ id: null, areaName: '全部' }]
refreshList()
if (!visible.value)
return // 用户在加载期间关闭了弹窗
// 自动展开单一根节点
const rootChildren = getChildren(null)
if (rootChildren.length === 1 && rootChildren[0].areaType !== 'FUNCTION') {
const root = rootChildren[0]
tabsData.value[0].selectedId = root.id
tabsData.value[0].label = root.areaName
const nextTab = createTab('请选择', root.id, null)
tabsData.value.push(nextTab)
activeTabKey.value = nextTab.key
}
}
defineExpose({ open })
</script>
<style lang="scss" scoped>
.picker-container {
.cascader {
overflow: hidden;
}
.picker-header {
// ---- 头部 ----
.cascader__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 12rpx;
padding: 36rpx 40rpx 24rpx;
}
.picker-title {
font-size: 30rpx;
.cascader__title {
font-size: 34rpx;
font-weight: bold;
color: #1f2937;
}
.picker-close {
.cascader__close {
width: 56rpx;
height: 56rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #f3f4f6;
&:active {
background: #e5e7eb;
}
}
// ---- Tab 导航 ----
.cascader__tabs {
border-bottom: 2rpx solid #f3f4f6;
}
.cascader__tabs-scroll {
white-space: nowrap;
&::-webkit-scrollbar {
display: none;
}
}
.cascader__tabs-inner {
display: inline-flex;
padding: 0 32rpx;
}
.cascader__tab {
position: relative;
padding: 20rpx 32rpx;
flex-shrink: 0;
}
.cascader__tab-text {
font-size: 28rpx;
font-weight: 600;
color: #9ca3af;
transition: color 0.2s;
}
.cascader__tab--active {
.cascader__tab-text {
color: #f97316;
}
.cascader__tab-line {
position: absolute;
bottom: 0;
left: 32rpx;
right: 32rpx;
height: 6rpx;
border-radius: 6rpx;
background: #f97316;
}
}
// ---- 选项列表 ----
.cascader__options {
background: #fafafa;
}
.cascader__list {
padding: 16rpx 32rpx 120rpx;
}
.cascader__empty {
display: flex;
align-items: center;
justify-content: center;
padding: 100rpx 0;
}
.cascader__item {
display: flex;
align-items: center;
padding: 28rpx 24rpx;
margin-bottom: 12rpx;
border-radius: 20rpx;
background: #fff;
transition: background 0.15s;
&:active {
background: #f3f4f6;
}
&--selected {
background: #fff7ed;
&:active {
background: #fff0de;
}
}
&--selectable {
border: 2rpx solid #d1fae5;
}
}
.cascader__item-icon {
width: 64rpx;
height: 64rpx;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 24rpx;
&--park {
background: #dbeafe;
color: #3b82f6;
}
&--building {
background: #e0e7ff;
color: #6366f1;
}
&--floor {
background: #d1fae5;
color: #10b981;
}
&--function {
background: #ecfdf5;
color: #10b981;
}
&--default {
background: #f3f4f6;
color: #6b7280;
}
}
.cascader__item-content {
flex: 1;
min-width: 0;
}
.cascader__item-name {
font-size: 28rpx;
font-weight: 600;
color: #1f2937;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cascader__item-desc {
font-size: 22rpx;
color: #9ca3af;
margin-top: 4rpx;
display: block;
}
.cascader__item-check {
flex-shrink: 0;
margin-left: 16rpx;
width: 48rpx;
height: 48rpx;
display: flex;
@@ -240,145 +486,19 @@ defineExpose({ open })
justify-content: center;
}
.breadcrumb {
display: flex;
flex-wrap: nowrap;
align-items: center;
padding: 0 32rpx 16rpx;
gap: 4rpx;
overflow-x: auto;
white-space: nowrap;
// 隐藏滚动条
&::-webkit-scrollbar {
display: none;
}
}
.breadcrumb-item {
display: flex;
align-items: center;
gap: 4rpx;
.cascader__item-action {
flex-shrink: 0;
}
.breadcrumb-text {
font-size: 24rpx;
color: #f97316;
font-weight: 600;
&--active {
color: #1f2937;
}
}
.breadcrumb-sep {
font-size: 24rpx;
color: #d1d5db;
margin: 0 4rpx;
}
.picker-list {
padding: 0 24rpx 24rpx;
}
.picker-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 60rpx 0;
}
.picker-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 20rpx;
border-radius: 16rpx;
margin-bottom: 10rpx;
background: #f9fafb;
&:active {
background: #fff7ed;
}
&--function {
background: #fff7ed;
border: 2rpx solid #fed7aa;
}
}
.picker-item-left {
display: flex;
align-items: center;
gap: 16rpx;
min-width: 0;
flex: 1;
}
.picker-item-icon {
width: 52rpx;
height: 52rpx;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
&--park {
background: #dbeafe;
color: #3b82f6;
}
&--building {
background: #e0e7ff;
color: #6366f1;
}
&--floor {
background: #d1fae5;
color: #10b981;
}
&--function {
background: #ffedd5;
color: #f97316;
}
&--default {
background: #f3f4f6;
color: #6b7280;
}
}
.picker-item-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.picker-item-name {
font-size: 26rpx;
font-weight: 600;
color: #1f2937;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.picker-item-type {
font-size: 20rpx;
color: #9ca3af;
margin-top: 2rpx;
}
.picker-item-right {
flex-shrink: 0;
margin-left: 12rpx;
}
.picker-item-select {
background: linear-gradient(135deg, #fb923c, #f97316);
margin-left: 16rpx;
background: #10b981;
color: #fff;
font-size: 22rpx;
font-weight: 600;
padding: 8rpx 24rpx;
font-weight: 700;
padding: 10rpx 28rpx;
border-radius: 999rpx;
}
.cascader__item-arrow {
flex-shrink: 0;
margin-left: 16rpx;
}
</style>

View File

@@ -1,60 +1,56 @@
<template>
<view class="bluetooth-verify">
<!-- 扫描中 -->
<view v-if="status === 'scanning'" class="mt-48rpx flex flex-col items-center">
<!-- 扫描中 / 成功共用进度条成功时进度跳到 100% -->
<view v-if="status === 'scanning' || status === 'success'" class="verify-state">
<!-- 脉冲动画 -->
<view class="pulse-container">
<view class="pulse-ring pulse-ring--1" />
<view class="pulse-ring pulse-ring--2" />
<view class="pulse-ring pulse-ring--3" />
<view class="pulse-dot">
<view class="i-carbon-bluetooth text-48rpx text-white" />
<view class="pulse-dot" :class="{ 'pulse-dot--done': status === 'success' }">
<view :class="status === 'success' ? 'i-carbon-checkmark' : 'i-carbon-bluetooth'" class="text-48rpx text-white" />
</view>
</view>
<text class="mt-32rpx text-32rpx text-[#1F2937] font-bold">定位验证中...</text>
<text class="mt-8rpx text-24rpx text-[#9CA3AF]">剩余 {{ remainingSeconds }} </text>
<text class="verify-title" :style="{ color: status === 'success' ? '#10B981' : '#1F2937' }">
{{ status === 'success' ? '定位成功' : '定位验证中...' }}
</text>
<!-- 进度条 -->
<view class="mx-64rpx mt-32rpx w-full px-32rpx">
<view class="h-12rpx overflow-hidden rounded-full bg-[#F3F4F6]">
<view class="progress-wrapper">
<view class="progress-bar">
<view
class="h-full rounded-full bg-[#F97316] transition-all"
:style="{ width: `${progress}%` }"
class="progress-fill"
:class="{ 'progress-fill--done': status === 'success' }"
:style="{ width: `${displayProgress}%` }"
/>
</view>
<text class="progress-percent" :class="{ 'progress-percent--done': status === 'success' }">
{{ displayProgress }}%
</text>
</view>
</view>
<!-- 成功 -->
<view v-if="status === 'success'" class="mt-48rpx flex flex-col items-center">
<view class="h-120rpx w-120rpx flex items-center justify-center rounded-full bg-[#ECFDF5]">
<view class="i-carbon-checkmark-filled text-60rpx text-[#10B981]" />
</view>
<text class="mt-24rpx text-32rpx text-[#10B981] font-bold">定位成功</text>
<text class="mt-8rpx text-24rpx text-[#9CA3AF]">已确认您在巡检区域内</text>
</view>
<!-- 失败/无权限 -->
<view v-if="status === 'failed' || status === 'no-permission'" class="mt-48rpx flex flex-col items-center">
<view class="h-120rpx w-120rpx flex items-center justify-center rounded-full bg-[#FEF2F2]">
<view class="i-carbon-warning-filled text-60rpx text-[#EF4444]" />
<view v-else-if="status === 'failed' || status === 'no-permission'" class="verify-state">
<view class="result-icon result-icon--fail">
<view class="i-carbon-warning text-56rpx text-[#EF4444]" />
</view>
<text class="mt-24rpx text-32rpx text-[#EF4444] font-bold">
<text class="verify-title" style="color: #EF4444;">
{{ status === 'no-permission' ? '缺少蓝牙权限' : '定位失败' }}
</text>
<text class="mt-8rpx text-24rpx text-[#9CA3AF]">{{ errorMessage }}</text>
<text class="verify-desc">{{ errorMessage }}</text>
<!-- 操作按钮 -->
<view class="mx-32rpx mt-48rpx">
<view class="action-group">
<view class="ai-btn-primary" @click="handleRetry">
<view class="i-carbon-renew mr-8rpx text-16px" />
{{ status === 'no-permission' ? '打开设置' : '重新定位' }}
</view>
<view
class="mt-20rpx border border-[#E5E7EB] rounded-24rpx border-solid bg-white py-28rpx text-center text-[#6B7280] font-bold"
@click="handleSkip"
>
跳过验证标记位置异常
<view class="btn-skip" @click="handleSkip">
<view class="i-carbon-skip-forward-outline mr-8rpx text-14px" />
跳过验证
<text class="btn-skip-hint">标记位置异常</text>
</view>
</view>
</view>
@@ -63,7 +59,7 @@
<script lang="ts" setup>
import type { InspectionBeaconConfigVO } from '@/api/ops/inspection'
import { onMounted, watch } from 'vue'
import { computed, onMounted, watch } from 'vue'
import { useBluetoothScan } from '../composables/use-bluetooth-scan'
const props = defineProps<{
@@ -86,12 +82,19 @@ const {
beaconConfig: props.beaconConfig,
})
// 验证成功后自动进入下一步
/** 显示进度:成功时直接 100 */
const displayProgress = computed(() => {
if (status.value === 'success')
return 100
return Math.round(progress.value)
})
// 验证成功后短暂展示 100% 再进入下一步
watch(status, (val) => {
if (val === 'success') {
setTimeout(() => {
emit('success')
}, 500)
}, 800)
}
})
@@ -118,60 +121,211 @@ function handleSkip() {
</script>
<style lang="scss" scoped>
.bluetooth-verify {
padding: 0 32rpx;
}
// ---- 状态容器 ----
.verify-state {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 64rpx;
}
.verify-title {
margin-top: 36rpx;
font-size: 34rpx;
font-weight: bold;
color: #1f2937;
}
.verify-desc {
margin-top: 12rpx;
font-size: 26rpx;
color: #9ca3af;
}
// ---- 脉冲动画 ----
.pulse-container {
position: relative;
width: 200rpx;
height: 200rpx;
width: 240rpx;
height: 240rpx;
display: flex;
align-items: center;
justify-content: center;
}
.pulse-dot {
width: 96rpx;
height: 96rpx;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
background: linear-gradient(135deg, #fb923c, #f97316);
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
box-shadow: 0 8rpx 32rpx rgba(249, 115, 22, 0.3);
transition: all 0.4s ease;
&--done {
background: linear-gradient(135deg, #34d399, #10b981);
box-shadow: 0 8rpx 32rpx rgba(16, 185, 129, 0.3);
}
}
.pulse-ring {
position: absolute;
border-radius: 50%;
border: 4rpx solid rgba(249, 115, 22, 0.3);
animation: pulse 2s ease-out infinite;
border: 4rpx solid rgba(249, 115, 22, 0.25);
animation: pulse 2.4s ease-out infinite;
&--1 {
width: 140rpx;
height: 140rpx;
width: 150rpx;
height: 150rpx;
animation-delay: 0s;
}
&--2 {
width: 180rpx;
height: 180rpx;
width: 196rpx;
height: 196rpx;
animation-delay: 0.6s;
}
&--3 {
width: 220rpx;
height: 220rpx;
width: 240rpx;
height: 240rpx;
animation-delay: 1.2s;
}
}
@keyframes pulse {
0% {
transform: scale(0.8);
opacity: 1;
transform: scale(0.85);
opacity: 0.8;
}
100% {
transform: scale(1.2);
transform: scale(1.15);
opacity: 0;
}
}
// ---- 进度条 ----
.progress-wrapper {
width: 100%;
margin-top: 48rpx;
padding: 0 32rpx;
}
.progress-bar {
height: 16rpx;
border-radius: 16rpx;
background: #f3f4f6;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 16rpx;
background: linear-gradient(90deg, #fb923c, #f97316);
transition:
width 0.5s ease,
background 0.4s ease;
position: relative;
&--done {
background: linear-gradient(90deg, #34d399, #10b981);
&::after {
display: none;
}
}
// 光泽动画
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
animation: shimmer 2s infinite;
}
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.progress-percent {
display: block;
margin-top: 16rpx;
font-size: 28rpx;
font-weight: bold;
color: #f97316;
text-align: center;
&--done {
color: #10b981;
}
}
// ---- 结果图标 ----
.result-icon {
width: 140rpx;
height: 140rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
&--success {
background: #ecfdf5;
box-shadow: 0 0 0 16rpx rgba(16, 185, 129, 0.08);
}
&--fail {
background: #fef2f2;
box-shadow: 0 0 0 16rpx rgba(239, 68, 68, 0.08);
}
}
// ---- 操作按钮 ----
.action-group {
width: 100%;
margin-top: 56rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
.btn-skip {
display: flex;
align-items: center;
justify-content: center;
padding: 28rpx 0;
border-radius: 48rpx;
background: #fff7ed;
color: #f97316;
font-size: 28rpx;
font-weight: 600;
&:active {
background: #fff0de;
}
}
.btn-skip-hint {
font-size: 22rpx;
color: #d4a574;
font-weight: normal;
margin-left: 4rpx;
}
</style>