feat(device): ED719 跌倒检测独立卡片 + ED713 体征越阈告警

- 新增 FallStatusCard 组件与 FALL_STATUS_MAP 枚举(3101~3104 跌倒/疑似/
  解除/驻留),ED719 设备分支渲染独立状态卡,跳过体征面板与心率波形拉取
- ED713 体征新增医学阈值告警:心率 <60 或 >100 BPM、呼吸率 <12 或 >24
  /min 数值转红;频繁活动/无人/运动/不在床枚举态转橙
- 新增 FallDetectSigns 接口与 HomeDevice.runTimeInfo 联合类型
- 详情页样式补 heartValueDanger / metricValueWarning / metricValueDanger
This commit is contained in:
ozh 2026-09-21 18:02:36 +08:00
parent 40783ccabe
commit ff15db2f72
6 changed files with 273 additions and 61 deletions

View File

@ -30,6 +30,13 @@ export interface VitalSigns {
bodyMovement?: number bodyMovement?: number
} }
// ED719 跌倒检测快照(2026-09-21 实测:status 枚举见 FALL_STATUS_MAP;version 为固件版本)
export interface FallDetectSigns {
lastTime?: string
status?: number
version?: string
}
// 响应字段按 2026-09-17 联调实测 // 响应字段按 2026-09-17 联调实测
export interface HomeDevice { export interface HomeDevice {
installId: string installId: string
@ -46,7 +53,7 @@ export interface HomeDevice {
status: number status: number
onlineStatus: number // 1-在线 0-离线 onlineStatus: number // 1-在线 0-离线
lastOnlineTime: string | null lastOnlineTime: string | null
runTimeInfo?: { properties?: VitalSigns } | null runTimeInfo?: { properties?: VitalSigns & FallDetectSigns } | null
} }
export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> { export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {

View File

@ -31,3 +31,11 @@ export const ONBED_STATUS_MAP: Record<number, string> = {
0: '不在床', 0: '不在床',
1: '在床' 1: '在床'
} }
// ED719 跌倒状态枚举(2026-09-21 用户提供:runTimeInfo.properties.status 实时值)
export const FALL_STATUS_MAP: Record<number, string> = {
3101: '摔倒报警',
3102: '疑似摔倒',
3103: '解除摔倒',
3104: '驻留超时'
}

View File

@ -53,6 +53,9 @@
line-height: 1.1; line-height: 1.1;
} }
// 心率越阈(<60 或 >100 BPM):大数字转红
.heartValueDanger { color: $color-danger; }
.heartUnit { margin-top: 8px; font-size: 30px; color: $color-text-secondary; } .heartUnit { margin-top: 8px; font-size: 30px; color: $color-text-secondary; }
.badge { .badge {
@ -143,6 +146,19 @@
color: $color-text; color: $color-text;
} }
// 状态告警色:warning 橙(注意态)、danger 红(越阈值)
.metricValueWarning {
font-size: 44px;
font-weight: 700;
color: $color-warning;
}
.metricValueDanger {
font-size: 44px;
font-weight: 700;
color: $color-danger;
}
.metricBlue { color: $color-primary-deep; } .metricBlue { color: $color-primary-deep; }
.metricOrange { color: #fa8c16; } .metricOrange { color: #fa8c16; }
.metricGreen { color: $color-success; } .metricGreen { color: $color-success; }

View File

@ -8,16 +8,33 @@ import { VitalWave } from '@/components/vital-wave'
import { ROUTES } from '@/constants/routes' import { ROUTES } from '@/constants/routes'
import { navigateTo, navigateBack } from '@/utils/navigation' import { navigateTo, navigateBack } from '@/utils/navigation'
import { showToast } from '@/utils/toast' import { showToast } from '@/utils/toast'
import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP } from '@/constants/enums' import { MOVE_STATE_MAP, BODY_STATUS_MAP, ONBED_STATUS_MAP, FALL_STATUS_MAP } from '@/constants/enums'
import { import {
getHomeDeviceDetail, getVitalRecords, unbindHomeDevice, type HomeDevice, type VitalSigns getHomeDeviceDetail, getVitalRecords, unbindHomeDevice, type HomeDevice, type VitalSigns
} from '@/api/homeDevice' } from '@/api/homeDevice'
import { FallStatusCard } from './fall-status-card'
import type { DeviceModel } from '@/constants/enums' import type { DeviceModel } from '@/constants/enums'
import styles from './detail.module.scss' import styles from './detail.module.scss'
// 轮询间隔:与 web 端 VitalSignsDashboard SWR refreshInterval 对齐 // 轮询间隔:与 web 端 VitalSignsDashboard SWR refreshInterval 对齐
const POLL_INTERVAL = 3000 const POLL_INTERVAL = 3000
// 数值告警阈值(医学常规):心率 60-100 BPM、呼吸率 12-24 /min
const HR_NORMAL: [number, number] = [60, 100]
const RR_NORMAL: [number, number] = [12, 24]
// 数值越阈 → 红;否则保持主文本色
const numericLevel = (v: number | undefined, range: [number, number]): 'normal' | 'danger' =>
v === undefined || v === null ? 'normal' : (v < range[0] || v > range[1] ? 'danger' : 'normal')
// 枚举状态 → 语义色:异常态橙红、正常态主文本色
const MOVE_STATE_LEVEL: Record<number, 'normal' | 'warning'> = { 2: 'warning' } // 频繁活动
const BODY_STATUS_LEVEL: Record<number, 'normal' | 'warning'> = { 0: 'warning', 1: 'warning' } // 无人/运动
const ONBED_LEVEL: Record<number, 'normal' | 'warning'> = { 0: 'warning' } // 不在床
const levelCls = (level: 'normal' | 'warning' | 'danger'): string =>
level === 'normal' ? styles.metricValue : level === 'warning' ? styles.metricValueWarning : styles.metricValueDanger
export default function Detail() { export default function Detail() {
const router = useRouter() const router = useRouter()
const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string } const params = router.params as { id?: string; name?: string; model?: DeviceModel; online?: string }
@ -44,7 +61,7 @@ export default function Detail() {
}) })
}, []) }, [])
// 单次刷新:设备详情(实时快照)+ 历史心率(波形) // 单次刷新:设备详情(实时快照)+ 历史心率(波形,仅 ED713)
const fetchAll = useCallback(async () => { const fetchAll = useCallback(async () => {
if (!installId) return if (!installId) return
try { try {
@ -52,6 +69,7 @@ export default function Detail() {
setDevice(d) setDevice(d)
setOnline(d.onlineStatus === 1) setOnline(d.onlineStatus === 1)
deviceNameRef.current = d.deviceName deviceNameRef.current = d.deviceName
if (d.subDeviceType === 'ED719') return
const records = await getVitalRecords(d.deviceName) const records = await getVitalRecords(d.deviceName)
// 倒序返回(最新在前),波形需按时间正序绘制 // 倒序返回(最新在前),波形需按时间正序绘制
const pts = (records ?? []).map((r) => r.heartRate).filter((v): v is number => typeof v === 'number') const pts = (records ?? []).map((r) => r.heartRate).filter((v): v is number => typeof v === 'number')
@ -86,6 +104,8 @@ export default function Detail() {
const signs: VitalSigns | undefined = device?.runTimeInfo?.properties const signs: VitalSigns | undefined = device?.runTimeInfo?.properties
const fmt = (v: number | undefined): string => (v === undefined || v === null ? '--' : String(v)) const fmt = (v: number | undefined): string => (v === undefined || v === null ? '--' : String(v))
// ED719 跌倒检测器走独立卡片,不渲染体征面板
const isFallDetector = deviceModel === 'ED719' || device?.subDeviceType === 'ED719'
const goBleConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG) const goBleConfig = () => navigateTo(ROUTES.DEVICE_BLE_CONFIG)
void goBleConfig void goBleConfig
@ -125,12 +145,21 @@ export default function Detail() {
<View className={styles.page}> <View className={styles.page}>
<Navbar title={deviceName} variant="hero" /> <Navbar title={deviceName} variant="hero" />
{isFallDetector ? (
<FallStatusCard
status={device?.runTimeInfo?.properties?.status}
lastTime={device?.runTimeInfo?.properties?.lastTime}
version={device?.runTimeInfo?.properties?.version}
online={online}
/>
) : (
<>
<View className={styles.heartCard}> <View className={styles.heartCard}>
<View className={styles.heartLeft}> <View className={styles.heartLeft}>
<View className={styles.heartIconWrap}> <View className={styles.heartIconWrap}>
<Text className={styles.heartIcon}>❤</Text> <Text className={styles.heartIcon}>❤</Text>
</View> </View>
<Text className={styles.heartValue}>{fmt(signs?.heartRate)}</Text> <Text className={`${styles.heartValue} ${numericLevel(signs?.heartRate, HR_NORMAL) === 'danger' ? styles.heartValueDanger : ''}`}>{fmt(signs?.heartRate)}</Text>
<Text className={styles.heartUnit}>BPM</Text> <Text className={styles.heartUnit}>BPM</Text>
<View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}> <View className={`${styles.badge} ${online ? styles.badgeOn : styles.badgeOff}`}>
<View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} /> <View className={`${styles.badgeDot} ${online ? styles.badgeDotOn : styles.badgeDotOff}`} />
@ -150,7 +179,7 @@ export default function Detail() {
<View className={styles.metricCard}> <View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>呼吸率</Text> <Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>呼吸率</Text>
<View className={styles.metricRow}> <View className={styles.metricRow}>
<Text className={styles.metricValue}>{fmt(signs?.respiratoryRate)}</Text> <Text className={levelCls(numericLevel(signs?.respiratoryRate, RR_NORMAL))}>{fmt(signs?.respiratoryRate)}</Text>
<Text className={styles.metricUnit}>/min</Text> <Text className={styles.metricUnit}>/min</Text>
</View> </View>
</View> </View>
@ -163,19 +192,25 @@ export default function Detail() {
<View className={styles.metricCard}> <View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>运动状态</Text> <Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>运动状态</Text>
<View className={styles.metricRow}> <View className={styles.metricRow}>
<Text className={styles.metricValue}>{MOVE_STATE_MAP[signs?.moveState ?? -1] ?? '--'}</Text> <Text className={levelCls(MOVE_STATE_LEVEL[signs?.moveState ?? -1] ?? 'normal')}>
{MOVE_STATE_MAP[signs?.moveState ?? -1] ?? '--'}
</Text>
</View> </View>
</View> </View>
<View className={styles.metricCard}> <View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelRed}`}>在床状态</Text> <Text className={`${styles.metricLabel} ${styles.metricLabelRed}`}>在床状态</Text>
<View className={styles.metricRow}> <View className={styles.metricRow}>
<Text className={styles.metricValue}>{ONBED_STATUS_MAP[signs?.onbedStatus ?? -1] ?? '--'}</Text> <Text className={levelCls(ONBED_LEVEL[signs?.onbedStatus ?? -1] ?? 'normal')}>
{ONBED_STATUS_MAP[signs?.onbedStatus ?? -1] ?? '--'}
</Text>
</View> </View>
</View> </View>
<View className={styles.metricCard}> <View className={styles.metricCard}>
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>人体状态</Text> <Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>人体状态</Text>
<View className={styles.metricRow}> <View className={styles.metricRow}>
<Text className={styles.metricValue}>{BODY_STATUS_MAP[signs?.bodyStatus ?? -1] ?? '--'}</Text> <Text className={levelCls(BODY_STATUS_LEVEL[signs?.bodyStatus ?? -1] ?? 'normal')}>
{BODY_STATUS_MAP[signs?.bodyStatus ?? -1] ?? '--'}
</Text>
</View> </View>
</View> </View>
<View className={styles.metricCard}> <View className={styles.metricCard}>
@ -186,6 +221,8 @@ export default function Detail() {
</View> </View>
</View> </View>
</View> </View>
</>
)}
<Text className={styles.infoTitle}>设备信息</Text> <Text className={styles.infoTitle}>设备信息</Text>
<View className={styles.infoCard}> <View className={styles.infoCard}>

View File

@ -0,0 +1,97 @@
// ED719 跌倒检测状态卡:状态色随语义变化(normal 绿 / warning 橙 / danger 红 / success 绿 / offline 灰)
// variables 由构建配置全局注入(config/index.ts sass.resource)
.fallCard {
margin: 24px;
padding: 32px;
border-radius: $radius-lg;
background: $color-bg-white;
box-shadow: $shadow-card;
display: flex;
flex-direction: column;
align-items: center;
}
.fallCardDanger { background: linear-gradient(160deg, #fff5f4 0%, #ffffff 60%); }
.fallCardWarning { background: linear-gradient(160deg, #fffaf0 0%, #ffffff 60%); }
.fallCardSuccess { background: linear-gradient(160deg, #f4fff0 0%, #ffffff 60%); }
.fallHead {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
}
.fallTitle {
font-size: 30px;
font-weight: 600;
color: $color-text;
}
.fallBadge {
display: flex;
align-items: center;
}
.fallBadgeDot {
width: 14px;
height: 14px;
border-radius: $radius-circle;
margin-right: 10px;
}
.fallBadgeDotOn { background: $color-success; }
.fallBadgeDotOff { background: $color-text-secondary; }
.fallBadgeTextOn { font-size: 24px; color: $color-success; }
.fallBadgeTextOff { font-size: 24px; color: $color-text-secondary; }
// 图标圆底:色随状态
.fallIconWrap {
width: 160px;
height: 160px;
margin-top: 40px;
border-radius: $radius-circle;
display: flex;
align-items: center;
justify-content: center;
background: $color-primary-light-bg;
}
.fallIconWrapNormal { background: $color-primary-light-bg; }
.fallIconWrapOffline { background: $color-bg-disabled; }
.fallIconWrapDanger { background: $color-danger-light-bg; }
.fallIconWrapWarning { background: #fff7e8; }
.fallIconWrapSuccess { background: $color-success-light-bg; }
.fallIcon {
font-size: 72px;
line-height: 1;
color: $color-primary;
}
.fallStatusText {
margin-top: 32px;
font-size: 44px;
font-weight: 700;
color: $color-text;
}
.fallStatusTextNormal { color: $color-primary-deep; }
.fallStatusTextOffline { color: $color-text-secondary; }
.fallStatusTextDanger { color: $color-danger; }
.fallStatusTextWarning { color: $color-warning; }
.fallStatusTextSuccess { color: $color-success; }
.fallTime {
margin-top: 16px;
font-size: 24px;
color: $color-text-tertiary;
}
.fallVersion {
margin-top: 8px;
font-size: 24px;
color: $color-text-tertiary;
}

View File

@ -0,0 +1,47 @@
import { View, Text } from '@tarojs/components'
import { FALL_STATUS_MAP } from '@/constants/enums'
import styles from './fall-status-card.module.scss'
interface Props {
status?: number
lastTime?: string
version?: string
online: boolean
}
// 状态 → 语义色(正常无编码/离线灰、疑似黄、摔倒红、解除绿、驻留橙)
const STATUS_CLS: Record<number, string> = {
3101: 'danger',
3102: 'warning',
3103: 'success',
3104: 'warning'
}
// ED719 跌倒检测状态卡:实时状态大卡(参考 web 端 DeviceScgDashboard DeviceItem 布局)
export function FallStatusCard({ status, lastTime, version, online }: Props) {
const label = FALL_STATUS_MAP[status ?? -1] ?? (online ? '正常' : '离线')
const level = STATUS_CLS[status ?? -1] ?? (online ? 'normal' : 'offline')
return (
<View className={`${styles.fallCard} ${styles[`fallCard${cap(level)}`]}`}>
<View className={styles.fallHead}>
<Text className={styles.fallTitle}>检测状态</Text>
<View className={styles.fallBadge}>
<View className={`${styles.fallBadgeDot} ${online ? styles.fallBadgeDotOn : styles.fallBadgeDotOff}`} />
<Text className={online ? styles.fallBadgeTextOn : styles.fallBadgeTextOff}>{online ? '在线' : '离线'}</Text>
</View>
</View>
<View className={`${styles.fallIconWrap} ${styles[`fallIconWrap${cap(level)}`]}`}>
<Text className={styles.fallIcon}>⌁</Text>
</View>
<Text className={`${styles.fallStatusText} ${styles[`fallStatusText${cap(level)}`]}`}>{label}</Text>
{lastTime && <Text className={styles.fallTime}>更新于 {lastTime}</Text>}
{version && <Text className={styles.fallVersion}>固件版本 {version}</Text>}
</View>
)
}
// CSS Modules 连续大写坑:camelCaseOnly 会把 TL 转 Tl(见记忆 css-modules-camelcase-trap),
// 动态拼 key 需首字母大写驼峰
const cap = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)