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.
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -16,20 +16,20 @@ export default function FirmwareProgress({ status, refreshError, onClose, onRetr
|
||||
<View className='modal-mask firmware-progress-mask'>
|
||||
<View className='modal-card firmware-progress-card'>
|
||||
<Text className='modal-title'>{view.title}</Text>
|
||||
{view.version && <Text className='firmware-progress-version'>本次更新版本 {view.version}</Text>}
|
||||
{view.version && <Text className='firmware-progress-version'>{view.current ? '当前版本' : '本次更新版本'} {view.version}</Text>}
|
||||
<Text className='firmware-progress-message'>{view.message}</Text>
|
||||
<Image className='firmware-progress-art' mode='aspectFit'
|
||||
src={view.complete ? require('../../assets/ui/dog-success.png')
|
||||
src={view.complete || view.current ? require('../../assets/ui/dog-success.png')
|
||||
: view.failed ? require('../../assets/ui/dog-failure.png')
|
||||
: require('../../assets/ui/role-banban.png')} />
|
||||
<View className={`firmware-progress-track ${view.failed ? 'failed' : ''}`}>
|
||||
{!view.current && <View className={`firmware-progress-track ${view.failed ? 'failed' : ''}`}>
|
||||
<View className='firmware-progress-fill' style={{ width: `${view.progress}%` }} />
|
||||
</View>
|
||||
<View className='firmware-progress-caption'>
|
||||
</View>}
|
||||
{!view.current && <View className='firmware-progress-caption'>
|
||||
<Text>{refreshError ? '上次更新进度' : '更新进度'}</Text>
|
||||
<Text>{view.hasProgress ? `${view.percent}%` : '等待设备上报'}</Text>
|
||||
</View>
|
||||
{!view.complete && !view.failed && <Text className='firmware-progress-processing'>处理中……</Text>}
|
||||
</View>}
|
||||
{!view.complete && !view.failed && !view.current && <Text className='firmware-progress-processing'>处理中……</Text>}
|
||||
<View className='modal-actions'>
|
||||
<Text className='modal-action cancel' onClick={onClose}>{view.complete ? '完成' : '关闭'}</Text>
|
||||
{view.failed && onRetry && <Text className='modal-action confirm' onClick={onRetry}>重试</Text>}
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
import type { DeviceFirmwareStatus } from '@/services/device'
|
||||
import { isFirmwareCurrent, isFirmwareUpdateInProgress } from '../../utils/firmware-status'
|
||||
|
||||
export function firmwareProgressView(status: DeviceFirmwareStatus | null, refreshError = false) {
|
||||
const state = String(status?.update_status || '').toLowerCase()
|
||||
const state = String(status?.update_status || '').trim().toLowerCase()
|
||||
const complete = state === 'success' || state === 'completed'
|
||||
const failed = state === 'failed'
|
||||
const current = !complete && !isFirmwareUpdateInProgress(state) && isFirmwareCurrent(status)
|
||||
const failed = state === 'failed' && !current
|
||||
const value = Number(status?.progress)
|
||||
const progress = complete ? 100 : Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||
const hasProgress = complete || progress > 0
|
||||
const target = status?.target_version
|
||||
const version = target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version
|
||||
const title = complete ? '固件更新完成' : failed ? '固件更新失败' : '固件更新中'
|
||||
const version = current ? status?.current_version : target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version
|
||||
const title = complete ? '固件更新完成' : current ? '当前已是最新版本' : failed ? '固件更新失败' : '固件更新中'
|
||||
const message = complete ? '设备已完成更新,可以继续使用。'
|
||||
: failed ? '更新未完成,请确认设备电量和网络后重试。'
|
||||
: refreshError ? '暂时无法刷新进度,正在重试。你可以先关闭此窗口。'
|
||||
: state === 'sent' ? '更新指令已发送,等待设备确认。'
|
||||
: progress >= 100 ? '进度已达 100%,等待设备确认更新结果。'
|
||||
: hasProgress ? '请保持设备电量充足,并连接网络。'
|
||||
: '设备正在处理更新,等待进度上报。'
|
||||
return { title, message, version, progress, percent: Math.floor(progress), hasProgress, complete, failed }
|
||||
: current ? '当前设备已运行最新版本,无需更新。'
|
||||
: failed ? '更新未完成,请确认设备电量和网络后重试。'
|
||||
: refreshError ? '暂时无法刷新进度,正在重试。你可以先关闭此窗口。'
|
||||
: state === 'sent' ? '更新指令已发送,等待设备确认。'
|
||||
: progress >= 100 ? '进度已达 100%,等待设备确认更新结果。'
|
||||
: hasProgress ? '请保持设备电量充足,并连接网络。'
|
||||
: '设备正在处理更新,等待进度上报。'
|
||||
return { title, message, version, progress, percent: Math.floor(progress), hasProgress, complete, failed, current }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Image } from '@tarojs/components'
|
||||
|
||||
const artwork = {
|
||||
玩伴: require('../../assets/ui/heading-chat.png'),
|
||||
设备定位: require('../../assets/ui/heading-location.png'),
|
||||
设备管理: require('../../assets/ui/heading-manage.png'),
|
||||
}
|
||||
|
||||
// Bundle the supplied design's lettering so headings do not depend on font loading.
|
||||
export default function PageTitle({ title }: { title: keyof typeof artwork }) {
|
||||
return <Image className={`page-title page-title-art ${title === '玩伴' ? 'short' : ''}`}
|
||||
src={artwork[title]} mode='aspectFit' ariaLabel={title} />
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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'>
|
||||
|
||||
@@ -63,7 +63,6 @@ textarea {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.page-title,
|
||||
.bind-header .title,
|
||||
.login-header .title {
|
||||
color: var(--bb-brown) !important;
|
||||
@@ -73,12 +72,35 @@ textarea {
|
||||
|
||||
.page-title {
|
||||
display: inline-block !important;
|
||||
padding: 9px 26px !important;
|
||||
border: 2px solid var(--bb-border);
|
||||
border-radius: 48% 52% 45% 55%;
|
||||
background: rgba(255, 253, 247, 0.94);
|
||||
box-shadow: 0 4px 0 rgba(237, 201, 148, 0.35);
|
||||
font-size: 38px !important;
|
||||
padding: 0 !important;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
color: #895731 !important;
|
||||
font-family: var(--bb-body-font);
|
||||
font-size: 36px !important;
|
||||
font-weight: 400 !important;
|
||||
line-height: 1.2;
|
||||
letter-spacing: 0 !important;
|
||||
}
|
||||
|
||||
.page-title-art { width: 160px; height: 38px; }
|
||||
.page-title-art.short { width: 80px; }
|
||||
|
||||
.chat-page,
|
||||
.location-page,
|
||||
.manage-page {
|
||||
padding-top: var(--bb-navigation-bar-top);
|
||||
|
||||
> .page-header {
|
||||
height: var(--bb-navigation-bar-height);
|
||||
padding: 0 var(--bb-navigation-side) !important;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.bind-header {
|
||||
@@ -407,8 +429,8 @@ textarea {
|
||||
}
|
||||
|
||||
.status-card {
|
||||
background: #c7e52d !important;
|
||||
border-color: #9fba1c !important;
|
||||
background: var(--bb-battery-background, #c7e52d) !important;
|
||||
border-color: var(--bb-battery-border, #9fba1c) !important;
|
||||
}
|
||||
|
||||
.status-card .status-title,
|
||||
@@ -451,9 +473,7 @@ textarea {
|
||||
|
||||
@media (max-width: 360px) {
|
||||
.page-title {
|
||||
font-size: 34px !important;
|
||||
padding-left: 20px !important;
|
||||
padding-right: 20px !important;
|
||||
font-size: 32px !important;
|
||||
}
|
||||
|
||||
.login-content {
|
||||
@@ -634,7 +654,7 @@ textarea {
|
||||
min-height: 310px;
|
||||
padding: 34px 34px 26px !important;
|
||||
border-radius: 34px !important;
|
||||
background: #c7e52d !important;
|
||||
background: var(--bb-battery-background, #c7e52d) !important;
|
||||
}
|
||||
|
||||
.device-avatar {
|
||||
|
||||
@@ -9,8 +9,7 @@ export function getRoleArtwork(roleKey?: string): string {
|
||||
return roleArtwork[String(roleKey || '').toLowerCase()] || roleArtwork.banban
|
||||
}
|
||||
|
||||
export function getParticipantArtwork(kind?: string, roleKey?: string): string {
|
||||
if (kind === 'parent') return require('../assets/ui/dog-happy.png')
|
||||
if (kind === 'child' || kind === 'device') return roleArtwork.banban
|
||||
return getRoleArtwork(roleKey)
|
||||
export function getParticipantArtwork(kind?: string, _roleKey?: string): string {
|
||||
if (kind === 'parent') return require('../assets/ui/avatar-parent.png')
|
||||
return roleArtwork.tuntunzai
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export 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 }
|
||||
}
|
||||
|
||||
export function getBatteryLevel(value?: number | null) {
|
||||
if (value === null || value === undefined || !Number.isFinite(Number(value))) return 'unknown'
|
||||
if (value > 85) return 'full'
|
||||
if (value > 60) return 'high'
|
||||
if (value > 35) return 'medium'
|
||||
return 'low'
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DeviceFirmwareStatus } from '@/services/device'
|
||||
|
||||
export function normalizeFirmwareUpdateStatus(status?: string | null): string {
|
||||
return String(status || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isFirmwareUpdateInProgress(status?: string | null): boolean {
|
||||
return ['sent', 'accepted', 'updating'].includes(normalizeFirmwareUpdateStatus(status))
|
||||
}
|
||||
|
||||
export function isFirmwareCurrent(status: DeviceFirmwareStatus | null): boolean {
|
||||
const knownVersion = (value?: string | null) => Boolean(value?.trim() && !['unknown', 'updating', '0.0.0', '--'].includes(value.trim().toLowerCase()))
|
||||
return Boolean(status && !status.update_available && knownVersion(status.current_version) && knownVersion(status.latest_version))
|
||||
}
|
||||
|
||||
export function firmwareSummaryView(status: DeviceFirmwareStatus | null) {
|
||||
if (!status) return { action: '暂不可用', subtitle: '暂时无法获取版本,请稍后重试' }
|
||||
if (isFirmwareUpdateInProgress(status.update_status)) return { action: '查看进度', subtitle: '设备正在更新,请稍候' }
|
||||
if (isFirmwareCurrent(status)) return { action: '已最新', subtitle: '当前已是最新版本' }
|
||||
if (normalizeFirmwareUpdateStatus(status.update_status) === 'failed') {
|
||||
return { action: status.update_available ? '更新' : '版本待确认', subtitle: status.can_update ? '更新失败,可重试' : '上次更新失败,当前版本待确认' }
|
||||
}
|
||||
return status.update_available
|
||||
? { action: '更新', subtitle: '发现可更新版本' }
|
||||
: { action: '版本待确认', subtitle: '暂无完整版本信息' }
|
||||
}
|
||||
@@ -4,15 +4,30 @@ import type { CSSProperties } from 'react'
|
||||
// Custom navigation must clear both the status bar and WeChat's capsule.
|
||||
export function getPageStyle(customNavigation = true): CSSProperties {
|
||||
let top = 12
|
||||
let navigationTop = 24
|
||||
let navigationHeight = 32
|
||||
let navigationSide = 100
|
||||
if (customNavigation) {
|
||||
let statusBarHeight = 24
|
||||
try {
|
||||
statusBarHeight = Taro.getSystemInfoSync().statusBarHeight || statusBarHeight
|
||||
const system = Taro.getSystemInfoSync()
|
||||
statusBarHeight = system.statusBarHeight || statusBarHeight
|
||||
const capsule = Taro.getMenuButtonBoundingClientRect()
|
||||
top = Math.max(statusBarHeight + 44, capsule?.bottom || 0) + 12
|
||||
navigationTop = capsule?.height > 0 ? capsule.top : statusBarHeight + 6
|
||||
navigationHeight = capsule?.height > 0 ? capsule.height : 32
|
||||
if (capsule?.left > 0 && system.windowWidth) {
|
||||
navigationSide = system.windowWidth - capsule.left + 8
|
||||
}
|
||||
} catch {
|
||||
top = statusBarHeight + 56
|
||||
navigationTop = statusBarHeight + 6
|
||||
}
|
||||
}
|
||||
return { '--bb-navigation-top': `${top}px` } as CSSProperties
|
||||
return {
|
||||
'--bb-navigation-top': `${top}px`,
|
||||
'--bb-navigation-bar-top': `${navigationTop}px`,
|
||||
'--bb-navigation-bar-height': `${navigationHeight}px`,
|
||||
'--bb-navigation-side': `${navigationSide}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ function loadTypeScript(file, mocks = {}) {
|
||||
})
|
||||
const module = { exports: {} }
|
||||
new Function('require', 'module', 'exports', outputText)(
|
||||
(id) => mocks[id] || require(id), module, module.exports,
|
||||
(id) => mocks[id] || (id.startsWith('.')
|
||||
? loadTypeScript(path.join(path.dirname(file), `${id}.ts`), mocks)
|
||||
: require(id)), module, module.exports,
|
||||
)
|
||||
return module.exports
|
||||
}
|
||||
@@ -77,3 +79,65 @@ test('custom pages clear the capsule while native pages only add content spacing
|
||||
assert.equal(getPageStyle()['--bb-navigation-top'], '102px')
|
||||
assert.equal(getPageStyle(false)['--bb-navigation-top'], '12px')
|
||||
})
|
||||
|
||||
const { firmwareSummaryView } = loadTypeScript('utils/firmware-status.ts')
|
||||
const currentFirmware = { device_id: 'test-device', current_version: '1.8.2', latest_version: '1.8.2', update_available: false, can_update: false, update_status: 'failed', progress: 40 }
|
||||
|
||||
test('latest version takes precedence over a historical failed update in the summary', () => {
|
||||
assert.deepEqual(firmwareSummaryView(currentFirmware), { action: '已最新', subtitle: '当前已是最新版本' })
|
||||
const view = firmwareProgressView(currentFirmware)
|
||||
assert.equal(view.current, true)
|
||||
assert.equal(view.failed, false)
|
||||
assert.equal(view.complete, false) // Current firmware does not rewrite the historical OTA outcome.
|
||||
assert.equal(view.title, '当前已是最新版本')
|
||||
assert.equal(view.version, '1.8.2')
|
||||
})
|
||||
|
||||
test('a failed update with a newer version still offers a retry', () => {
|
||||
const pending = { ...currentFirmware, current_version: '1.8.1', update_available: true, can_update: true }
|
||||
assert.deepEqual(firmwareSummaryView(pending), { action: '更新', subtitle: '更新失败,可重试' })
|
||||
assert.equal(firmwareProgressView(pending).failed, true)
|
||||
})
|
||||
|
||||
test('missing or placeholder versions never claim the device is up to date', () => {
|
||||
const cases = [null, ...['', 'unknown', '0.0.0', '--', 'updating'].flatMap(value => [
|
||||
{ ...currentFirmware, current_version: value }, { ...currentFirmware, latest_version: value },
|
||||
])]
|
||||
for (const value of cases) {
|
||||
assert.notEqual(firmwareSummaryView(value).action, '已最新')
|
||||
assert.equal(firmwareProgressView(value).current, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('an active update continues showing progress even after the version has changed', () => {
|
||||
for (const update_status of ['sent', 'accepted', 'updating']) {
|
||||
const updating = { ...currentFirmware, update_status }
|
||||
assert.equal(firmwareSummaryView(updating).action, '查看进度')
|
||||
assert.equal(firmwareProgressView(updating).current, false)
|
||||
}
|
||||
})
|
||||
|
||||
test('title rows align to the capsule and reserve its width on both sides', () => {
|
||||
const { getPageStyle } = loadTypeScript('utils/page-style.ts', {
|
||||
'@tarojs/taro': {
|
||||
getSystemInfoSync: () => ({ statusBarHeight: 44, windowWidth: 375 }),
|
||||
getMenuButtonBoundingClientRect: () => ({ top: 50, bottom: 82, height: 32, left: 278 }),
|
||||
},
|
||||
})
|
||||
const style = getPageStyle()
|
||||
assert.equal(style['--bb-navigation-bar-top'], '50px')
|
||||
assert.equal(style['--bb-navigation-bar-height'], '32px')
|
||||
assert.equal(style['--bb-navigation-side'], '105px')
|
||||
})
|
||||
|
||||
test('navigation keeps usable dimensions when the capsule is unavailable', () => {
|
||||
const { getPageStyle } = loadTypeScript('utils/page-style.ts', {
|
||||
'@tarojs/taro': {
|
||||
getSystemInfoSync: () => ({ statusBarHeight: 24 }),
|
||||
getMenuButtonBoundingClientRect: () => { throw new Error('unavailable') },
|
||||
},
|
||||
})
|
||||
const style = getPageStyle()
|
||||
assert.equal(style['--bb-navigation-bar-top'], '30px')
|
||||
assert.equal(style['--bb-navigation-bar-height'], '32px')
|
||||
})
|
||||
|
||||