feat(device): 首页设备列表卡片网格 + ED713 体征详情页

- 新增 api/device.js(getDeviceList / getVitalSigns / getHeartRateHistory,
  当前 mock,后端就绪后关闭 USE_MOCK)
- 新增 components/vital-wave.vue 心率波形组件(canvas 自绘 ECG 风格面积图)
- 新增 pages/device/detail.vue ED713 体征仪表盘(心率大数字+波形、六指标
  +设备信息卡,加载/错误/重试三态)
- pages/index 设备列表改为双列卡片网格(loading/error/empty 三态,型号→图标映射,
  点卡片进详情页)
- pages.json / constants/routes 新增 DEVICE_DETAIL 路由注册,
  pages/scene/select up-navbar 启用 auto-back
This commit is contained in:
ozh 2026-09-08 09:27:20 +08:00
parent 054f05af70
commit c7cebc2fc9
7 changed files with 782 additions and 6 deletions

97
api/device.js Normal file
View File

@ -0,0 +1,97 @@
import request from '@/api/http/client'
// 后端就绪后置为 false 即可恢复真实请求。
const USE_MOCK = true
// 模拟设备列表数据(后端 /device/list 就绪后删除)。
const MOCK_DEVICES = [
{ id: 1, name: '客厅体征监测仪', model: 'ED713', online: true },
{ id: 2, name: '卧室体征监测仪', model: 'ED713', online: false },
{ id: 3, name: '书房跌倒检测雷达', model: 'ED719', online: true },
{ id: 4, name: '走廊跌倒检测雷达', model: 'ED719', online: false }
]
/**
* 获取指定场所下的设备列表。
* 约定:data 为数组,每项至少含 { id, name, model, online },model 取值 ED713 | ED719。
* @param {*} sceneId 场景 id,全部/未选择时可传 null
*/
export function getDeviceList(sceneId) {
if (USE_MOCK) {
// 模拟网络延迟,让 loading 态可见。
return new Promise((resolve) => setTimeout(() => resolve(MOCK_DEVICES), 500))
}
return request.get('/device/list', { sceneId })
}
// 模拟 ED713 实时体征数据(后端 /device/vitals 就绪后删除)。
// 字段与 web 端 runTimeInfo.properties 对齐;枚举值见 detail 页映射表。
const MOCK_VITALS = {
heartRate: 72,
respiratoryRate: 16,
bodyMovement: 3,
moveState: 3,
onbedStatus: 1,
bodyStatus: 2,
bodyPosition: 8
}
// 心率读数模拟状态:跨调用保留最近 50 个读数(与 web 端 pageSize:50 一致),
// 每次轮询推进 2~3 个新读数、挤出最旧的——等价于 web 端每 3 秒按 createTime 倒序重拉最新 50 条。
const heartState = {
series: [],
value: 72
}
// 心率读数小幅随机游走(60~90 BPM)。
function driftHeartRate() {
heartState.value += Math.round((Math.random() - 0.5) * 4)
heartState.value = Math.min(90, Math.max(60, heartState.value))
return heartState.value
}
// 初始预填 50 个读数,首屏即有完整曲线。
for (let i = 0; i < 50; i++) {
heartState.series.push(driftHeartRate())
}
// 推进若干新读数并保留最近 50 个。
function pushHeartReadings() {
const n = 2 + Math.round(Math.random())
for (let i = 0; i < n; i++) {
heartState.series.push(driftHeartRate())
}
if (heartState.series.length > 50) {
heartState.series = heartState.series.slice(-50)
}
}
/**
* 获取 ED713 设备实时体征属性。
* 约定:data 含 { heartRate, respiratoryRate, bodyMovement, moveState, onbedStatus, bodyStatus, bodyPosition }。
* @param {*} deviceId 设备 id
*/
export function getVitalSigns(deviceId) {
if (USE_MOCK) {
const heartRate = driftHeartRate()
return new Promise((resolve) =>
setTimeout(() => resolve({ ...MOCK_VITALS, heartRate, deviceId }), 300)
)
}
return request.get('/device/vitals', { deviceId })
}
/**
* 获取心率历史序列(与 web 端一致:最近 50 条心率读数、按时间升序喂给面积图)。
* 约定:data 为 number 数组;每次调用推进若干新读数,曲线随轮询向左滚动。
* @param {*} deviceId 设备 id
*/
export function getHeartRateHistory(deviceId) {
if (USE_MOCK) {
pushHeartReadings()
return new Promise((resolve) =>
setTimeout(() => resolve([...heartState.series]), 300)
)
}
return request.get('/device/heart-history', { deviceId })
}

179
components/vital-wave.vue Normal file
View File

@ -0,0 +1,179 @@
<!-- 心率历史波形:canvas 自绘红心电风格面积图(复刻 web 端 TinyArea 视觉) -->
<template>
<view class="wave-box">
<canvas
:id="canvasId"
class="wave-canvas"
:canvas-id="canvasId"
:style="{ width: canvasWidth + 'px', height: canvasHeight + 'px' }"
/>
<!-- ECG 风格虚线分割:上/中/下三部分 -->
<view class="wave-divider wave-divider--top" />
<view class="wave-divider wave-divider--bottom" />
<view v-if="empty" class="wave-empty">
<text>暂无数据</text>
</view>
</view>
</template>
<script>
// 与 props.canvasHeight 保持一致;波形上下各留 8px 呼吸空间
const V_PADDING = 8
export default {
name: 'vital-wave',
props: {
// 数据点(升序 number 数组)
points: {
type: Array,
default: () => []
},
// canvas id,多实例场景需外部保证唯一
canvasId: {
type: String,
default: 'vitalWave'
}
},
data() {
return {
canvasWidth: 200, // 容器实际宽(px),mounted 后测量
canvasHeight: 110
}
},
computed: {
empty() {
return !this.points || this.points.length < 2
}
},
watch: {
// 数据变化重绘(详情页轮询刷新时触发)
points() {
this.draw()
}
},
mounted() {
this.measure()
},
methods: {
// 测量容器宽度:宽度自适应父级,高度固定 110px 与 web 端一致
measure() {
uni
.createSelectorQuery()
.in(this)
.select('.wave-box')
.boundingClientRect((rect) => {
if (rect && rect.width > 0) {
this.canvasWidth = Math.floor(rect.width)
this.$nextTick(() => this.draw())
}
})
.exec()
},
// 绘制平滑面积图(对齐 web 端 TinyArea:smooth 曲线 + 自适应 y 值域 + padding:0)
draw() {
if (this.empty) return
const ctx = uni.createCanvasContext(this.canvasId, this)
const w = this.canvasWidth
const h = this.canvasHeight
const pts = this.points
const min = Math.min(...pts)
const max = Math.max(...pts)
// 值域映射到画布;无波动时居中画直线
const span = max - min || 1
const xAt = (i) => (pts.length <= 1 ? 0 : (i / (pts.length - 1)) * w)
const yAt = (v) => {
const t = (v - min) / span
return h - V_PADDING - t * (h - V_PADDING * 2)
}
// smooth: true —— Catmull-Rom 样条插值(G2 smooth 的实现方式),
// 每个点作为控制点求相邻点间的三次贝塞尔,曲线过每个数据点
const pathTo = (closeBottom) => {
ctx.beginPath()
ctx.moveTo(xAt(0), yAt(pts[0]))
if (pts.length === 2) {
ctx.lineTo(xAt(1), yAt(pts[1]))
}
for (let i = 0; i < pts.length - 1; i++) {
const x0 = xAt(Math.max(0, i - 1))
const y0 = yAt(pts[Math.max(0, i - 1)])
const x1 = xAt(i)
const y1 = yAt(pts[i])
const x2 = xAt(i + 1)
const y2 = yAt(pts[i + 1])
const x3 = xAt(Math.min(pts.length - 1, i + 2))
const y3 = yAt(pts[Math.min(pts.length - 1, i + 2)])
// Catmull-Rom → 贝塞尔控制点
const c1x = x1 + (x2 - x0) / 6
const c1y = y1 + (y2 - y0) / 6
const c2x = x2 - (x3 - x1) / 6
const c2y = y2 - (y3 - y1) / 6
ctx.bezierCurveTo(c1x, c1y, c2x, c2y, x2, y2)
}
if (closeBottom) {
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.closePath()
}
}
// 面积:曲线 + 封底,渐变填充(对齐 l(270) 0:#f5222d 1:transparent)
pathTo(true)
const grad = ctx.createLinearGradient(0, 0, 0, h)
grad.addColorStop(0, 'rgba(245, 34, 45, 0.25)')
grad.addColorStop(1, 'rgba(245, 34, 45, 0)')
ctx.setFillStyle && ctx.setFillStyle(grad)
ctx.fill()
// 描边线:2px
pathTo(false)
ctx.setStrokeStyle && ctx.setStrokeStyle('#f5222d')
ctx.setLineWidth && ctx.setLineWidth(2)
ctx.stroke()
ctx.draw()
}
}
}
</script>
<style lang="scss" scoped>
.wave-box {
position: relative;
width: 100%;
height: 220rpx;
}
.wave-canvas {
display: block;
}
.wave-divider {
position: absolute;
left: 0;
right: 0;
border-top: 1px dashed rgba(245, 34, 45, 0.25);
&--top {
top: 33.3%;
}
&--bottom {
top: 66.6%;
}
}
.wave-empty {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 24rpx;
color: #b5b9c0;
}
</style>

View File

@ -8,6 +8,7 @@ export const ROUTES = Object.freeze({
MINE: '/pages/mine/index',
DEVICE_ADD: '/pages/device/add',
DEVICE_CONFIG: '/pages/device/config',
DEVICE_DETAIL: '/pages/device/detail',
SCENE_SELECT: '/pages/scene/select'
})

View File

@ -36,6 +36,12 @@
"navigationStyle": "custom"
}
},
{
"path": "pages/device/detail",
"style": {
"navigationStyle": "custom"
}
},
{
"path": "pages/scene/select",
"style": {

343
pages/device/detail.vue Normal file
View File

@ -0,0 +1,343 @@
<!-- 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>

View File

@ -29,8 +29,39 @@
<up-icon name="arrow-right" size="14" color="#26c996" />
</view>
<view class="empty-area">
<up-empty mode="list" text="暂无数据" />
<!-- 设备列表:loading / 错误 / 空态 / 双列卡片网格 -->
<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="loadDevices" />
</view>
<view v-else-if="deviceList.length === 0" class="state-wrap">
<up-empty mode="list" text="暂无设备" />
<up-button type="success" shape="circle" text="添加设备" class="retry-btn" @click="goAdd" />
</view>
<view v-else class="device-grid">
<view
v-for="device in deviceList"
:key="device.id"
class="device-card"
@click="goDetail(device)"
>
<view class="device-icon" :class="{ 'device-icon--offline': !device.online }">
<up-icon :name="iconOf(device)" :color="device.online ? '#ffffff' : '#b5b9c0'" size="30" />
</view>
<text class="device-name">{{ device.name }}</text>
<view class="device-status">
<view class="status-dot" :class="device.online ? 'status-dot--on' : 'status-dot--off'" />
<text class="status-text" :class="{ 'status-text--off': !device.online }">
{{ device.online ? '在线' : '离线' }}
</text>
</view>
</view>
</view>
<view class="fab-wrap" @click="goAdd">
@ -40,19 +71,31 @@
</template>
<script>
import { getDeviceList } from '@/api/device'
import { ROUTES } from '@/constants/routes'
import { navigateTo } from '@/utils/navigation'
import { getCurrentScene } from '@/store/scene'
// 设备型号 -> 图标映射,未命中走兜底图标。
const DEVICE_ICON_MAP = {
ED713: 'home',
ED719: 'bell'
}
const FALLBACK_ICON = 'setting'
export default {
data() {
return {
noticeText: '点击选择场所后可进入设备配置流程',
sceneName: ''
sceneName: '',
loading: true,
loadError: false,
deviceList: []
}
},
onShow() {
this.refreshScene()
this.loadDevices()
},
methods: {
// 从全局场景状态刷新回显文案。
@ -60,6 +103,24 @@ export default {
const scene = getCurrentScene()
this.sceneName = scene ? scene.name : ''
},
// 拉取当前场所的设备列表;在线状态需新鲜,每次 onShow 重拉。
async loadDevices() {
this.loading = true
this.loadError = false
try {
const scene = getCurrentScene()
const data = await getDeviceList(scene ? scene.id : null)
this.deviceList = Array.isArray(data) ? data : []
} catch (error) {
this.loadError = true
} finally {
this.loading = false
}
},
// 设备型号对应图标。
iconOf(device) {
return DEVICE_ICON_MAP[device.model] || FALLBACK_ICON
},
// 进入场景选择页。
goSceneSelect() {
navigateTo(ROUTES.SCENE_SELECT)
@ -71,6 +132,11 @@ export default {
// 进入设备配置页。
goConfig() {
navigateTo(ROUTES.DEVICE_CONFIG)
},
// 进入设备详情页:携带基础信息,后续接详情接口后改为仅传 id。
goDetail(device) {
const name = encodeURIComponent(device.name || '')
navigateTo(`${ROUTES.DEVICE_DETAIL}?id=${device.id}&name=${name}&model=${device.model || ''}&online=${device.online ? 1 : 0}`)
}
}
}
@ -142,12 +208,96 @@ export default {
font-weight: 600;
}
.empty-area {
/* 列表三态容器 */
.state-wrap {
margin-top: 80rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.retry-btn {
margin-top: 40rpx;
min-height: 680rpx;
width: 240rpx;
}
/* 设备双列网格 */
.device-grid {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 24rpx 24rpx 0;
}
.device-card {
width: 48.5%;
margin-bottom: 24rpx;
padding: 36rpx 20rpx 28rpx;
box-sizing: border-box;
background: #ffffff;
border-radius: 24rpx;
display: flex;
flex-direction: column;
align-items: center;
&:active {
background: #f7f8fa;
}
}
.device-icon {
width: 108rpx;
height: 108rpx;
border-radius: 50%;
background: linear-gradient(135deg, #1fca97 0%, #2dbf9c 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20rpx;
&--offline {
background: #eef0f4;
}
}
.device-name {
max-width: 100%;
font-size: 28rpx;
font-weight: 600;
color: #2a2d33;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-status {
margin-top: 12rpx;
display: flex;
align-items: center;
}
.status-dot {
width: 14rpx;
height: 14rpx;
border-radius: 50%;
margin-right: 10rpx;
&--on {
background: #2ccb98;
}
&--off {
background: #c0c4cc;
}
}
.status-text {
font-size: 24rpx;
color: #12a37a;
&--off {
color: #9ea1a6;
}
}
.fab-wrap {

View File

@ -1,7 +1,7 @@
<!-- 场景选择页:加载场景列表,点选后保存并返回首页 -->
<template>
<view class="page page-base">
<up-navbar title="选择场所" :fixed="false" :safe-area-inset-top="true" :border="false" />
<up-navbar title="选择场所" :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="加载中..." />