344 lines
8.2 KiB
Vue
344 lines
8.2 KiB
Vue
<!-- ED713 设备详情页:体征仪表盘(参考 web 端 VitalSignsDashboard 布局) -->
|
||
<template>
|
||
<view class="page page-base">
|
||
<up-navbar :title="deviceName" :fixed="false" :safe-area-inset-top="true" :border="false" auto-back />
|
||
|
||
<view v-if="loading" class="state-wrap">
|
||
<up-loading-icon mode="circle" size="48" text="加载中..." />
|
||
</view>
|
||
|
||
<view v-else-if="loadError" class="state-wrap">
|
||
<up-empty mode="wifi" text="加载失败,请检查网络" />
|
||
<up-button type="success" shape="circle" text="重试" class="retry-btn" @click="loadVitals" />
|
||
</view>
|
||
|
||
<template v-else>
|
||
<!-- 心率区:左侧大数字 + 右侧波形 -->
|
||
<view class="heart-card">
|
||
<view class="heart-left">
|
||
<text class="heart-icon">❤</text>
|
||
<text class="heart-value">{{ valueOf(vitals.heartRate) }}</text>
|
||
<text class="heart-unit">BPM</text>
|
||
<view class="heart-badge" :class="online ? 'heart-badge--on' : 'heart-badge--off'">
|
||
<view class="badge-dot" :class="online ? 'badge-dot--on' : 'badge-dot--off'" />
|
||
<text>{{ online ? '正常' : '离线' }}</text>
|
||
</view>
|
||
</view>
|
||
<view class="heart-right">
|
||
<view class="heart-right-title">
|
||
<text class="heart-health-label">心率健康</text>
|
||
</view>
|
||
<vital-wave :points="wavePoints" canvas-id="heartWave" />
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 六个指标 2 列网格 -->
|
||
<view class="metric-grid">
|
||
<view v-for="m in metrics" :key="m.label" class="metric-card">
|
||
<text class="metric-label">{{ m.label }}</text>
|
||
<view class="metric-value-row">
|
||
<text class="metric-value" :style="{ color: m.color }">{{ m.value }}</text>
|
||
<text v-if="m.unit" class="metric-unit">{{ m.unit }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<!-- 设备信息 -->
|
||
<view class="info-card">
|
||
<view class="info-row">
|
||
<text class="info-label">设备型号</text>
|
||
<text class="info-value">{{ deviceModel || '-' }}</text>
|
||
</view>
|
||
<view class="info-row">
|
||
<text class="info-label">设备 ID</text>
|
||
<text class="info-value">{{ deviceId || '-' }}</text>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
import vitalWave from '@/components/vital-wave.vue'
|
||
import { getHeartRateHistory, getVitalSigns } from '@/api/device'
|
||
|
||
// 枚举值中文映射,与 web 端 i18n (zh-CN) 一致
|
||
const MOVE_STATE_MAP = { 0: '无人', 1: '轻微活动', 2: '频繁活动', 3: '静卧' }
|
||
const BODY_STATUS_MAP = { 0: '无人', 1: '运动', 2: '呼吸', 3: '进入呼吸' }
|
||
const ONBED_STATUS_MAP = { 0: '不在床', 1: '在床' }
|
||
|
||
// 指标卡配色与 web 端 MetricCard color 一致
|
||
const METRIC_COLORS = {
|
||
breath: '#1677ff',
|
||
motion: '#fa8c16',
|
||
moveState: '#52c41a',
|
||
bedState: '#f5222d',
|
||
bodyState: '#1677ff',
|
||
bodyPosition: '#52c41a'
|
||
}
|
||
|
||
// 与 web 端 SWR refreshInterval 一致的轮询间隔
|
||
const REFRESH_INTERVAL = 3000
|
||
|
||
export default {
|
||
components: { vitalWave },
|
||
data() {
|
||
return {
|
||
deviceId: '',
|
||
deviceName: '设备详情',
|
||
deviceModel: '',
|
||
online: false,
|
||
loading: true,
|
||
loadError: false,
|
||
vitals: {},
|
||
wavePoints: [],
|
||
pollTimer: null
|
||
}
|
||
},
|
||
computed: {
|
||
// 六个指标卡数据(2 列网格顺序:呼吸率/体动/运动状态/在床状态/人体状态/人体位置)
|
||
metrics() {
|
||
const v = this.vitals
|
||
return [
|
||
{ label: '呼吸率', value: this.valueOf(v.respiratoryRate), unit: '/min', color: METRIC_COLORS.breath },
|
||
{ label: '体动', value: this.valueOf(v.bodyMovement), unit: '', color: METRIC_COLORS.motion },
|
||
{ label: '运动状态', value: MOVE_STATE_MAP[v.moveState] ?? this.valueOf(v.moveState), unit: '', color: METRIC_COLORS.moveState },
|
||
{ label: '在床状态', value: ONBED_STATUS_MAP[v.onbedStatus] ?? this.valueOf(v.onbedStatus), unit: '', color: METRIC_COLORS.bedState },
|
||
{ label: '人体状态', value: BODY_STATUS_MAP[v.bodyStatus] ?? this.valueOf(v.bodyStatus), unit: '', color: METRIC_COLORS.bodyState },
|
||
{ label: '人体位置', value: this.valueOf(v.bodyPosition), unit: '分米', color: METRIC_COLORS.bodyPosition }
|
||
]
|
||
}
|
||
},
|
||
onLoad(options) {
|
||
this.deviceId = options.id || ''
|
||
if (options.name) this.deviceName = decodeURIComponent(options.name)
|
||
this.deviceModel = options.model || ''
|
||
this.online = options.online === '1'
|
||
this.loadVitals()
|
||
},
|
||
onShow() {
|
||
this.startPolling()
|
||
},
|
||
onHide() {
|
||
this.stopPolling()
|
||
},
|
||
onUnload() {
|
||
this.stopPolling()
|
||
},
|
||
methods: {
|
||
// 拉取实时体征与心率历史;首拉失败切错误态供重试,轮询失败静默(避免 toast 每 3 秒弹一次)。
|
||
async loadVitals() {
|
||
this.loading = true
|
||
this.loadError = false
|
||
try {
|
||
await this.fetchVitals()
|
||
} catch (error) {
|
||
this.loadError = true
|
||
} finally {
|
||
this.loading = false
|
||
}
|
||
},
|
||
// 仅拉数据并落 state;wavePoints 引用变化触发波形组件重绘。
|
||
async fetchVitals() {
|
||
const [vitals, history] = await Promise.all([
|
||
getVitalSigns(this.deviceId),
|
||
getHeartRateHistory(this.deviceId)
|
||
])
|
||
this.vitals = vitals || {}
|
||
this.wavePoints = Array.isArray(history) ? history : []
|
||
},
|
||
// 轮询:每 3 秒静默刷新体征与波形(页面隐藏/卸载时停止)。
|
||
startPolling() {
|
||
this.stopPolling()
|
||
this.pollTimer = setInterval(() => {
|
||
this.fetchVitals().catch(() => {})
|
||
}, REFRESH_INTERVAL)
|
||
},
|
||
stopPolling() {
|
||
if (this.pollTimer) {
|
||
clearInterval(this.pollTimer)
|
||
this.pollTimer = null
|
||
}
|
||
},
|
||
// 空值统一显示 '-'
|
||
valueOf(v) {
|
||
return v === undefined || v === null || v === '' ? '-' : v
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
@import '@/styles/common.css';
|
||
|
||
.state-wrap {
|
||
margin-top: 120rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.retry-btn {
|
||
margin-top: 40rpx;
|
||
width: 240rpx;
|
||
}
|
||
|
||
/* 心率区 */
|
||
.heart-card {
|
||
margin: 24rpx;
|
||
padding: 32rpx;
|
||
background: #ffffff;
|
||
border-radius: 24rpx;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
}
|
||
|
||
.heart-left {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
min-width: 200rpx;
|
||
}
|
||
|
||
.heart-icon {
|
||
font-size: 72rpx;
|
||
color: #f5222d;
|
||
line-height: 1;
|
||
}
|
||
|
||
.heart-value {
|
||
margin-top: 16rpx;
|
||
font-size: 96rpx;
|
||
font-weight: 700;
|
||
color: #2a2d33;
|
||
line-height: 1.1;
|
||
}
|
||
|
||
.heart-unit {
|
||
margin-top: 8rpx;
|
||
font-size: 30rpx;
|
||
color: #9ea1a6;
|
||
}
|
||
|
||
.heart-badge {
|
||
margin-top: 20rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 8rpx 24rpx;
|
||
border-radius: 999rpx;
|
||
font-size: 24rpx;
|
||
|
||
&--on {
|
||
background: #f6ffed;
|
||
color: #52c41a;
|
||
}
|
||
|
||
&--off {
|
||
background: #f5f5f5;
|
||
color: #999999;
|
||
}
|
||
}
|
||
|
||
.badge-dot {
|
||
width: 14rpx;
|
||
height: 14rpx;
|
||
border-radius: 50%;
|
||
margin-right: 10rpx;
|
||
|
||
&--on {
|
||
background: #52c41a;
|
||
}
|
||
|
||
&--off {
|
||
background: #999999;
|
||
}
|
||
}
|
||
|
||
.heart-right {
|
||
flex: 1;
|
||
min-width: 0;
|
||
margin-left: 32rpx;
|
||
}
|
||
|
||
.heart-right-title {
|
||
margin-bottom: 8rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.heart-health-label {
|
||
font-size: 30rpx;
|
||
font-weight: 500;
|
||
color: #2a2d33;
|
||
}
|
||
|
||
/* 六指标 2 列网格 */
|
||
.metric-grid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
padding: 0 24rpx;
|
||
}
|
||
|
||
.metric-card {
|
||
width: 48.5%;
|
||
box-sizing: border-box;
|
||
margin-bottom: 20rpx;
|
||
padding: 24rpx;
|
||
background: #ffffff;
|
||
border-radius: 16rpx;
|
||
}
|
||
|
||
.metric-label {
|
||
font-size: 26rpx;
|
||
color: #9ea1a6;
|
||
}
|
||
|
||
.metric-value-row {
|
||
margin-top: 12rpx;
|
||
display: flex;
|
||
align-items: baseline;
|
||
}
|
||
|
||
.metric-value {
|
||
font-size: 44rpx;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.metric-unit {
|
||
margin-left: 8rpx;
|
||
font-size: 24rpx;
|
||
color: #b5b9c0;
|
||
}
|
||
|
||
/* 设备信息 */
|
||
.info-card {
|
||
margin: 4rpx 24rpx 24rpx;
|
||
padding: 8rpx 32rpx;
|
||
background: #ffffff;
|
||
border-radius: 24rpx;
|
||
}
|
||
|
||
.info-row {
|
||
min-height: 104rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
|
||
+ .info-row {
|
||
border-top: 1rpx solid #f0f1f3;
|
||
}
|
||
}
|
||
|
||
.info-label {
|
||
font-size: 28rpx;
|
||
color: #9ea1a6;
|
||
}
|
||
|
||
.info-value {
|
||
font-size: 28rpx;
|
||
color: #2a2d33;
|
||
}
|
||
</style>
|