feat:登录注册完成

This commit is contained in:
李伊帆
2026-10-09 14:29:58 +08:00
parent da83a58948
commit ee3ecc00eb
106 changed files with 4937 additions and 59 deletions

View File

@@ -0,0 +1,91 @@
.add-device-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.add-device-page__scroll {
flex: 1;
height: 0;
}
.add-device-page__body {
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.add-device-page__title {
display: block;
padding: 0 40rpx;
font-size: 40rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 20rpx;
text-align: center;
}
.add-device-page__desc {
display: block;
padding: 0 48rpx;
font-size: 26rpx;
color: #98A2B3;
line-height: 1.65;
margin-bottom: 32rpx;
text-align: center;
}
.add-device-page__footer {
padding: 40rpx 16rpx 0;
margin-top: 8rpx;
}
.add-device-page__btn {
width: 100%;
height: 96rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 40rpx;
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
margin-bottom: 28rpx;
}
.add-device-page__btn-text {
font-size: 32rpx;
font-weight: 500;
color: #fff;
}
.add-device-page__btn-badge {
min-width: 44rpx;
height: 44rpx;
padding: 0 12rpx;
background: #ffc940;
border-radius: 12rpx;
display: flex;
align-items: center;
justify-content: center;
border: 3rpx solid #fff;
}
.add-device-page__btn-badge-num {
font-size: 26rpx;
font-weight: 700;
color: #fff;
}
.add-device-page__later-wrap {
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx 0 8rpx;
}
.add-device-page__later {
font-size: 28rpx;
color: #999;
text-align: center;
}

View File

@@ -0,0 +1,11 @@
export interface BindStepItem {
id: number
label: string
}
export interface BindFeatureItem {
id: string
title: string
desc: string
icon: string
}

View File

@@ -0,0 +1,219 @@
.complete-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.complete-page__scroll {
flex: 1;
height: 0;
}
.complete-page__body {
padding-bottom: 24rpx;
}
.complete-page__status {
padding: 0 40rpx;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.complete-page__title {
font-size: 40rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
}
.complete-page__desc {
font-size: 26rpx;
color: #98a2b3;
line-height: 1.65;
text-align: center;
}
.complete-page__device-card {
margin: 0 32rpx 24rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 20rpx;
}
.complete-page__thumb {
width: 88rpx;
height: 88rpx;
border-radius: 16rpx;
background: #f0f2f5;
flex-shrink: 0;
}
.complete-page__device-info {
flex: 1;
min-width: 0;
}
.complete-page__device-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.complete-page__device-serial {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
margin-bottom: 8rpx;
}
.complete-page__bt-status {
display: flex;
align-items: center;
gap: 8rpx;
}
.complete-page__bt-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: $color-primary;
}
.complete-page__bt-text {
font-size: 24rpx;
color: $color-primary;
font-weight: 500;
}
.complete-page__form {
margin: 0 32rpx 24rpx;
padding: 8rpx 24rpx 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.complete-page__field {
padding-top: 24rpx;
}
.complete-page__label {
display: block;
font-size: 28rpx;
font-weight: 500;
color: $color-text;
margin-bottom: 16rpx;
}
.complete-page__input-wrap {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 16rpx;
gap: 16rpx;
}
.complete-page__input {
flex: 1;
font-size: 28rpx;
color: $color-text;
min-width: 0;
}
.complete-page__edit-icon {
width: 32rpx;
height: 32rpx;
flex-shrink: 0;
}
.complete-page__room-row {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 24rpx;
background: #f7f8fa;
border-radius: 16rpx;
gap: 12rpx;
}
.complete-page__room-icon {
width: 36rpx;
height: 36rpx;
flex-shrink: 0;
}
.complete-page__room-text {
flex: 1;
font-size: 28rpx;
color: $color-text;
}
.complete-page__room-arrow {
width: 24rpx;
height: 24rpx;
flex-shrink: 0;
opacity: 0.5;
}
.complete-page__notice {
display: flex;
align-items: flex-start;
gap: 12rpx;
margin: 0 32rpx 24rpx;
padding: 0 8rpx;
}
.complete-page__notice-icon {
width: 32rpx;
height: 32rpx;
border-radius: 50%;
background: #4a9eff;
color: #fff;
font-size: 22rpx;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-top: 2rpx;
}
.complete-page__notice-text {
flex: 1;
font-size: 24rpx;
color: #98a2b3;
line-height: 1.6;
}
.complete-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
}
.complete-page__submit {
height: 96rpx;
border-radius: 24rpx;
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
box-shadow: 0 8rpx 28rpx rgba(33, 194, 94, 0.28);
display: flex;
align-items: center;
justify-content: center;
}
.complete-page__submit-text {
font-size: 32rpx;
font-weight: 500;
color: #fff;
}

View File

@@ -0,0 +1,71 @@
<template>
<view class="complete-success-hero">
<view class="complete-success-hero__visual">
<view class="complete-success-hero__glow" aria-hidden="true" />
<image
class="complete-success-hero__illus"
:src="staticAssets.iconBindComplete"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.complete-success-hero {
display: flex;
justify-content: center;
padding: 8rpx 0 24rpx;
}
.complete-success-hero__visual {
position: relative;
width: 280rpx;
height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
}
.complete-success-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 220rpx;
height: 220rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.4) 0%,
rgba(201, 239, 217, 0.15) 45%,
transparent 70%
);
animation: complete-success-glow 2.4s ease-in-out infinite;
}
.complete-success-hero__illus {
position: relative;
z-index: 1;
width: 200rpx;
height: 200rpx;
flex-shrink: 0;
}
@keyframes complete-success-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.88;
}
}
</style>

View File

@@ -0,0 +1,112 @@
<template>
<view class="complete-page">
<app-navbar title="设备配置" />
<scroll-view class="complete-page__scroll" scroll-y>
<view class="complete-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<complete-success-hero />
<view class="complete-page__status">
<text class="complete-page__title">配对成功!</text>
<text class="complete-page__desc">请确认设备信息后完成添加</text>
</view>
<view class="complete-page__device-card">
<image
class="complete-page__thumb"
:src="staticAssets.logo"
mode="aspectFill"
/>
<view class="complete-page__device-info">
<text class="complete-page__device-name">{{ deviceName }}</text>
<text class="complete-page__device-serial">
设备编号:{{ serialMasked }}
</text>
<view class="complete-page__bt-status">
<view class="complete-page__bt-dot" />
<text class="complete-page__bt-text">蓝牙已连接</text>
</view>
</view>
</view>
<view class="complete-page__form">
<view class="complete-page__field">
<text class="complete-page__label">设备名称</text>
<view class="complete-page__input-wrap">
<input
v-model="deviceName"
class="complete-page__input"
type="text"
maxlength="32"
placeholder="请输入设备名称"
/>
<image
class="complete-page__edit-icon"
:src="staticAssets.iconDeviceNameEdit"
mode="aspectFit"
/>
</view>
</view>
<view class="complete-page__field">
<text class="complete-page__label">所在房间</text>
<view class="complete-page__room-row" @tap="onPickRoom">
<image
class="complete-page__room-icon"
:src="staticAssets.iconHomeSelector"
mode="aspectFit"
/>
<text class="complete-page__room-text">{{ roomName }}</text>
<image
class="complete-page__room-arrow"
:src="staticAssets.iconHomeArrow"
mode="aspectFit"
/>
</view>
</view>
</view>
<view class="complete-page__notice">
<text class="complete-page__notice-icon">i</text>
<text class="complete-page__notice-text">
设备名称和房间可随时在设备详情中修改。添加后设备将出现在首页设备列表中。
</text>
</view>
</view>
</scroll-view>
<view class="complete-page__footer">
<view class="complete-page__submit" @tap="onFinishAdd">
<text class="complete-page__submit-text">完成添加</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import { staticAssets } from '@/utils/static-assets'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import CompleteSuccessHero from './components/complete-success-hero/index.vue'
import { useCompleteDevice } from './use-complete-device'
const {
currentStep,
deviceName,
serialMasked,
roomName,
onPickRoom,
onFinishAdd,
} = useCompleteDevice()
onLoad((query) => {
if (query?.name) {
deviceName.value = decodeURIComponent(String(query.name))
}
if (query?.serial) {
serialMasked.value = decodeURIComponent(String(query.serial))
}
})
</script>
<style lang="scss" scoped>
@import './complete.scss';
</style>

View File

@@ -0,0 +1,42 @@
import { ref } from 'vue'
const ROOM_OPTIONS = ['客厅', '主卧', '卧室', '厨房', '卫生间']
export function useCompleteDevice() {
const currentStep = 4
const deviceName = ref('Z2 空气净化器')
const serialMasked = ref('****A3B2')
const roomName = ref('客厅')
const roomOptions = ROOM_OPTIONS
function onPickRoom() {
uni.showActionSheet({
itemList: roomOptions,
success: (res) => {
const picked = roomOptions[res.tapIndex]
if (picked) roomName.value = picked
},
})
}
function onFinishAdd() {
const name = deviceName.value.trim()
if (!name) {
uni.showToast({ title: '请输入设备名称', icon: 'none' })
return
}
uni.showToast({ title: '添加成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/device/index' })
}, 400)
}
return {
currentStep,
deviceName,
serialMasked,
roomName,
onPickRoom,
onFinishAdd,
}
}

View File

@@ -0,0 +1,127 @@
<template>
<view class="bind-bluetooth-hero">
<view class="bind-bluetooth-hero__visual">
<view class="bind-bluetooth-hero__glow" aria-hidden="true" />
<view class="bind-bluetooth-hero__pulses" aria-hidden="true">
<view
v-for="i in 3"
:key="i"
class="bind-bluetooth-hero__pulse"
:style="{ animationDelay: `${(i - 1) * 0.9}s` }"
/>
</view>
<image
class="bind-bluetooth-hero__icon"
:src="staticAssets.iconBluetooth"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.bind-bluetooth-hero {
display: flex;
justify-content: center;
padding: 16rpx 0 40rpx;
}
.bind-bluetooth-hero__visual {
position: relative;
width: 280rpx;
height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
}
.bind-bluetooth-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 220rpx;
height: 220rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.45) 0%,
rgba(201, 239, 217, 0.18) 42%,
transparent 72%
);
animation: bind-bt-glow 2.8s ease-in-out infinite;
}
.bind-bluetooth-hero__pulses {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.bind-bluetooth-hero__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 104rpx;
height: 104rpx;
border-radius: 50%;
border: 3rpx solid rgba(33, 194, 94, 0.28);
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, -50%) scale(1);
animation: bind-bt-pulse 2.7s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
}
.bind-bluetooth-hero__icon {
position: relative;
z-index: 2;
width: 104rpx;
height: 104rpx;
flex-shrink: 0;
animation: bind-bt-icon-breathe 2.8s ease-in-out infinite;
}
@keyframes bind-bt-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
70% {
opacity: 0.08;
}
100% {
transform: translate(-50%, -50%) scale(2.65);
opacity: 0;
}
}
@keyframes bind-bt-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.85;
}
}
@keyframes bind-bt-icon-breathe {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.03);
}
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<view class="bind-feature-list">
<view
v-for="(item, index) in items"
:key="item.id"
class="bind-feature-list__row"
:class="{ 'bind-feature-list__row--border': index < items.length - 1 }"
>
<image
class="bind-feature-list__icon"
:src="item.icon"
mode="aspectFit"
/>
<view class="bind-feature-list__text">
<text class="bind-feature-list__title">{{ item.title }}</text>
<text class="bind-feature-list__desc">{{ item.desc }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { BindFeatureItem } from '../../add-device.types'
defineProps<{
items: BindFeatureItem[]
}>()
</script>
<style lang="scss" scoped>
.bind-feature-list {
margin: 0 32rpx;
background: #fff;
border-radius: 24rpx;
padding: 8rpx 24rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.bind-feature-list__row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 28rpx 0;
}
.bind-feature-list__row--border {
border-bottom: 1rpx solid #f0f0f0;
}
.bind-feature-list__icon {
width: 80rpx;
height: 80rpx;
flex-shrink: 0;
}
.bind-feature-list__text {
flex: 1;
min-width: 0;
}
.bind-feature-list__title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.bind-feature-list__desc {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,131 @@
<template>
<view class="bind-steps">
<view
v-for="(step, index) in steps"
:key="step.id"
class="bind-steps__item"
>
<view class="bind-steps__top">
<view
v-if="index > 0"
class="bind-steps__line bind-steps__line--left"
:class="{ 'bind-steps__line--active': current >= step.id - 1 }"
/>
<view
class="bind-steps__dot"
:class="{ 'bind-steps__dot--active': step.id <= current }"
>
<text class="bind-steps__num">{{ step.id }}</text>
</view>
<view
v-if="index < steps.length - 1"
class="bind-steps__line bind-steps__line--right"
:class="{ 'bind-steps__line--active': current >= step.id }"
/>
</view>
<text
class="bind-steps__label"
:class="{ 'bind-steps__label--active': step.id === current }"
>
{{ step.label }}
</text>
</view>
</view>
</template>
<script setup lang="ts">
import type { BindStepItem } from '../../add-device.types'
defineProps<{
steps: BindStepItem[]
current: number
}>()
</script>
<style lang="scss" scoped>
.bind-steps {
display: flex;
padding: 24rpx 16rpx 32rpx;
}
.bind-steps__item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 0;
}
.bind-steps__top {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 48rpx;
margin-bottom: 12rpx;
}
.bind-steps__dot {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: #e8e8e8;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
z-index: 1;
}
.bind-steps__dot--active {
background: $color-primary;
}
.bind-steps__num {
font-size: 24rpx;
color: #999;
font-weight: 500;
}
.bind-steps__dot--active .bind-steps__num {
color: #fff;
}
.bind-steps__line {
position: absolute;
top: 50%;
height: 4rpx;
background: #e8e8e8;
transform: translateY(-50%);
}
.bind-steps__line--left {
left: 0;
right: 50%;
margin-right: 24rpx;
}
.bind-steps__line--right {
left: 50%;
right: 0;
margin-left: 24rpx;
}
.bind-steps__line--active {
background: $color-primary;
}
.bind-steps__label {
font-size: 22rpx;
color: #999;
text-align: center;
line-height: 1.3;
padding: 0 4rpx;
}
.bind-steps__label--active {
color: #333;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<view class="add-device-page">
<app-navbar title="添加设备" />
<scroll-view class="add-device-page__scroll" scroll-y>
<view class="add-device-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<bind-bluetooth-hero />
<text class="add-device-page__title">需要开启蓝牙权限</text>
<text class="add-device-page__desc">
电子智能家需要使用蓝牙来发现和连接附近的智能设备,实现设备的快速配置和本地控制。
</text>
<bind-feature-list :items="BIND_FEATURES" />
<view class="add-device-page__footer">
<view class="add-device-page__btn" @tap="onOpenBluetooth">
<text class="add-device-page__btn-text">去开启蓝牙</text>
</view>
<view class="add-device-page__later-wrap" @tap="onBindLater">
<text class="add-device-page__later">稍后绑定</text>
</view>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import BindBluetoothHero from './components/bind-bluetooth-hero/index.vue'
import BindFeatureList from './components/bind-feature-list/index.vue'
import BindSteps from './components/bind-steps/index.vue'
import { BIND_FEATURES, BIND_STEPS, useAddDevice } from './use-add-device'
const { currentStep, onOpenBluetooth, onBindLater } = useAddDevice()
</script>
<style lang="scss" scoped>
@import './add-device.scss';
</style>

View File

@@ -0,0 +1,132 @@
<template>
<view class="pair-check-list">
<text class="pair-check-list__title">配对自检</text>
<view
v-for="(item, index) in items"
:key="item.id"
class="pair-check-list__row"
:class="{ 'pair-check-list__row--border': index < items.length - 1 }"
>
<view
class="pair-check-list__status"
:class="`pair-check-list__status--${item.status}`"
>
<text v-if="item.status === 'done'" class="pair-check-list__check">✓</text>
</view>
<text class="pair-check-list__label">{{ item.title }}</text>
<view
class="pair-check-list__tag"
:class="`pair-check-list__tag--${item.status}`"
>
<text class="pair-check-list__tag-text">{{ item.tag }}</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { PairCheckItem } from '../../pair.types'
defineProps<{
items: PairCheckItem[]
}>()
</script>
<style lang="scss" scoped>
.pair-check-list {
margin: 0 32rpx 24rpx;
padding: 28rpx 24rpx 8rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.pair-check-list__title {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.pair-check-list__row {
display: flex;
align-items: center;
gap: 16rpx;
padding: 24rpx 0;
}
.pair-check-list__row--border {
border-bottom: 1rpx solid #f0f0f0;
}
.pair-check-list__status {
width: 36rpx;
height: 36rpx;
border-radius: 50%;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.pair-check-list__status--done {
background: $color-primary;
}
.pair-check-list__status--syncing {
background: #ff9f43;
}
.pair-check-list__status--pending {
background: #e8eaed;
}
.pair-check-list__check {
font-size: 22rpx;
color: #fff;
font-weight: 700;
line-height: 1;
}
.pair-check-list__label {
flex: 1;
font-size: 28rpx;
color: $color-text;
}
.pair-check-list__tag {
padding: 8rpx 20rpx;
border-radius: 999rpx;
flex-shrink: 0;
}
.pair-check-list__tag--done {
background: #effbf4;
}
.pair-check-list__tag--syncing {
background: #fff4e8;
}
.pair-check-list__tag--pending {
background: #f5f6f8;
}
.pair-check-list__tag-text {
font-size: 24rpx;
font-weight: 500;
}
.pair-check-list__tag--done .pair-check-list__tag-text {
color: $color-primary;
}
.pair-check-list__tag--syncing .pair-check-list__tag-text {
color: #e67a2e;
}
.pair-check-list__tag--pending .pair-check-list__tag-text {
color: #98a2b3;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<view class="pair-device-summary">
<image class="pair-device-summary__thumb" :src="thumbSrc" mode="aspectFill" />
<view class="pair-device-summary__info">
<text class="pair-device-summary__name">{{ device.name }}</text>
<text class="pair-device-summary__serial">设备编号:{{ device.serialMasked }}</text>
</view>
<view class="pair-device-summary__status">
<view class="pair-device-summary__status-dot" />
<text class="pair-device-summary__status-text">连接中</text>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import type { PairDeviceInfo } from '../../pair.types'
withDefaults(
defineProps<{
device: PairDeviceInfo
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
)
</script>
<style lang="scss" scoped>
.pair-device-summary {
margin: 0 32rpx;
padding: 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
display: flex;
align-items: center;
gap: 20rpx;
}
.pair-device-summary__thumb {
width: 88rpx;
height: 88rpx;
border-radius: 16rpx;
background: #f0f2f5;
flex-shrink: 0;
}
.pair-device-summary__info {
flex: 1;
min-width: 0;
}
.pair-device-summary__name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.pair-device-summary__serial {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
}
.pair-device-summary__status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 10rpx 16rpx;
background: #effbf4;
border-radius: 999rpx;
flex-shrink: 0;
}
.pair-device-summary__status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
border: 3rpx solid $color-primary;
box-sizing: border-box;
}
.pair-device-summary__status-text {
font-size: 24rpx;
color: $color-primary;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,123 @@
<template>
<view class="pair-hero">
<view class="pair-hero__visual">
<view class="pair-hero__glow" aria-hidden="true" />
<view class="pair-hero__pulses" aria-hidden="true">
<view
v-for="i in 3"
:key="i"
class="pair-hero__pulse"
:style="{ animationDelay: `${(i - 1) * 0.9}s` }"
/>
</view>
<image class="pair-hero__avatar" :src="thumbSrc" mode="aspectFill" />
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
withDefaults(
defineProps<{
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
)
</script>
<style lang="scss" scoped>
.pair-hero {
display: flex;
justify-content: center;
padding: 8rpx 0 24rpx;
}
.pair-hero__visual {
position: relative;
width: 280rpx;
height: 280rpx;
display: flex;
align-items: center;
justify-content: center;
}
.pair-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 220rpx;
height: 220rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.45) 0%,
rgba(201, 239, 217, 0.18) 42%,
transparent 72%
);
animation: pair-hero-glow 2.8s ease-in-out infinite;
}
.pair-hero__pulses {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.pair-hero__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 120rpx;
height: 120rpx;
border-radius: 50%;
border: 3rpx solid rgba(33, 194, 94, 0.28);
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, -50%) scale(1);
animation: pair-hero-pulse 2.7s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
}
.pair-hero__avatar {
position: relative;
z-index: 2;
width: 112rpx;
height: 112rpx;
border-radius: 50%;
border: 4rpx solid #fff;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
flex-shrink: 0;
background: #f0f2f5;
}
@keyframes pair-hero-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
70% {
opacity: 0.08;
}
100% {
transform: translate(-50%, -50%) scale(2.65);
opacity: 0;
}
}
@keyframes pair-hero-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.85;
}
}
</style>

View File

@@ -0,0 +1,65 @@
<template>
<view class="pair-page">
<app-navbar title="设备配置" />
<scroll-view class="pair-page__scroll" scroll-y>
<view class="pair-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<pair-hero />
<view class="pair-page__status">
<text class="pair-page__title">配对中...</text>
<text class="pair-page__desc">正在与设备建立安全连接,请保持手机靠近</text>
</view>
<view class="pair-page__progress-block">
<view class="pair-page__progress-head">
<text class="pair-page__progress-label">正在同步设备配置...</text>
<text class="pair-page__progress-percent">{{ progress }}%</text>
</view>
<view class="pair-page__progress-track">
<view
class="pair-page__progress-fill"
:style="{ width: `${progress}%` }"
/>
</view>
<text class="pair-page__progress-tip">
{{ progress >= 100 ? '配置同步完成' : `预计还需约 ${etaSeconds} 秒,请勿离开当前页面` }}
</text>
</view>
<pair-check-list :items="checkItems" />
<pair-device-summary :device="device" />
</view>
</scroll-view>
<view class="pair-page__footer">
<view class="pair-page__cancel" @tap="onCancelPair">
<text class="pair-page__cancel-text">取消配对</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { onLoad } from '@dcloudio/uni-app'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import PairCheckList from './components/pair-check-list/index.vue'
import PairDeviceSummary from './components/pair-device-summary/index.vue'
import PairHero from './components/pair-hero/index.vue'
import { usePairDevice } from './use-pair-device'
const { currentStep, device, progress, etaSeconds, checkItems, onCancelPair } =
usePairDevice()
onLoad((query) => {
if (query?.name) {
device.value.name = decodeURIComponent(String(query.name))
}
if (query?.serial) {
device.value.serialMasked = decodeURIComponent(String(query.serial))
}
})
</script>
<style lang="scss" scoped>
@import './pair.scss';
</style>

View File

@@ -0,0 +1,109 @@
.pair-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.pair-page__scroll {
flex: 1;
height: 0;
}
.pair-page__body {
padding-bottom: 24rpx;
}
.pair-page__status {
padding: 0 40rpx;
margin-bottom: 32rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.pair-page__title {
font-size: 40rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
text-align: center;
}
.pair-page__desc {
font-size: 26rpx;
color: #98a2b3;
line-height: 1.65;
text-align: center;
padding: 0 24rpx;
}
.pair-page__progress-block {
margin: 0 32rpx 24rpx;
padding: 28rpx 24rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
}
.pair-page__progress-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20rpx;
}
.pair-page__progress-label {
font-size: 28rpx;
color: $color-text;
font-weight: 500;
}
.pair-page__progress-percent {
font-size: 28rpx;
font-weight: 600;
color: $color-primary;
}
.pair-page__progress-track {
height: 16rpx;
border-radius: 999rpx;
background: #eef1f4;
overflow: hidden;
margin-bottom: 16rpx;
}
.pair-page__progress-fill {
height: 100%;
border-radius: 999rpx;
background: linear-gradient(90deg, #45d685 0%, #21c25e 100%);
transition: width 0.4s ease;
}
.pair-page__progress-tip {
font-size: 24rpx;
color: #98a2b3;
line-height: 1.5;
}
.pair-page__footer {
padding: 16rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
background: $color-bg-page;
}
.pair-page__cancel {
height: 96rpx;
border-radius: 24rpx;
background: #fff;
border: 2rpx solid #e8eaed;
display: flex;
align-items: center;
justify-content: center;
}
.pair-page__cancel-text {
font-size: 32rpx;
color: #666;
font-weight: 500;
}

View File

@@ -0,0 +1,13 @@
export type PairCheckStatus = 'done' | 'syncing' | 'pending'
export interface PairCheckItem {
id: string
title: string
status: PairCheckStatus
tag: string
}
export interface PairDeviceInfo {
name: string
serialMasked: string
}

View File

@@ -0,0 +1,81 @@
import { onUnmounted, ref, watch } from 'vue'
import type { PairCheckItem, PairDeviceInfo } from './pair.types'
const DEFAULT_DEVICE: PairDeviceInfo = {
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
}
export function usePairDevice(initial?: Partial<PairDeviceInfo>) {
const currentStep = 3
const device = ref<PairDeviceInfo>({
...DEFAULT_DEVICE,
...initial,
})
const progress = ref(65)
const etaSeconds = ref(15)
const checkItems = ref<PairCheckItem[]>([
{ id: 'signal', title: '蓝牙信号强度', status: 'done', tag: '良好' },
{ id: 'credential', title: '设备凭证校验', status: 'done', tag: '通过' },
{ id: 'network', title: '网络配置下发', status: 'syncing', tag: '同步中' },
])
let etaTick = 0
const tick = setInterval(() => {
if (progress.value < 100) {
progress.value = Math.min(100, progress.value + 1)
}
etaTick += 1
if (etaTick % 2 === 0 && etaSeconds.value > 0 && progress.value < 100) {
etaSeconds.value = Math.max(0, etaSeconds.value - 1)
}
if (progress.value >= 100) {
checkItems.value = checkItems.value.map((item) =>
item.id === 'network'
? { ...item, status: 'done', tag: '完成' }
: item,
)
}
}, 500)
let navigated = false
watch(progress, (value) => {
if (value < 100 || navigated) return
navigated = true
const d = device.value
const q = [
`name=${encodeURIComponent(d.name)}`,
`serial=${encodeURIComponent(d.serialMasked)}`,
].join('&')
setTimeout(() => {
uni.redirectTo({ url: `/pages/add-device/complete/index?${q}` })
}, 600)
})
onUnmounted(() => {
clearInterval(tick)
})
function onCancelPair() {
uni.showModal({
title: '取消配对',
content: '确定要取消当前设备的配对吗?',
success: (res) => {
if (res.confirm) {
uni.navigateBack()
}
},
})
}
return {
currentStep,
device,
progress,
etaSeconds,
checkItems,
onCancelPair,
}
}

View File

@@ -0,0 +1,102 @@
<template>
<view
class="scan-device-item"
:class="{
'scan-device-item--selected': selected,
'scan-device-item--disabled': !device.canAdd,
}"
@tap="emit('select', device.id)"
>
<image
class="scan-device-item__thumb"
:src="thumbSrc"
mode="aspectFill"
/>
<view class="scan-device-item__info">
<text class="scan-device-item__name">{{ device.name }}</text>
<text class="scan-device-item__serial">设备编号:{{ device.serialMasked }}</text>
</view>
<image
class="scan-device-item__action"
:src="selected ? staticAssets.iconScanDeviceAction : staticAssets.iconScanDeviceActionDisabled"
mode="aspectFit"
@tap.stop="emit('action', device)"
/>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
import type { ScannedDeviceItem } from '../../scan.types'
withDefaults(
defineProps<{
device: ScannedDeviceItem
selected: boolean
thumbSrc?: string
}>(),
{ thumbSrc: staticAssets.logo },
)
const emit = defineEmits<{
select: [id: string]
action: [device: ScannedDeviceItem]
}>()
</script>
<style lang="scss" scoped>
.scan-device-item {
display: flex;
align-items: center;
gap: 20rpx;
padding: 20rpx 24rpx;
margin-bottom: 20rpx;
background: #fff;
border-radius: 20rpx;
border: 2rpx solid transparent;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.04);
box-sizing: border-box;
}
.scan-device-item--selected {
border-color: $color-primary;
box-shadow: 0 4rpx 24rpx rgba(30, 198, 113, 0.12);
}
.scan-device-item--disabled {
opacity: 0.92;
}
.scan-device-item__thumb {
width: 96rpx;
height: 96rpx;
border-radius: 16rpx;
background: #f0f2f5;
flex-shrink: 0;
}
.scan-device-item__info {
flex: 1;
min-width: 0;
}
.scan-device-item__name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: $color-text;
margin-bottom: 8rpx;
}
.scan-device-item__serial {
display: block;
font-size: 24rpx;
color: $color-text-secondary;
}
.scan-device-item__action {
width: 64rpx;
height: 64rpx;
flex-shrink: 0;
}
</style>

View File

@@ -0,0 +1,76 @@
<template>
<view class="scan-help-tip">
<view class="scan-help-tip__icon-wrap">
<view class="scan-help-tip__bulb" />
</view>
<view class="scan-help-tip__content">
<text class="scan-help-tip__title">扫描不到设备?</text>
<text class="scan-help-tip__line">1. 确认目标设备已开机</text>
<text class="scan-help-tip__line">2. 确保设备处于配对/可发现模式</text>
<text class="scan-help-tip__line">3. 检查手机蓝牙已开启</text>
</view>
</view>
</template>
<style lang="scss" scoped>
.scan-help-tip {
display: flex;
gap: 20rpx;
margin: 8rpx 32rpx 0;
padding: 28rpx 24rpx;
background: #fff8f0;
border-radius: 20rpx;
box-sizing: border-box;
}
.scan-help-tip__icon-wrap {
flex-shrink: 0;
width: 56rpx;
height: 56rpx;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 4rpx;
}
.scan-help-tip__bulb {
width: 36rpx;
height: 36rpx;
border-radius: 50% 50% 45% 45%;
background: #ffc940;
position: relative;
box-shadow: 0 0 0 6rpx rgba(255, 201, 64, 0.25);
&::after {
content: '';
position: absolute;
bottom: -8rpx;
left: 50%;
transform: translateX(-50%);
width: 20rpx;
height: 8rpx;
background: #e8a82a;
border-radius: 0 0 4rpx 4rpx;
}
}
.scan-help-tip__content {
flex: 1;
min-width: 0;
}
.scan-help-tip__title {
display: block;
font-size: 28rpx;
font-weight: 600;
color: #e67a2e;
margin-bottom: 12rpx;
}
.scan-help-tip__line {
display: block;
font-size: 24rpx;
color: #c4845a;
line-height: 1.55;
}
</style>

View File

@@ -0,0 +1,116 @@
<template>
<view class="scan-hero">
<view class="scan-hero__visual">
<view class="scan-hero__glow" aria-hidden="true" />
<view class="scan-hero__pulses" aria-hidden="true">
<view
v-for="i in 3"
:key="i"
class="scan-hero__pulse"
:style="{ animationDelay: `${(i - 1) * 0.9}s` }"
/>
</view>
<image
class="scan-hero__illus"
:src="staticAssets.iconScanDevice"
mode="aspectFit"
/>
</view>
</view>
</template>
<script setup lang="ts">
import { staticAssets } from '@/utils/static-assets'
</script>
<style lang="scss" scoped>
.scan-hero {
display: flex;
justify-content: center;
padding: 8rpx 0 28rpx;
}
.scan-hero__visual {
position: relative;
width: 320rpx;
height: 320rpx;
display: flex;
align-items: center;
justify-content: center;
}
.scan-hero__glow {
position: absolute;
left: 50%;
top: 50%;
width: 240rpx;
height: 240rpx;
transform: translate(-50%, -50%);
border-radius: 50%;
pointer-events: none;
background: radial-gradient(
circle,
rgba(201, 239, 217, 0.35) 0%,
rgba(201, 239, 217, 0.12) 45%,
transparent 70%
);
animation: scan-hero-glow 2.8s ease-in-out infinite;
}
.scan-hero__pulses {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
pointer-events: none;
}
.scan-hero__pulse {
position: absolute;
left: 50%;
top: 50%;
width: 140rpx;
height: 140rpx;
border-radius: 50%;
border: 3rpx solid rgba(33, 194, 94, 0.28);
box-sizing: border-box;
opacity: 0;
transform: translate(-50%, -50%) scale(1);
animation: scan-hero-pulse 2.7s cubic-bezier(0.22, 0.61, 0.36, 1) infinite;
}
.scan-hero__illus {
position: relative;
z-index: 2;
width: 240rpx;
height: 280rpx;
flex-shrink: 0;
}
@keyframes scan-hero-pulse {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.5;
}
70% {
opacity: 0.08;
}
100% {
transform: translate(-50%, -50%) scale(2.65);
opacity: 0;
}
}
@keyframes scan-hero-glow {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 1;
}
50% {
transform: translate(-50%, -50%) scale(1.08);
opacity: 0.85;
}
}
</style>

View File

@@ -0,0 +1,70 @@
<template>
<view class="scan-page">
<app-navbar title="扫描设备" />
<scroll-view class="scan-page__scroll" scroll-y>
<view class="scan-page__body">
<bind-steps :steps="BIND_STEPS" :current="currentStep" />
<scan-hero />
<view class="scan-page__status">
<text class="scan-page__title">正在扫描附近设备...</text>
<view class="scan-page__hint">
<view class="scan-page__hint-dot" />
<text class="scan-page__hint-text">{{ statusHint }}</text>
</view>
</view>
<view class="scan-page__section-head">
<text class="scan-page__section-title">发现的设备</text>
<text class="scan-page__section-timer">
{{ scanning ? `${secondsLeft}秒后停止` : '扫描已停止' }}
</text>
</view>
<view class="scan-page__list">
<scan-device-item
v-for="item in devices"
:key="item.id"
:device="item"
:selected="selectedId === item.id"
@select="onSelectDevice"
@action="onDeviceAction"
/>
</view>
<scan-help-tip />
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import BindSteps from '../components/bind-steps/index.vue'
import { BIND_STEPS } from '../use-add-device'
import ScanDeviceItem from './components/scan-device-item/index.vue'
import ScanHelpTip from './components/scan-help-tip/index.vue'
import ScanHero from './components/scan-hero/index.vue'
import { useScanDevice } from './use-scan-device'
const {
currentStep,
devices,
selectedId,
secondsLeft,
scanning,
onSelectDevice,
onDeviceAction,
} = useScanDevice()
const statusHint = computed(() => {
const count = devices.value.length
if (scanning.value) {
return `已发现 ${count} 台设备,继续扫描中`
}
return `已发现 ${count} 台设备,扫描已停止`
})
</script>
<style lang="scss" scoped>
@import './scan.scss';
</style>

View File

@@ -0,0 +1,78 @@
.scan-page {
min-height: 100vh;
background: $color-bg-page;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
.scan-page__scroll {
flex: 1;
height: 0;
}
.scan-page__body {
padding-bottom: calc(32rpx + env(safe-area-inset-bottom));
}
.scan-page__status {
padding: 0 40rpx;
margin-bottom: 36rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.scan-page__title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: $color-text;
margin-bottom: 16rpx;
line-height: 1.4;
text-align: center;
}
.scan-page__hint {
display: flex;
align-items: center;
justify-content: center;
gap: 12rpx;
}
.scan-page__hint-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #4a9eff;
flex-shrink: 0;
}
.scan-page__hint-text {
font-size: 26rpx;
color: #98a2b3;
line-height: 1.5;
text-align: center;
}
.scan-page__section-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 32rpx 16rpx;
}
.scan-page__section-title {
font-size: 30rpx;
font-weight: 600;
color: $color-text;
}
.scan-page__section-timer {
font-size: 24rpx;
color: #98a2b3;
}
.scan-page__list {
padding: 0 32rpx;
}

View File

@@ -0,0 +1,7 @@
export interface ScannedDeviceItem {
id: string
name: string
serialMasked: string
/** 是否可添加(未知设备为 false 时箭头置灰) */
canAdd: boolean
}

View File

@@ -0,0 +1,69 @@
import { onUnmounted, ref } from 'vue'
import type { ScannedDeviceItem } from './scan.types'
const MOCK_DEVICES: ScannedDeviceItem[] = [
{
id: 'z2-purifier',
name: 'Z2 空气净化器',
serialMasked: '****A3B2',
canAdd: true,
},
{
id: 'unknown',
name: '未知设备',
serialMasked: '****7C91',
canAdd: false,
},
]
const SCAN_SECONDS = 20
export function useScanDevice() {
const currentStep = 2
const devices = ref<ScannedDeviceItem[]>(MOCK_DEVICES)
const selectedId = ref(MOCK_DEVICES[0]?.id ?? '')
const secondsLeft = ref(SCAN_SECONDS)
const scanning = ref(true)
const timer = setInterval(() => {
if (secondsLeft.value <= 0) {
scanning.value = false
return
}
secondsLeft.value -= 1
if (secondsLeft.value === 0) {
scanning.value = false
}
}, 1000)
onUnmounted(() => {
clearInterval(timer)
})
function onSelectDevice(id: string) {
selectedId.value = id
}
function onDeviceAction(device: ScannedDeviceItem) {
selectedId.value = device.id
if (!device.canAdd) {
uni.showToast({ title: '暂无法添加该设备', icon: 'none' })
return
}
const q = [
`name=${encodeURIComponent(device.name)}`,
`serial=${encodeURIComponent(device.serialMasked)}`,
].join('&')
uni.navigateTo({ url: `/pages/add-device/pair/index?${q}` })
}
return {
currentStep,
devices,
selectedId,
secondsLeft,
scanning,
onSelectDevice,
onDeviceAction,
}
}

View File

@@ -0,0 +1,48 @@
import { staticAssets } from '@/utils/static-assets'
import type { BindFeatureItem, BindStepItem } from './add-device.types'
export const BIND_STEPS: BindStepItem[] = [
{ id: 1, label: '开启蓝牙' },
{ id: 2, label: '扫描设备' },
{ id: 3, label: '配对连接' },
{ id: 4, label: '完成配置' },
]
export const BIND_FEATURES: BindFeatureItem[] = [
{
id: 'discover',
title: '发现附近设备',
desc: '通过蓝牙扫描发现您周围的电子智能设备',
icon: staticAssets.iconBindDiscover,
},
{
id: 'pair',
title: '快速配对连接',
desc: '一键完成设备配对,无需复杂的网络配置',
icon: staticAssets.iconBindPair,
},
{
id: 'secure',
title: '本地安全控制',
desc: '蓝牙直连控制,数据加密传输,保护您的隐私',
icon: staticAssets.iconBindSecure,
},
]
export function useAddDevice() {
const currentStep = 1
function onOpenBluetooth() {
uni.navigateTo({ url: '/pages/add-device/scan/index' })
}
function onBindLater() {
uni.navigateBack()
}
return {
currentStep,
onOpenBluetooth,
onBindLater,
}
}