fix(mini): address UI feedback and device status display

Correct battery colors, participant artwork, navigation icons, title layout, and map spacing.

Animate the binding countdown using the existing expiry, recover polling after transient failures, and show online and battery data on success. Keep current firmware messaging consistent with version availability.

Validation: WeChat mini-program build, 16 UI tests, and isolated developer-tool scenarios passed. Five pre-existing TypeScript errors remain; physical-device validation is pending.
This commit is contained in:
stu2not
2026-09-16 14:14:56 +08:00
parent a19e62af92
commit 7bcace0410
29 changed files with 321 additions and 102 deletions
+70 -7
View File
@@ -1,9 +1,11 @@
import { getPageStyle } from '@/utils/page-style'
import { formatBatteryDisplay } from '@/utils/battery'
import { View, Text, Image, Input, Button } from '@tarojs/components'
import { useEffect, useRef, useState } from 'react'
import Taro, { useDidShow, useDidHide, useRouter } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { DeviceOnlineStatus, DeviceStatus, getDeviceOnlineStatus, getDeviceStatus } from '@/services/device'
import {
getNFCBindSession,
reportBindInitialLocation,
@@ -179,11 +181,19 @@ export default function Bind() {
const [bindResultDestination, setBindResultDestination] = useState('/pages/device/index')
const [bindExpiresAt, setBindExpiresAt] = useState('')
const [remainingBindSeconds, setRemainingBindSeconds] = useState(0)
const [totalBindSeconds, setTotalBindSeconds] = useState(0)
const [boundDeviceId, setBoundDeviceId] = useState('')
const [boundDeviceStatus, setBoundDeviceStatus] = useState<DeviceStatus | null>(null)
const [boundDeviceOnline, setBoundDeviceOnline] = useState<DeviceOnlineStatus | null>(null)
const [loadingBoundDevice, setLoadingBoundDevice] = useState(false)
const pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pollGenerationRef = useRef(0)
const pageVisibleRef = useRef(true)
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
const appliedLaunchQrRef = useRef('')
useDidShow(() => {
pageVisibleRef.current = true
void loadPageData()
if (bindStatus === SESSION_STATUS_PENDING && bindToken) {
schedulePoll(bindToken)
@@ -191,14 +201,32 @@ export default function Bind() {
})
useDidHide(() => {
pageVisibleRef.current = false
stopPolling()
})
useEffect(() => () => {
pageVisibleRef.current = false
stopPolling()
stopCountdown()
}, [])
useEffect(() => {
if (bindStatus !== SESSION_STATUS_COMPLETED || !boundDeviceId) return
let cancelled = false
setLoadingBoundDevice(true)
void Promise.all([
getDeviceStatus(boundDeviceId).catch(() => null),
getDeviceOnlineStatus(boundDeviceId).catch(() => null),
]).then(([status, online]) => {
if (cancelled) return
setBoundDeviceStatus(status)
setBoundDeviceOnline(online)
setLoadingBoundDevice(false)
})
return () => { cancelled = true }
}, [bindStatus, boundDeviceId])
const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
const isPendingBinding = Boolean(pendingDeviceId)
const isAddingCard = isCardMode && Boolean(deviceId.trim())
@@ -245,6 +273,7 @@ export default function Bind() {
}
const stopPolling = () => {
pollGenerationRef.current += 1
if (pollingRef.current) {
clearTimeout(pollingRef.current)
pollingRef.current = null
@@ -260,8 +289,10 @@ export default function Bind() {
const schedulePoll = (nextBindToken?: string) => {
stopPolling()
if (!pageVisibleRef.current) return
const generation = pollGenerationRef.current
pollingRef.current = setTimeout(() => {
void pollBindSession(nextBindToken)
void pollBindSession(nextBindToken, generation)
}, 1500)
}
@@ -274,6 +305,10 @@ export default function Bind() {
setBindHint('')
setBindExpiresAt('')
setRemainingBindSeconds(0)
setTotalBindSeconds(0)
setBoundDeviceId('')
setBoundDeviceStatus(null)
setBoundDeviceOnline(null)
}
const applyLaunchQrPayload = () => {
@@ -336,12 +371,13 @@ export default function Bind() {
return () => stopCountdown()
}, [bindStatus, bindExpiresAt])
const pollBindSession = async (nextBindToken?: string) => {
const pollBindSession = async (nextBindToken?: string, generation = pollGenerationRef.current) => {
const activeBindToken = (nextBindToken || bindToken).trim()
if (!activeBindToken) return
if (!activeBindToken || !pageVisibleRef.current || generation !== pollGenerationRef.current) return
try {
const session = await getNFCBindSession(activeBindToken)
if (!pageVisibleRef.current || generation !== pollGenerationRef.current) return
setBindStatus(session.status)
setCardUUID(session.card_uuid || '')
setBindExpiresAt(session.expires_at || '')
@@ -360,6 +396,7 @@ export default function Bind() {
stopCountdown()
if (session.status === SESSION_STATUS_COMPLETED) {
setBoundDeviceId(session.device_id)
setSelectedBindingDeviceId(session.device_id)
if (!isAddingCard && session.child_id) {
void uploadBindInitialLocation(session.device_id)
@@ -391,8 +428,10 @@ export default function Bind() {
return
}
} catch (error: any) {
if (!pageVisibleRef.current || generation !== pollGenerationRef.current) return
console.error('[bind] poll bind session failed:', error)
setBindHint(error?.message || '查询绑定状态失败,请重试')
setBindHint('暂时无法查询绑定结果,正在重试,请稍候')
schedulePoll(activeBindToken)
}
}
@@ -526,6 +565,7 @@ export default function Bind() {
setBindStatus(session.status)
setBindExpiresAt(session.expires_at)
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
setTotalBindSeconds(getRemainingBindSeconds(session.expires_at))
setBindHint('已发送加卡指令,请在倒计时结束前去设备上贴新卡')
schedulePoll(session.bind_token)
Taro.showToast({ title: '请去设备上贴新卡', icon: 'none' })
@@ -546,6 +586,7 @@ export default function Bind() {
setBindStatus(session.status)
setBindExpiresAt(session.expires_at)
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
setTotalBindSeconds(getRemainingBindSeconds(session.expires_at))
setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
schedulePoll(session.bind_token)
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
@@ -581,6 +622,8 @@ export default function Bind() {
if (sessionVisible) {
const completed = bindStatus === SESSION_STATUS_COMPLETED
const pending = bindStatus === SESSION_STATUS_PENDING
const countdownAngle = totalBindSeconds > 0 ? Math.max(0, Math.min(360, remainingBindSeconds / totalBindSeconds * 360)) : 0
const resultBattery = formatBatteryDisplay(boundDeviceStatus?.power ?? boundDeviceStatus?.battery_pct)
const title = pending ? '请在设备上贴卡' : completed
? isAddingCard ? '新卡添加完成' : '设备绑定成功'
: isExpiredBind ? '贴卡时间已过' : '贴卡未完成'
@@ -597,6 +640,12 @@ export default function Bind() {
<>
<Text className='bind-result-title'>{title}</Text>
<View className='bind-countdown-ring'>
<View className='bind-countdown-half right'>
<View className='bind-countdown-arc' style={{ transform: `rotate(${Math.min(180, countdownAngle)}deg)` }} />
</View>
<View className='bind-countdown-half left'>
<View className='bind-countdown-arc' style={{ transform: `rotate(${Math.max(0, countdownAngle - 180)}deg)` }} />
</View>
<Text className='bind-countdown-value'>{formatRemainingTime(remainingBindSeconds)}</Text>
<Text className='bind-countdown-label'>剩余贴卡时间</Text>
</View>
@@ -613,9 +662,23 @@ export default function Bind() {
<Text className='bind-result-title'>{title}</Text>
<Text className='bind-result-description'>{bindHint}</Text>
{completed ? (
<Button className='bind-result-button primary' onClick={() => Taro.reLaunch({ url: bindResultDestination })}>
{bindResultDestination.includes('/sleep/') ? '去关联宝贝' : '返回首页'}
</Button>
<>
<View className='bind-device-summary'>
<View className='bind-device-row'>
<Text>设备状态</Text>
<Text className={`bind-device-value ${boundDeviceOnline?.online === true ? 'online' : ''}`}>
{loadingBoundDevice ? '查询中' : boundDeviceOnline ? boundDeviceOnline.online ? '在线' : '离线' : '--'}
</Text>
</View>
<View className='bind-device-row'>
<Text>当前电量</Text>
<Text className='bind-device-value'>{loadingBoundDevice ? '查询中' : `${resultBattery.text}${resultBattery.showPercent ? '%' : ''}`}</Text>
</View>
</View>
<Button className='bind-result-button primary' onClick={() => Taro.reLaunch({ url: bindResultDestination })}>
{bindResultDestination.includes('/sleep/') ? '去关联宝贝' : '返回首页'}
</Button>
</>
) : (
<>
<Button className='bind-result-button primary' onClick={() => {