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',
|
||||
DEVICE_ADD: '/pages/device/add',
|
||||
DEVICE_CONFIG: '/pages/device/config',
|
||||
DEVICE_DETAIL: '/pages/device/detail',
|
||||
SCENE_SELECT: '/pages/scene/select'
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,12 @@
|
|||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/device/detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/scene/select",
|
||||
"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" />
|
||||
</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 {
|
||||
|
|
|
|||
|
|
@ -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="加载中..." />
|
||||
|
|
|
|||
Loading…
Reference in New Issue