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:
parent
40783ccabe
commit
ff15db2f72
|
|
@ -30,6 +30,13 @@ export interface VitalSigns {
|
|||
bodyMovement?: number
|
||||
}
|
||||
|
||||
// ED719 跌倒检测快照(2026-09-21 实测:status 枚举见 FALL_STATUS_MAP;version 为固件版本)
|
||||
export interface FallDetectSigns {
|
||||
lastTime?: string
|
||||
status?: number
|
||||
version?: string
|
||||
}
|
||||
|
||||
// 响应字段按 2026-09-17 联调实测
|
||||
export interface HomeDevice {
|
||||
installId: string
|
||||
|
|
@ -46,7 +53,7 @@ export interface HomeDevice {
|
|||
status: number
|
||||
onlineStatus: number // 1-在线 0-离线
|
||||
lastOnlineTime: string | null
|
||||
runTimeInfo?: { properties?: VitalSigns } | null
|
||||
runTimeInfo?: { properties?: VitalSigns & FallDetectSigns } | null
|
||||
}
|
||||
|
||||
export function getHomeDeviceList(params: HomeDeviceListReq): Promise<HomeDevice[]> {
|
||||
|
|
|
|||
|
|
@ -31,3 +31,11 @@ export const ONBED_STATUS_MAP: Record<number, string> = {
|
|||
0: '不在床',
|
||||
1: '在床'
|
||||
}
|
||||
|
||||
// ED719 跌倒状态枚举(2026-09-21 用户提供:runTimeInfo.properties.status 实时值)
|
||||
export const FALL_STATUS_MAP: Record<number, string> = {
|
||||
3101: '摔倒报警',
|
||||
3102: '疑似摔倒',
|
||||
3103: '解除摔倒',
|
||||
3104: '驻留超时'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,6 +53,9 @@
|
|||
line-height: 1.1;
|
||||
}
|
||||
|
||||
// 心率越阈(<60 或 >100 BPM):大数字转红
|
||||
.heartValueDanger { color: $color-danger; }
|
||||
|
||||
.heartUnit { margin-top: 8px; font-size: 30px; color: $color-text-secondary; }
|
||||
|
||||
.badge {
|
||||
|
|
@ -143,6 +146,19 @@
|
|||
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; }
|
||||
.metricOrange { color: #fa8c16; }
|
||||
.metricGreen { color: $color-success; }
|
||||
|
|
|
|||
|
|
@ -8,16 +8,33 @@ import { VitalWave } from '@/components/vital-wave'
|
|||
import { ROUTES } from '@/constants/routes'
|
||||
import { navigateTo, navigateBack } from '@/utils/navigation'
|
||||
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 {
|
||||
getHomeDeviceDetail, getVitalRecords, unbindHomeDevice, type HomeDevice, type VitalSigns
|
||||
} from '@/api/homeDevice'
|
||||
import { FallStatusCard } from './fall-status-card'
|
||||
import type { DeviceModel } from '@/constants/enums'
|
||||
import styles from './detail.module.scss'
|
||||
|
||||
// 轮询间隔:与 web 端 VitalSignsDashboard SWR refreshInterval 对齐
|
||||
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() {
|
||||
const router = useRouter()
|
||||
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 () => {
|
||||
if (!installId) return
|
||||
try {
|
||||
|
|
@ -52,6 +69,7 @@ export default function Detail() {
|
|||
setDevice(d)
|
||||
setOnline(d.onlineStatus === 1)
|
||||
deviceNameRef.current = d.deviceName
|
||||
if (d.subDeviceType === 'ED719') return
|
||||
const records = await getVitalRecords(d.deviceName)
|
||||
// 倒序返回(最新在前),波形需按时间正序绘制
|
||||
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 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)
|
||||
void goBleConfig
|
||||
|
|
@ -125,67 +145,84 @@ export default function Detail() {
|
|||
<View className={styles.page}>
|
||||
<Navbar title={deviceName} variant="hero" />
|
||||
|
||||
<View className={styles.heartCard}>
|
||||
<View className={styles.heartLeft}>
|
||||
<View className={styles.heartIconWrap}>
|
||||
<Text className={styles.heartIcon}>❤</Text>
|
||||
{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.heartLeft}>
|
||||
<View className={styles.heartIconWrap}>
|
||||
<Text className={styles.heartIcon}>❤</Text>
|
||||
</View>
|
||||
<Text className={`${styles.heartValue} ${numericLevel(signs?.heartRate, HR_NORMAL) === 'danger' ? styles.heartValueDanger : ''}`}>{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}`} />
|
||||
<Text>{online ? '在线' : '离线'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.heartRight}>
|
||||
<Text className={styles.heartHealthLabel}>心率健康</Text>
|
||||
<VitalWave points={wavePoints} canvasId="heartWave" />
|
||||
{signs?.lastTime && (
|
||||
<Text className={styles.heartTime}>更新于 {signs.lastTime}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
<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}`} />
|
||||
<Text>{online ? '在线' : '离线'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.heartRight}>
|
||||
<Text className={styles.heartHealthLabel}>心率健康</Text>
|
||||
<VitalWave points={wavePoints} canvasId="heartWave" />
|
||||
{signs?.lastTime && (
|
||||
<Text className={styles.heartTime}>更新于 {signs.lastTime}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className={styles.metricGrid}>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>呼吸率</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{fmt(signs?.respiratoryRate)}</Text>
|
||||
<Text className={styles.metricUnit}>/min</Text>
|
||||
<View className={styles.metricGrid}>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>呼吸率</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={levelCls(numericLevel(signs?.respiratoryRate, RR_NORMAL))}>{fmt(signs?.respiratoryRate)}</Text>
|
||||
<Text className={styles.metricUnit}>/min</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelOrange}`}>体动</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{fmt(signs?.bodyMovement)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>运动状态</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={levelCls(MOVE_STATE_LEVEL[signs?.moveState ?? -1] ?? 'normal')}>
|
||||
{MOVE_STATE_MAP[signs?.moveState ?? -1] ?? '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelRed}`}>在床状态</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={levelCls(ONBED_LEVEL[signs?.onbedStatus ?? -1] ?? 'normal')}>
|
||||
{ONBED_STATUS_MAP[signs?.onbedStatus ?? -1] ?? '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>人体状态</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={levelCls(BODY_STATUS_LEVEL[signs?.bodyStatus ?? -1] ?? 'normal')}>
|
||||
{BODY_STATUS_MAP[signs?.bodyStatus ?? -1] ?? '--'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>人体位置</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{fmt(signs?.bodyPosition)}</Text>
|
||||
<Text className={styles.metricUnit}>分米</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelOrange}`}>体动</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{fmt(signs?.bodyMovement)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>运动状态</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{MOVE_STATE_MAP[signs?.moveState ?? -1] ?? '--'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelRed}`}>在床状态</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{ONBED_STATUS_MAP[signs?.onbedStatus ?? -1] ?? '--'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelBlue}`}>人体状态</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{BODY_STATUS_MAP[signs?.bodyStatus ?? -1] ?? '--'}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View className={styles.metricCard}>
|
||||
<Text className={`${styles.metricLabel} ${styles.metricLabelGreen}`}>人体位置</Text>
|
||||
<View className={styles.metricRow}>
|
||||
<Text className={styles.metricValue}>{fmt(signs?.bodyPosition)}</Text>
|
||||
<Text className={styles.metricUnit}>分米</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text className={styles.infoTitle}>设备信息</Text>
|
||||
<View className={styles.infoCard}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
Loading…
Reference in New Issue