11
This commit is contained in:
+1
-2
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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">></span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="medical-note">健康建议仅用于日常管理演示,不构成医疗诊断或治疗意见。</p>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user