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:
@@ -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 → true,O(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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user