Files
banban/banban-mini/src/pages/device/index.tsx
2026-07-01 15:29:22 +08:00

641 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { View, Text, Slider, Image } from '@tarojs/components'
import { useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { DEVICE_UNAVAILABLE_MESSAGE } from '@/services/api'
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
import { Child } from '@/services/child'
import { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, getDeviceVolumeCommandStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device'
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
function formatTime(value?: string | null): string {
if (!value) return '--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function formatCoordinates(status: DeviceStatus | null): string {
if (!status || status.lat === null || status.lat === undefined || status.lng === null || status.lng === undefined) {
return '--'
}
return `${status.lat.toFixed(6)}, ${status.lng.toFixed(6)}`
}
function getSignalLabel(value?: number | null): string {
if (value === null || value === undefined) return '--'
const normalized = Number(value)
if (!Number.isFinite(normalized)) return '--'
if (normalized <= 4) {
if (normalized >= 3) return '强'
if (normalized >= 2) return '中'
return '弱'
}
if (normalized >= 67) return '强'
if (normalized >= 34) return '中'
return '弱'
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}
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()
if (!start || !end) return '未设置'
return `${start}-${end}`
}
function goLoginWithRedirect(url = '/pages/device/index') {
Taro.setStorageSync('postLoginRedirect', url)
Taro.navigateTo({ url: '/pages/login/index' })
}
function formatAlarmTime(value?: string | null): string {
if (!value) return '--'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
const second = `${date.getSeconds()}`.padStart(2, '0')
return `${month}-${day} ${hour}:${minute}:${second}`
}
function getAlarmTypeLabel(value?: string | null): string {
const normalized = String(value || '').trim()
if (!normalized) return '--'
if (normalized === '010') return '紧急告警'
return normalized
}
function getAlarmChildLabel(alarm: DeviceAlarmItem | null, fallbackChildName?: string | null): string {
const alarmChildName = String(alarm?.child_name || '').trim()
if (alarmChildName) return alarmChildName
const fallbackName = String(fallbackChildName || '').trim()
if (fallbackName) return fallbackName
if (alarm?.child_id) return `儿童 ${alarm.child_id}`
return '未关联儿童'
}
function formatAlarmCoordinates(alarm: DeviceAlarmItem | null): string {
if (!alarm || alarm.lat === null || alarm.lat === undefined || alarm.lng === null || alarm.lng === undefined) {
return ''
}
return `${Number(alarm.lat).toFixed(6)}, ${Number(alarm.lng).toFixed(6)}`
}
function getAlarmLocationLabel(alarm: DeviceAlarmItem | null): string {
const address = String(alarm?.address || '').trim()
if (address) return address
const coordinates = formatAlarmCoordinates(alarm)
if (coordinates) return coordinates
return '暂无告警位置'
}
function getAlarmLocationHint(alarm: DeviceAlarmItem | null): string {
if (!alarm?.location_updated_at) return '设备还没有可关联的位置上报'
const prefix = `位置更新于 ${formatAlarmTime(alarm.location_updated_at)}`
if (alarm.location_stale) return `${prefix},可能不是告警发生时的位置`
if (!String(alarm.address || '').trim() && alarm.address_resolve_status === 0) return `${prefix},地址解析中`
if (!String(alarm.address || '').trim() && alarm.address_resolve_status === 2) return `${prefix},地址解析失败`
return prefix
}
export default function Device() {
const systemBanner = useSystemBanner()
const [binding, setBinding] = useState<Binding | null>(null)
const [child, setChild] = useState<Child | null>(null)
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
const [deviceAlarms, setDeviceAlarms] = useState<DeviceAlarmItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isDetailExpanded, setIsDetailExpanded] = useState(false)
const [isSavingVolume, setIsSavingVolume] = useState(false)
const [sleepWakePending, setSleepWakePending] = useState<'on' | 'off' | null>(null)
const [volumeValue, setVolumeValue] = useState(0)
const [isGuest, setIsGuest] = useState(false)
const volumeCommandInFlightRef = useRef(false)
useDidShow(() => {
void loadDeviceInfo()
})
const loadDeviceInfo = async () => {
if (!getToken()) {
setBinding(null)
setChild(null)
setDeviceStatus(null)
setDeviceAlarms([])
setVolumeValue(0)
setIsGuest(true)
setIsLoading(false)
return
}
setIsLoading(true)
setIsGuest(false)
try {
const context = await loadCurrentChildBindingContext()
setChild(context.currentChild)
setBinding(context.currentBinding)
if (context.currentBinding?.device_id) {
const [nextStatus, nextAlarms] = await Promise.all([
getDeviceStatus(context.currentBinding.device_id),
getDeviceAlarms(context.currentBinding.device_id, 20),
])
setDeviceStatus(nextStatus)
setDeviceAlarms(nextAlarms.items || [])
setVolumeValue(nextStatus?.volume ?? 0)
} else {
setDeviceStatus(null)
setDeviceAlarms([])
setVolumeValue(0)
}
} catch (error: any) {
console.error('[device] load failed:', error)
Taro.showToast({
title: error?.message || '加载失败,请稍后重试',
icon: 'none',
})
} finally {
setIsLoading(false)
}
}
const handleOpenSleepSchedule = () => {
Taro.navigateTo({ url: '/pages/sleep-schedule/index' })
}
const handleToggleDetail = () => {
setIsDetailExpanded((current) => !current)
}
const handleVolumeChanging = (e: any) => {
if (volumeCommandInFlightRef.current) return
setVolumeValue(Number(e.detail?.value || 0))
}
const handleVolumeCommit = async (e: any) => {
if (!binding?.device_id) return
if (volumeCommandInFlightRef.current) return
const nextLevel = Number(e.detail?.value || 0)
const fallbackLevel = deviceStatus?.volume ?? volumeValue ?? 0
volumeCommandInFlightRef.current = true
setVolumeValue(nextLevel)
setIsSavingVolume(true)
try {
const command = await setDeviceVolume(nextLevel, binding.device_id)
let completedLevel: number | null = null
let failedMessage = ''
for (let attempt = 0; attempt < 13; attempt += 1) {
await sleep(800)
const status = await getDeviceVolumeCommandStatus(binding.device_id, command.time)
if (status.status === 'completed') {
completedLevel = status.current_level ?? nextLevel
break
}
if (status.status === 'failed' || status.status === 'timeout') {
failedMessage = status.error || (status.status === 'timeout' ? '设备未确认音量调整,请稍后查看设备状态' : '设备音量调整失败')
break
}
}
if (completedLevel === null) {
throw new Error(failedMessage || '设备未确认音量调整,请稍后查看设备状态')
}
setVolumeValue(completedLevel)
setDeviceStatus((current) => (current ? { ...current, volume: completedLevel } : current))
Taro.showToast({
title: '音量已成功调整',
icon: 'success',
})
} catch (error: any) {
setVolumeValue(deviceStatus?.volume ?? fallbackLevel)
const message = error?.message === DEVICE_UNAVAILABLE_MESSAGE
? '设备不在线或处于休眠中,暂时无法调节音量'
: error?.message || '音量设置失败'
Taro.showToast({
title: message,
icon: 'none',
})
} finally {
volumeCommandInFlightRef.current = false
setIsSavingVolume(false)
}
}
const handleRemoteSleepWake = async (switchValue: 'on' | 'off') => {
if (!binding?.device_id || sleepWakePending) return
setSleepWakePending(switchValue)
try {
await setDeviceRemoteSleepWake(switchValue, binding.device_id)
Taro.showToast({
title: switchValue === 'off' ? '休眠指令已发送' : '唤醒指令已发送',
icon: 'success',
})
} catch (error: any) {
Taro.showToast({
title: error?.message || '操作失败,请重试',
icon: 'none',
})
} finally {
setSleepWakePending(null)
}
}
if (isLoading) {
return (
<View className='device-page'>
<View className='loading'>
<Text>...</Text>
</View>
{systemBanner}
</View>
)
}
if (isGuest) {
return (
<View className='device-page'>
<View className='guest-hero'>
<View className='guest-device-icon'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='guest-title'></Text>
<Text className='guest-subtitle'></Text>
</View>
<View className='guest-feature-grid'>
<View className='guest-feature-card'>
<Image className='guest-feature-icon' src={require('../../assets/tab-icons/device-icon.png')} mode='aspectFit' />
<Text className='guest-feature-title'></Text>
<Text className='guest-feature-desc'></Text>
</View>
<View className='guest-feature-card'>
<Image className='guest-feature-icon' src={require('../../assets/tab-icons/location-icon.png')} mode='aspectFit' />
<Text className='guest-feature-title'></Text>
<Text className='guest-feature-desc'></Text>
</View>
<View className='guest-feature-card'>
<Image className='guest-feature-icon' src={require('../../assets/tab-icons/chat-icon.png')} mode='aspectFit' />
<Text className='guest-feature-title'></Text>
<Text className='guest-feature-desc'></Text>
</View>
<View className='guest-feature-card'>
<Image className='guest-feature-icon' src={require('../../assets/tab-icons/manage-icon.png')} mode='aspectFit' />
<Text className='guest-feature-title'></Text>
<Text className='guest-feature-desc'></Text>
</View>
</View>
<View className='guest-action-card'>
<Text className='guest-action-title'></Text>
<Text className='guest-action-desc'></Text>
<View className='guest-primary-btn' onClick={() => goLoginWithRedirect('/pages/device/index')}>
<Text className='guest-primary-btn-text'></Text>
</View>
</View>
{systemBanner}
</View>
)
}
if (!child) {
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'></Text>
</View>
<View className='empty-card'>
<View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='empty-title'></Text>
<Text className='empty-subtitle'></Text>
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='empty-btn-text'></Text>
</View>
</View>
{systemBanner}
</View>
)
}
if (!binding) {
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'>{child.child_name} </Text>
</View>
<View className='child-focus-card'>
<View className='child-focus-avatar'>
<Text className='child-focus-icon'>🧒</Text>
</View>
<View className='child-focus-info'>
<Text className='child-focus-name'>{child.child_name}</Text>
<Text className='child-focus-desc'></Text>
</View>
</View>
<View className='empty-card'>
<View className='empty-robot'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
<Text className='empty-title'></Text>
<Text className='empty-subtitle'></Text>
<View className='empty-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
<Text className='empty-btn-text'></Text>
</View>
</View>
{systemBanner}
</View>
)
}
const childName = child.child_name || '未设置'
const pageTitle = `${childName} 的伴伴`
const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
const batteryDisplay = formatBatteryDisplay(batteryValue)
const signalLabel = getSignalLabel(deviceStatus?.signal)
const versionLabel = deviceStatus?.version || '--'
const coordinateLabel = formatCoordinates(deviceStatus)
const volumeLabel = `${volumeValue}%`
const sleepRangeLabel = formatSleepRange(deviceStatus)
const latestAlarm = deviceAlarms[0] || null
const historyAlarms = latestAlarm ? deviceAlarms.slice(1) : []
const latestAlarmChildName = getAlarmChildLabel(latestAlarm, childName)
return (
<View className='device-page'>
<View className='page-header'>
<Text className='page-title'>{pageTitle}</Text>
</View>
<View className='status-card'>
<View className='status-badge'>
<View className='status-dot'></View>
<Text className='status-text'></Text>
</View>
<View className='battery-section'>
<Text className='battery-percent'>
{batteryDisplay.text}
{batteryDisplay.showPercent && <Text className='percent'>%</Text>}
</Text>
<Text className='battery-label'></Text>
</View>
<View className='status-meta'>
<View className='status-pill'>
<Text className='status-pill-label'></Text>
<Text className='status-pill-value'>{signalLabel}</Text>
</View>
<View className='status-pill'>
<Text className='status-pill-label'></Text>
<Text className='status-pill-value'>{versionLabel}</Text>
</View>
</View>
<View className='device-avatar'>
<View className='robot-icon'>
<Image className='robot-img' src={require('../../assets/tab-icons/robot.png')} mode='aspectFit' />
</View>
</View>
</View>
<View className='section-title'></View>
<View className='control-card'>
<View className='control-item sleep-item' onClick={handleOpenSleepSchedule}>
<View className='control-left'>
<View className='icon-bg purple'>
<Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
</View>
<View className='control-info'>
<Text className='control-name'></Text>
<Text className='control-detail'>{sleepRangeLabel}</Text>
</View>
</View>
<Text className='control-link'></Text>
</View>
<View className='divider'></View>
<View className='control-item quick-sleep-item'>
<View className='quick-sleep-header'>
<View className='control-left'>
<View className='icon-bg purple'>
<Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
</View>
<View className='control-info'>
<Text className='control-name'>/</Text>
<Text className='control-detail'>
{sleepWakePending === 'off'
? '正在发送休眠指令...'
: sleepWakePending === 'on'
? '正在发送唤醒指令...'
: '立即控制当前设备'}
</Text>
</View>
</View>
</View>
<View className='quick-sleep-actions'>
<View
className={`quick-sleep-btn sleep ${sleepWakePending ? 'disabled' : ''}`}
onClick={sleepWakePending ? undefined : () => handleRemoteSleepWake('off')}
>
<Text className='quick-sleep-btn-text'></Text>
</View>
<View
className={`quick-sleep-btn wake ${sleepWakePending ? 'disabled' : ''}`}
onClick={sleepWakePending ? undefined : () => handleRemoteSleepWake('on')}
>
<Text className='quick-sleep-btn-text'></Text>
</View>
</View>
</View>
<View className='divider'></View>
<View className='control-item volume-item'>
<View className='volume-header'>
<View className='control-left'>
<View className='icon-bg orange'>
<Image
className='control-icon-img'
src={require('../../assets/tab-icons/orange-speaker.png')}
mode='aspectFit'
/>
</View>
<View className='control-info'>
<Text className='control-name'></Text>
<Text className='control-detail'>{isSavingVolume ? '调整中...' : `当前 ${volumeLabel}`}</Text>
</View>
</View>
</View>
<View className='volume-slider'>
<Image
className='volume-icon'
src={require('../../assets/tab-icons/unspeaker.png')}
mode='aspectFit'
/>
<Slider
className='slider'
min={0}
max={100}
value={volumeValue}
disabled={isSavingVolume}
onChanging={handleVolumeChanging}
onChange={handleVolumeCommit}
activeColor='#FF8C42'
backgroundColor='#E5E5E5'
blockColor='#FF8C42'
/>
<Image
className='volume-icon'
src={require('../../assets/tab-icons/orange-speaker.png')}
mode='aspectFit'
/>
</View>
</View>
</View>
<View className='alarm-card'>
<View className='alarm-header'>
<Text className='alarm-title'></Text>
<Text className='alarm-subtitle'>
{latestAlarm ? '已收到设备主动上报的告警,可继续查看以往记录' : '暂时没有收到新的设备告警'}
</Text>
</View>
{latestAlarm ? (
<View>
<View className='alarm-body'>
<View className='alarm-row'>
<Text className='alarm-label'></Text>
<Text className='alarm-value'>{formatAlarmTime(latestAlarm.created_at)}</Text>
</View>
<View className='alarm-row'>
<Text className='alarm-label'></Text>
<Text className='alarm-value'>{latestAlarmChildName}</Text>
</View>
<View className='alarm-row'>
<Text className='alarm-label'></Text>
<Text className='alarm-value'>{latestAlarm.device_id}</Text>
</View>
<View className='alarm-row'>
<Text className='alarm-label'></Text>
<Text className='alarm-value'>{getAlarmTypeLabel(latestAlarm.source_msg_id)}</Text>
</View>
<View className='alarm-row alarm-row-vertical'>
<Text className='alarm-label'></Text>
<View className='alarm-location-value'>
<Text className='alarm-location-main'>{getAlarmLocationLabel(latestAlarm)}</Text>
<Text className='alarm-location-hint'>{getAlarmLocationHint(latestAlarm)}</Text>
</View>
</View>
</View>
<View className='alarm-history'>
<View className='alarm-history-header'>
<Text className='alarm-history-title'></Text>
<Text className='alarm-history-count'> {historyAlarms.length} </Text>
</View>
{historyAlarms.length > 0 ? (
historyAlarms.map((alarm) => (
<View className='alarm-history-item' key={alarm.alarm_id}>
<View className='alarm-history-main'>
<Text className='alarm-history-time'>{formatAlarmTime(alarm.created_at)}</Text>
<Text className='alarm-history-type'>{getAlarmTypeLabel(alarm.source_msg_id)}</Text>
</View>
<View className='alarm-history-meta'>
<Text className='alarm-history-child'>
{getAlarmChildLabel(alarm, childName)}
</Text>
<Text className='alarm-history-device'>{alarm.device_id}</Text>
</View>
<Text className='alarm-history-location'>{getAlarmLocationLabel(alarm)}</Text>
{alarm.location_stale ? (
<Text className='alarm-history-location-hint'></Text>
) : null}
</View>
))
) : (
<View className='alarm-history-empty'>
<Text className='alarm-history-empty-text'></Text>
</View>
)}
</View>
</View>
) : (
<View className='alarm-empty'>
<Text className='alarm-empty-text'></Text>
</View>
)}
</View>
<View className='fold-card'>
<View className='fold-header' onClick={handleToggleDetail}>
<View className='fold-heading'>
<Text className='fold-title'></Text>
<Text className='fold-subtitle'>{binding.device_id}</Text>
</View>
<Text className='fold-action'>{isDetailExpanded ? '收起' : '展开'}</Text>
</View>
{isDetailExpanded && (
<View className='detail-body'>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{childName}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{binding.device_id}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{formatTime(binding.bound_at)}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{coordinateLabel}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{formatTime(deviceStatus?.settings_updated_at)}</Text>
</View>
<View className='detail-row'>
<Text className='detail-label'></Text>
<Text className='detail-value'>{formatTime(deviceStatus?.location_updated_at)}</Text>
</View>
</View>
)}
</View>
{systemBanner}
</View>
)
}