feat:登录注册完成
@@ -10,4 +10,6 @@ onHide(() => {
|
||||
console.log("App Hide");
|
||||
});
|
||||
</script>
|
||||
<style></style>
|
||||
<style lang="scss">
|
||||
@import '@/styles/index.scss';
|
||||
</style>
|
||||
|
||||
25
src/api/auth.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { request } from './request'
|
||||
import type { AuthTokenResult, SendSmsCodeBody, SmsCodeScene } from '@/types/api'
|
||||
import { ApiError } from '@/utils/api-error'
|
||||
|
||||
/** POST /app/auth/sendSmsCode */
|
||||
export function sendSmsCode(phone: string, scene: SmsCodeScene) {
|
||||
const data: SendSmsCodeBody = {
|
||||
phonenumber: phone.trim(),
|
||||
scene,
|
||||
}
|
||||
return request<void>({
|
||||
url: '/app/auth/sendSmsCode',
|
||||
method: 'POST',
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export function saveAuthToken(data: AuthTokenResult) {
|
||||
const token = data?.token ?? data?.accessToken
|
||||
if (!token) {
|
||||
throw new ApiError('登录失败,未获取到令牌')
|
||||
}
|
||||
uni.setStorageSync('token', token)
|
||||
return token
|
||||
}
|
||||
49
src/api/request.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
import { ApiError, isApiSuccess, unwrapApiData } from '@/utils/api-error'
|
||||
|
||||
const BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''
|
||||
|
||||
export interface RequestOptions extends Omit<UniApp.RequestOptions, 'url'> {
|
||||
url: string
|
||||
}
|
||||
|
||||
export function request<T = unknown>(options: RequestOptions): Promise<T> {
|
||||
const token = uni.getStorageSync('token') as string
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
url: `${BASE_URL}${options.url}`,
|
||||
header: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...options.header,
|
||||
},
|
||||
success: (res) => {
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
const body = res.data as ApiResponse | undefined
|
||||
reject(
|
||||
new ApiError(
|
||||
body?.msg || body?.message || `请求失败(${res.statusCode})`,
|
||||
body?.code ?? res.statusCode,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const body = (res.data ?? {}) as ApiResponse<T> & Record<string, unknown>
|
||||
if (!isApiSuccess(body)) {
|
||||
reject(
|
||||
new ApiError(body.msg || body.message || '请求失败', body.code),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
resolve(unwrapApiData<T>(body))
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(new ApiError(err.errMsg || '网络异常'))
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
88
src/components/agreement-checkbox/index.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<view class="agreement-checkbox" @tap="toggle">
|
||||
<view
|
||||
class="agreement-checkbox__box"
|
||||
:class="{ 'agreement-checkbox__box--checked': modelValue }"
|
||||
>
|
||||
<image
|
||||
v-if="modelValue"
|
||||
class="agreement-checkbox__checked"
|
||||
:src="staticAssets.iconChecked"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="agreement-checkbox__text">
|
||||
<text class="agreement-checkbox__plain">我已阅读并同意</text>
|
||||
<text class="agreement-checkbox__link" @tap.stop="openAgreement('service')">《用户服务协议》</text>
|
||||
<text class="agreement-checkbox__plain">和</text>
|
||||
<text class="agreement-checkbox__link" @tap.stop="openAgreement('privacy')">《隐私政策》</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean]
|
||||
}>()
|
||||
|
||||
function toggle() {
|
||||
emit('update:modelValue', !props.modelValue)
|
||||
}
|
||||
|
||||
function openAgreement(type: 'service' | 'privacy') {
|
||||
uni.navigateTo({
|
||||
url: `/pages/agreement/index?type=${type}`,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agreement-checkbox {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 16rpx;
|
||||
padding: 0 8rpx;
|
||||
}
|
||||
|
||||
.agreement-checkbox__box {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
border: 2rpx solid #ccc;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.agreement-checkbox__box--checked {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.agreement-checkbox__checked {
|
||||
width: 36rpx;
|
||||
height: 36rpx;
|
||||
}
|
||||
|
||||
.agreement-checkbox__text {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.agreement-checkbox__plain {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.agreement-checkbox__link {
|
||||
color: $color-link;
|
||||
}
|
||||
</style>
|
||||
72
src/components/app-navbar/index.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<view class="app-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="app-navbar__bar">
|
||||
<view class="app-navbar__back" @tap="onBack">
|
||||
<text class="app-navbar__back-icon">‹</text>
|
||||
</view>
|
||||
<text class="app-navbar__title">{{ title }}</text>
|
||||
<view class="app-navbar__placeholder" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
}>()
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
const sys = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = sys.statusBarHeight ?? 0
|
||||
|
||||
function onBack() {
|
||||
const pages = getCurrentPages()
|
||||
if (pages.length > 1) {
|
||||
uni.navigateBack()
|
||||
} else {
|
||||
uni.reLaunch({ url: '/pages/device/index' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-navbar {
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.app-navbar__bar {
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.app-navbar__back {
|
||||
width: 80rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.app-navbar__back-icon {
|
||||
font-size: 52rpx;
|
||||
line-height: 1;
|
||||
color: $color-text;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.app-navbar__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
font-weight: 500;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.app-navbar__placeholder {
|
||||
width: 80rpx;
|
||||
}
|
||||
</style>
|
||||
129
src/components/app-tab-bar/index.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<view class="app-tab-bar" :style="{ paddingBottom: safeBottom + 'px' }">
|
||||
<view
|
||||
class="app-tab-bar__item"
|
||||
:class="{ 'app-tab-bar__item--active': activeTab === 'device' }"
|
||||
@tap="switchTo('device')"
|
||||
>
|
||||
<view class="app-tab-bar__icon-box">
|
||||
<image
|
||||
class="app-tab-bar__icon"
|
||||
:src="activeTab === 'device' ? staticAssets.tabDeviceActive : staticAssets.tabDevice"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="app-tab-bar__text">设备</text>
|
||||
</view>
|
||||
<view
|
||||
class="app-tab-bar__item"
|
||||
:class="{ 'app-tab-bar__item--active': activeTab === 'mine' }"
|
||||
@tap="switchTo('mine')"
|
||||
>
|
||||
<view class="app-tab-bar__icon-box">
|
||||
<image
|
||||
class="app-tab-bar__icon"
|
||||
:src="activeTab === 'mine' ? staticAssets.tabMineActive : staticAssets.tabMine"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="app-tab-bar__text">我的</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
|
||||
export type TabKey = 'device' | 'mine'
|
||||
|
||||
const props = defineProps<{
|
||||
current: TabKey
|
||||
}>()
|
||||
|
||||
const safeBottom = uni.getSystemInfoSync().safeAreaInsets?.bottom ?? 0
|
||||
|
||||
const routes: Record<TabKey, string> = {
|
||||
device: '/pages/device/index',
|
||||
mine: '/pages/mine/index',
|
||||
}
|
||||
|
||||
function resolveTabFromRoute(): TabKey {
|
||||
const route = getCurrentPages().at(-1)?.route ?? ''
|
||||
return route.includes('mine') ? 'mine' : 'device'
|
||||
}
|
||||
|
||||
const activeTab = ref<TabKey>(props.current)
|
||||
|
||||
watch(
|
||||
() => props.current,
|
||||
(value) => {
|
||||
activeTab.value = value
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
activeTab.value = resolveTabFromRoute()
|
||||
})
|
||||
|
||||
function switchTo(key: TabKey) {
|
||||
if (activeTab.value === key) return
|
||||
activeTab.value = key
|
||||
uni.reLaunch({ url: routes[key] })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.app-tab-bar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-around;
|
||||
min-height: 100rpx;
|
||||
padding-top: 10rpx;
|
||||
background: #ffffff;
|
||||
border-top: 1rpx solid #eeeeee;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.app-tab-bar__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8rpx;
|
||||
min-height: 96rpx;
|
||||
}
|
||||
|
||||
.app-tab-bar__icon-box {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.app-tab-bar__icon {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
}
|
||||
|
||||
/* 设备 / 我的:未选中、选中文字样式完全一致 */
|
||||
.app-tab-bar__text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 400;
|
||||
color: #999999;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.app-tab-bar__item--active .app-tab-bar__text {
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
119
src/components/base-input/index.vue
Normal file
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<view class="base-input">
|
||||
<view v-if="iconSrc" class="base-input__icon">
|
||||
<image
|
||||
class="base-input__icon-img"
|
||||
:class="{ 'base-input__icon-img--lock': icon === 'lock' }"
|
||||
:src="iconSrc"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<input
|
||||
class="base-input__field"
|
||||
:type="inputType"
|
||||
:password="password"
|
||||
:value="modelValue"
|
||||
:placeholder="placeholder"
|
||||
:placeholder-class="placeholderClass"
|
||||
:maxlength="maxlength"
|
||||
@input="onInput"
|
||||
/>
|
||||
<view v-if="$slots.suffix" class="base-input__suffix">
|
||||
<slot name="suffix" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
icon?: 'phone' | 'shield' | 'lock'
|
||||
type?: 'text' | 'password' | 'number'
|
||||
maxlength?: number
|
||||
}>(),
|
||||
{
|
||||
placeholder: '',
|
||||
type: 'text',
|
||||
maxlength: 140,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const password = computed(() => props.type === 'password')
|
||||
const inputType = computed(() => {
|
||||
if (props.type === 'number') return 'number'
|
||||
if (props.type === 'password') return 'text'
|
||||
return 'text'
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
if (!props.icon) return ''
|
||||
if (props.icon === 'phone') return staticAssets.iconPhone
|
||||
if (props.icon === 'lock') return staticAssets.iconPassword
|
||||
return staticAssets.iconCode
|
||||
})
|
||||
|
||||
const placeholderClass = 'base-input__placeholder'
|
||||
|
||||
function onInput(e: Event) {
|
||||
const { value } = (e as CustomEvent<{ value: string }>).detail
|
||||
emit('update:modelValue', value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.base-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 96rpx;
|
||||
padding: 0 32rpx;
|
||||
background: $color-bg-input;
|
||||
border: 2rpx solid $color-border;
|
||||
border-radius: $radius-input;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.base-input__icon {
|
||||
flex-shrink: 0;
|
||||
margin-right: 20rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.base-input__icon-img {
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.base-input__icon-img--lock {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.base-input__field {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.base-input__suffix {
|
||||
flex-shrink: 0;
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
:deep(.base-input__placeholder) {
|
||||
color: $color-text-placeholder;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
35
src/components/brand-logo/index.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<view class="brand-logo">
|
||||
<image
|
||||
class="brand-logo__image"
|
||||
:src="staticAssets.logo"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="brand-logo__name">虫子智家</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.brand-logo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-logo__image {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.brand-logo__name {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
letter-spacing: 4rpx;
|
||||
}
|
||||
</style>
|
||||
51
src/components/primary-button/index.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<view
|
||||
class="primary-button"
|
||||
:class="{ 'primary-button--disabled': disabled }"
|
||||
@tap="onTap"
|
||||
>
|
||||
<text class="primary-button__text">{{ text }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
text: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
click: []
|
||||
}>()
|
||||
|
||||
function onTap() {
|
||||
if (props.disabled) return
|
||||
emit('click')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.primary-button {
|
||||
height: 96rpx;
|
||||
background: $color-primary;
|
||||
border-radius: $radius-button;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: $shadow-button;
|
||||
}
|
||||
|
||||
.primary-button--disabled {
|
||||
opacity: 0.45;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.primary-button__text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
56
src/components/sms-code-btn/index.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<view
|
||||
class="sms-code-btn"
|
||||
:class="{ 'sms-code-btn--disabled': disabled || counting }"
|
||||
@tap="onTap"
|
||||
>
|
||||
<text class="sms-code-btn__text">{{ label }}</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
counting: boolean
|
||||
seconds: number
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: []
|
||||
}>()
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.counting) return `${props.seconds}s`
|
||||
return '获取验证码'
|
||||
})
|
||||
|
||||
function onTap() {
|
||||
if (props.disabled || props.counting) return
|
||||
emit('send')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.sms-code-btn {
|
||||
padding: 12rpx 20rpx;
|
||||
border: 2rpx solid $color-primary;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.sms-code-btn--disabled {
|
||||
opacity: 0.5;
|
||||
border-color: #ccc;
|
||||
}
|
||||
|
||||
.sms-code-btn__text {
|
||||
font-size: 24rpx;
|
||||
color: $color-primary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sms-code-btn--disabled .sms-code-btn__text {
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
37
src/hooks/use-countdown.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
export function useCountdown(duration = 60) {
|
||||
const counting = ref(false)
|
||||
const seconds = ref(0)
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function clearTimer() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
clearTimer()
|
||||
seconds.value = duration
|
||||
counting.value = true
|
||||
timer = setInterval(() => {
|
||||
if (seconds.value <= 1) {
|
||||
stop()
|
||||
} else {
|
||||
seconds.value -= 1
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
clearTimer()
|
||||
counting.value = false
|
||||
seconds.value = 0
|
||||
}
|
||||
|
||||
onUnmounted(stop)
|
||||
|
||||
return { counting, seconds, start, stop }
|
||||
}
|
||||
26
src/hooks/use-sms-code.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { sendSmsCode } from '@/api/auth'
|
||||
import type { SmsCodeScene } from '@/types/api'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { isValidPhone } from '@/utils/validate'
|
||||
import { useCountdown } from './use-countdown'
|
||||
|
||||
export function useSmsCode(scene: SmsCodeScene) {
|
||||
const { counting, seconds, start } = useCountdown(60)
|
||||
|
||||
async function sendCode(phone: string) {
|
||||
if (counting.value) return
|
||||
if (!isValidPhone(phone)) {
|
||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await sendSmsCode(phone, scene)
|
||||
uni.showToast({ title: '验证码已发送', icon: 'none' })
|
||||
start()
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '发送失败,请稍后重试'), icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
return { counting, seconds, sendCode }
|
||||
}
|
||||
14
src/hooks/use-tab-page.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import type { TabKey } from '@/components/app-tab-bar/index.vue'
|
||||
|
||||
/** Tab 页在 onShow 时同步底栏高亮 */
|
||||
export function useTabPage(tab: TabKey) {
|
||||
const tabCurrent = ref<TabKey>(tab)
|
||||
|
||||
onShow(() => {
|
||||
tabCurrent.value = tab
|
||||
})
|
||||
|
||||
return { tabCurrent }
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createSSRApp } from "vue";
|
||||
import App from "./App.vue";
|
||||
import { registerGlobalComponents } from "./plugins/register-global-components";
|
||||
|
||||
export function createApp() {
|
||||
const app = createSSRApp(App);
|
||||
registerGlobalComponents(app);
|
||||
return {
|
||||
app,
|
||||
};
|
||||
|
||||
105
src/pages.json
@@ -1,16 +1,93 @@
|
||||
{
|
||||
"pages": [ //pages数组中第一项表示应用启动页,参考:https://uniapp.dcloud.io/collocation/pages
|
||||
{
|
||||
"path": "pages/index/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "uni-app"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "uni-app",
|
||||
"navigationBarBackgroundColor": "#F8F8F8",
|
||||
"backgroundColor": "#F8F8F8"
|
||||
}
|
||||
"easycom": {
|
||||
"autoscan": true,
|
||||
"custom": {
|
||||
"^([a-z][a-z0-9]*(?:-[a-z0-9]+)+)$": "@/components/$1/index.vue"
|
||||
}
|
||||
},
|
||||
"pages": [
|
||||
{
|
||||
"path": "pages/login/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设备"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add-device/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "添加设备"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add-device/scan/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "扫描设备"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add-device/pair/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设备配置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/add-device/complete/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "设备配置"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mine/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/register/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "注册"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/forgot-password/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "忘记密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/change-password/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "修改密码"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/agreement/index",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "用户服务协议"
|
||||
}
|
||||
}
|
||||
],
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarTitleText": "虫子智家",
|
||||
"navigationBarBackgroundColor": "#F7F8FA",
|
||||
"backgroundColor": "#F7F8FA"
|
||||
}
|
||||
}
|
||||
|
||||
91
src/pages/add-device/add-device.scss
Normal 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;
|
||||
}
|
||||
11
src/pages/add-device/add-device.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export interface BindStepItem {
|
||||
id: number
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface BindFeatureItem {
|
||||
id: string
|
||||
title: string
|
||||
desc: string
|
||||
icon: string
|
||||
}
|
||||
219
src/pages/add-device/complete/complete.scss
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
112
src/pages/add-device/complete/index.vue
Normal 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>
|
||||
42
src/pages/add-device/complete/use-complete-device.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
127
src/pages/add-device/components/bind-bluetooth-hero/index.vue
Normal 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>
|
||||
75
src/pages/add-device/components/bind-feature-list/index.vue
Normal 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>
|
||||
131
src/pages/add-device/components/bind-steps/index.vue
Normal 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>
|
||||
37
src/pages/add-device/index.vue
Normal 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>
|
||||
132
src/pages/add-device/pair/components/pair-check-list/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
123
src/pages/add-device/pair/components/pair-hero/index.vue
Normal 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>
|
||||
65
src/pages/add-device/pair/index.vue
Normal 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>
|
||||
109
src/pages/add-device/pair/pair.scss
Normal 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;
|
||||
}
|
||||
13
src/pages/add-device/pair/pair.types.ts
Normal 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
|
||||
}
|
||||
81
src/pages/add-device/pair/use-pair-device.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
102
src/pages/add-device/scan/components/scan-device-item/index.vue
Normal 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>
|
||||
76
src/pages/add-device/scan/components/scan-help-tip/index.vue
Normal 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>
|
||||
116
src/pages/add-device/scan/components/scan-hero/index.vue
Normal 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>
|
||||
70
src/pages/add-device/scan/index.vue
Normal 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>
|
||||
78
src/pages/add-device/scan/scan.scss
Normal 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;
|
||||
}
|
||||
7
src/pages/add-device/scan/scan.types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ScannedDeviceItem {
|
||||
id: string
|
||||
name: string
|
||||
serialMasked: string
|
||||
/** 是否可添加(未知设备为 false 时箭头置灰) */
|
||||
canAdd: boolean
|
||||
}
|
||||
69
src/pages/add-device/scan/use-scan-device.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
48
src/pages/add-device/use-add-device.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
11
src/pages/agreement/agreement.api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { AgreementDetail, AgreementType } from './agreement.types'
|
||||
|
||||
/** GET /app/auth/agreement/detail — 协议详情 */
|
||||
export function fetchAgreementDetail(type: AgreementType) {
|
||||
return request<AgreementDetail>({
|
||||
url: '/app/auth/agreement/detail',
|
||||
method: 'POST',
|
||||
data: { type },
|
||||
})
|
||||
}
|
||||
10
src/pages/agreement/agreement.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type AgreementType = 'service' | 'privacy'
|
||||
|
||||
/** /app/auth/agreement/detail 返回 data 结构 */
|
||||
export interface AgreementDetail {
|
||||
agreementNo?: string
|
||||
name?: string
|
||||
versionName?: string
|
||||
content?: string
|
||||
publishTime?: string
|
||||
}
|
||||
52
src/pages/agreement/index.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<view class="agreement-page">
|
||||
<app-navbar :title="title" />
|
||||
<scroll-view class="agreement-page__scroll" scroll-y>
|
||||
<view class="agreement-page__content">
|
||||
<view v-if="loading" class="agreement-page__loading">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<text v-else class="agreement-page__text">{{ content }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAgreement } from './use-agreement'
|
||||
|
||||
const { title, content, loading } = useAgreement()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.agreement-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agreement-page__scroll {
|
||||
flex: 1;
|
||||
height: calc(100vh - 88rpx);
|
||||
}
|
||||
|
||||
.agreement-page__content {
|
||||
padding: 32rpx $page-padding-x 64rpx;
|
||||
min-height: 200rpx;
|
||||
}
|
||||
|
||||
.agreement-page__loading {
|
||||
padding: 48rpx 0;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.agreement-page__text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
51
src/pages/agreement/use-agreement.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { fetchAgreementDetail } from './agreement.api'
|
||||
import type { AgreementDetail, AgreementType } from './agreement.types'
|
||||
|
||||
const DEFAULT_TITLE: Record<AgreementType, string> = {
|
||||
service: '用户服务协议',
|
||||
privacy: '隐私政策',
|
||||
}
|
||||
|
||||
function normalizeDetail(type: AgreementType, data: AgreementDetail) {
|
||||
const title = data.name?.trim() || DEFAULT_TITLE[type]
|
||||
const content = data.content?.trim() || ''
|
||||
return { title, content }
|
||||
}
|
||||
|
||||
export function useAgreement() {
|
||||
const type = ref<AgreementType>('service')
|
||||
const title = ref(DEFAULT_TITLE.service)
|
||||
const content = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
async function loadDetail(agreementType: AgreementType) {
|
||||
loading.value = true
|
||||
title.value = DEFAULT_TITLE[agreementType]
|
||||
content.value = ''
|
||||
try {
|
||||
const data = await fetchAgreementDetail(agreementType)
|
||||
const normalized = normalizeDetail(agreementType, data ?? {})
|
||||
title.value = normalized.title
|
||||
content.value = normalized.content
|
||||
if (!content.value) {
|
||||
uni.showToast({ title: '协议内容为空', icon: 'none' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '加载协议失败'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad((query) => {
|
||||
const t = query?.type as AgreementType
|
||||
const agreementType = t === 'privacy' || t === 'service' ? t : 'service'
|
||||
type.value = agreementType
|
||||
loadDetail(agreementType)
|
||||
})
|
||||
|
||||
return { title, content, loading }
|
||||
}
|
||||
17
src/pages/change-password/change-password.api.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { ForgetPasswordResetBody } from '@/types/api'
|
||||
import type { ChangePasswordForm } from './change-password.types'
|
||||
|
||||
/** POST /app/auth/forgetPassword/doReset — 凭重置令牌设置新密码 */
|
||||
export function resetPasswordByToken(data: ChangePasswordForm) {
|
||||
const body: ForgetPasswordResetBody = {
|
||||
resetToken: data.resetToken,
|
||||
newPassword: data.newPassword,
|
||||
confirmPassword: data.confirmPassword,
|
||||
}
|
||||
return request<void>({
|
||||
url: '/app/auth/forgetPassword/doReset',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
5
src/pages/change-password/change-password.types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ChangePasswordForm {
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
resetToken: string
|
||||
}
|
||||
49
src/pages/change-password/index.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<view class="auth-form-page">
|
||||
<app-navbar title="修改密码" />
|
||||
<view class="auth-form-page__body">
|
||||
<text class="auth-form-page__heading">修改密码</text>
|
||||
|
||||
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
|
||||
<base-input v-model="confirmPassword" icon="lock" type="password" placeholder="确认密码" />
|
||||
|
||||
<view class="auth-form-page__btn">
|
||||
<primary-button text="确认修改" @click="submit" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useChangePassword } from './use-change-password'
|
||||
|
||||
const { password, confirmPassword, submit } = useChangePassword()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-form-page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.auth-form-page__body {
|
||||
padding: 32rpx $page-padding-x 48rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__heading {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__btn {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
:deep(.base-input) {
|
||||
border: none;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
</style>
|
||||
55
src/pages/change-password/use-change-password.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { isValidPassword } from '@/utils/validate'
|
||||
import { resetPasswordByToken } from './change-password.api'
|
||||
|
||||
export function useChangePassword() {
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const resetToken = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onLoad((query) => {
|
||||
const raw = (query?.resetToken as string) || ''
|
||||
resetToken.value = raw ? decodeURIComponent(raw) : ''
|
||||
if (!resetToken.value) {
|
||||
uni.showToast({ title: '请先完成验证码校验', icon: 'none' })
|
||||
setTimeout(() => uni.navigateBack(), 1500)
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
if (!resetToken.value) {
|
||||
uni.showToast({ title: '重置令牌无效', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidPassword(password.value)) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
uni.showToast({ title: '两次密码不一致', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await resetPasswordByToken({
|
||||
resetToken: resetToken.value,
|
||||
newPassword: password.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
})
|
||||
uni.showToast({ title: '修改成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.reLaunch({ url: '/pages/login/index' })
|
||||
}, 800)
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '修改失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { password, confirmPassword, submit }
|
||||
}
|
||||
118
src/pages/device/components/device-empty/index.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<view class="device-empty">
|
||||
<view class="device-empty__state">
|
||||
<view class="device-empty__halo">
|
||||
<image
|
||||
class="device-empty__icon"
|
||||
:src="staticAssets.iconDeviceEmpty"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<text class="device-empty__title">还没有添加设备</text>
|
||||
</view>
|
||||
<view class="device-empty__btn" @tap="$emit('bind')">
|
||||
<text class="device-empty__btn-text">开始绑定</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
stepHint?: number
|
||||
}>(),
|
||||
{ stepHint: 0 },
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
bind: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0 48rpx 56rpx;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 设计稿红框区域:图标 + 文案,固定高度 */
|
||||
.device-empty__state {
|
||||
width: 100%;
|
||||
height: 480rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 外圈浅绿光晕(设计稿 #C9EFD9) */
|
||||
.device-empty__halo {
|
||||
width: 220rpx;
|
||||
height: 220rpx;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
flex-shrink: 0;
|
||||
background: radial-gradient(71% 71% at 50% 50%, #eaf7f0 0%, #f2faf6 62%, #f2faf600 74%);
|
||||
}
|
||||
|
||||
.device-empty__icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
}
|
||||
|
||||
.device-empty__title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.device-empty__btn {
|
||||
width: 100%;
|
||||
width: 335px;
|
||||
height: 48px;
|
||||
border-radius: 15rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(98deg, #45d685 0%, #21c25e 100%);
|
||||
box-shadow: 0px 4px 14px 0px rgba(33, 194, 94, 0.28);
|
||||
}
|
||||
|
||||
.device-empty__btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.device-empty__badge {
|
||||
position: absolute;
|
||||
right: 32rpx;
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
background: #ffc940;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 4rpx solid #fff;
|
||||
}
|
||||
|
||||
.device-empty__badge-num {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
96
src/pages/device/components/device-header/index.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<view class="device-header" :style="{ paddingTop: headerPaddingTop + 'px' }">
|
||||
<view class="device-header__bar">
|
||||
<view class="device-header__home" @tap="$emit('home')">
|
||||
<image
|
||||
class="device-header__home-icon"
|
||||
:src="staticAssets.iconHomeSelector"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="device-header__home-text">{{ homeName }}</text>
|
||||
<image
|
||||
class="device-header__arrow"
|
||||
:src="staticAssets.iconHomeArrow"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
<view class="device-header__notify-wrap" @tap="$emit('notify')">
|
||||
<image
|
||||
class="device-header__notify-icon"
|
||||
:src="staticAssets.iconNotify"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { staticAssets } from '@/utils/static-assets'
|
||||
|
||||
defineProps<{
|
||||
homeName: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
home: []
|
||||
notify: []
|
||||
}>()
|
||||
|
||||
const sys = uni.getSystemInfoSync()
|
||||
const statusBarHeight = sys.statusBarHeight ?? 0
|
||||
/** 状态栏下再留一段间距;H5 无状态栏时用固定上内边距 */
|
||||
const headerPaddingTop =
|
||||
statusBarHeight > 0
|
||||
? statusBarHeight + uni.upx2px(24)
|
||||
: uni.upx2px(48)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.device-header {
|
||||
padding-left: 32rpx;
|
||||
padding-right: 32rpx;
|
||||
padding-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.device-header__bar {
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.device-header__home {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
padding: 12rpx 28rpx 12rpx 20rpx;
|
||||
background: #EFFBF4;
|
||||
border-radius: 40rpx;
|
||||
border: 1px solid #C9EFD9;
|
||||
}
|
||||
|
||||
.device-header__home-icon {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-header__home-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1fa85c;
|
||||
}
|
||||
|
||||
.device-header__arrow {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.device-header__notify-icon {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
97
src/pages/device/components/hot-devices/index.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<view class="hot-devices">
|
||||
<view class="hot-devices__head">
|
||||
<text class="hot-devices__title">热门设备</text>
|
||||
<text class="hot-devices__link" @tap="$emit('mall')">去商城看看 ></text>
|
||||
</view>
|
||||
<scroll-view class="hot-devices__scroll" scroll-x :show-scrollbar="false">
|
||||
<view class="hot-devices__list">
|
||||
<view v-for="item in items" :key="item.id" class="hot-devices__card">
|
||||
<view class="hot-devices__img" />
|
||||
<text class="hot-devices__name">{{ item.name }}</text>
|
||||
<text class="hot-devices__desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HotDeviceItem } from '../../device.types'
|
||||
|
||||
defineProps<{
|
||||
items: HotDeviceItem[]
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
mall: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.hot-devices {
|
||||
padding: 0 32rpx 32rpx;
|
||||
}
|
||||
|
||||
.hot-devices__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.hot-devices__title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
}
|
||||
|
||||
.hot-devices__link {
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.hot-devices__scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hot-devices__list {
|
||||
display: inline-flex;
|
||||
gap: 24rpx;
|
||||
padding-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.hot-devices__card {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
width: 300rpx;
|
||||
padding: 24rpx;
|
||||
background: #fff;
|
||||
border-radius: 24rpx;
|
||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
vertical-align: top;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.hot-devices__img {
|
||||
width: 100%;
|
||||
height: 200rpx;
|
||||
background: linear-gradient(145deg, #f0f2f5 0%, #e8eaed 100%);
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.hot-devices__name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.hot-devices__desc {
|
||||
font-size: 22rpx;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
74
src/pages/device/components/room-tabs/index.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<scroll-view class="room-tabs" scroll-x :show-scrollbar="false">
|
||||
<view class="room-tabs__inner">
|
||||
<view
|
||||
v-for="room in rooms"
|
||||
:key="room.id"
|
||||
class="room-tabs__item"
|
||||
:class="{ 'room-tabs__item--active': room.id === activeId }"
|
||||
@tap="$emit('select', room.id)"
|
||||
>
|
||||
<text class="room-tabs__text">{{ room.name }}</text>
|
||||
</view>
|
||||
<view class="room-tabs__item room-tabs__item--add" @tap="$emit('add')">
|
||||
<text class="room-tabs__text">+ 添加</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { RoomTab } from '../../device.types'
|
||||
|
||||
defineProps<{
|
||||
rooms: RoomTab[]
|
||||
activeId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
select: [id: string]
|
||||
add: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.room-tabs {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
padding: 8rpx 32rpx 24rpx;
|
||||
}
|
||||
|
||||
.room-tabs__inner {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.room-tabs__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16rpx 36rpx;
|
||||
border-radius: 40rpx;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.room-tabs__item--active {
|
||||
background: $color-primary;
|
||||
}
|
||||
|
||||
.room-tabs__item--active .room-tabs__text {
|
||||
color: #fff;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.room-tabs__item--add .room-tabs__text {
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
|
||||
.room-tabs__text {
|
||||
font-size: 28rpx;
|
||||
color: $color-text;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
6
src/pages/device/device.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.device-page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
10
src/pages/device/device.types.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface RoomTab {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface HotDeviceItem {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
}
|
||||
47
src/pages/device/index.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<view class="device-page">
|
||||
<device-header
|
||||
:home-name="homeName"
|
||||
@home="onHomePicker"
|
||||
@notify="onNotify"
|
||||
/>
|
||||
<room-tabs
|
||||
:rooms="rooms"
|
||||
:active-id="activeRoomId"
|
||||
@select="selectRoom"
|
||||
@add="onAddRoom"
|
||||
/>
|
||||
<device-empty :step-hint="bindStepHint" @bind="onStartBind" />
|
||||
<hot-devices :items="hotDevices" @mall="onGoMall" />
|
||||
<app-tab-bar :current="tabCurrent" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTabPage } from '@/hooks/use-tab-page'
|
||||
import DeviceEmpty from './components/device-empty/index.vue'
|
||||
import DeviceHeader from './components/device-header/index.vue'
|
||||
import HotDevices from './components/hot-devices/index.vue'
|
||||
import RoomTabs from './components/room-tabs/index.vue'
|
||||
import { useDevice } from './use-device'
|
||||
|
||||
const { tabCurrent } = useTabPage('device')
|
||||
|
||||
const {
|
||||
homeName,
|
||||
rooms,
|
||||
activeRoomId,
|
||||
hotDevices,
|
||||
bindStepHint,
|
||||
selectRoom,
|
||||
onAddRoom,
|
||||
onStartBind,
|
||||
onGoMall,
|
||||
onNotify,
|
||||
onHomePicker,
|
||||
} = useDevice()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './device.scss';
|
||||
</style>
|
||||
66
src/pages/device/use-device.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ref } from 'vue'
|
||||
import type { HotDeviceItem, RoomTab } from './device.types'
|
||||
|
||||
const DEFAULT_ROOMS: RoomTab[] = [
|
||||
{ id: 'all', name: '全部' },
|
||||
{ id: 'living', name: '客厅' },
|
||||
]
|
||||
|
||||
const DEFAULT_HOT_DEVICES: HotDeviceItem[] = [
|
||||
{
|
||||
id: 'z2',
|
||||
name: 'Z2 空气净化器',
|
||||
desc: '高效除醛 · 智能检测',
|
||||
},
|
||||
{
|
||||
id: 'lamp',
|
||||
name: '智能护眼台灯',
|
||||
desc: '无频闪 · 多场景模式',
|
||||
},
|
||||
]
|
||||
|
||||
export function useDevice() {
|
||||
const homeName = ref('我的家')
|
||||
const rooms = ref<RoomTab[]>(DEFAULT_ROOMS)
|
||||
const activeRoomId = ref('all')
|
||||
const hotDevices = ref<HotDeviceItem[]>(DEFAULT_HOT_DEVICES)
|
||||
const bindStepHint = ref(3)
|
||||
|
||||
function selectRoom(id: string) {
|
||||
activeRoomId.value = id
|
||||
}
|
||||
|
||||
function onAddRoom() {
|
||||
uni.showToast({ title: '添加房间功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function onStartBind() {
|
||||
uni.navigateTo({ url: '/pages/add-device/index' })
|
||||
}
|
||||
|
||||
function onGoMall() {
|
||||
uni.showToast({ title: '商城功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function onNotify() {
|
||||
uni.showToast({ title: '消息功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
function onHomePicker() {
|
||||
uni.showToast({ title: '切换家庭功能开发中', icon: 'none' })
|
||||
}
|
||||
|
||||
return {
|
||||
homeName,
|
||||
rooms,
|
||||
activeRoomId,
|
||||
hotDevices,
|
||||
bindStepHint,
|
||||
selectRoom,
|
||||
onAddRoom,
|
||||
onStartBind,
|
||||
onGoMall,
|
||||
onNotify,
|
||||
onHomePicker,
|
||||
}
|
||||
}
|
||||
16
src/pages/forgot-password/forgot-password.api.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { ForgetPasswordVerifyBody, ForgetPasswordVerifyResult } from '@/types/api'
|
||||
import type { ForgotPasswordForm } from './forgot-password.types'
|
||||
|
||||
/** POST /app/auth/forgetPassword/verifyCode — 校验手机号与验证码,返回重置令牌 */
|
||||
export function verifyForgetPasswordCode(data: ForgotPasswordForm) {
|
||||
const body: ForgetPasswordVerifyBody = {
|
||||
phonenumber: data.phone.trim(),
|
||||
smsCode: data.code.trim(),
|
||||
}
|
||||
return request<ForgetPasswordVerifyResult>({
|
||||
url: '/app/auth/forgetPassword/verifyCode',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
4
src/pages/forgot-password/forgot-password.types.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ForgotPasswordForm {
|
||||
phone: string
|
||||
code: string
|
||||
}
|
||||
48
src/pages/forgot-password/index.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<view class="auth-form-page">
|
||||
<app-navbar title="忘记密码" />
|
||||
<view class="auth-form-page__body">
|
||||
<text class="auth-form-page__heading">忘记密码</text>
|
||||
|
||||
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
|
||||
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
|
||||
<template #suffix>
|
||||
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
|
||||
</template>
|
||||
</base-input>
|
||||
|
||||
<view class="auth-form-page__btn">
|
||||
<primary-button text="下一步" @click="nextStep" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useForgotPassword } from './use-forgot-password'
|
||||
|
||||
const { phone, code, counting, seconds, onSendCode, nextStep } = useForgotPassword()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-form-page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.auth-form-page__body {
|
||||
padding: 32rpx $page-padding-x 48rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__heading {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__btn {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
</style>
|
||||
47
src/pages/forgot-password/use-forgot-password.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSmsCode } from '@/hooks/use-sms-code'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { isValidPhone, isValidSmsCode } from '@/utils/validate'
|
||||
import { SMS_CODE_SCENE } from '@/types/api'
|
||||
import { verifyForgetPasswordCode } from './forgot-password.api'
|
||||
|
||||
export function useForgotPassword() {
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.reset)
|
||||
|
||||
function onSendCode() {
|
||||
sendCode(phone.value)
|
||||
}
|
||||
|
||||
async function nextStep() {
|
||||
if (!isValidPhone(phone.value)) {
|
||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidSmsCode(code.value)) {
|
||||
uni.showToast({ title: '请输入验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await verifyForgetPasswordCode({ phone: phone.value, code: code.value })
|
||||
if (!res.resetToken) {
|
||||
uni.showToast({ title: '验证失败,未获取重置令牌', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/pages/change-password/index?resetToken=${encodeURIComponent(res.resetToken)}`,
|
||||
})
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '验证失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { phone, code, counting, seconds, onSendCode, nextStep }
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<template>
|
||||
<view class="content">
|
||||
<image class="logo" src="/static/logo.png" />
|
||||
<view class="text-area">
|
||||
<text class="title">{{ title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
const title = ref('Hello')
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 200rpx;
|
||||
width: 200rpx;
|
||||
margin-top: 200rpx;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin-bottom: 50rpx;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 36rpx;
|
||||
color: #8f8f94;
|
||||
}
|
||||
</style>
|
||||
|
||||
69
src/pages/login/index.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<view class="login-page" :style="{ paddingTop: statusBarHeight + 'px' }">
|
||||
<view class="login-page__hero">
|
||||
<brand-logo />
|
||||
<view class="login-page__welcome">
|
||||
<text class="login-page__title">欢迎使用</text>
|
||||
<text class="login-page__subtitle">智能硬件管理平台,让你的家更智能、更健康</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<template v-if="mode === 'code'">
|
||||
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
|
||||
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
|
||||
<template #suffix>
|
||||
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
|
||||
</template>
|
||||
</base-input>
|
||||
<view class="login-page__links login-page__links--single">
|
||||
<text class="login-page__link" @tap="switchMode('password')">账号密码登录</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<base-input v-model="account" icon="phone" placeholder="请输入账号" />
|
||||
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
|
||||
<view class="login-page__links">
|
||||
<text class="login-page__link" @tap="switchMode('code')">验证码登录</text>
|
||||
<text class="login-page__link" @tap="goForgotPassword">忘记密码?</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<view class="login-page__footer">
|
||||
<view class="login-page__agreement">
|
||||
<agreement-checkbox v-model="agreed" />
|
||||
</view>
|
||||
<primary-button text="登录" @click="submit" />
|
||||
<view class="login-page__register">
|
||||
<text class="login-page__link" @tap="goRegister">还没有账号?立即注册</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useLogin } from './use-login'
|
||||
|
||||
const statusBarHeight = ref(uni.getSystemInfoSync().statusBarHeight ?? 0)
|
||||
|
||||
const {
|
||||
mode,
|
||||
agreed,
|
||||
phone,
|
||||
code,
|
||||
account,
|
||||
password,
|
||||
counting,
|
||||
seconds,
|
||||
switchMode,
|
||||
onSendCode,
|
||||
submit,
|
||||
goForgotPassword,
|
||||
goRegister,
|
||||
} = useLogin()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import './login.scss';
|
||||
</style>
|
||||
29
src/pages/login/login.api.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { AuthTokenResult, LoginPasswordBody, LoginSmsBody } from '@/types/api'
|
||||
import type { LoginCodeForm, LoginPasswordForm } from './login.types'
|
||||
|
||||
/** POST /app/auth/login/sms — 短信验证码登录 */
|
||||
export function loginBySms(data: LoginCodeForm) {
|
||||
const body: LoginSmsBody = {
|
||||
phonenumber: data.phone.trim(),
|
||||
smsCode: data.code.trim(),
|
||||
}
|
||||
return request<AuthTokenResult>({
|
||||
url: '/app/auth/login/sms',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /app/auth/login/password — 账号密码登录(账号可为用户名或手机号) */
|
||||
export function loginByPassword(data: LoginPasswordForm) {
|
||||
const body: LoginPasswordBody = {
|
||||
account: data.account.trim(),
|
||||
password: data.password,
|
||||
}
|
||||
return request<AuthTokenResult>({
|
||||
url: '/app/auth/login/password',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
59
src/pages/login/login.scss
Normal file
@@ -0,0 +1,59 @@
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: #fff;
|
||||
padding: 0 $page-padding-x 48rpx;
|
||||
}
|
||||
|
||||
.login-page__hero {
|
||||
padding-top: 120rpx;
|
||||
margin-bottom: 64rpx;
|
||||
}
|
||||
|
||||
.login-page__welcome {
|
||||
margin-top: 80rpx;
|
||||
}
|
||||
|
||||
.login-page__title {
|
||||
display: block;
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.login-page__subtitle {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $color-text-secondary;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.login-page__links {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 8rpx 0 48rpx;
|
||||
}
|
||||
|
||||
.login-page__links--single {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.login-page__link {
|
||||
font-size: 28rpx;
|
||||
color: $color-link;
|
||||
}
|
||||
|
||||
.login-page__footer {
|
||||
margin-top: auto;
|
||||
padding-top: 48rpx;
|
||||
}
|
||||
|
||||
.login-page__agreement {
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.login-page__register {
|
||||
margin-top: 32rpx;
|
||||
text-align: center;
|
||||
}
|
||||
11
src/pages/login/login.types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type LoginMode = 'code' | 'password'
|
||||
|
||||
export interface LoginCodeForm {
|
||||
phone: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface LoginPasswordForm {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
96
src/pages/login/use-login.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSmsCode } from '@/hooks/use-sms-code'
|
||||
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
|
||||
import { saveAuthToken } from '@/api/auth'
|
||||
import { getErrorMessage } from '@/utils/api-error'
|
||||
import { SMS_CODE_SCENE } from '@/types/api'
|
||||
import { loginByPassword, loginBySms } from './login.api'
|
||||
import type { LoginMode } from './login.types'
|
||||
|
||||
export function useLogin() {
|
||||
const mode = ref<LoginMode>('code')
|
||||
const agreed = ref(true)
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.login)
|
||||
|
||||
function switchMode(next: LoginMode) {
|
||||
mode.value = next
|
||||
}
|
||||
|
||||
function onSendCode() {
|
||||
sendCode(phone.value)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先阅读并同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
if (mode.value === 'code') {
|
||||
if (!isValidPhone(phone.value)) {
|
||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidSmsCode(code.value)) {
|
||||
uni.showToast({ title: '请输入验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const smsRes = await loginBySms({ phone: phone.value, code: code.value })
|
||||
saveAuthToken(smsRes)
|
||||
} else {
|
||||
if (!account.value.trim()) {
|
||||
uni.showToast({ title: '请输入账号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidPassword(password.value)) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const pwdRes = await loginByPassword({
|
||||
account: account.value,
|
||||
password: password.value,
|
||||
})
|
||||
saveAuthToken(pwdRes)
|
||||
}
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
uni.reLaunch({ url: '/pages/device/index' })
|
||||
} catch (err) {
|
||||
uni.showToast({ title: getErrorMessage(err, '登录失败,请检查信息'), icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
uni.navigateTo({ url: '/pages/register/index' })
|
||||
}
|
||||
|
||||
function goForgotPassword() {
|
||||
uni.navigateTo({ url: '/pages/forgot-password/index' })
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
agreed,
|
||||
phone,
|
||||
code,
|
||||
account,
|
||||
password,
|
||||
counting,
|
||||
seconds,
|
||||
switchMode,
|
||||
onSendCode,
|
||||
submit,
|
||||
goRegister,
|
||||
goForgotPassword,
|
||||
}
|
||||
}
|
||||
44
src/pages/mine/index.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<view class="mine-page">
|
||||
<view class="mine-page__placeholder">
|
||||
<text class="mine-page__title">我的</text>
|
||||
<text class="mine-page__hint">个人中心页面开发中</text>
|
||||
</view>
|
||||
<app-tab-bar :current="tabCurrent" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useTabPage } from '@/hooks/use-tab-page'
|
||||
|
||||
const { tabCurrent } = useTabPage('mine')
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mine-page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: calc(120rpx + env(safe-area-inset-bottom));
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mine-page__placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mine-page__title {
|
||||
display: block;
|
||||
font-size: 40rpx;
|
||||
font-weight: 600;
|
||||
color: $color-text;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.mine-page__hint {
|
||||
font-size: 28rpx;
|
||||
color: $color-text-secondary;
|
||||
}
|
||||
</style>
|
||||
70
src/pages/register/index.vue
Normal file
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<view class="auth-form-page">
|
||||
<app-navbar title="注册" />
|
||||
<view class="auth-form-page__body">
|
||||
<text class="auth-form-page__heading">注册用户</text>
|
||||
|
||||
<base-input v-model="phone" icon="phone" type="number" placeholder="请输入手机号" :maxlength="11" />
|
||||
<base-input v-model="code" icon="shield" type="number" placeholder="请输入验证码" :maxlength="6">
|
||||
<template #suffix>
|
||||
<sms-code-btn :counting="counting" :seconds="seconds" @send="onSendCode" />
|
||||
</template>
|
||||
</base-input>
|
||||
<base-input v-model="password" icon="lock" type="password" placeholder="请输入密码" />
|
||||
<base-input v-model="confirmPassword" icon="lock" type="password" placeholder="请确认密码" />
|
||||
|
||||
<view class="auth-form-page__btn">
|
||||
<primary-button text="立即注册" @click="submit" />
|
||||
</view>
|
||||
|
||||
<view class="auth-form-page__agreement">
|
||||
<agreement-checkbox v-model="agreed" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRegister } from './use-register'
|
||||
|
||||
const {
|
||||
phone,
|
||||
code,
|
||||
password,
|
||||
confirmPassword,
|
||||
agreed,
|
||||
counting,
|
||||
seconds,
|
||||
onSendCode,
|
||||
submit,
|
||||
} = useRegister()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.auth-form-page {
|
||||
min-height: 100vh;
|
||||
background: $color-bg-page;
|
||||
}
|
||||
|
||||
.auth-form-page__body {
|
||||
padding: 32rpx $page-padding-x 48rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__heading {
|
||||
display: block;
|
||||
font-size: 44rpx;
|
||||
font-weight: 700;
|
||||
color: $color-text;
|
||||
margin-bottom: 48rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__btn {
|
||||
margin-top: 16rpx;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.auth-form-page__agreement {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
18
src/pages/register/register.api.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { request } from '@/api/request'
|
||||
import type { RegisterBody } from '@/types/api'
|
||||
import type { RegisterForm } from './register.types'
|
||||
|
||||
/** POST /app/auth/register */
|
||||
export function registerUser(data: RegisterForm) {
|
||||
const body: RegisterBody = {
|
||||
phonenumber: data.phone.trim(),
|
||||
smsCode: data.code.trim(),
|
||||
password: data.password,
|
||||
confirmPassword: data.confirmPassword,
|
||||
}
|
||||
return request<void>({
|
||||
url: '/app/auth/register',
|
||||
method: 'POST',
|
||||
data: body,
|
||||
})
|
||||
}
|
||||
6
src/pages/register/register.types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface RegisterForm {
|
||||
phone: string
|
||||
code: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
73
src/pages/register/use-register.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { ref } from 'vue'
|
||||
import { useSmsCode } from '@/hooks/use-sms-code'
|
||||
import { isValidPassword, isValidPhone, isValidSmsCode } from '@/utils/validate'
|
||||
import { SMS_CODE_SCENE } from '@/types/api'
|
||||
import { registerUser } from './register.api'
|
||||
|
||||
export function useRegister() {
|
||||
const phone = ref('')
|
||||
const code = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const agreed = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
const { counting, seconds, sendCode } = useSmsCode(SMS_CODE_SCENE.register)
|
||||
|
||||
function onSendCode() {
|
||||
sendCode(phone.value)
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!agreed.value) {
|
||||
uni.showToast({ title: '请先阅读并同意协议', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidPhone(phone.value)) {
|
||||
uni.showToast({ title: '请输入正确手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidSmsCode(code.value)) {
|
||||
uni.showToast({ title: '请输入验证码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!isValidPassword(password.value)) {
|
||||
uni.showToast({ title: '密码至少 6 位', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
uni.showToast({ title: '两次密码不一致', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await registerUser({
|
||||
phone: phone.value,
|
||||
code: code.value,
|
||||
password: password.value,
|
||||
confirmPassword: confirmPassword.value,
|
||||
})
|
||||
uni.showToast({ title: '注册成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 800)
|
||||
} catch {
|
||||
uni.showToast({ title: '注册失败,请稍后重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phone,
|
||||
code,
|
||||
password,
|
||||
confirmPassword,
|
||||
agreed,
|
||||
counting,
|
||||
seconds,
|
||||
onSendCode,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
24
src/plugins/register-global-components.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { App, Component } from 'vue'
|
||||
import AgreementCheckbox from '@/components/agreement-checkbox/index.vue'
|
||||
import AppNavbar from '@/components/app-navbar/index.vue'
|
||||
import BaseInput from '@/components/base-input/index.vue'
|
||||
import BrandLogo from '@/components/brand-logo/index.vue'
|
||||
import PrimaryButton from '@/components/primary-button/index.vue'
|
||||
import SmsCodeBtn from '@/components/sms-code-btn/index.vue'
|
||||
import AppTabBar from '@/components/app-tab-bar/index.vue'
|
||||
|
||||
const globalComponents: Record<string, Component> = {
|
||||
'app-tab-bar': AppTabBar,
|
||||
'agreement-checkbox': AgreementCheckbox,
|
||||
'app-navbar': AppNavbar,
|
||||
'base-input': BaseInput,
|
||||
'brand-logo': BrandLogo,
|
||||
'primary-button': PrimaryButton,
|
||||
'sms-code-btn': SmsCodeBtn,
|
||||
}
|
||||
|
||||
export function registerGlobalComponents(app: App) {
|
||||
Object.entries(globalComponents).forEach(([name, component]) => {
|
||||
app.component(name, component)
|
||||
})
|
||||
}
|
||||
BIN
src/static/icon-checked@2x.png
Normal file
|
After Width: | Height: | Size: 797 B |
BIN
src/static/icon-code@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src/static/icon-phone@2x.png
Normal file
|
After Width: | Height: | Size: 616 B |
BIN
src/static/logo-center@2x.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
src/static/tab-device-active.png
Normal file
|
After Width: | Height: | Size: 434 B |
BIN
src/static/tab-device.png
Normal file
|
After Width: | Height: | Size: 382 B |
BIN
src/static/tab-mine-active.png
Normal file
|
After Width: | Height: | Size: 559 B |
BIN
src/static/tab-mine.png
Normal file
|
After Width: | Height: | Size: 461 B |
BIN
src/static/图标 4.png
Normal file
|
After Width: | Height: | Size: 352 B |
BIN
src/static/图标 8.png
Normal file
|
After Width: | Height: | Size: 629 B |
BIN
src/static/图标 9.png
Normal file
|
After Width: | Height: | Size: 302 B |
BIN
src/static/组 27.png
Normal file
|
After Width: | Height: | Size: 695 B |
BIN
src/static/组 281@2x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/组 286@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/组 291@2x.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src/static/组 41@2x.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
src/static/组 468@2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src/static/组 476@2x.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
src/static/组 600@2x.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/static/组 9.png
Normal file
|
After Width: | Height: | Size: 208 B |
BIN
src/static/蓝牙-F.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src/static/路径.png
Normal file
|
After Width: | Height: | Size: 358 B |
13
src/styles/index.scss
Normal file
@@ -0,0 +1,13 @@
|
||||
@import './variables.scss';
|
||||
|
||||
page {
|
||||
background-color: $color-bg-page;
|
||||
color: $color-text;
|
||||
font-size: 28rpx;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
view,
|
||||
text {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
14
src/styles/variables.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
$color-primary: #1ec671;
|
||||
$color-primary-dark: #18a85d;
|
||||
$color-text: #1a1a1a;
|
||||
$color-text-secondary: #666666;
|
||||
$color-text-placeholder: #c0c0c0;
|
||||
$color-border: #e8e8e8;
|
||||
$color-bg-page: #f7f8fa;
|
||||
$color-bg-input: #ffffff;
|
||||
$color-link: #1ec671;
|
||||
|
||||
$page-padding-x: 48rpx;
|
||||
$radius-input: 48rpx;
|
||||
$radius-button: 24rpx;
|
||||
$shadow-button: 0 8rpx 24rpx rgba(30, 198, 113, 0.35);
|
||||
62
src/types/api.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/** 发送验证码场景(与后端 SmsCodeSceneConstants 一致) */
|
||||
export const SMS_CODE_SCENE = {
|
||||
register: 'register',
|
||||
login: 'login',
|
||||
reset: 'reset',
|
||||
changePhoneOld: 'change_phone_old',
|
||||
changePhoneNew: 'change_phone_new',
|
||||
} as const
|
||||
|
||||
export type SmsCodeScene = (typeof SMS_CODE_SCENE)[keyof typeof SMS_CODE_SCENE]
|
||||
|
||||
/** 常见后端统一响应(如 RuoYi:code 200 为成功) */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
code?: number
|
||||
msg?: string
|
||||
message?: string
|
||||
data?: T
|
||||
}
|
||||
|
||||
export interface SendSmsCodeBody {
|
||||
phonenumber: string
|
||||
scene: SmsCodeScene
|
||||
}
|
||||
|
||||
export interface LoginSmsBody {
|
||||
phonenumber: string
|
||||
smsCode: string
|
||||
}
|
||||
|
||||
export interface LoginPasswordBody {
|
||||
account: string
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface RegisterBody {
|
||||
phonenumber: string
|
||||
smsCode: string
|
||||
password: string
|
||||
confirmPassword: string
|
||||
}
|
||||
|
||||
export interface AuthTokenResult {
|
||||
token?: string
|
||||
accessToken?: string
|
||||
}
|
||||
|
||||
/** POST /app/auth/forgetPassword/verifyCode */
|
||||
export interface ForgetPasswordVerifyBody {
|
||||
phonenumber: string
|
||||
smsCode: string
|
||||
}
|
||||
|
||||
export interface ForgetPasswordVerifyResult {
|
||||
resetToken: string
|
||||
}
|
||||
|
||||
/** POST /app/auth/forgetPassword/doReset */
|
||||
export interface ForgetPasswordResetBody {
|
||||
resetToken: string
|
||||
newPassword: string
|
||||
confirmPassword: string
|
||||
}
|
||||
34
src/utils/api-error.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ApiResponse } from '@/types/api'
|
||||
|
||||
export class ApiError extends Error {
|
||||
code?: number
|
||||
|
||||
constructor(message: string, code?: number) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
|
||||
export function isApiSuccess(body: ApiResponse): boolean {
|
||||
if (body.code === undefined) return true
|
||||
return body.code === 200 || body.code === 0
|
||||
}
|
||||
|
||||
export function unwrapApiData<T>(body: ApiResponse<T> & Record<string, unknown>): T {
|
||||
if (body.data !== undefined && body.data !== null) {
|
||||
return body.data as T
|
||||
}
|
||||
return body as T
|
||||
}
|
||||
|
||||
export function getErrorMessage(err: unknown, fallback = '请求失败'): string {
|
||||
if (err instanceof ApiError) return err.message
|
||||
if (err instanceof Error && err.message) return err.message
|
||||
if (err && typeof err === 'object' && 'data' in err) {
|
||||
const data = (err as { data?: ApiResponse }).data
|
||||
if (data?.msg) return data.msg
|
||||
if (data?.message) return data.message
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
66
src/utils/static-assets.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/** 设计稿静态图标(src/static) */
|
||||
import logoCenter from '@/static/logo-center@2x.png'
|
||||
import iconPhone from '@/static/icon-phone@2x.png'
|
||||
import iconCode from '@/static/icon-code@2x.png'
|
||||
import iconChecked from '@/static/icon-checked@2x.png'
|
||||
import iconPassword from '@/static/路径.png'
|
||||
import iconHomeSelector from '@/static/图标 4.png'
|
||||
import iconHomeArrow from '@/static/组 9.png'
|
||||
import iconNotify from '@/static/组 27.png'
|
||||
import iconDeviceEmpty from '@/static/图标 8.png'
|
||||
import iconDeviceNameEdit from '@/static/图标 9.png'
|
||||
import iconBindComplete from '@/static/组 41@2x.png'
|
||||
import iconBluetooth from '@/static/蓝牙-F.png'
|
||||
import iconBindDiscover from '@/static/组 281@2x.png'
|
||||
import iconBindPair from '@/static/组 286@2x.png'
|
||||
import iconBindSecure from '@/static/组 291@2x.png'
|
||||
import iconScanDevice from '@/static/组 600@2x.png'
|
||||
import iconScanDeviceAction from '@/static/组 468@2x.png'
|
||||
import iconScanDeviceActionDisabled from '@/static/组 476@2x.png'
|
||||
import tabDevice from '@/static/tab-device-active.png'
|
||||
import tabDeviceActive from '@/static/tab-device.png'
|
||||
import tabMine from '@/static/tab-mine-active.png'
|
||||
import tabMineActive from '@/static/tab-mine.png'
|
||||
|
||||
export const staticAssets = {
|
||||
/** 组 15106@2x.png — 登录区中心大图标 */
|
||||
logo: logoCenter,
|
||||
/** 手机号@2x.png */
|
||||
iconPhone,
|
||||
/** 验证码@2x.png */
|
||||
iconCode,
|
||||
/** 选中@2x.png */
|
||||
iconChecked,
|
||||
/** 路径.png — 密码框 */
|
||||
iconPassword,
|
||||
/** 图标 4.png — 首页「我的家」 */
|
||||
iconHomeSelector,
|
||||
/** 组 9.png — 「我的家」下拉箭头 */
|
||||
iconHomeArrow,
|
||||
/** 组 27.png — 设备页右上角通知 */
|
||||
iconNotify,
|
||||
/** 图标 8.png — 设备空状态中心图标 */
|
||||
iconDeviceEmpty,
|
||||
/** 图标 9.png — 完成配置「设备名称」编辑 */
|
||||
iconDeviceNameEdit,
|
||||
/** 组 41@2x.png — 完成配置成功示意图 */
|
||||
iconBindComplete,
|
||||
/** 蓝牙-F.png — 添加设备页蓝牙示意图 */
|
||||
iconBluetooth,
|
||||
/** 组 281@2x.png — 添加设备「发现附近设备」 */
|
||||
iconBindDiscover,
|
||||
/** 组 286@2x.png — 添加设备「快速配对连接」 */
|
||||
iconBindPair,
|
||||
/** 组 291@2x.png — 添加设备「本地安全控制」 */
|
||||
iconBindSecure,
|
||||
/** 组 600@2x.png — 扫描设备页示意图 */
|
||||
iconScanDevice,
|
||||
/** 组 468@2x.png — 发现设备列表「可添加」箭头 */
|
||||
iconScanDeviceAction,
|
||||
/** 组 476@2x.png — 发现设备列表「不可添加」箭头 */
|
||||
iconScanDeviceActionDisabled,
|
||||
tabDevice,
|
||||
tabDeviceActive,
|
||||
tabMine,
|
||||
tabMineActive,
|
||||
} as const
|
||||
11
src/utils/validate.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export function isValidPhone(phone: string): boolean {
|
||||
return /^1\d{10}$/.test(phone.trim())
|
||||
}
|
||||
|
||||
export function isValidSmsCode(code: string): boolean {
|
||||
return /^\d{4,6}$/.test(code.trim())
|
||||
}
|
||||
|
||||
export function isValidPassword(password: string): boolean {
|
||||
return password.length >= 6 && password.length <= 32
|
||||
}
|
||||