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:
parent
054f05af70
commit
c7cebc2fc9
|
|
@ -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 })
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -8,6 +8,7 @@ export const ROUTES = Object.freeze({
|
||||||
MINE: '/pages/mine/index',
|
MINE: '/pages/mine/index',
|
||||||
DEVICE_ADD: '/pages/device/add',
|
DEVICE_ADD: '/pages/device/add',
|
||||||
DEVICE_CONFIG: '/pages/device/config',
|
DEVICE_CONFIG: '/pages/device/config',
|
||||||
|
DEVICE_DETAIL: '/pages/device/detail',
|
||||||
SCENE_SELECT: '/pages/scene/select'
|
SCENE_SELECT: '/pages/scene/select'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,12 @@
|
||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/device/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/scene/select",
|
"path": "pages/scene/select",
|
||||||
"style": {
|
"style": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -29,8 +29,39 @@
|
||||||
<up-icon name="arrow-right" size="14" color="#26c996" />
|
<up-icon name="arrow-right" size="14" color="#26c996" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="empty-area">
|
<!-- 设备列表:loading / 错误 / 空态 / 双列卡片网格 -->
|
||||||
<up-empty mode="list" text="暂无数据" />
|
<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>
|
||||||
|
|
||||||
<view class="fab-wrap" @click="goAdd">
|
<view class="fab-wrap" @click="goAdd">
|
||||||
|
|
@ -40,19 +71,31 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { getDeviceList } from '@/api/device'
|
||||||
import { ROUTES } from '@/constants/routes'
|
import { ROUTES } from '@/constants/routes'
|
||||||
import { navigateTo } from '@/utils/navigation'
|
import { navigateTo } from '@/utils/navigation'
|
||||||
import { getCurrentScene } from '@/store/scene'
|
import { getCurrentScene } from '@/store/scene'
|
||||||
|
|
||||||
|
// 设备型号 -> 图标映射,未命中走兜底图标。
|
||||||
|
const DEVICE_ICON_MAP = {
|
||||||
|
ED713: 'home',
|
||||||
|
ED719: 'bell'
|
||||||
|
}
|
||||||
|
const FALLBACK_ICON = 'setting'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
noticeText: '点击选择场所后可进入设备配置流程',
|
noticeText: '点击选择场所后可进入设备配置流程',
|
||||||
sceneName: ''
|
sceneName: '',
|
||||||
|
loading: true,
|
||||||
|
loadError: false,
|
||||||
|
deviceList: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
this.refreshScene()
|
this.refreshScene()
|
||||||
|
this.loadDevices()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
// 从全局场景状态刷新回显文案。
|
// 从全局场景状态刷新回显文案。
|
||||||
|
|
@ -60,6 +103,24 @@ export default {
|
||||||
const scene = getCurrentScene()
|
const scene = getCurrentScene()
|
||||||
this.sceneName = scene ? scene.name : ''
|
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() {
|
goSceneSelect() {
|
||||||
navigateTo(ROUTES.SCENE_SELECT)
|
navigateTo(ROUTES.SCENE_SELECT)
|
||||||
|
|
@ -71,6 +132,11 @@ export default {
|
||||||
// 进入设备配置页。
|
// 进入设备配置页。
|
||||||
goConfig() {
|
goConfig() {
|
||||||
navigateTo(ROUTES.DEVICE_CONFIG)
|
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;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-area {
|
/* 列表三态容器 */
|
||||||
|
.state-wrap {
|
||||||
|
margin-top: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
margin-top: 40rpx;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.fab-wrap {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<!-- 场景选择页:加载场景列表,点选后保存并返回首页 -->
|
<!-- 场景选择页:加载场景列表,点选后保存并返回首页 -->
|
||||||
<template>
|
<template>
|
||||||
<view class="page page-base">
|
<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">
|
<view v-if="loading" class="state-wrap">
|
||||||
<up-loading-icon mode="circle" size="48" text="加载中..." />
|
<up-loading-icon mode="circle" size="48" text="加载中..." />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue