This commit is contained in:
fuenmao
2026-09-22 15:40:58 +08:00
parent dec3dfdfe3
commit e6215912d9
6 changed files with 100 additions and 693 deletions
+1 -2
View File
@@ -1,6 +1,5 @@
import { createRouter, createWebHistory } from 'vue-router'
import AuthView from './views/AuthView.vue'
import OnboardingView from './views/OnboardingView.vue'
import DashboardView from './views/DashboardView.vue'
import AssessmentView from './views/AssessmentView.vue'
import ProfileView from './views/ProfileView.vue'
@@ -12,7 +11,7 @@ const router = createRouter({
routes: [
{ path: '/', redirect: '/dashboard' },
{ path: '/auth', component: AuthView, meta: { guest: true } },
{ path: '/onboarding', component: OnboardingView },
{ path: '/onboarding', redirect: '/assessment' },
{ path: '/dashboard', component: DashboardView },
{ path: '/assessment', component: AssessmentView },
{ path: '/profile', component: ProfileView },
-5
View File
@@ -230,11 +230,6 @@ export const healthApi = {
login: (payload: { phone: string; password: string }) =>
http.post<never, AuthOutput>('/api/health/auth/login', payload),
dashboard: () => http.get<never, Dashboard>('/api/health/dashboard'),
saveProfile: (payload: HealthProfile) =>
http.put<never, HealthProfile>('/api/health/profile', payload),
saveModules: (moduleCodes: string[]) =>
http.put<never, string[]>('/api/health/modules', { moduleCodes }),
generatePlan: () => http.post<never, HealthPlan>('/api/health/plans/generate'),
getAssessment: () => http.get<never, HealthAssessmentOutput>('/api/health/assessment'),
saveAssessment: (payload: SaveHealthAssessmentInput) =>
http.post<never, HealthAssessmentOutput>('/api/health/assessment', payload),
+63 -21
View File
@@ -33,13 +33,12 @@ button { cursor: pointer; }
.auth-page { min-height: 100vh; background: var(--cream); }
.brand-panel {
min-height: 42vh;
padding: 54px 28px 42px;
padding: 46px 24px 46px;
color: #fff;
background:
radial-gradient(circle at 82% 12%, rgba(236, 202, 137, .28), transparent 28%),
linear-gradient(145deg, #0b5548, #123d37 74%);
border-radius: 0 0 42px 42px;
border-radius: 0 0 36px 36px;
}
.brand-badge {
display: inline-flex;
@@ -70,11 +69,11 @@ button { cursor: pointer; }
border: 1px solid rgba(255,255,255,.42); border-radius: 50%; font-family: serif; font-size: 24px;
}
.brand-panel h1 { max-width: 560px; margin: 0; font-family: "Songti SC", serif; font-size: clamp(32px, 8vw, 54px); line-height: 1.2; font-weight: 500; }
.brand-copy { max-width: 480px; margin: 18px 0 24px; color: rgba(255,255,255,.72); line-height: 1.7; }
.feature-row, .feature-pill-row { display: flex; gap: 8px; flex-wrap: wrap; }
.brand-copy { max-width: 480px; margin: 14px 0 16px; color: rgba(255,255,255,.78); line-height: 1.6; }
.feature-row, .feature-pill-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.feature-row span, .feature-pill-row span { padding: 6px 12px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.16); border-radius: 20px; font-size: 12px; }
.auth-card { position: relative; width: min(520px, calc(100% - 32px)); margin: -20px auto 0; padding: 24px 20px; background: #fff; border-radius: 24px; box-shadow: 0 18px 50px rgba(24,59,52,.12); }
.auth-card { position: relative; width: calc(100% - 32px); max-width: 440px; margin: -16px auto 32px; padding: 24px 20px; background: #fff; border-radius: 24px; box-shadow: 0 14px 40px rgba(24,59,52,.12); }
.auth-tab-switch {
display: flex;
background: #f1efea;
@@ -771,8 +770,8 @@ button { cursor: pointer; }
.loading-card { color: #73817d; text-align: center; }
@media (max-width: 520px) {
.brand-panel { min-height: 390px; }
.auth-card { margin-top: -54px; }
.brand-panel { padding: 42px 20px 42px; }
.auth-card { margin-top: -16px; }
.plan-heading { display: block; }
.plan-heading span { display: block; margin-top: 5px; text-align: left; }
.device-grid { grid-template-columns: 1fr; }
@@ -782,7 +781,20 @@ button { cursor: pointer; }
}
.dashboard-page {
padding-bottom: 84px !important;
padding: 16px 14px 84px !important;
}
.dashboard-header {
margin: 0 auto 12px !important;
padding: 4px 4px 0 !important;
}
.dashboard-header h1 {
margin: 2px 0 3px !important;
font-size: 26px !important;
}
.dashboard-header > div > p:last-child {
margin: 0 !important;
font-size: 13px !important;
line-height: 1.4 !important;
}
/* 评估未填提醒卡片 */
@@ -850,25 +862,28 @@ button { cursor: pointer; }
/* 个性化舒睡启动参数卡片 */
.personalized-startup-card {
background: #ffffff;
border: 1px solid rgba(12, 80, 69, 0.12);
border: 1px solid rgba(12, 80, 69, 0.1);
border-radius: 20px;
padding: 16px;
padding: 18px 16px;
margin-bottom: 14px;
box-shadow: 0 4px 16px rgba(18, 63, 55, 0.04);
}
.customized-tag {
font-size: 11px;
color: #0c5045;
background: #eef6f3;
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
.personalized-startup-card .section-title {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.personalized-startup-card .section-title > span {
font-size: 17px;
font-weight: 700;
color: #143d36;
letter-spacing: 0.01em;
}
.startup-card-desc {
margin: 4px 0 12px;
margin: 0 0 14px;
font-size: 12px;
color: #6b7d76;
line-height: 1.5;
color: #72847e;
line-height: 1.55;
}
.startup-params-tags {
display: grid;
@@ -892,6 +907,33 @@ button { cursor: pointer; }
font-size: 12px;
color: #173d36;
}
.startup-scene-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 4px 0 12px;
}
.startup-scene-actions button {
min-height: 42px;
border-radius: 12px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.startup-scene-actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.startup-action-primary {
border: 1px solid #0c5045;
background: #0c5045;
color: #ffffff;
}
.startup-action-secondary {
border: 1px solid rgba(12, 80, 69, 0.2);
background: #f7f6f2;
color: #335b53;
}
.detail-entry-bar {
display: flex;
align-items: center;
+1 -1
View File
@@ -19,7 +19,7 @@ async function submit() {
: await healthApi.register(form)
saveTokens(output.accessToken, output.refreshToken)
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
await router.replace(mode.value === 'login' ? '/dashboard' : '/onboarding')
await router.replace(mode.value === 'login' ? '/dashboard' : '/assessment')
} catch (error) {
showToast(error instanceof Error ? error.message : '操作失败')
} finally {
+35 -565
View File
@@ -7,33 +7,14 @@ import { clearTokens } from '../services/auth'
import {
healthApi,
type Dashboard,
type HomeAssistantDiscoveredDevice,
type HomeAssistantStatus
} from '../services/health'
const router = useRouter()
const loading = ref(true)
const generating = ref(false)
const sceneRunning = ref<'sleep' | 'all-off'>()
const dashboard = ref<Dashboard>()
const homeAssistant = ref<HomeAssistantStatus>()
const moduleNames: Record<string, string> = {
sleep: '睡眠',
exercise: '运动',
diet: '饮食'
}
const categoryNames: Record<string, string> = {
EnvironmentControl: '场景控制',
EnvironmentSensor: '环境健康',
Sleep: '睡眠健康',
BodyComposition: '身体健康',
Fragrance: '香氛场景',
Lighting: '灯光场景',
Media: '音频场景',
Other: '智能外设'
}
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 11) return '早上好'
@@ -41,176 +22,8 @@ const greeting = computed(() => {
return '晚上好'
})
const totalDeviceCount = computed(() => homeAssistant.value?.summary?.totalDevices ?? homeAssistant.value?.devices?.length ?? 0)
const onlineDeviceCount = computed(() => homeAssistant.value?.summary?.onlineDevices ?? homeAssistant.value?.devices?.filter(d => d.online).length ?? 0)
const totalEntityCount = computed(() => homeAssistant.value?.summary?.totalEntities ?? 0)
const availableEntityCount = computed(() => homeAssistant.value?.summary?.availableEntities ?? 0)
const sceneTotal = computed(() => homeAssistant.value?.sceneDevices?.total ?? 0)
const sceneAvailable = computed(() => homeAssistant.value?.sceneDevices?.available ?? 0)
const sceneDevices = computed(() => {
if (!homeAssistant.value?.devices) return []
const configuredCategories = new Set(
(homeAssistant.value.scenePresets || []).map(item => item.category)
)
return homeAssistant.value.devices.filter(d => configuredCategories.has(d.category))
})
const envDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'EnvironmentSensor')
})
const sleepDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'Sleep')
})
const bodyDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'BodyComposition')
})
function getDeviceStatusType(device: HomeAssistantDiscoveredDevice): 'green' | 'yellow' | 'gray' {
if (!device.online) return 'gray'
if (device.category === 'Media') {
const hasMedia = device.entities.some(e => e.domain === 'media_player' && e.available)
return hasMedia ? 'green' : 'yellow'
}
const hasAvailable = device.entities.some(e => e.available)
return hasAvailable ? 'green' : 'yellow'
}
function getDeviceStatusText(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'gray') return '设备离线'
if (type === 'yellow') {
if (device.category === 'Media') return '在线 · 播放功能暂不可用'
return '在线 · 部分功能受限'
}
if (device.category === 'EnvironmentControl') {
const humidity = device.metrics?.humidity
return humidity ? `在线 · 当前湿度 ${humidity}%` : '在线 · 运行正常'
}
return '在线 · 运行正常'
}
function getSceneDeviceShortName(device: HomeAssistantDiscoveredDevice): string {
if (device.category === 'EnvironmentControl') return '除湿机 Max'
if (device.category === 'Fragrance') return '智能香氛机'
if (device.category === 'Lighting') return 'Lemesh 色温灯'
if (device.category === 'Media') return 'Sound Pro 音箱'
return device.name
}
function getSceneDeviceStatus(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'green') {
if (device.category === 'EnvironmentControl' && device.metrics?.humidity) {
return `${device.metrics.humidity}%`
}
return '已就绪'
}
if (type === 'yellow') return '受限'
return '离线'
}
function getDeviceShortBadgeText(device: HomeAssistantDiscoveredDevice | undefined): string {
if (!device) return '未接入'
const type = getDeviceStatusType(device)
if (type === 'green') return '设备在线'
if (type === 'yellow') return '部分受限'
return '设备离线'
}
function getDeviceListBadgeText(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'green') return '在线'
if (type === 'yellow') return '受限'
return '离线'
}
function getDeviceSubModel(device: HomeAssistantDiscoveredDevice | undefined, fallback: string): string {
if (!device) return fallback
if (device.category === 'EnvironmentSensor') return '罗威亚检测仪'
if (device.category === 'Sleep') return '8H 睡眠床垫'
if (device.category === 'BodyComposition') return '米家体脂秤'
return device.name
}
function getTemperatureTag(temp: number | undefined | null): string {
if (temp == null) return '待采集'
if (temp < 18) return '偏凉'
if (temp <= 26) return '适宜'
return '偏暖'
}
function getHumidityTag(hum: number | undefined | null): string {
if (hum == null) return '待采集'
if (hum < 40) return '干燥'
if (hum <= 60) return '舒适'
if (hum <= 70) return '稍潮'
return '潮湿'
}
const switchingDeviceId = ref<string | null>(null)
const syncingSensor = ref(false)
const showScenePresets = ref(true)
function isControllable(device: HomeAssistantDiscoveredDevice): boolean {
return !!device.primaryControlEntityId
}
function getPrimaryControlEntity(device: HomeAssistantDiscoveredDevice) {
if (!device.primaryControlEntityId) return undefined
return device.entities.find(e => e.entityId === device.primaryControlEntityId)
}
function isPrimaryControlAvailable(device: HomeAssistantDiscoveredDevice): boolean {
return !!getPrimaryControlEntity(device)?.available
}
function isDeviceTurnedOn(device: HomeAssistantDiscoveredDevice): boolean {
const target = getPrimaryControlEntity(device)
if (!target) return false
return target.state === 'on' || target.state === 'playing'
}
async function toggleDeviceSwitch(device: HomeAssistantDiscoveredDevice) {
if (!isPrimaryControlAvailable(device)) {
showToast('设备主控功能当前不可用')
return
}
const currentOn = isDeviceTurnedOn(device)
const nextOn = !currentOn
switchingDeviceId.value = device.deviceId
try {
const res = await healthApi.switchDevice({
deviceId: device.deviceId,
turnOn: nextOn,
entityId: device.primaryControlEntityId || undefined
})
showToast({
type: res.success ? 'success' : 'fail',
message: res.message || (nextOn ? '已开启设备' : '已关闭设备')
})
homeAssistant.value = await healthApi.homeAssistantStatus()
} catch (error) {
showToast(error instanceof Error ? error.message : '控制失败')
} finally {
switchingDeviceId.value = null
}
}
async function syncSensorData() {
syncingSensor.value = true
try {
homeAssistant.value = await healthApi.homeAssistantStatus()
showToast({ type: 'success', message: '状态已刷新' })
} catch (error) {
showToast(error instanceof Error ? error.message : '刷新失败')
} finally {
syncingSensor.value = false
}
}
async function load() {
loading.value = true
try {
@@ -242,19 +55,6 @@ async function executeScene(scene: 'sleep' | 'all-off') {
}
}
async function generate() {
generating.value = true
try {
await healthApi.generatePlan()
await load()
showToast({ type: 'success', message: '方案已更新' })
} catch (error) {
showToast(error instanceof Error ? error.message : '生成失败')
} finally {
generating.value = false
}
}
function logout() {
clearTokens()
router.replace('/auth')
@@ -279,6 +79,21 @@ onMounted(load)
<section v-if="loading" class="section-card loading-card">正在读取今日健康方案…</section>
<template v-else>
<section class="hero-card">
<p class="eyebrow">今日健康建议</p>
<h2>{{ dashboard?.currentPlan?.title || '尚未完成健康评估' }}</h2>
<p>{{ dashboard?.currentPlan?.summary || '完成健康评估后,系统会自动生成个人健康方案与家居启动参数。' }}</p>
<van-button
v-if="!dashboard?.currentPlan"
round
color="#f1c37a"
text-color="#183b34"
@click="router.push('/assessment')"
>
去健康评估
</van-button>
</section>
<!-- 未完成评估提示横幅 -->
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.push('/assessment')">
<div class="guide-badge">重要提醒</div>
@@ -295,9 +110,8 @@ onMounted(load)
<section v-else class="section-card personalized-startup-card">
<div class="section-title">
<span>专属舒睡启动参数</span>
<span class="customized-tag">根据身体评估专属定制</span>
</div>
<p class="startup-card-desc">依据您的冷热偏好、入睡状态与骨骼肌肉承托需求,智能中枢已计算出全套设备预设:</p>
<p class="startup-card-desc">依据您的体征评估与冷热、承托偏好,智能中枢已生成专属预设:</p>
<div class="startup-params-tags">
<div class="startup-param-badge">
@@ -322,6 +136,25 @@ onMounted(load)
</div>
</div>
<div class="startup-scene-actions">
<button
class="startup-action-primary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启' }}
</button>
<button
class="startup-action-secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}
</button>
</div>
<div class="detail-entry-bar" @click="router.push('/device-control')">
<div class="entry-left">
<span class="icon">⚙️</span>
@@ -334,375 +167,12 @@ onMounted(load)
</div>
</section>
<section class="hero-card">
<p class="eyebrow">今日健康建议</p>
<h2>{{ dashboard?.currentPlan?.title || '尚未生成健康方案' }}</h2>
<p>{{ dashboard?.currentPlan?.summary || '完成健康板块选择后,即可生成第一版个人参数。' }}</p>
<div class="tag-row">
<span v-for="code in dashboard?.selectedModules" :key="code">{{ moduleNames[code] || code }}</span>
</div>
<van-button
v-if="!dashboard?.currentPlan"
round
color="#f1c37a"
text-color="#183b34"
:loading="generating"
@click="generate"
>
立即生成
</van-button>
</section>
<section class="metric-strip">
<div><small>睡眠目标</small><strong>7–8<em>h</em></strong></div>
<div><small>活动目标</small><strong>30<em>min</em></strong></div>
<div><small>方案版本</small><strong>V{{ dashboard?.currentPlan?.version || 0 }}</strong></div>
</section>
<section class="section-card">
<div class="section-title"><span>我的健康板块</span><button type="button" @click="router.push('/onboarding')">调整</button></div>
<article v-for="item in dashboard?.currentPlan?.items" :key="item.moduleCode" class="plan-item">
<div class="plan-index">{{ item.moduleCode === 'sleep' ? '☾' : item.moduleCode === 'exercise' ? '↗' : '◒' }}</div>
<div>
<div class="plan-heading"><strong>{{ item.title }}</strong><span>{{ item.target }}</span></div>
<p>{{ item.advice }}</p>
</div>
</article>
</section>
<section class="section-card smart-card">
<!-- 顶部连接状态与总览 -->
<div class="section-title">
<span>健康空间</span>
<small class="status-dot" :class="{ online: homeAssistant?.connected }">
{{ homeAssistant?.connected
? `已连接 · ${onlineDeviceCount}/${totalDeviceCount} 在线`
: (homeAssistant?.message || '正在连接 Home Assistant') }}
</small>
</div>
<!-- 汇总胶囊条 -->
<div v-if="homeAssistant?.connected" class="ha-summary-bar">
<div class="summary-item">
<span>在线设备</span>
<strong>{{ onlineDeviceCount }}<em>/{{ totalDeviceCount }}</em></strong>
</div>
<div class="summary-item">
<span>可用实体</span>
<strong>{{ availableEntityCount }}<em>/{{ totalEntityCount }}</em></strong>
</div>
<div class="summary-item">
<span>场景就绪</span>
<strong>{{ sceneAvailable }}<em>/{{ sceneTotal }}</em></strong>
</div>
</div>
<!-- 1. 睡眠场景控制 -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌙</span>
<strong>睡眠场景控制</strong>
<span class="subcard-model">({{ sceneAvailable }}/{{ sceneTotal }} 项可用)</span>
</div>
<button
class="preset-toggle-btn"
type="button"
@click="showScenePresets = !showScenePresets"
>
{{ showScenePresets ? '收起参数 ▲' : '控制参数详情 ▼' }}
</button>
</div>
<div class="scene-row">
<div class="scene-info">
<strong>一键舒睡</strong>
<p>仅执行已配置且当前可用的恒湿、香氛与暖光动作</p>
</div>
<div class="scene-actions">
<button
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '执行中…' : '一键舒睡' }}
</button>
<button
class="secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '执行中…' : '关闭场景设备' }}
</button>
</div>
</div>
<!-- 场景设备实时可用状态 -->
<div v-if="sceneDevices.length" class="scene-device-list">
<div
v-for="d in sceneDevices"
:key="d.deviceId"
class="scene-device-chip"
:class="getDeviceStatusType(d)"
>
<span class="dot"></span>
<span class="name">{{ getSceneDeviceShortName(d) }}</span>
<span class="device-mini-status" :class="getDeviceStatusType(d)">
{{ getSceneDeviceStatus(d) }}
</span>
</div>
</div>
<!-- 后台真实配置的场景动作 -->
<div v-if="showScenePresets && homeAssistant?.scenePresets?.length" class="scene-presets-panel">
<div class="preset-panel-header">
<span>场景执行参数</span>
<small>仅展示后台实际下发的动作</small>
</div>
<div class="preset-items-grid">
<div
v-for="item in homeAssistant.scenePresets"
:key="item.deviceName"
class="preset-card-item"
:class="{ unavailable: !item.available }"
>
<div class="preset-card-top">
<span class="preset-icon">
{{ item.category === 'EnvironmentControl' ? '💧' : item.category === 'Fragrance' ? '🌸' : item.category === 'Lighting' ? '💡' : item.category === 'Media' ? '🔊' : item.category === 'Sleep' ? '🛏️' : item.category === 'EnvironmentSensor' ? '🌿' : '⚖️' }}
</span>
<strong class="preset-name">{{ item.deviceName }}</strong>
<span class="preset-action">{{ item.action }}</span>
</div>
<p class="preset-detail">{{ item.detail }}</p>
<p class="preset-availability" :class="{ unavailable: !item.available }">
{{ item.availabilityMessage }}
</p>
</div>
</div>
</div>
</div>
<!-- 2. 环境健康板块 (空气检测仪) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌿</span>
<strong>环境健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(envDevice, '罗威亚检测仪') }}</span>
</div>
<button
class="sensor-mini-sync-btn"
:disabled="syncingSensor"
type="button"
@click="syncSensorData"
>
{{ syncingSensor ? '刷新中…' : '立即刷新' }}
</button>
</div>
<div class="env-container">
<!-- 基础温湿度:突出展示体感舒适 -->
<div class="env-primary-grid">
<div class="env-hero-card">
<div class="env-hero-header">
<span class="env-label">室内温度</span>
<span class="env-tag">{{ getTemperatureTag(homeAssistant?.environment?.temperature) }}</span>
</div>
<div class="env-hero-value">
<strong>{{ homeAssistant?.environment?.temperature ?? '—' }}</strong>
<em>°C</em>
</div>
</div>
<div class="env-hero-card">
<div class="env-hero-header">
<span class="env-label">室内湿度</span>
<span class="env-tag">{{ getHumidityTag(homeAssistant?.environment?.humidity) }}</span>
</div>
<div class="env-hero-value">
<strong>{{ homeAssistant?.environment?.humidity ?? '—' }}</strong>
<em>%</em>
</div>
</div>
</div>
<!-- 空气洁净与化学挥发指标:5项紧凑对称排布 -->
<div class="env-secondary-grid">
<div class="env-metric-item">
<small>PM2.5</small>
<strong>{{ homeAssistant?.environment?.pm25 ?? '—' }}</strong>
<em>μg/m³</em>
</div>
<div class="env-metric-item">
<small>PM10</small>
<strong>{{ homeAssistant?.environment?.pm10 ?? '—' }}</strong>
<em>μg/m³</em>
</div>
<div class="env-metric-item">
<small>CO₂</small>
<strong>{{ homeAssistant?.environment?.co2 ?? '—' }}</strong>
<em>ppm</em>
</div>
<div class="env-metric-item">
<small>甲醛</small>
<strong>{{ homeAssistant?.environment?.hcho ?? '—' }}</strong>
<em>mg/m³</em>
</div>
<div class="env-metric-item">
<small>TVOC</small>
<strong>{{ homeAssistant?.environment?.tvoc ?? '—' }}</strong>
<em>mg/m³</em>
</div>
</div>
</div>
</div>
<!-- 3. 睡眠健康板块 (8H 智能床垫) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🛏️</span>
<strong>睡眠健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(sleepDevice, '8H 睡眠床垫') }}</span>
</div>
<span
v-if="sleepDevice"
class="device-mini-status"
:class="getDeviceStatusType(sleepDevice)"
>
{{ getDeviceShortBadgeText(sleepDevice) }}
</span>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>在床监测</small>
<strong>{{ sleepDevice?.metrics?.sleepStatus || (sleepDevice?.online ? '实时监测' : '待同步') }}</strong>
</div>
<div class="feature-item">
<small>左侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.leftFirmness || '暂无数据' }}</strong>
</div>
<div class="feature-item">
<small>右侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.rightFirmness || '暂无数据' }}</strong>
</div>
<div class="feature-item">
<small>智能调温</small>
<strong>{{ sleepDevice?.online ? '状态待读取' : '待接入' }}</strong>
</div>
</div>
</div>
<!-- 4. 身体健康板块 (体脂秤) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">⚖️</span>
<strong>身体健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(bodyDevice, '米家体脂秤') }}</span>
</div>
<button
class="sensor-mini-sync-btn"
:disabled="syncingSensor"
type="button"
@click="syncSensorData"
>
{{ syncingSensor ? '刷新中…' : '刷新状态' }}
</button>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>体脂秤状态</small>
<strong>{{ bodyDevice?.online ? '设备在线' : '待同步' }}</strong>
</div>
<div class="feature-item">
<small>最新体重</small>
<strong>{{ bodyDevice?.metrics?.weight ?? '—' }}<em v-if="bodyDevice?.metrics?.weight != null">kg</em></strong>
</div>
<div class="feature-item">
<small>体脂率</small>
<strong>{{ bodyDevice?.metrics?.bodyFatRate ?? '—' }}<em v-if="bodyDevice?.metrics?.bodyFatRate != null">%</em></strong>
</div>
<div class="feature-item">
<small>测量算法</small>
<strong>BIA 深度测量</strong>
</div>
</div>
</div>
<!-- 5. 全部智能设备 (7 台设备列表) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">📱</span>
<strong>全部智能设备</strong>
<span class="subcard-model">({{ totalDeviceCount }} 台)</span>
</div>
<small style="color: #82908c; font-size: 11px;">已确认主控能力的设备可操作</small>
</div>
<div v-if="homeAssistant?.devices?.length" class="all-device-list">
<article
v-for="item in homeAssistant.devices"
:key="item.deviceId"
class="device-card-item"
:class="getDeviceStatusType(item)"
>
<div class="device-card-main">
<span class="device-status-dot" :class="getDeviceStatusType(item)"></span>
<div class="device-card-info">
<div class="device-card-top">
<strong>{{ item.name }}</strong>
<span class="category-tag">{{ categoryNames[item.category] || item.category }}</span>
</div>
<p class="device-card-meta">
<span>{{ item.areaName || '未分区' }}</span>
<span>· {{ item.model }}</span>
</p>
</div>
</div>
<div class="device-card-extra">
<button
v-if="isControllable(item)"
class="device-switch-pill"
:class="{ on: isDeviceTurnedOn(item), disabled: !isPrimaryControlAvailable(item) }"
:disabled="!isPrimaryControlAvailable(item) || switchingDeviceId === item.deviceId"
type="button"
@click="toggleDeviceSwitch(item)"
>
<span class="switch-knob"></span>
<span class="switch-text">{{ switchingDeviceId === item.deviceId ? '…' : (isDeviceTurnedOn(item) ? '已开启' : '已关闭') }}</span>
</button>
<button
v-else
class="sensor-sync-pill"
:disabled="syncingSensor"
type="button"
@click="syncSensorData"
>
{{ syncingSensor ? '同步中' : '刷新' }}
</button>
<small class="entity-count">{{ item.entities.filter(e => e.available).length }}/{{ item.entities.length }} 实体</small>
</div>
</article>
</div>
<div v-else style="padding: 16px; text-align: center; color: #82908c; font-size: 12px;">
暂无已发现的设备
</div>
<div class="all-control-banner" @click="router.push('/device-control')">
<div class="ac-text">
<span class="ac-icon">🎛️</span>
<div>
<strong>查看全部 {{ totalDeviceCount }} 台设备状态与独立参数控制</strong>
<small>支持湿度、色温、左右加热档位与承托模式单独调节</small>
</div>
</div>
<span class="ac-arrow">&gt;</span>
</div>
</div>
</section>
<p class="medical-note">健康建议仅用于日常管理演示,不构成医疗诊断或治疗意见。</p>
</template>
-99
View File
@@ -1,99 +0,0 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, Field as VanField, showToast } from 'vant'
import { healthApi } from '../services/health'
const router = useRouter()
const loading = ref(false)
const selected = ref<string[]>(['sleep', 'exercise', 'diet'])
const modules = [
{ code: 'sleep', icon: '☾', title: '睡眠质量', copy: '环境与节律深睡' },
{ code: 'exercise', icon: '↗', title: '运动情况', copy: '日常活动与活力' },
{ code: 'diet', icon: '◒', title: '饮食情况', copy: '三餐热量与偏好' }
]
const profile = reactive({
gender: '',
heightCm: undefined as number | undefined,
weightKg: undefined as number | undefined,
averageSleepHours: undefined as number | undefined,
dailyExerciseMinutes: undefined as number | undefined,
dietPreference: '',
healthGoal: ''
})
function toggle(code: string) {
selected.value = selected.value.includes(code)
? selected.value.filter((item) => item !== code)
: [...selected.value, code]
}
async function submit() {
if (!selected.value.length) {
showToast('请至少选择一个健康板块')
return
}
loading.value = true
try {
await Promise.all([
healthApi.saveModules(selected.value),
healthApi.saveProfile(profile)
])
await healthApi.generatePlan()
showToast({ type: 'success', message: '专属方案已生成' })
await router.replace('/dashboard')
} catch (error) {
showToast(error instanceof Error ? error.message : '保存失败')
} finally {
loading.value = false
}
}
</script>
<template>
<main class="page-shell">
<header class="page-header">
<p class="eyebrow">PERSONAL SETUP · 01</p>
<h1>今天,想先关注什么?</h1>
<p>选择健康板块,系统将结合您的基础信息生成第一版个人参数。</p>
</header>
<section class="section-card">
<div class="section-title"><span>健康板块</span><small>可多选,已选 {{ selected.length }}/3</small></div>
<div class="module-grid">
<button
v-for="item in modules"
:key="item.code"
class="module-card"
:class="{ selected: selected.includes(item.code) }"
type="button"
@click="toggle(item.code)"
>
<span class="module-icon">{{ item.icon }}</span>
<strong>{{ item.title }}</strong>
<small>{{ item.copy }}</small>
<i>{{ selected.includes(item.code) ? '✓ 已选' : '+ 选择' }}</i>
</button>
</div>
</section>
<section class="section-card form-card">
<div class="section-title"><span>身体基线数据</span><small>用于生成参数</small></div>
<div class="two-column">
<van-field v-model="profile.gender" label="性别" label-width="40px" placeholder="男 / 女" />
<van-field v-model.number="profile.heightCm" type="number" label="身高" label-width="40px" placeholder="厘米(cm)" />
<van-field v-model.number="profile.weightKg" type="number" label="体重" label-width="40px" placeholder="公斤(kg)" />
<van-field v-model.number="profile.averageSleepHours" type="number" label="睡眠" label-width="40px" placeholder="小时/天" />
</div>
<van-field v-model.number="profile.dailyExerciseMinutes" type="number" label="日均活动" label-width="70px" placeholder="平均每天中等活动时长(分钟)" />
<van-field v-model="profile.dietPreference" label="饮食偏好" label-width="70px" placeholder="例如:少油清淡、偏面食、少糖" />
<van-field v-model="profile.healthGoal" label="健康目标" label-width="70px" type="textarea" rows="2" placeholder="例如:希望改善晚上入睡困难、提升精力" />
</section>
<div class="sticky-action">
<van-button block round color="#0c5045" :loading="loading" @click="submit">
生成我的健康方案
</van-button>
</div>
</main>
</template>