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-mask firmware-progress-mask'>
|
||||||
<View className='modal-card firmware-progress-card'>
|
<View className='modal-card firmware-progress-card'>
|
||||||
<Text className='modal-title'>{view.title}</Text>
|
<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>
|
<Text className='firmware-progress-message'>{view.message}</Text>
|
||||||
<Image className='firmware-progress-art' mode='aspectFit'
|
<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')
|
: view.failed ? require('../../assets/ui/dog-failure.png')
|
||||||
: require('../../assets/ui/role-banban.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 className='firmware-progress-fill' style={{ width: `${view.progress}%` }} />
|
||||||
</View>
|
</View>}
|
||||||
<View className='firmware-progress-caption'>
|
{!view.current && <View className='firmware-progress-caption'>
|
||||||
<Text>{refreshError ? '上次更新进度' : '更新进度'}</Text>
|
<Text>{refreshError ? '上次更新进度' : '更新进度'}</Text>
|
||||||
<Text>{view.hasProgress ? `${view.percent}%` : '等待设备上报'}</Text>
|
<Text>{view.hasProgress ? `${view.percent}%` : '等待设备上报'}</Text>
|
||||||
</View>
|
</View>}
|
||||||
{!view.complete && !view.failed && <Text className='firmware-progress-processing'>处理中……</Text>}
|
{!view.complete && !view.failed && !view.current && <Text className='firmware-progress-processing'>处理中……</Text>}
|
||||||
<View className='modal-actions'>
|
<View className='modal-actions'>
|
||||||
<Text className='modal-action cancel' onClick={onClose}>{view.complete ? '完成' : '关闭'}</Text>
|
<Text className='modal-action cancel' onClick={onClose}>{view.complete ? '完成' : '关闭'}</Text>
|
||||||
{view.failed && onRetry && <Text className='modal-action confirm' onClick={onRetry}>重试</Text>}
|
{view.failed && onRetry && <Text className='modal-action confirm' onClick={onRetry}>重试</Text>}
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import type { DeviceFirmwareStatus } from '@/services/device'
|
import type { DeviceFirmwareStatus } from '@/services/device'
|
||||||
|
import { isFirmwareCurrent, isFirmwareUpdateInProgress } from '../../utils/firmware-status'
|
||||||
|
|
||||||
export function firmwareProgressView(status: DeviceFirmwareStatus | null, refreshError = false) {
|
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 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 value = Number(status?.progress)
|
||||||
const progress = complete ? 100 : Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
const progress = complete ? 100 : Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
|
||||||
const hasProgress = complete || progress > 0
|
const hasProgress = complete || progress > 0
|
||||||
const target = status?.target_version
|
const target = status?.target_version
|
||||||
const version = target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version
|
const version = current ? status?.current_version : target && !['unknown', 'updating'].includes(target) ? target : status?.latest_version
|
||||||
const title = complete ? '固件更新完成' : failed ? '固件更新失败' : '固件更新中'
|
const title = complete ? '固件更新完成' : current ? '当前已是最新版本' : failed ? '固件更新失败' : '固件更新中'
|
||||||
const message = complete ? '设备已完成更新,可以继续使用。'
|
const message = complete ? '设备已完成更新,可以继续使用。'
|
||||||
: failed ? '更新未完成,请确认设备电量和网络后重试。'
|
: current ? '当前设备已运行最新版本,无需更新。'
|
||||||
: refreshError ? '暂时无法刷新进度,正在重试。你可以先关闭此窗口。'
|
: failed ? '更新未完成,请确认设备电量和网络后重试。'
|
||||||
: state === 'sent' ? '更新指令已发送,等待设备确认。'
|
: refreshError ? '暂时无法刷新进度,正在重试。你可以先关闭此窗口。'
|
||||||
: progress >= 100 ? '进度已达 100%,等待设备确认更新结果。'
|
: state === 'sent' ? '更新指令已发送,等待设备确认。'
|
||||||
: hasProgress ? '请保持设备电量充足,并连接网络。'
|
: progress >= 100 ? '进度已达 100%,等待设备确认更新结果。'
|
||||||
: '设备正在处理更新,等待进度上报。'
|
: hasProgress ? '请保持设备电量充足,并连接网络。'
|
||||||
return { title, message, version, progress, percent: Math.floor(progress), hasProgress, complete, failed }
|
: '设备正在处理更新,等待进度上报。'
|
||||||
|
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-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-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-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-value { font-size: 62px; font-weight: 700; color: #ec913e; }
|
||||||
.bind-countdown-label { margin-top: 12px; font-size: 22px; color: #aa8154; }
|
.bind-countdown-label { margin-top: 12px; font-size: 22px; color: #aa8154; }
|
||||||
.bind-instruction { padding: 28px; border: 2px solid #d2b48c; border-radius: 46px; background: #fffdf7; }
|
.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.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.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-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 { getPageStyle } from '@/utils/page-style'
|
||||||
|
import { formatBatteryDisplay } from '@/utils/battery'
|
||||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import Taro, { useDidShow, useDidHide, useRouter } from '@tarojs/taro'
|
import Taro, { useDidShow, useDidHide, useRouter } from '@tarojs/taro'
|
||||||
|
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
|
import { DeviceOnlineStatus, DeviceStatus, getDeviceOnlineStatus, getDeviceStatus } from '@/services/device'
|
||||||
import {
|
import {
|
||||||
getNFCBindSession,
|
getNFCBindSession,
|
||||||
reportBindInitialLocation,
|
reportBindInitialLocation,
|
||||||
@@ -179,11 +181,19 @@ export default function Bind() {
|
|||||||
const [bindResultDestination, setBindResultDestination] = useState('/pages/device/index')
|
const [bindResultDestination, setBindResultDestination] = useState('/pages/device/index')
|
||||||
const [bindExpiresAt, setBindExpiresAt] = useState('')
|
const [bindExpiresAt, setBindExpiresAt] = useState('')
|
||||||
const [remainingBindSeconds, setRemainingBindSeconds] = useState(0)
|
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 pollingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const pollGenerationRef = useRef(0)
|
||||||
|
const pageVisibleRef = useRef(true)
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
const appliedLaunchQrRef = useRef('')
|
const appliedLaunchQrRef = useRef('')
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
|
pageVisibleRef.current = true
|
||||||
void loadPageData()
|
void loadPageData()
|
||||||
if (bindStatus === SESSION_STATUS_PENDING && bindToken) {
|
if (bindStatus === SESSION_STATUS_PENDING && bindToken) {
|
||||||
schedulePoll(bindToken)
|
schedulePoll(bindToken)
|
||||||
@@ -191,14 +201,32 @@ export default function Bind() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
useDidHide(() => {
|
useDidHide(() => {
|
||||||
|
pageVisibleRef.current = false
|
||||||
stopPolling()
|
stopPolling()
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
|
pageVisibleRef.current = false
|
||||||
stopPolling()
|
stopPolling()
|
||||||
stopCountdown()
|
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 hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim())
|
||||||
const isPendingBinding = Boolean(pendingDeviceId)
|
const isPendingBinding = Boolean(pendingDeviceId)
|
||||||
const isAddingCard = isCardMode && Boolean(deviceId.trim())
|
const isAddingCard = isCardMode && Boolean(deviceId.trim())
|
||||||
@@ -245,6 +273,7 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
|
pollGenerationRef.current += 1
|
||||||
if (pollingRef.current) {
|
if (pollingRef.current) {
|
||||||
clearTimeout(pollingRef.current)
|
clearTimeout(pollingRef.current)
|
||||||
pollingRef.current = null
|
pollingRef.current = null
|
||||||
@@ -260,8 +289,10 @@ export default function Bind() {
|
|||||||
|
|
||||||
const schedulePoll = (nextBindToken?: string) => {
|
const schedulePoll = (nextBindToken?: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
|
if (!pageVisibleRef.current) return
|
||||||
|
const generation = pollGenerationRef.current
|
||||||
pollingRef.current = setTimeout(() => {
|
pollingRef.current = setTimeout(() => {
|
||||||
void pollBindSession(nextBindToken)
|
void pollBindSession(nextBindToken, generation)
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,6 +305,10 @@ export default function Bind() {
|
|||||||
setBindHint('')
|
setBindHint('')
|
||||||
setBindExpiresAt('')
|
setBindExpiresAt('')
|
||||||
setRemainingBindSeconds(0)
|
setRemainingBindSeconds(0)
|
||||||
|
setTotalBindSeconds(0)
|
||||||
|
setBoundDeviceId('')
|
||||||
|
setBoundDeviceStatus(null)
|
||||||
|
setBoundDeviceOnline(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyLaunchQrPayload = () => {
|
const applyLaunchQrPayload = () => {
|
||||||
@@ -336,12 +371,13 @@ export default function Bind() {
|
|||||||
return () => stopCountdown()
|
return () => stopCountdown()
|
||||||
}, [bindStatus, bindExpiresAt])
|
}, [bindStatus, bindExpiresAt])
|
||||||
|
|
||||||
const pollBindSession = async (nextBindToken?: string) => {
|
const pollBindSession = async (nextBindToken?: string, generation = pollGenerationRef.current) => {
|
||||||
const activeBindToken = (nextBindToken || bindToken).trim()
|
const activeBindToken = (nextBindToken || bindToken).trim()
|
||||||
if (!activeBindToken) return
|
if (!activeBindToken || !pageVisibleRef.current || generation !== pollGenerationRef.current) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await getNFCBindSession(activeBindToken)
|
const session = await getNFCBindSession(activeBindToken)
|
||||||
|
if (!pageVisibleRef.current || generation !== pollGenerationRef.current) return
|
||||||
setBindStatus(session.status)
|
setBindStatus(session.status)
|
||||||
setCardUUID(session.card_uuid || '')
|
setCardUUID(session.card_uuid || '')
|
||||||
setBindExpiresAt(session.expires_at || '')
|
setBindExpiresAt(session.expires_at || '')
|
||||||
@@ -360,6 +396,7 @@ export default function Bind() {
|
|||||||
stopCountdown()
|
stopCountdown()
|
||||||
|
|
||||||
if (session.status === SESSION_STATUS_COMPLETED) {
|
if (session.status === SESSION_STATUS_COMPLETED) {
|
||||||
|
setBoundDeviceId(session.device_id)
|
||||||
setSelectedBindingDeviceId(session.device_id)
|
setSelectedBindingDeviceId(session.device_id)
|
||||||
if (!isAddingCard && session.child_id) {
|
if (!isAddingCard && session.child_id) {
|
||||||
void uploadBindInitialLocation(session.device_id)
|
void uploadBindInitialLocation(session.device_id)
|
||||||
@@ -391,8 +428,10 @@ export default function Bind() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (!pageVisibleRef.current || generation !== pollGenerationRef.current) return
|
||||||
console.error('[bind] poll bind session failed:', error)
|
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)
|
setBindStatus(session.status)
|
||||||
setBindExpiresAt(session.expires_at)
|
setBindExpiresAt(session.expires_at)
|
||||||
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
|
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
|
||||||
|
setTotalBindSeconds(getRemainingBindSeconds(session.expires_at))
|
||||||
setBindHint('已发送加卡指令,请在倒计时结束前去设备上贴新卡')
|
setBindHint('已发送加卡指令,请在倒计时结束前去设备上贴新卡')
|
||||||
schedulePoll(session.bind_token)
|
schedulePoll(session.bind_token)
|
||||||
Taro.showToast({ title: '请去设备上贴新卡', icon: 'none' })
|
Taro.showToast({ title: '请去设备上贴新卡', icon: 'none' })
|
||||||
@@ -546,6 +586,7 @@ export default function Bind() {
|
|||||||
setBindStatus(session.status)
|
setBindStatus(session.status)
|
||||||
setBindExpiresAt(session.expires_at)
|
setBindExpiresAt(session.expires_at)
|
||||||
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
|
setRemainingBindSeconds(getRemainingBindSeconds(session.expires_at))
|
||||||
|
setTotalBindSeconds(getRemainingBindSeconds(session.expires_at))
|
||||||
setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
|
setBindHint(childId ? '已发送绑卡指令,请在倒计时结束前去设备上贴自己的卡' : '已发送绑卡指令,贴卡后设备会先进入待关联状态')
|
||||||
schedulePoll(session.bind_token)
|
schedulePoll(session.bind_token)
|
||||||
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||||
@@ -581,6 +622,8 @@ export default function Bind() {
|
|||||||
if (sessionVisible) {
|
if (sessionVisible) {
|
||||||
const completed = bindStatus === SESSION_STATUS_COMPLETED
|
const completed = bindStatus === SESSION_STATUS_COMPLETED
|
||||||
const pending = bindStatus === SESSION_STATUS_PENDING
|
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
|
const title = pending ? '请在设备上贴卡' : completed
|
||||||
? isAddingCard ? '新卡添加完成' : '设备绑定成功'
|
? isAddingCard ? '新卡添加完成' : '设备绑定成功'
|
||||||
: isExpiredBind ? '贴卡时间已过' : '贴卡未完成'
|
: isExpiredBind ? '贴卡时间已过' : '贴卡未完成'
|
||||||
@@ -597,6 +640,12 @@ export default function Bind() {
|
|||||||
<>
|
<>
|
||||||
<Text className='bind-result-title'>{title}</Text>
|
<Text className='bind-result-title'>{title}</Text>
|
||||||
<View className='bind-countdown-ring'>
|
<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-value'>{formatRemainingTime(remainingBindSeconds)}</Text>
|
||||||
<Text className='bind-countdown-label'>剩余贴卡时间</Text>
|
<Text className='bind-countdown-label'>剩余贴卡时间</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -613,9 +662,23 @@ export default function Bind() {
|
|||||||
<Text className='bind-result-title'>{title}</Text>
|
<Text className='bind-result-title'>{title}</Text>
|
||||||
<Text className='bind-result-description'>{bindHint}</Text>
|
<Text className='bind-result-description'>{bindHint}</Text>
|
||||||
{completed ? (
|
{completed ? (
|
||||||
<Button className='bind-result-button primary' onClick={() => Taro.reLaunch({ url: bindResultDestination })}>
|
<>
|
||||||
{bindResultDestination.includes('/sleep/') ? '去关联宝贝' : '返回首页'}
|
<View className='bind-device-summary'>
|
||||||
</Button>
|
<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={() => {
|
<Button className='bind-result-button primary' onClick={() => {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getParticipantArtwork } from '@/utils/artwork'
|
import { getParticipantArtwork } from '@/utils/artwork'
|
||||||
|
import PageTitle from '@/components/page-title'
|
||||||
import { getPageStyle } from '@/utils/page-style'
|
import { getPageStyle } from '@/utils/page-style'
|
||||||
import { setActiveTabRoute } from '@/utils/tab-bar'
|
import { setActiveTabRoute } from '@/utils/tab-bar'
|
||||||
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
import { View, Text, ScrollView, Image } from '@tarojs/components'
|
||||||
@@ -80,7 +81,7 @@ export default function Chat() {
|
|||||||
return (
|
return (
|
||||||
<View className='chat-page' style={getPageStyle(true)}>
|
<View className='chat-page' style={getPageStyle(true)}>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>玩伴</Text>
|
<PageTitle title='玩伴' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isGuest ? (
|
{isGuest ? (
|
||||||
|
|||||||
@@ -132,6 +132,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.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;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin: 0 24px 32px;
|
margin: 0 24px 32px;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getPageStyle } from '@/utils/page-style'
|
import { getPageStyle } from '@/utils/page-style'
|
||||||
|
import { formatBatteryDisplay, getBatteryLevel } from '@/utils/battery'
|
||||||
import { setActiveTabRoute } from '@/utils/tab-bar'
|
import { setActiveTabRoute } from '@/utils/tab-bar'
|
||||||
import { View, Text, Slider, Image } from '@tarojs/components'
|
import { View, Text, Slider, Image } from '@tarojs/components'
|
||||||
import { useRef, useState } from 'react'
|
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 {
|
function formatSleepRange(status: DeviceStatus | null): string {
|
||||||
const start = status?.disable_time_start?.trim()
|
const start = status?.disable_time_start?.trim()
|
||||||
const end = status?.disable_time_end?.trim()
|
const end = status?.disable_time_end?.trim()
|
||||||
@@ -404,16 +397,14 @@ export default function Device() {
|
|||||||
const pageTitle = `${childName} 的伴伴`
|
const pageTitle = `${childName} 的伴伴`
|
||||||
const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
|
const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
|
||||||
const batteryDisplay = formatBatteryDisplay(batteryValue)
|
const batteryDisplay = formatBatteryDisplay(batteryValue)
|
||||||
const statusMascotSrc =
|
const batteryLevel = getBatteryLevel(batteryValue)
|
||||||
batteryValue === null
|
const statusMascotSrc = {
|
||||||
? require('../../assets/ui/dog-happy.png')
|
unknown: require('../../assets/ui/dog-happy.png'),
|
||||||
: batteryValue > 85
|
full: require('../../assets/ui/dog-happy.png'),
|
||||||
? require('../../assets/ui/dog-happy.png')
|
high: require('../../assets/ui/dog-sparkle.png'),
|
||||||
: batteryValue > 60
|
medium: require('../../assets/ui/dog-lying.png'),
|
||||||
? require('../../assets/ui/dog-sparkle.png')
|
low: require('../../assets/ui/dog-question.png'),
|
||||||
: batteryValue > 35
|
}[batteryLevel]
|
||||||
? require('../../assets/ui/dog-lying.png')
|
|
||||||
: require('../../assets/ui/dog-question.png')
|
|
||||||
const signalLabel = getSignalLabel(deviceStatus?.signal)
|
const signalLabel = getSignalLabel(deviceStatus?.signal)
|
||||||
const versionLabel = deviceStatus?.version || '--'
|
const versionLabel = deviceStatus?.version || '--'
|
||||||
const coordinateLabel = formatCoordinates(deviceStatus)
|
const coordinateLabel = formatCoordinates(deviceStatus)
|
||||||
@@ -429,7 +420,7 @@ export default function Device() {
|
|||||||
<Text className='page-title'>{pageTitle}</Text>
|
<Text className='page-title'>{pageTitle}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='status-card'>
|
<View className={`status-card battery-${batteryLevel}`}>
|
||||||
<View className='status-badge'>
|
<View className='status-badge'>
|
||||||
<View className='status-dot'></View>
|
<View className='status-dot'></View>
|
||||||
<Text className='status-text'>已完成绑定</Text>
|
<Text className='status-text'>已完成绑定</Text>
|
||||||
|
|||||||
@@ -53,7 +53,9 @@
|
|||||||
.map-section {
|
.map-section {
|
||||||
height: 50vh;
|
height: 50vh;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-bottom: 24px;
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.guest-map-section {
|
.guest-map-section {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getPageStyle } from '@/utils/page-style'
|
import { getPageStyle } from '@/utils/page-style'
|
||||||
|
import PageTitle from '@/components/page-title'
|
||||||
import { setActiveTabRoute } from '@/utils/tab-bar'
|
import { setActiveTabRoute } from '@/utils/tab-bar'
|
||||||
import { View, Text, Map } from '@tarojs/components'
|
import { View, Text, Map } from '@tarojs/components'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
@@ -381,7 +382,7 @@ export default function Location() {
|
|||||||
return (
|
return (
|
||||||
<View className='location-page' style={getPageStyle(true)}>
|
<View className='location-page' style={getPageStyle(true)}>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>设备定位</Text>
|
<PageTitle title='设备定位' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isGuest ? (
|
{isGuest ? (
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import FirmwareProgress from '@/components/firmware-progress'
|
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 { getPageStyle } from '@/utils/page-style'
|
||||||
import { setActiveTabRoute } from '@/utils/tab-bar'
|
import { setActiveTabRoute } from '@/utils/tab-bar'
|
||||||
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
import { View, Text, Image, Input, Button } from '@tarojs/components'
|
||||||
@@ -63,17 +65,8 @@ interface MenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const FIRMWARE_STATUS_POLL_INTERVAL_MS = 3000
|
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'])
|
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 {
|
function isFirmwareUpdateSuccessful(status?: string | null): boolean {
|
||||||
return FIRMWARE_UPDATE_SUCCESS_STATUSES.has(normalizeFirmwareUpdateStatus(status))
|
return FIRMWARE_UPDATE_SUCCESS_STATUSES.has(normalizeFirmwareUpdateStatus(status))
|
||||||
}
|
}
|
||||||
@@ -288,6 +281,8 @@ export default function Sleep() {
|
|||||||
const nextStatus = normalizeFirmwareUpdateStatus(nextFirmwareStatus?.update_status)
|
const nextStatus = normalizeFirmwareUpdateStatus(nextFirmwareStatus?.update_status)
|
||||||
if (isFirmwareUpdateSuccessful(nextStatus)) {
|
if (isFirmwareUpdateSuccessful(nextStatus)) {
|
||||||
Taro.showToast({ title: '系统更新完成', icon: 'success' })
|
Taro.showToast({ title: '系统更新完成', icon: 'success' })
|
||||||
|
} else if (isFirmwareCurrent(nextFirmwareStatus)) {
|
||||||
|
Taro.showToast({ title: '当前已是最新版本', icon: 'none' })
|
||||||
} else if (nextStatus === 'failed') {
|
} else if (nextStatus === 'failed') {
|
||||||
Taro.showToast({ title: '系统更新失败,可重试', icon: 'none' })
|
Taro.showToast({ title: '系统更新失败,可重试', icon: 'none' })
|
||||||
}
|
}
|
||||||
@@ -999,7 +994,7 @@ export default function Sleep() {
|
|||||||
return (
|
return (
|
||||||
<View className='manage-page' style={getPageStyle(true)}>
|
<View className='manage-page' style={getPageStyle(true)}>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>设置与管理</Text>
|
<PageTitle title='设备管理' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='guest-manage-card'>
|
<View className='guest-manage-card'>
|
||||||
@@ -1065,6 +1060,7 @@ export default function Sleep() {
|
|||||||
isFamilyOwner
|
isFamilyOwner
|
||||||
)
|
)
|
||||||
const isFirmwareUpdatingByStatus = isFirmwareUpdateInProgress(firmwareStatus?.update_status)
|
const isFirmwareUpdatingByStatus = isFirmwareUpdateInProgress(firmwareStatus?.update_status)
|
||||||
|
const firmwareSummary = firmwareSummaryView(firmwareStatus)
|
||||||
const canUpdateFirmware = Boolean(
|
const canUpdateFirmware = Boolean(
|
||||||
binding?.device_id &&
|
binding?.device_id &&
|
||||||
firmwareStatus?.can_update &&
|
firmwareStatus?.can_update &&
|
||||||
@@ -1078,11 +1074,7 @@ export default function Sleep() {
|
|||||||
? '查询中'
|
? '查询中'
|
||||||
: isUpdatingFirmware
|
: isUpdatingFirmware
|
||||||
? '发送中'
|
? '发送中'
|
||||||
: isFirmwareUpdatingByStatus
|
: firmwareSummary.action
|
||||||
? '查看进度'
|
|
||||||
: firmwareStatus?.update_available
|
|
||||||
? '更新'
|
|
||||||
: '已最新'
|
|
||||||
const roleValue = !binding?.device_id
|
const roleValue = !binding?.device_id
|
||||||
? '未绑定'
|
? '未绑定'
|
||||||
: isLoadingRoles
|
: isLoadingRoles
|
||||||
@@ -1104,13 +1096,7 @@ export default function Sleep() {
|
|||||||
? '绑定设备后可查看系统版本'
|
? '绑定设备后可查看系统版本'
|
||||||
: isLoadingFirmware
|
: isLoadingFirmware
|
||||||
? '正在查询当前设备版本'
|
? '正在查询当前设备版本'
|
||||||
: isFirmwareUpdatingByStatus
|
: firmwareSummary.subtitle
|
||||||
? '设备正在更新,请稍候'
|
|
||||||
: normalizeFirmwareUpdateStatus(firmwareStatus?.update_status) === 'failed'
|
|
||||||
? '更新失败,可重试'
|
|
||||||
: firmwareStatus?.update_available
|
|
||||||
? '发现可更新版本'
|
|
||||||
: '当前设备版本状态'
|
|
||||||
const cardValue = !binding?.device_id
|
const cardValue = !binding?.device_id
|
||||||
? '未绑定'
|
? '未绑定'
|
||||||
: isLoadingCards
|
: isLoadingCards
|
||||||
@@ -1185,16 +1171,12 @@ export default function Sleep() {
|
|||||||
return (
|
return (
|
||||||
<View className='manage-page' style={getPageStyle(true)}>
|
<View className='manage-page' style={getPageStyle(true)}>
|
||||||
<View className='page-header'>
|
<View className='page-header'>
|
||||||
<Text className='page-title'>设置与管理</Text>
|
<PageTitle title='设备管理' />
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='user-card'>
|
<View className='user-card'>
|
||||||
<View className='user-avatar'>
|
<View className='user-avatar'>
|
||||||
{parentInfo?.avatar_url ? (
|
<Image className='avatar-img image-avatar' src={getParticipantArtwork('parent')} mode='aspectFit' />
|
||||||
<Image className='avatar-img image-avatar' src={parentInfo.avatar_url} mode='aspectFill' />
|
|
||||||
) : (
|
|
||||||
<Text className='avatar-img'>👤</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<View className='user-info'>
|
<View className='user-info'>
|
||||||
<Text className='user-name'>{parentDisplayName}</Text>
|
<Text className='user-name'>{parentDisplayName}</Text>
|
||||||
@@ -1605,11 +1587,7 @@ export default function Sleep() {
|
|||||||
return (
|
return (
|
||||||
<View key={member.user_id} className='family-member-item'>
|
<View key={member.user_id} className='family-member-item'>
|
||||||
<View className='family-member-avatar'>
|
<View className='family-member-avatar'>
|
||||||
{member.avatar_url ? (
|
<Image className='family-member-avatar-img' src={getParticipantArtwork('parent')} mode='aspectFit' />
|
||||||
<Image className='family-member-avatar-img' src={member.avatar_url} mode='aspectFill' />
|
|
||||||
) : (
|
|
||||||
<Text className='family-member-avatar-text'>👤</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<View className='family-member-main'>
|
<View className='family-member-main'>
|
||||||
<View className='family-member-head'>
|
<View className='family-member-head'>
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ textarea {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title,
|
|
||||||
.bind-header .title,
|
.bind-header .title,
|
||||||
.login-header .title {
|
.login-header .title {
|
||||||
color: var(--bb-brown) !important;
|
color: var(--bb-brown) !important;
|
||||||
@@ -73,12 +72,35 @@ textarea {
|
|||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
display: inline-block !important;
|
display: inline-block !important;
|
||||||
padding: 9px 26px !important;
|
padding: 0 !important;
|
||||||
border: 2px solid var(--bb-border);
|
border: 0;
|
||||||
border-radius: 48% 52% 45% 55%;
|
background: transparent;
|
||||||
background: rgba(255, 253, 247, 0.94);
|
box-shadow: none;
|
||||||
box-shadow: 0 4px 0 rgba(237, 201, 148, 0.35);
|
color: #895731 !important;
|
||||||
font-size: 38px !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 {
|
.bind-header {
|
||||||
@@ -407,8 +429,8 @@ textarea {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
background: #c7e52d !important;
|
background: var(--bb-battery-background, #c7e52d) !important;
|
||||||
border-color: #9fba1c !important;
|
border-color: var(--bb-battery-border, #9fba1c) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card .status-title,
|
.status-card .status-title,
|
||||||
@@ -451,9 +473,7 @@ textarea {
|
|||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
.page-title {
|
.page-title {
|
||||||
font-size: 34px !important;
|
font-size: 32px !important;
|
||||||
padding-left: 20px !important;
|
|
||||||
padding-right: 20px !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-content {
|
.login-content {
|
||||||
@@ -634,7 +654,7 @@ textarea {
|
|||||||
min-height: 310px;
|
min-height: 310px;
|
||||||
padding: 34px 34px 26px !important;
|
padding: 34px 34px 26px !important;
|
||||||
border-radius: 34px !important;
|
border-radius: 34px !important;
|
||||||
background: #c7e52d !important;
|
background: var(--bb-battery-background, #c7e52d) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.device-avatar {
|
.device-avatar {
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ export function getRoleArtwork(roleKey?: string): string {
|
|||||||
return roleArtwork[String(roleKey || '').toLowerCase()] || roleArtwork.banban
|
return roleArtwork[String(roleKey || '').toLowerCase()] || roleArtwork.banban
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getParticipantArtwork(kind?: string, roleKey?: string): string {
|
export function getParticipantArtwork(kind?: string, _roleKey?: string): string {
|
||||||
if (kind === 'parent') return require('../assets/ui/dog-happy.png')
|
if (kind === 'parent') return require('../assets/ui/avatar-parent.png')
|
||||||
if (kind === 'child' || kind === 'device') return roleArtwork.banban
|
return roleArtwork.tuntunzai
|
||||||
return getRoleArtwork(roleKey)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
// Custom navigation must clear both the status bar and WeChat's capsule.
|
||||||
export function getPageStyle(customNavigation = true): CSSProperties {
|
export function getPageStyle(customNavigation = true): CSSProperties {
|
||||||
let top = 12
|
let top = 12
|
||||||
|
let navigationTop = 24
|
||||||
|
let navigationHeight = 32
|
||||||
|
let navigationSide = 100
|
||||||
if (customNavigation) {
|
if (customNavigation) {
|
||||||
let statusBarHeight = 24
|
let statusBarHeight = 24
|
||||||
try {
|
try {
|
||||||
statusBarHeight = Taro.getSystemInfoSync().statusBarHeight || statusBarHeight
|
const system = Taro.getSystemInfoSync()
|
||||||
|
statusBarHeight = system.statusBarHeight || statusBarHeight
|
||||||
const capsule = Taro.getMenuButtonBoundingClientRect()
|
const capsule = Taro.getMenuButtonBoundingClientRect()
|
||||||
top = Math.max(statusBarHeight + 44, capsule?.bottom || 0) + 12
|
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 {
|
} catch {
|
||||||
top = statusBarHeight + 56
|
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: {} }
|
const module = { exports: {} }
|
||||||
new Function('require', 'module', 'exports', outputText)(
|
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
|
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()['--bb-navigation-top'], '102px')
|
||||||
assert.equal(getPageStyle(false)['--bb-navigation-top'], '12px')
|
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')
|
||||||
|
})
|
||||||
|
|||||||