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 { 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(null) const [child, setChild] = useState(null) const [deviceStatus, setDeviceStatus] = useState(null) const [deviceAlarms, setDeviceAlarms] = useState([]) 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 ( 加载中... {systemBanner} ) } if (isGuest) { return ( 伴伴儿童陪伴设备 查看设备状态、定位轨迹、亲子消息和休眠管理。浏览功能后,可在需要绑定设备时登录。 设备状态 查看电量、信号、版本和告警记录。 定位展示 绑定后可查看设备当前位置。 亲子互动 查看孩子和设备的消息会话。 设备管理 管理孩子资料、绑定设备和休眠设置。 准备绑定或管理设备 登录后可绑定设备,并在绑定流程中创建或选择孩子资料。 goLoginWithRedirect('/pages/device/index')}> 登录后继续 {systemBanner} ) } if (!child) { return ( 设备首页 先绑定一台伴伴设备 绑定时可以选择已有孩子,或直接新建孩子昵称 Taro.navigateTo({ url: '/pages/bind/index' })}> 添加设备 {systemBanner} ) } if (!binding) { return ( {child.child_name} 的伴伴 🧒 {child.child_name} 当前孩子已选中,还没有绑定设备 给当前孩子绑定一台设备 绑定后,这个孩子的聊天记录、定位和设备状态都会显示在首页 Taro.navigateTo({ url: '/pages/bind/index' })}> 去绑定设备 {systemBanner} ) } 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 ( {pageTitle} 已完成绑定 {batteryDisplay.text} {batteryDisplay.showPercent && %} 当前电量 信号 {signalLabel} 版本 {versionLabel} 设备控制 定时休眠 {sleepRangeLabel} 设置 立即休眠/唤醒 {sleepWakePending === 'off' ? '正在发送休眠指令...' : sleepWakePending === 'on' ? '正在发送唤醒指令...' : '立即控制当前设备'} handleRemoteSleepWake('off')} > 立即休眠 handleRemoteSleepWake('on')} > 立即唤醒 外放音量 {isSavingVolume ? '调整中...' : `当前 ${volumeLabel}`} 最近告警 {latestAlarm ? '已收到设备主动上报的告警,可继续查看以往记录' : '暂时没有收到新的设备告警'} {latestAlarm ? ( 告警时间 {formatAlarmTime(latestAlarm.created_at)} 来源儿童 {latestAlarmChildName} 来源设备 {latestAlarm.device_id} 上报类型 {getAlarmTypeLabel(latestAlarm.source_msg_id)} 告警位置 {getAlarmLocationLabel(latestAlarm)} {getAlarmLocationHint(latestAlarm)} 以往告警 最近 {historyAlarms.length} 条 {historyAlarms.length > 0 ? ( historyAlarms.map((alarm) => ( {formatAlarmTime(alarm.created_at)} {getAlarmTypeLabel(alarm.source_msg_id)} 来源儿童:{getAlarmChildLabel(alarm, childName)} {alarm.device_id} {getAlarmLocationLabel(alarm)} {alarm.location_stale ? ( 位置可能不是告警发生时的位置 ) : null} )) ) : ( 还没有更早的告警记录。 )} ) : ( 设备触发长按告警后,会在这里显示最近一条记录。 )} 设备详情 {binding.device_id} {isDetailExpanded ? '收起' : '展开'} {isDetailExpanded && ( 当前儿童 {childName} 当前设备 {binding.device_id} 绑定时间 {formatTime(binding.bound_at)} 当前坐标 {coordinateLabel} 状态更新时间 {formatTime(deviceStatus?.settings_updated_at)} 定位更新时间 {formatTime(deviceStatus?.location_updated_at)} )} {systemBanner} ) }