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
|
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[]> {
|
||||||
|
|
|
||||||
|
|
@ -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: '驻留超时'
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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