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
+24 -1
View File
@@ -388,7 +388,25 @@
.bind-result-title { display: block; color: #895731; font-size: 46px; font-weight: 700; line-height: 1.35; }
.bind-result-art { display: block; width: 310px; height: 290px; margin: 12px auto 20px; }
.bind-result-description { display: block; margin-top: 18px; color: #a68d74; font-size: 27px; line-height: 1.65; word-break: break-word; }
.bind-countdown-ring { width: 220px; height: 220px; border: 12px solid #f9d96c; border-top-color: #ffa24f; border-radius: 50%; margin: 44px auto; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #fffdf7; }
.bind-countdown-ring {
position: relative;
width: 244px;
height: 244px;
border-radius: 50%;
margin: 44px auto;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fffdf7;
box-shadow: inset 0 0 0 12px #f9d96c;
}
.bind-countdown-half { position: absolute; top: 0; width: 50%; height: 100%; overflow: hidden; pointer-events: none; }
.bind-countdown-half.right { right: 0; }
.bind-countdown-half.left { left: 0; }
.bind-countdown-arc { position: absolute; top: 0; width: 100%; height: 100%; box-sizing: border-box; border: 12px solid #ffa24f; transition: transform .8s linear; }
.bind-countdown-half.right .bind-countdown-arc { left: -100%; border-right: 0; border-radius: 122px 0 0 122px; transform-origin: 100% 50%; }
.bind-countdown-half.left .bind-countdown-arc { left: 100%; border-left: 0; border-radius: 0 122px 122px 0; transform-origin: 0 50%; }
.bind-countdown-value { font-size: 62px; font-weight: 700; color: #ec913e; }
.bind-countdown-label { margin-top: 12px; font-size: 22px; color: #aa8154; }
.bind-instruction { padding: 28px; border: 2px solid #d2b48c; border-radius: 46px; background: #fffdf7; }
@@ -398,3 +416,8 @@
.bind-result-button.primary { background: linear-gradient(#ffce8d, #efa05a); border: 2px solid #cf935a; color: #895731; }
.bind-result-button.secondary { background: #fffdf7; border: 2px solid #cf935a; color: #ad8054; }
.bind-result-button.waiting { background: linear-gradient(#eeede9, #c9c7c1); border: 2px solid #aaa69c; color: #777168; }
.bind-device-summary { margin-top: 32px; padding: 12px 28px; border: 2px solid #d2b48c; border-radius: 28px; background: #fffdf7; text-align: left; }
.bind-device-row { display: flex; align-items: center; justify-content: space-between; padding: 20px 0; color: #a68d74; font-size: 26px; }
.bind-device-row + .bind-device-row { border-top: 1px solid #eee3d5; }
.bind-device-value { color: #895731; font-weight: 600; }
.bind-device-value.online { color: #93b516; }
+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={() => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { getParticipantArtwork } from '@/utils/artwork'
import PageTitle from '@/components/page-title'
import { getPageStyle } from '@/utils/page-style'
import { setActiveTabRoute } from '@/utils/tab-bar'
import { View, Text, ScrollView, Image } from '@tarojs/components'
@@ -80,7 +81,7 @@ export default function Chat() {
return (
<View className='chat-page' style={getPageStyle(true)}>
<View className='page-header'>
<Text className='page-title'>玩伴</Text>
<PageTitle title='玩伴' />
</View>
{isGuest ? (
+5
View File
@@ -132,6 +132,11 @@
}
.status-card {
&.battery-full { --bb-battery-background: #c7e52d; --bb-battery-border: #a8bf35; }
&.battery-high { --bb-battery-background: #f4d16c; --bb-battery-border: #dbb553; }
&.battery-medium { --bb-battery-background: #f6ad5c; --bb-battery-border: #df9549; }
&.battery-low { --bb-battery-background: #f47f7c; --bb-battery-border: #d96565; }
&.battery-unknown { --bb-battery-background: #e5dac6; --bb-battery-border: #cbbb9f; }
position: relative;
overflow: hidden;
margin: 0 24px 32px;
+10 -19
View File
@@ -1,4 +1,5 @@
import { getPageStyle } from '@/utils/page-style'
import { formatBatteryDisplay, getBatteryLevel } from '@/utils/battery'
import { setActiveTabRoute } from '@/utils/tab-bar'
import { View, Text, Slider, Image } from '@tarojs/components'
import { useRef, useState } from 'react'
@@ -49,14 +50,6 @@ function sleep(ms: number): Promise<void> {
})
}
function formatBatteryDisplay(value?: number | null): { text: string; showPercent: boolean } {
if (value === null || value === undefined) return { text: '--', showPercent: false }
const normalized = Number(value)
if (!Number.isFinite(normalized)) return { text: '--', showPercent: false }
if (normalized === 0) return { text: '*', showPercent: false }
return { text: String(normalized), showPercent: true }
}
function formatSleepRange(status: DeviceStatus | null): string {
const start = status?.disable_time_start?.trim()
const end = status?.disable_time_end?.trim()
@@ -404,16 +397,14 @@ export default function Device() {
const pageTitle = `${childName} 的伴伴`
const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
const batteryDisplay = formatBatteryDisplay(batteryValue)
const statusMascotSrc =
batteryValue === null
? require('../../assets/ui/dog-happy.png')
: batteryValue > 85
? require('../../assets/ui/dog-happy.png')
: batteryValue > 60
? require('../../assets/ui/dog-sparkle.png')
: batteryValue > 35
? require('../../assets/ui/dog-lying.png')
: require('../../assets/ui/dog-question.png')
const batteryLevel = getBatteryLevel(batteryValue)
const statusMascotSrc = {
unknown: require('../../assets/ui/dog-happy.png'),
full: require('../../assets/ui/dog-happy.png'),
high: require('../../assets/ui/dog-sparkle.png'),
medium: require('../../assets/ui/dog-lying.png'),
low: require('../../assets/ui/dog-question.png'),
}[batteryLevel]
const signalLabel = getSignalLabel(deviceStatus?.signal)
const versionLabel = deviceStatus?.version || '--'
const coordinateLabel = formatCoordinates(deviceStatus)
@@ -429,7 +420,7 @@ export default function Device() {
<Text className='page-title'>{pageTitle}</Text>
</View>
<View className='status-card'>
<View className={`status-card battery-${batteryLevel}`}>
<View className='status-badge'>
<View className='status-dot'></View>
<Text className='status-text'>已完成绑定</Text>
+3 -1
View File
@@ -53,7 +53,9 @@
.map-section {
height: 50vh;
position: relative;
margin-bottom: 24px;
margin: 0 24px 24px;
border-radius: 24px;
overflow: hidden;
}
.guest-map-section {
+2 -1
View File
@@ -1,4 +1,5 @@
import { getPageStyle } from '@/utils/page-style'
import PageTitle from '@/components/page-title'
import { setActiveTabRoute } from '@/utils/tab-bar'
import { View, Text, Map } from '@tarojs/components'
import { useState } from 'react'
@@ -381,7 +382,7 @@ export default function Location() {
return (
<View className='location-page' style={getPageStyle(true)}>
<View className='page-header'>
<Text className='page-title'>设备定位</Text>
<PageTitle title='设备定位' />
</View>
{isGuest ? (
+12 -34
View File
@@ -1,5 +1,7 @@
import FirmwareProgress from '@/components/firmware-progress'
import { getRoleArtwork } from '@/utils/artwork'
import PageTitle from '@/components/page-title'
import { getParticipantArtwork, getRoleArtwork } from '@/utils/artwork'
import { firmwareSummaryView, isFirmwareCurrent, isFirmwareUpdateInProgress, normalizeFirmwareUpdateStatus } from '@/utils/firmware-status'
import { getPageStyle } from '@/utils/page-style'
import { setActiveTabRoute } from '@/utils/tab-bar'
import { View, Text, Image, Input, Button } from '@tarojs/components'
@@ -63,17 +65,8 @@ interface MenuItem {
}
const FIRMWARE_STATUS_POLL_INTERVAL_MS = 3000
const FIRMWARE_UPDATE_IN_PROGRESS_STATUSES = new Set(['sent', 'accepted', 'updating'])
const FIRMWARE_UPDATE_SUCCESS_STATUSES = new Set(['success', 'completed'])
function normalizeFirmwareUpdateStatus(status?: string | null): string {
return String(status || '').trim().toLowerCase()
}
function isFirmwareUpdateInProgress(status?: string | null): boolean {
return FIRMWARE_UPDATE_IN_PROGRESS_STATUSES.has(normalizeFirmwareUpdateStatus(status))
}
function isFirmwareUpdateSuccessful(status?: string | null): boolean {
return FIRMWARE_UPDATE_SUCCESS_STATUSES.has(normalizeFirmwareUpdateStatus(status))
}
@@ -288,6 +281,8 @@ export default function Sleep() {
const nextStatus = normalizeFirmwareUpdateStatus(nextFirmwareStatus?.update_status)
if (isFirmwareUpdateSuccessful(nextStatus)) {
Taro.showToast({ title: '系统更新完成', icon: 'success' })
} else if (isFirmwareCurrent(nextFirmwareStatus)) {
Taro.showToast({ title: '当前已是最新版本', icon: 'none' })
} else if (nextStatus === 'failed') {
Taro.showToast({ title: '系统更新失败,可重试', icon: 'none' })
}
@@ -999,7 +994,7 @@ export default function Sleep() {
return (
<View className='manage-page' style={getPageStyle(true)}>
<View className='page-header'>
<Text className='page-title'>设置与管理</Text>
<PageTitle title='设备管理' />
</View>
<View className='guest-manage-card'>
@@ -1065,6 +1060,7 @@ export default function Sleep() {
isFamilyOwner
)
const isFirmwareUpdatingByStatus = isFirmwareUpdateInProgress(firmwareStatus?.update_status)
const firmwareSummary = firmwareSummaryView(firmwareStatus)
const canUpdateFirmware = Boolean(
binding?.device_id &&
firmwareStatus?.can_update &&
@@ -1078,11 +1074,7 @@ export default function Sleep() {
? '查询中'
: isUpdatingFirmware
? '发送中'
: isFirmwareUpdatingByStatus
? '查看进度'
: firmwareStatus?.update_available
? '更新'
: '已最新'
: firmwareSummary.action
const roleValue = !binding?.device_id
? '未绑定'
: isLoadingRoles
@@ -1104,13 +1096,7 @@ export default function Sleep() {
? '绑定设备后可查看系统版本'
: isLoadingFirmware
? '正在查询当前设备版本'
: isFirmwareUpdatingByStatus
? '设备正在更新,请稍候'
: normalizeFirmwareUpdateStatus(firmwareStatus?.update_status) === 'failed'
? '更新失败,可重试'
: firmwareStatus?.update_available
? '发现可更新版本'
: '当前设备版本状态'
: firmwareSummary.subtitle
const cardValue = !binding?.device_id
? '未绑定'
: isLoadingCards
@@ -1185,16 +1171,12 @@ export default function Sleep() {
return (
<View className='manage-page' style={getPageStyle(true)}>
<View className='page-header'>
<Text className='page-title'>设置与管理</Text>
<PageTitle title='设备管理' />
</View>
<View className='user-card'>
<View className='user-avatar'>
{parentInfo?.avatar_url ? (
<Image className='avatar-img image-avatar' src={parentInfo.avatar_url} mode='aspectFill' />
) : (
<Text className='avatar-img'>👤</Text>
)}
<Image className='avatar-img image-avatar' src={getParticipantArtwork('parent')} mode='aspectFit' />
</View>
<View className='user-info'>
<Text className='user-name'>{parentDisplayName}</Text>
@@ -1605,11 +1587,7 @@ export default function Sleep() {
return (
<View key={member.user_id} className='family-member-item'>
<View className='family-member-avatar'>
{member.avatar_url ? (
<Image className='family-member-avatar-img' src={member.avatar_url} mode='aspectFill' />
) : (
<Text className='family-member-avatar-text'>👤</Text>
)}
<Image className='family-member-avatar-img' src={getParticipantArtwork('parent')} mode='aspectFit' />
</View>
<View className='family-member-main'>
<View className='family-member-head'>