feat(device): 设备详情接入实时体征——EventChannel 传设备对象 + 3s 轮询刷新

- HomeDevice 补 runTimeInfo.properties 体征字段类型(2026-09-18 联调实测)
- 列表页跳详情改用 Taro.navigateTo 原生调用,EventChannel emit 整个 device 对象
- 详情页 getOpenerEventChannel 接收首屏零延迟渲染;setInterval 3s 轮询列表接口
  按 installId 定位刷新(对齐 web 端 VitalSignsDashboard SWR refreshInterval=3000)
- 体征六指标卡:呼吸率/体动/运动状态/在床状态/人体状态/人体位置,
  枚举文案复用 MOVE_STATE_MAP/BODY_STATUS_MAP/ONBED_STATUS_MAP
- 详情信息行新增安装房间
This commit is contained in:
ozh 2026-09-18 17:24:48 +08:00
parent ccca973d62
commit d7bd20eb79
4 changed files with 124 additions and 10 deletions

View File

@ -18,6 +18,18 @@ export interface HomeDeviceUnbindReq {
installId: string // 设备安装 ID
}
// 体征实时快照(runTimeInfo.properties,2026-09-18 联调实测;字段与 web 端 VitalSignsDashboard 一致)
export interface VitalSigns {
lastTime?: string
heartRate?: number
respiratoryRate?: number
moveState?: number
bodyStatus?: number
onbedStatus?: number
bodyPosition?: number
bodyMovement?: number
}
// 响应字段按 2026-09-17 联调实测
export interface HomeDevice {
installId: string
@ -34,6 +46,7 @@ export interface HomeDevice {
status: number
onlineStatus: number // 1-在线 0-离线
lastOnlineTime: string | null
runTimeInfo?: { properties?: VitalSigns } | null
}
export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {

View File

@ -71,6 +71,11 @@
display: block;
}
.heartTime {
font-size: 24px;
color: $color-text-tertiary;
}
.metricGrid {
display: flex;
flex-wrap: wrap;
@ -98,6 +103,11 @@
.metricValue { font-size: 44px; font-weight: 700; }
.metricBlue { color: $color-primary-deep; }
.metricOrange { color: #fa8c16; }
.metricGreen { color: $color-success; }
.metricRed { color: $color-danger; }
.metricUnit {
margin-left: 8px;
font-size: 24px;

View File

@ -1,22 +1,66 @@
import { useState, useEffect, useRef } from 'react'
import { useRouter } from '@tarojs/taro'
import Taro from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Navbar } from '@/components/navbar'
import { ListRow, ListRowGroup } from '@/components/list-row'
import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation'
import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP } from '@/constants/enums'
import { getHomeDeviceList, type HomeDevice, type VitalSigns } from '@/api/homeDevice'
import type { DeviceModel } from '@/constants/enums'
import styles from './detail.module.scss'
// 体征数据后端暂无对应接口:详情页先保留设备信息静态展示,
// 心率/呼吸等实时体征待接口定型后接入
// 体征轮询间隔:与 web 端 VitalSignsDashboard 的 SWR refreshInterval 对齐
const POLL_INTERVAL = 3000
export default function Detail() {
const router = useRouter()
const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string }
const deviceId = params.id ?? ''
const installId = params.id ?? ''
const deviceName = params.name ? decodeURIComponent(params.name) : '设备详情'
const deviceModel = params.model ?? ''
const online = params.online === '1'
// 列表页 EventChannel 传入的设备对象:首屏零延迟
const [device, setDevice] = useState<HomeDevice | null>(null)
const [online, setOnline] = useState(params.online === '1')
const homeIdRef = useRef<string | null>(null)
useEffect(() => {
const inst = Taro.getCurrentInstance()
const page = inst.page as unknown as { getOpenerEventChannel?: () => { on: (e: string, cb: (d: HomeDevice) => void) => void } } | undefined
page?.getOpenerEventChannel?.()?.on('device', (d) => {
setDevice(d)
setOnline(d.onlineStatus === 1)
homeIdRef.current = d.homeId
})
}, [])
// 轮询列表接口按 installId 定位目标设备,刷新体征快照(对齐 web 端 SWR 轮询模式)
useEffect(() => {
const poll = async () => {
if (!installId) return
const homeId = homeIdRef.current
if (!homeId) return
try {
const list = await getHomeDeviceList({ homeId })
const next = (list ?? []).find((d) => d.installId === installId)
if (next) {
setDevice(next)
setOnline(next.onlineStatus === 1)
}
} catch {
// 单次轮询失败不中断定时器,下个周期重试
}
}
const timer = setInterval(poll, POLL_INTERVAL)
return () => clearInterval(timer)
}, [installId])
const signs: VitalSigns | undefined = device?.runTimeInfo?.properties
// 空值统一显示占位符
const fmt = (v: number | undefined): string => (v === undefined || v === null ? '--' : String(v))
const goBleConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
const goWifiConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
@ -28,7 +72,7 @@ export default function Detail() {
<View className={styles.heartCard}>
<View className={styles.heartLeft}>
<Text className={styles.heartIcon}>❤</Text>
<Text className={styles.heartValue}>--</Text>
<Text className={styles.heartValue}>{fmt(signs?.heartRate)}</Text>
<Text className={styles.heartUnit}>BPM</Text>
<View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}>
<View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} />
@ -36,14 +80,59 @@ export default function Detail() {
</View>
</View>
<View className={styles.heartRight}>
<Text className={styles.heartHealthLabel}>体征数据接入中</Text>
<Text className={styles.heartHealthLabel}>心率健康</Text>
{signs?.lastTime && (
<Text className={styles.heartTime}>更新于 {signs.lastTime}</Text>
)}
</View>
</View>
<View className={styles.metricGrid}>
<View className={styles.metricCard}>
<Text className={styles.metricLabel}>呼吸率</Text>
<View className={styles.metricRow}>
<Text className={`${styles.metricValue} ${styles.metricBlue}`}>{fmt(signs?.respiratoryRate)}</Text>
<Text className={styles.metricUnit}>/min</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={styles.metricLabel}>体动</Text>
<View className={styles.metricRow}>
<Text className={`${styles.metricValue} ${styles.metricOrange}`}>{fmt(signs?.bodyMovement)}</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={styles.metricLabel}>运动状态</Text>
<View className={styles.metricRow}>
<Text className={`${styles.metricValue} ${styles.metricGreen}`}>{MOVE_STATE_MAP[signs?.moveState ?? -1] ?? '--'}</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={styles.metricLabel}>在床状态</Text>
<View className={styles.metricRow}>
<Text className={`${styles.metricValue} ${styles.metricRed}`}>{ONBED_STATUS_MAP[signs?.onbedStatus ?? -1] ?? '--'}</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={styles.metricLabel}>人体状态</Text>
<View className={styles.metricRow}>
<Text className={`${styles.metricValue} ${styles.metricBlue}`}>{BODY_STATUS_MAP[signs?.bodyStatus ?? -1] ?? '--'}</Text>
</View>
</View>
<View className={styles.metricCard}>
<Text className={styles.metricLabel}>人体位置</Text>
<View className={styles.metricRow}>
<Text className={`${styles.metricValue} ${styles.metricGreen}`}>{fmt(signs?.bodyPosition)}</Text>
<Text className={styles.metricUnit}>分米</Text>
</View>
</View>
</View>
<View className={styles.infoCard}>
<ListRowGroup>
<ListRow label="设备型号" value={deviceModel || '-'} />
<ListRow label="设备 ID" value={deviceId || '-'} />
<ListRow label="设备 ID" value={installId || '-'} />
<ListRow label="安装房间" value={device?.roomName ?? '-'} />
<ListRow label="网络配置" arrow onClick={goWifiConfig} />
<ListRow label="设备配置" arrow onClick={goBleConfig} />
</ListRowGroup>

View File

@ -100,11 +100,13 @@ export default function DeviceList() {
loadRooms()
})
// EventChannel 把整个设备对象传给详情页:首屏零延迟渲染体征快照
const goDetail = (device: HomeDevice) => {
const name = encodeURIComponent(deviceLabel(device))
navigateTo(
`${ROUTES.DEVICE_DETAIL}?id=${device.installId}&name=${name}&model=${device.subDeviceType ?? ''}&online=${device.onlineStatus ? 1 : 0}`
)
Taro.navigateTo({
url: `${ROUTES.DEVICE_DETAIL}?id=${device.installId}&name=${name}&model=${device.subDeviceType ?? ''}&online=${device.onlineStatus ? 1 : 0}`,
success: (res) => res.eventChannel.emit('device', device)
})
}
const goAdd = () => navigateTo(ROUTES.DEVICE_ADD)
const goRoom = () => navigateTo(ROUTES.SCENE_SELECT)