This commit is contained in:
fuenmao
2026-09-22 16:55:21 +08:00
parent e6215912d9
commit 8c2d262066
9 changed files with 407 additions and 62 deletions
+12
View File
@@ -5,6 +5,18 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0c5045" />
<title>常青树健康管理</title>
<script>
function hideWxToolbar() {
if (typeof WeixinJSBridge !== 'undefined' && WeixinJSBridge.call) {
WeixinJSBridge.call('hideToolbar');
}
}
if (typeof WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', hideWxToolbar, false);
} else {
hideWxToolbar();
}
</script>
</head>
<body>
<div id="app"></div>
+17 -15
View File
@@ -14,11 +14,11 @@ const currentTab = computed(() => {
function navigate(target: string) {
if (target === 'home' && route.path !== '/dashboard') {
router.push('/dashboard')
router.replace('/dashboard')
} else if (target === 'assessment' && route.path !== '/assessment') {
router.push('/assessment')
router.replace('/assessment')
} else if (target === 'profile' && route.path !== '/profile') {
router.push('/profile')
router.replace('/profile')
}
}
</script>
@@ -80,15 +80,19 @@ function navigate(target: string) {
transform: translateX(-50%);
width: 100%;
max-width: 480px;
height: 60px;
background: rgba(255, 255, 255, 0.96);
min-height: 56px;
height: calc(56px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(18, 63, 55, 0.08);
display: flex;
justify-content: space-around;
align-items: center;
z-index: 99;
align-items: flex-start;
padding-top: 6px;
padding-bottom: env(safe-area-inset-bottom, 0px);
z-index: 999;
box-shadow: 0 -4px 16px rgba(18, 63, 55, 0.04);
}
@@ -98,35 +102,33 @@ function navigate(target: string) {
align-items: center;
justify-content: center;
flex: 1;
height: 100%;
height: 44px;
color: #7d8e87;
cursor: pointer;
transition: all 0.2s ease;
transition: color 0.2s ease;
user-select: none;
}
.tab-icon {
margin-bottom: 2px;
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease;
}
.tab-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
margin-top: 2px;
line-height: 1.2;
}
.tab-item.active {
color: #0c5045;
}
.tab-item.active .tab-icon {
transform: translateY(-1px);
}
.tab-item.active .tab-label {
font-weight: 700;
}
+6
View File
@@ -24,4 +24,10 @@ router.beforeEach((to) => {
if (to.meta.guest && hasToken()) return '/dashboard'
})
router.afterEach(() => {
if (typeof (window as any).WeixinJSBridge !== 'undefined' && (window as any).WeixinJSBridge.call) {
(window as any).WeixinJSBridge.call('hideToolbar')
}
})
export default router
+1
View File
@@ -171,6 +171,7 @@ export type HomeAssistantDeviceSwitchOutput = {
deviceId: string
entityId: string
state: string
isMuted?: boolean | null
message: string
}
+10 -1
View File
@@ -11,7 +11,15 @@
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #e8e6df; }
html, body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 100vh;
background: #e8e6df;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
#app {
max-width: 480px;
margin: 0 auto;
@@ -19,6 +27,7 @@ body { margin: 0; min-width: 320px; min-height: 100vh; background: #e8e6df; }
position: relative;
background: #f2f0e9;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.08);
overflow-x: hidden;
}
button, input, textarea { font: inherit; }
button { cursor: pointer; }
+1 -1
View File
@@ -245,7 +245,7 @@ async function handleSave() {
answers: { ...answers }
})
showToast({ type: 'success', message: '评估已完成,已更新智能设备方案' })
router.push('/dashboard')
router.replace('/dashboard')
} catch (err) {
showToast(err instanceof Error ? err.message : '保存失败,请重试')
} finally {
+2 -2
View File
@@ -88,14 +88,14 @@ onMounted(load)
round
color="#f1c37a"
text-color="#183b34"
@click="router.push('/assessment')"
@click="router.replace('/assessment')"
>
去健康评估
</van-button>
</section>
<!-- 未完成评估提示横幅 -->
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.push('/assessment')">
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.replace('/assessment')">
<div class="guide-badge">重要提醒</div>
<div class="guide-body">
<div class="guide-text">
+355 -40
View File
@@ -1,7 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showToast, Slider as VanSlider } from 'vant'
import {
Button as VanButton,
showFailToast,
showLoadingToast,
showSuccessToast,
Slider as VanSlider
} from 'vant'
import {
healthApi,
type HomeAssistantDiscoveredDevice,
@@ -14,6 +20,44 @@ const haStatus = ref<HomeAssistantStatus>()
const sceneRunning = ref<'sleep' | 'all-off'>()
const settingParam = ref<string | null>(null)
const switchingDeviceId = ref<string | null>(null)
const lastUpdatedAt = ref('')
const actionFeedback = ref<{ type: 'success' | 'fail'; message: string }>()
const parameterLabels: Record<string, string> = {
humidity: '目标湿度',
dehumidifier_mode: '除湿模式',
fan_level: '风速档位',
uv: 'UV 杀菌',
brightness: '灯光亮度',
color_temp: '灯光色温',
volume: '音箱音量',
speaker_sleep_mode: '音箱睡眠模式',
mattress_left_heating: '床垫左侧加热',
mattress_right_heating: '床垫右侧加热',
mattress_left_mode: '床垫左侧承托模式',
mattress_right_mode: '床垫右侧承托模式'
}
function setFeedback(type: 'success' | 'fail', message: string) {
actionFeedback.value = { type, message }
}
const operationBusy = computed(() =>
loading.value || Boolean(sceneRunning.value) || Boolean(settingParam.value) || Boolean(switchingDeviceId.value)
)
const currentOperationText = computed(() => {
if (sceneRunning.value === 'sleep') return '正在启动全屋舒睡模式…'
if (sceneRunning.value === 'all-off') return '正在关闭场景设备…'
if (switchingDeviceId.value) return '正在同步设备开关状态…'
if (settingParam.value) {
const parameterType = Object.keys(parameterLabels)
.find(key => settingParam.value?.endsWith(`_${key}`))
return `正在设置${parameterLabels[parameterType ?? ''] ?? '设备参数'}…`
}
if (loading.value) return '正在刷新设备状态…'
return ''
})
// 本地参数临时状态映射
const localParams = reactive({
@@ -38,6 +82,10 @@ const localParams = reactive({
mattressRightMode: '智能适配'
})
function clamp(val: number, min: number, max: number): number {
return Math.min(max, Math.max(min, val))
}
function numberValue(value: unknown): number | undefined {
if (typeof value === 'number' && Number.isFinite(value)) return value
if (typeof value === 'string') {
@@ -47,33 +95,42 @@ function numberValue(value: unknown): number | undefined {
return undefined
}
async function loadStatus() {
async function loadStatus(notify = false) {
loading.value = true
try {
haStatus.value = await healthApi.homeAssistantStatus()
const controls = haStatus.value?.controlParameters ?? {}
const humidifier = controls.humidity
const humidity = numberValue(humidifier?.attributes?.humidity)
if (humidity !== undefined) localParams.dehumidifierHumidity = humidity
if (humidity !== undefined) localParams.dehumidifierHumidity = clamp(humidity, 40, 70)
if (typeof humidifier?.attributes?.mode === 'string') localParams.dehumidifierMode = humidifier.attributes.mode
if (controls.fan_level?.state) localParams.dehumidifierFan = controls.fan_level.state.replace('档', '挡')
localParams.dehumidifierUv = controls.uv?.state === 'on'
const brightness = numberValue(controls.brightness?.attributes?.brightness)
if (brightness !== undefined) localParams.lightBrightness = Math.round(brightness / 255 * 100)
if (brightness !== undefined) localParams.lightBrightness = clamp(Math.round(brightness / 255 * 100), 1, 100)
const kelvin = numberValue(controls.color_temp?.attributes?.color_temp_kelvin)
if (kelvin !== undefined) localParams.lightColorTemp = kelvin
if (kelvin !== undefined) localParams.lightColorTemp = clamp(kelvin, 2200, 6500)
const volume = numberValue(controls.volume?.attributes?.volume_level)
if (volume !== undefined) localParams.speakerVolume = Math.round(volume * 100)
if (volume !== undefined) localParams.speakerVolume = clamp(Math.round(volume * 100), 0, 100)
localParams.speakerSleepMode = controls.speaker_sleep_mode?.state === 'on'
localParams.mattressLeftHeating = numberValue(controls.mattress_left_heating?.state) ?? 1
localParams.mattressRightHeating = numberValue(controls.mattress_right_heating?.state) ?? 1
localParams.mattressLeftHeating = clamp(numberValue(controls.mattress_left_heating?.state) ?? 1, 0, 4)
localParams.mattressRightHeating = clamp(numberValue(controls.mattress_right_heating?.state) ?? 1, 0, 4)
if (controls.mattress_left_mode?.state) localParams.mattressLeftMode = controls.mattress_left_mode.state
if (controls.mattress_right_mode?.state) localParams.mattressRightMode = controls.mattress_right_mode.state
lastUpdatedAt.value = new Intl.DateTimeFormat('zh-CN', {
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false
}).format(new Date())
if (notify) {
setFeedback('success', '设备状态已刷新')
showSuccessToast('设备状态已刷新')
}
} catch (err) {
showToast(err instanceof Error ? err.message : '获取设备状态失败')
const message = err instanceof Error ? err.message : '获取设备状态失败'
setFeedback('fail', message)
showFailToast(message)
} finally {
loading.value = false
}
@@ -107,31 +164,64 @@ function isDeviceTurnedOn(device?: HomeAssistantDiscoveredDevice): boolean {
if (!device || !device.primaryControlEntityId) return false
const target = device.entities.find(e => e.entityId === device.primaryControlEntityId)
if (!target) return false
if (target.domain === 'media_player') {
const muted = target.attributes?.is_volume_muted
if (typeof muted === 'boolean') return !muted
if (typeof muted === 'string') return muted.toLowerCase() !== 'true'
}
return target.state === 'on' || target.state === 'playing'
}
function updateEntityState(deviceId: string, entityId: string, state: string, isMuted?: boolean | null) {
const device = haStatus.value?.devices.find(item => item.deviceId === deviceId)
const entity = device?.entities.find(item => item.entityId === entityId)
if (!entity) return
entity.state = state
if (typeof isMuted === 'boolean') {
entity.attributes = { ...(entity.attributes ?? {}), is_volume_muted: isMuted }
}
}
// 单独开关设备
async function toggleDevice(device?: HomeAssistantDiscoveredDevice) {
if (!device || !device.primaryControlEntityId) {
showToast('该设备暂无可控主开关')
const message = '该设备暂无可控主开关'
setFeedback('fail', message)
showFailToast(message)
return
}
const nextOn = !isDeviceTurnedOn(device)
const isMediaDevice = device.category === 'Media'
switchingDeviceId.value = device.deviceId
const loadingToast = showLoadingToast({
message: isMediaDevice
? (nextOn ? '正在取消静音…' : '正在静音…')
: (nextOn ? '正在开启设备…' : '正在关闭设备…'),
forbidClick: true,
duration: 0
})
try {
const res = await healthApi.switchDevice({
deviceId: device.deviceId,
turnOn: nextOn,
entityId: device.primaryControlEntityId
})
showToast({
type: res.success ? 'success' : 'fail',
message: res.message || (nextOn ? '已开启' : '已关闭')
})
haStatus.value = await healthApi.homeAssistantStatus()
const message = res.message || (isMediaDevice
? (nextOn ? '音箱声音已开启' : '音箱已静音')
: (nextOn ? '设备已开启' : '设备已关闭'))
if (!res.success) throw new Error(message)
await loadStatus()
// 命令接口已经回读并确认真实状态;覆盖可能仍处于短暂缓存中的状态列表。
updateEntityState(res.deviceId, res.entityId, res.state, res.isMuted)
setFeedback('success', message)
showSuccessToast(message)
} catch (err) {
showToast(err instanceof Error ? err.message : '控制失败')
const message = err instanceof Error ? err.message : '控制失败'
await loadStatus()
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
switchingDeviceId.value = null
}
}
@@ -140,6 +230,12 @@ async function toggleDevice(device?: HomeAssistantDiscoveredDevice) {
async function applyParam(deviceId: string, paramType: string, val: unknown, entityId?: string) {
const key = `${deviceId}_${paramType}`
settingParam.value = key
const label = parameterLabels[paramType] || '设备参数'
const loadingToast = showLoadingToast({
message: `正在设置${label}…`,
forbidClick: true,
duration: 0
})
try {
const res = await healthApi.setParameter({
deviceId,
@@ -147,34 +243,73 @@ async function applyParam(deviceId: string, paramType: string, val: unknown, ent
parameterType: paramType,
value: val
})
showToast({
type: res.success ? 'success' : 'fail',
message: res.message || '参数设置成功'
})
const message = res.message || `${label}设置成功`
if (!res.success) throw new Error(message)
await loadStatus()
setFeedback('success', message)
showSuccessToast(message)
} catch (err) {
showToast(err instanceof Error ? err.message : '参数设置失败')
const message = err instanceof Error ? err.message : `${label}设置失败`
await loadStatus()
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
settingParam.value = null
}
}
async function setMattressMode(side: 'left' | 'right', mode: string) {
const device = mattressDevice.value
if (!device) return
if (side === 'left') {
localParams.mattressLeftMode = mode
if (mode !== '无') localParams.mattressRightMode = '无'
await applyParam(device.deviceId, 'mattress_left_mode', mode)
return
}
localParams.mattressRightMode = mode
if (mode !== '无') localParams.mattressLeftMode = '无'
await applyParam(device.deviceId, 'mattress_right_mode', mode)
}
// 一键场景
async function executeScene(scene: 'sleep' | 'all-off') {
sceneRunning.value = scene
const loadingToast = showLoadingToast({
message: scene === 'sleep' ? '正在启动舒睡场景…' : '正在关闭场景设备…',
forbidClick: true,
duration: 0
})
try {
const res = scene === 'sleep'
? await healthApi.executeSleepScene()
: await healthApi.executeAllOffScene()
showToast({ type: res.success ? 'success' : 'fail', message: res.message })
haStatus.value = await healthApi.homeAssistantStatus()
if (!res.success) throw new Error(res.message || '场景执行失败')
await loadStatus()
setFeedback('success', res.message)
showSuccessToast(res.message)
} catch (err) {
showToast(err instanceof Error ? err.message : '场景执行失败')
const message = err instanceof Error ? err.message : '场景执行失败'
await loadStatus()
setFeedback('fail', message)
showFailToast(message)
} finally {
loadingToast.close()
sceneRunning.value = undefined
}
}
function handleBack() {
if (window.history.length > 1) {
router.back()
} else {
router.replace('/dashboard')
}
}
onMounted(() => {
loadStatus()
})
@@ -184,14 +319,16 @@ onMounted(() => {
<div class="control-page">
<!-- 顶部返回条 -->
<header class="top-nav">
<button class="back-btn" @click="router.back()">
<button class="back-btn" @click="handleBack">
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6" />
</svg>
<span>返回首页</span>
</button>
<h2>智能设备详情与参数调节</h2>
<button class="refresh-btn" @click="loadStatus">刷新</button>
<button class="refresh-btn" :disabled="operationBusy" @click="loadStatus(true)">
{{ loading ? '刷新中…' : '刷新' }}
</button>
</header>
<main class="control-content">
@@ -215,30 +352,45 @@ onMounted(() => {
<div class="scene-actions-grid">
<button
class="scene-btn sleep-btn"
:disabled="sceneRunning !== undefined"
:disabled="operationBusy"
@click="executeScene('sleep')"
>
<div class="s-icon">🌙</div>
<div class="s-text">
<strong>一键启动全屋舒睡模式</strong>
<strong>{{ sceneRunning === 'sleep' ? '正在启动舒睡模式…' : '一键启动全屋舒睡模式' }}</strong>
<small>协同控制除湿、香氛、灯光、音箱与床垫</small>
</div>
</button>
<button
class="scene-btn off-btn"
:disabled="sceneRunning !== undefined"
:disabled="operationBusy"
@click="executeScene('all-off')"
>
<div class="s-icon">⚪</div>
<div class="s-text">
<strong>一键关闭设备</strong>
<strong>{{ sceneRunning === 'all-off' ? '正在关闭设备…' : '一键关闭设备' }}</strong>
<small>安全待机休眠</small>
</div>
</button>
</div>
</section>
<div
class="operation-status"
:class="actionFeedback?.type"
role="status"
aria-live="polite"
>
<span class="operation-message">
<i v-if="operationBusy" class="operation-spinner" aria-hidden="true"></i>
<i v-else-if="actionFeedback?.type === 'success'" class="operation-result" aria-hidden="true">✓</i>
<i v-else-if="actionFeedback?.type === 'fail'" class="operation-result" aria-hidden="true">!</i>
{{ currentOperationText || actionFeedback?.message || '操作完成后会自动刷新设备状态' }}
</span>
<small v-if="lastUpdatedAt">最后刷新 {{ lastUpdatedAt }}</small>
</div>
<!-- 设备 1:米家变频净化除湿机 Max -->
<section class="device-card" v-if="dehumidifierDevice">
<div class="device-head">
@@ -255,6 +407,7 @@ onMounted(() => {
:type="isDeviceTurnedOn(dehumidifierDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(dehumidifierDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === dehumidifierDevice.deviceId"
:disabled="operationBusy"
@click="toggleDevice(dehumidifierDevice)"
>
{{ isDeviceTurnedOn(dehumidifierDevice) ? '已开启' : '已关闭' }}
@@ -273,6 +426,7 @@ onMounted(() => {
:min="40"
:max="70"
:step="1"
:disabled="operationBusy"
active-color="#0c5045"
@change="applyParam(dehumidifierDevice.deviceId, 'humidity', localParams.dehumidifierHumidity)"
/>
@@ -291,6 +445,7 @@ onMounted(() => {
v-for="mode in ['睡眠', '智能']"
:key="mode"
:class="{ active: localParams.dehumidifierMode === mode }"
:disabled="operationBusy"
@click="localParams.dehumidifierMode = mode; applyParam(dehumidifierDevice.deviceId, 'dehumidifier_mode', mode)"
>{{ mode }}</button>
</div>
@@ -304,6 +459,7 @@ onMounted(() => {
v-for="fan in ['1挡', '2挡', '3挡']"
:key="fan"
:class="{ active: localParams.dehumidifierFan === fan }"
:disabled="operationBusy"
@click="localParams.dehumidifierFan = fan; applyParam(dehumidifierDevice.deviceId, 'fan_level', fan)"
>
{{ fan }}
@@ -320,6 +476,7 @@ onMounted(() => {
<button
class="mini-toggle"
:class="{ active: localParams.dehumidifierUv }"
:disabled="operationBusy"
@click="localParams.dehumidifierUv = !localParams.dehumidifierUv; applyParam(dehumidifierDevice.deviceId, 'uv', localParams.dehumidifierUv)"
>
{{ localParams.dehumidifierUv ? '已开启' : '已关闭' }}
@@ -344,6 +501,7 @@ onMounted(() => {
:type="isDeviceTurnedOn(lightDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(lightDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === lightDevice.deviceId"
:disabled="operationBusy"
@click="toggleDevice(lightDevice)"
>
{{ isDeviceTurnedOn(lightDevice) ? '已点亮' : '已熄灭' }}
@@ -361,9 +519,15 @@ onMounted(() => {
v-model="localParams.lightBrightness"
:min="1"
:max="100"
:disabled="operationBusy"
active-color="#d6a657"
@change="applyParam(lightDevice.deviceId, 'brightness', localParams.lightBrightness)"
/>
<div class="slider-scale">
<span>1% 柔和微光</span>
<span>50% 舒缓</span>
<span>100% 满亮</span>
</div>
</div>
<!-- 色温滑块 -->
@@ -375,14 +539,15 @@ onMounted(() => {
<VanSlider
v-model="localParams.lightColorTemp"
:min="2200"
:max="5000"
:max="6500"
:step="50"
:disabled="operationBusy"
active-color="#c87f2b"
@change="applyParam(lightDevice.deviceId, 'color_temp', localParams.lightColorTemp)"
/>
<div class="slider-scale">
<span>2200K 超暖橙光 (利于褪黑素)</span>
<span>5000K 明亮白光</span>
<span>6500K 清爽冷白光</span>
</div>
</div>
</div>
@@ -404,9 +569,10 @@ onMounted(() => {
:type="isDeviceTurnedOn(speakerDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(speakerDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === speakerDevice.deviceId"
:disabled="operationBusy"
@click="toggleDevice(speakerDevice)"
>
{{ isDeviceTurnedOn(speakerDevice) ? '正在播放' : '已暂停' }}
{{ isDeviceTurnedOn(speakerDevice) ? '声音开启' : '已静音' }}
</VanButton>
</div>
@@ -421,9 +587,15 @@ onMounted(() => {
v-model="localParams.speakerVolume"
:min="1"
:max="100"
:disabled="operationBusy"
active-color="#0c5045"
@change="applyParam(speakerDevice.deviceId, 'volume', localParams.speakerVolume)"
/>
<div class="slider-scale">
<span>1% 轻柔助眠</span>
<span>50% 适中聆听</span>
<span>100% 满格音量</span>
</div>
</div>
<div class="param-row inline-row">
@@ -434,6 +606,7 @@ onMounted(() => {
<button
class="mini-toggle"
:class="{ active: localParams.speakerSleepMode }"
:disabled="operationBusy"
@click="localParams.speakerSleepMode = !localParams.speakerSleepMode; applyParam(speakerDevice.deviceId, 'speaker_sleep_mode', localParams.speakerSleepMode)"
>
{{ localParams.speakerSleepMode ? '已开启' : '已关闭' }}
@@ -458,6 +631,7 @@ onMounted(() => {
:type="isDeviceTurnedOn(fragranceDevice) ? 'primary' : 'default'"
:color="isDeviceTurnedOn(fragranceDevice) ? '#0c5045' : ''"
:loading="switchingDeviceId === fragranceDevice.deviceId"
:disabled="operationBusy"
@click="toggleDevice(fragranceDevice)"
>
{{ isDeviceTurnedOn(fragranceDevice) ? '释放中' : '已关闭' }}
@@ -496,6 +670,7 @@ onMounted(() => {
:min="0"
:max="4"
:step="1"
:disabled="operationBusy"
active-color="#d66927"
@change="applyParam(mattressDevice.deviceId, 'mattress_left_heating', localParams.mattressLeftHeating)"
/>
@@ -517,9 +692,21 @@ onMounted(() => {
:min="0"
:max="4"
:step="1"
:disabled="operationBusy"
active-color="#d66927"
@change="applyParam(mattressDevice.deviceId, 'mattress_right_heating', localParams.mattressRightHeating)"
/>
<div class="slider-scale">
<span>0 关闭</span>
<span>2 舒适</span>
<span>4 强加热</span>
</div>
</div>
<div class="param-row">
<p class="mattress-mode-note">
左右承托共用气泵,同一时间仅能运行一侧;启动一侧后,另一侧会自动停止。
</p>
</div>
<div class="param-row">
@@ -529,7 +716,8 @@ onMounted(() => {
v-for="mode in ['无', '瑜伽助眠', '智能适配', '智能腰托']"
:key="`left-${mode}`"
:class="{ active: localParams.mattressLeftMode === mode }"
@click="localParams.mattressLeftMode = mode; applyParam(mattressDevice.deviceId, 'mattress_left_mode', mode)"
:disabled="operationBusy"
@click="setMattressMode('left', mode)"
>{{ mode }}</button>
</div>
</div>
@@ -541,7 +729,8 @@ onMounted(() => {
v-for="mode in ['无', '瑜伽助眠', '智能适配', '智能腰托']"
:key="`right-${mode}`"
:class="{ active: localParams.mattressRightMode === mode }"
@click="localParams.mattressRightMode = mode; applyParam(mattressDevice.deviceId, 'mattress_right_mode', mode)"
:disabled="operationBusy"
@click="setMattressMode('right', mode)"
>{{ mode }}</button>
</div>
</div>
@@ -558,7 +747,9 @@ onMounted(() => {
{{ sensorDevice.online ? '实时采集正常' : '设备离线' }}
</span>
</div>
<button class="text-refresh" @click="loadStatus">手动刷新指标</button>
<button class="text-refresh" :disabled="operationBusy" @click="loadStatus(true)">
{{ loading ? '刷新中…' : '手动刷新指标' }}
</button>
</div>
<div class="sensor-grid">
@@ -613,6 +804,7 @@ onMounted(() => {
background: #f2f0e9;
min-height: 100vh;
padding-bottom: 40px;
overflow-x: hidden;
}
.top-nav {
@@ -656,6 +848,12 @@ onMounted(() => {
cursor: pointer;
}
.refresh-btn:disabled,
.text-refresh:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-content {
padding: 14px;
}
@@ -716,6 +914,11 @@ onMounted(() => {
transition: all 0.2s;
}
.scene-btn:disabled {
opacity: 0.62;
cursor: wait;
}
.sleep-btn {
background: rgba(214, 166, 87, 0.2);
color: #fff;
@@ -747,6 +950,72 @@ onMounted(() => {
margin-top: 2px;
}
.operation-status {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
min-height: 38px;
padding: 9px 12px;
margin: -4px 2px 14px;
border: 1px solid rgba(12, 80, 69, 0.12);
border-radius: 12px;
background: rgba(255, 255, 255, 0.72);
color: #536a62;
font-size: 11px;
}
.operation-status.success {
border-color: rgba(12, 80, 69, 0.22);
background: #f0f8f5;
color: #0c5045;
}
.operation-status.fail {
border-color: rgba(176, 72, 56, 0.24);
background: #fff4f1;
color: #9f3f32;
}
.operation-message {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 7px;
font-weight: 600;
}
.operation-status small {
flex: none;
color: #86968f;
font-size: 10px;
}
.operation-result {
display: inline-grid;
place-items: center;
width: 17px;
height: 17px;
border-radius: 50%;
background: currentColor;
color: #fff;
font-size: 11px;
font-style: normal;
}
.operation-spinner {
width: 15px;
height: 15px;
border: 2px solid rgba(12, 80, 69, 0.18);
border-top-color: #0c5045;
border-radius: 50%;
animation: operation-spin 0.75s linear infinite;
}
@keyframes operation-spin {
to { transform: rotate(360deg); }
}
/* 设备卡片 */
.device-card {
background: #ffffff;
@@ -803,13 +1072,13 @@ onMounted(() => {
.params-body {
display: flex;
flex-direction: column;
gap: 14px;
gap: 18px;
}
.param-row {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
}
.param-row.inline-row {
@@ -822,18 +1091,21 @@ onMounted(() => {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.p-title {
font-size: 12px;
font-size: 13px;
color: #556c64;
font-weight: 600;
line-height: 1.2;
}
.p-val {
font-size: 14px;
color: #0c5045;
font-weight: 700;
line-height: 1.2;
}
.p-tip {
@@ -841,12 +1113,49 @@ onMounted(() => {
color: #8fa099;
}
.mattress-mode-note {
margin: 0;
padding: 9px 11px;
border-radius: 9px;
background: #fff7e8;
color: #8a6428;
font-size: 11px;
line-height: 1.55;
}
/* 滑块样式:左右各预留 10px 间距,使滑块在 0% 和 100% 端点时,外凸的圆形按钮完整居中且不被裁剪 */
.param-row :deep(.van-slider) {
margin: 10px 10px 14px 10px;
height: 4px;
border-radius: 2px;
background-color: #e6eae8;
box-sizing: border-box;
}
.param-row :deep(.van-slider__bar) {
height: 4px;
border-radius: 2px;
}
.param-row :deep(.van-slider__button-wrapper) {
z-index: 2;
}
.param-row :deep(.van-slider__button) {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #ffffff;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.16);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.slider-scale {
display: flex;
justify-content: space-between;
font-size: 10px;
color: #8d9e97;
margin-top: 4px;
padding: 0 2px;
}
.btn-group {
@@ -874,6 +1183,12 @@ onMounted(() => {
font-weight: 700;
}
.btn-group button:disabled,
.mini-toggle:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.mini-toggle {
padding: 6px 14px;
border-radius: 14px;
+3 -3
View File
@@ -122,7 +122,7 @@ onMounted(() => {
<section class="profile-card">
<div class="card-head">
<h3>身体基线档案</h3>
<button class="edit-btn" @click="router.push('/assessment')">编辑指标</button>
<button class="edit-btn" @click="router.replace('/assessment')">编辑指标</button>
</div>
<div class="metrics-grid">
@@ -180,7 +180,7 @@ onMounted(() => {
size="small"
round
color="#0c5045"
@click="router.push('/assessment')"
@click="router.replace('/assessment')"
>
立即进行健康评估
</VanButton>
@@ -213,7 +213,7 @@ onMounted(() => {
<!-- 快捷功能入口与操作菜单 -->
<section class="action-card-menu">
<div class="menu-item" @click="router.push('/assessment')">
<div class="menu-item" @click="router.replace('/assessment')">
<div class="menu-left">
<div class="menu-icon icon-assess">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">