111
This commit is contained in:
@@ -8,6 +8,7 @@ const router = useRouter()
|
||||
const currentTab = computed(() => {
|
||||
const path = route.path
|
||||
if (path.startsWith('/assessment')) return 'assessment'
|
||||
if (path.startsWith('/tcm-assessment')) return 'tcm'
|
||||
if (path.startsWith('/profile')) return 'profile'
|
||||
return 'home'
|
||||
})
|
||||
@@ -17,6 +18,8 @@ function navigate(target: string) {
|
||||
router.replace('/dashboard')
|
||||
} else if (target === 'assessment' && route.path !== '/assessment') {
|
||||
router.replace('/assessment')
|
||||
} else if (target === 'tcm' && route.path !== '/tcm-assessment') {
|
||||
router.replace('/tcm-assessment')
|
||||
} else if (target === 'profile' && route.path !== '/profile') {
|
||||
router.replace('/profile')
|
||||
}
|
||||
@@ -39,6 +42,22 @@ function navigate(target: string) {
|
||||
<span class="tab-label">首页</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'tcm' }"
|
||||
@click="navigate('tcm')"
|
||||
>
|
||||
<div class="tab-icon">
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 3c-4 4-4 8 0 9s4 5 0 9" />
|
||||
<circle cx="9" cy="8" r="1" fill="currentColor" stroke="none" />
|
||||
<circle cx="15" cy="16" r="1" fill="currentColor" stroke="none" />
|
||||
</svg>
|
||||
</div>
|
||||
<span class="tab-label">中医四诊</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="tab-item"
|
||||
:class="{ active: currentTab === 'assessment' }"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AuthView from './views/AuthView.vue'
|
||||
import DashboardView from './views/DashboardView.vue'
|
||||
import AssessmentView from './views/AssessmentView.vue'
|
||||
import TcmAssessmentView from './views/TcmAssessmentView.vue'
|
||||
import ProfileView from './views/ProfileView.vue'
|
||||
import DeviceControlView from './views/DeviceControlView.vue'
|
||||
import { hasToken } from './services/auth'
|
||||
@@ -14,6 +15,7 @@ const router = createRouter({
|
||||
{ path: '/onboarding', redirect: '/assessment' },
|
||||
{ path: '/dashboard', component: DashboardView },
|
||||
{ path: '/assessment', component: AssessmentView },
|
||||
{ path: '/tcm-assessment', component: TcmAssessmentView },
|
||||
{ path: '/profile', component: ProfileView },
|
||||
{ path: '/device-control', component: DeviceControlView }
|
||||
]
|
||||
|
||||
+40
-2
@@ -83,7 +83,6 @@ export type HealthAssessmentOutput = {
|
||||
hasCompletedAssessment: boolean
|
||||
basicInfo: HealthProfile
|
||||
answers: HealthAssessmentAnswers
|
||||
sceneConfig: PersonalizedSceneConfig
|
||||
bmi?: number | null
|
||||
bmiStatus: string
|
||||
}
|
||||
@@ -97,6 +96,42 @@ export type Dashboard = {
|
||||
bmi?: number | null
|
||||
bmiStatus?: string
|
||||
sceneConfig?: PersonalizedSceneConfig
|
||||
tcmResult?: TcmAssessmentResult
|
||||
mattressComfort?: MattressComfort
|
||||
}
|
||||
|
||||
export type MattressComfort = {
|
||||
bmi?: number | null
|
||||
firmness?: string | null
|
||||
heatingLevel: number
|
||||
isWinter: boolean
|
||||
outdoorTemperatureCelsius?: number | null
|
||||
feelsLikeCelsius?: number | null
|
||||
weatherAvailable: boolean
|
||||
weatherObservedAt?: string | null
|
||||
weatherReason: string
|
||||
firmnessReason: string
|
||||
}
|
||||
|
||||
export type TcmAssessmentInput = {
|
||||
answers: Record<string, string>
|
||||
notes: Record<string, string>
|
||||
}
|
||||
|
||||
export type TcmAssessmentResult = {
|
||||
hasCompleted: boolean
|
||||
constitutionCode: string
|
||||
constitutionName: string
|
||||
secondaryName?: string | null
|
||||
summary: string
|
||||
aromaRecommendation: string
|
||||
musicRecommendation: string
|
||||
roomTemperatureRecommendation: string
|
||||
scores: Record<string, number>
|
||||
answers: Record<string, string>
|
||||
notes: Record<string, string>
|
||||
sceneConfig: PersonalizedSceneConfig
|
||||
assessedAt?: string | null
|
||||
}
|
||||
|
||||
export type HealthDeviceCategory =
|
||||
@@ -251,6 +286,9 @@ export const healthApi = {
|
||||
getAssessment: () => http.get<never, HealthAssessmentOutput>('/api/health/assessment'),
|
||||
saveAssessment: (payload: SaveHealthAssessmentInput) =>
|
||||
http.post<never, HealthAssessmentOutput>('/api/health/assessment', payload),
|
||||
getTcmAssessment: () => http.get<never, TcmAssessmentResult>('/api/health/tcm-assessment'),
|
||||
saveTcmAssessment: (payload: TcmAssessmentInput) =>
|
||||
http.post<never, TcmAssessmentResult>('/api/health/tcm-assessment', payload),
|
||||
homeAssistantStatus: () =>
|
||||
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
|
||||
lightTransitionStatus: () =>
|
||||
@@ -266,7 +304,7 @@ export const healthApi = {
|
||||
playSpeakerDemo: () =>
|
||||
http.post<never, HomeAssistantSpeakerDemo>('/api/health/home-assistant/speaker/play-demo'),
|
||||
executeSleepScene: () =>
|
||||
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep'),
|
||||
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep', undefined, { timeout: 90000 }),
|
||||
executeAllOffScene: () =>
|
||||
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off'),
|
||||
switchDevice: (payload: { deviceId: string; turnOn: boolean; entityId?: string }) =>
|
||||
|
||||
+19
-251
@@ -6,8 +6,7 @@ import AppTabBar from '../components/AppTabBar.vue'
|
||||
import {
|
||||
healthApi,
|
||||
type HealthAssessmentAnswers,
|
||||
type HealthProfile,
|
||||
type PersonalizedSceneConfig
|
||||
type HealthProfile
|
||||
} from '../services/health'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -55,129 +54,6 @@ const bmiStatus = computed(() => {
|
||||
return { label: '肥胖预警', color: '#f56c6c' }
|
||||
})
|
||||
|
||||
// 实时前端预计算家具参数
|
||||
const liveSceneConfig = computed<PersonalizedSceneConfig>(() => {
|
||||
let humidity = 52
|
||||
let fan = '1挡'
|
||||
let mattressTemp = 35.5
|
||||
let mattressHeating = 2
|
||||
if (answers.temperaturePreference === 'heat_fear') {
|
||||
humidity = 48
|
||||
fan = '2挡'
|
||||
mattressTemp = 32.0
|
||||
mattressHeating = 1
|
||||
} else if (answers.temperaturePreference === 'cold_fear') {
|
||||
humidity = 58
|
||||
fan = '1挡'
|
||||
mattressTemp = 37.5
|
||||
mattressHeating = 3
|
||||
}
|
||||
|
||||
let lightBrightness = 18
|
||||
let lightColorTemp = 2700
|
||||
let fragranceLevel = 1
|
||||
let speakerSleep = true
|
||||
let speakerMode = '舒缓轻音乐'
|
||||
let speakerVol = 15
|
||||
|
||||
if (answers.fallingAsleepSpeed === 'hard') {
|
||||
lightBrightness = 8
|
||||
lightColorTemp = 2200
|
||||
fragranceLevel = 2
|
||||
speakerSleep = true
|
||||
speakerMode = '助眠白噪音'
|
||||
speakerVol = 12
|
||||
} else if (answers.fallingAsleepSpeed === 'fast') {
|
||||
lightBrightness = 25
|
||||
lightColorTemp = 3000
|
||||
fragranceLevel = 1
|
||||
speakerSleep = false
|
||||
speakerMode = '关闭助眠音频'
|
||||
speakerVol = 10
|
||||
}
|
||||
|
||||
if (answers.sleepDuration === '<6') {
|
||||
lightBrightness -= 3
|
||||
speakerVol += 3
|
||||
fragranceLevel += 1
|
||||
} else if (answers.sleepDuration === '6-7') {
|
||||
lightBrightness -= 1
|
||||
speakerVol += 1
|
||||
} else if (answers.sleepDuration === '>8') {
|
||||
lightBrightness += 2
|
||||
speakerVol -= 2
|
||||
}
|
||||
|
||||
let leftFirm = 5
|
||||
let rightFirm = 5
|
||||
let mattressMode = '智能适配'
|
||||
if (answers.muscleFatigueArea === 'waist_back' || answers.muscleFatigueArea === 'back_neck' || answers.mattressPreference === 'firm') {
|
||||
leftFirm = 7
|
||||
rightFirm = 7
|
||||
mattressMode = '智能腰托'
|
||||
} else if (answers.mattressPreference === 'soft') {
|
||||
leftFirm = 4
|
||||
rightFirm = 4
|
||||
mattressMode = '瑜伽助眠'
|
||||
} else if (answers.muscleFatigueArea === 'legs') {
|
||||
leftFirm = 6
|
||||
rightFirm = 6
|
||||
}
|
||||
|
||||
if (answers.nightAwakening === 'frequent') {
|
||||
fan = '1挡'
|
||||
lightBrightness -= 2
|
||||
}
|
||||
if (answers.exerciseFrequency === 'none') mattressHeating += 1
|
||||
if (answers.exerciseFrequency === 'daily') mattressHeating -= 1
|
||||
if (answers.activeTimeOfDay === 'morning') lightColorTemp += 150
|
||||
if (answers.activeTimeOfDay === 'night') {
|
||||
lightColorTemp -= 250
|
||||
lightBrightness -= 2
|
||||
}
|
||||
if (answers.activeTimeOfDay === 'irregular') {
|
||||
speakerSleep = true
|
||||
speakerVol += 2
|
||||
}
|
||||
|
||||
let uv = answers.lateNightEating === 'often' || answers.lateNightEating === 'sometimes' || answers.flavorPreference === 'heavy'
|
||||
if (answers.mealRegularity === 'irregular') {
|
||||
uv = true
|
||||
fragranceLevel += 1
|
||||
} else if (answers.mealRegularity === 'skip_breakfast') {
|
||||
fragranceLevel += 1
|
||||
}
|
||||
if (answers.dailyWaterIntake === 'scarce') humidity += 3
|
||||
if (answers.dailyWaterIntake === 'abundant') humidity -= 2
|
||||
|
||||
const bmi = computedBmi.value
|
||||
if (bmi && bmi < 18.5) mattressHeating += 1
|
||||
if (bmi && bmi >= 28) humidity -= 2
|
||||
if (age.value && age.value >= 60) {
|
||||
lightBrightness += 3
|
||||
speakerVol = Math.max(8, speakerVol - 2)
|
||||
}
|
||||
|
||||
return {
|
||||
targetHumidity: Math.min(70, Math.max(40, humidity)),
|
||||
dehumidifierMode: answers.nightAwakening === 'none' ? '智能' : '睡眠',
|
||||
fanLevel: fan,
|
||||
enableUv: uv,
|
||||
lightBrightnessPercent: Math.min(100, Math.max(1, lightBrightness)),
|
||||
lightColorTemperatureKelvin: Math.min(5000, Math.max(2200, lightColorTemp)),
|
||||
mattressTargetTemp: mattressTemp,
|
||||
mattressLeftFirmness: leftFirm,
|
||||
mattressRightFirmness: rightFirm,
|
||||
mattressHeatingLevel: Math.min(4, Math.max(0, mattressHeating)),
|
||||
mattressMode,
|
||||
enableFragrance: true,
|
||||
fragranceLevel: Math.min(3, Math.max(1, fragranceLevel)),
|
||||
enableSpeakerSleepMode: speakerSleep,
|
||||
speakerAudioMode: speakerMode,
|
||||
speakerVolumePercent: Math.min(50, Math.max(1, speakerVol))
|
||||
}
|
||||
})
|
||||
|
||||
async function loadExisting() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -244,7 +120,7 @@ async function handleSave() {
|
||||
basicInfo: { ...basicInfo },
|
||||
answers: { ...answers }
|
||||
})
|
||||
showToast({ type: 'success', message: '评估已完成,已更新智能设备方案' })
|
||||
showToast({ type: 'success', message: '健康评估已保存' })
|
||||
router.replace('/dashboard')
|
||||
} catch (err) {
|
||||
showToast(err instanceof Error ? err.message : '保存失败,请重试')
|
||||
@@ -265,7 +141,7 @@ onMounted(() => {
|
||||
<div class="brand-tag">常青树 · 全面身体健康评估</div>
|
||||
<h1>个性化健康评估</h1>
|
||||
<p class="header-desc">
|
||||
三大板块均为必选。您的不同身体状态将直接决定卧室除湿机、色温灯、香氛机、助眠音箱及智能床垫的专属启动参数。
|
||||
三大板块均为必选。这里记录您的身体基本情况和生活习惯,用于生成日常健康建议;智能家居启动参数由中医四诊单独生成。
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -276,7 +152,7 @@ onMounted(() => {
|
||||
<span class="step-num">00</span>
|
||||
<h3>基础体征指标</h3>
|
||||
</div>
|
||||
<p class="section-subtitle">用于计算 BMI 身体质量指数及睡眠承托基准</p>
|
||||
<p class="section-subtitle">用于记录身体基线并计算 BMI 身体质量指数</p>
|
||||
|
||||
<div class="form-grid">
|
||||
<div class="form-field">
|
||||
@@ -361,7 +237,7 @@ onMounted(() => {
|
||||
<span class="step-num">01</span>
|
||||
<h3>睡眠质量板块 (必选)</h3>
|
||||
</div>
|
||||
<p class="section-subtitle">深度关联:香氛开关、灯光色温、音箱睡眠模式、床垫加热与承托模式</p>
|
||||
<p class="section-subtitle">了解作息、入睡习惯和睡眠感受</p>
|
||||
|
||||
<!-- Q1: 时长 -->
|
||||
<div class="question-block">
|
||||
@@ -415,7 +291,7 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">入睡困难 / 易失眠 (耗时>30分钟)</span>
|
||||
<span class="opt-tag">定制超暖低亮+音箱睡眠模式</span>
|
||||
<span class="opt-tag">入睡较慢</span>
|
||||
</div>
|
||||
<p class="opt-desc">睡前思绪繁杂、对光线声音敏感,常在床上翻来覆去无法入眠</p>
|
||||
</div>
|
||||
@@ -450,7 +326,7 @@ onMounted(() => {
|
||||
<div class="question-block">
|
||||
<div class="question-label">
|
||||
<span class="q-badge">Q3</span>
|
||||
<strong>睡眠体温耐受与体质倾向</strong>
|
||||
<strong>睡眠时的冷热感受</strong>
|
||||
</div>
|
||||
<div class="options-grid cols-1">
|
||||
<div
|
||||
@@ -460,7 +336,7 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">燥热偏热 (夜间易出汗、盗汗、怕闷热)</span>
|
||||
<span class="opt-tag tag-cool">恒湿48% · 清凉32°C</span>
|
||||
<span class="opt-tag tag-cool">偏热</span>
|
||||
</div>
|
||||
<p class="opt-desc">被窝温度稍高就烦躁踢被,需要更干爽清凉的卧房环境</p>
|
||||
</div>
|
||||
@@ -472,7 +348,7 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">畏寒怕冷 (手脚冰凉、极怕冷风)</span>
|
||||
<span class="opt-tag tag-warm">恒湿58% · 床垫预热37.5°C</span>
|
||||
<span class="opt-tag tag-warm">偏冷</span>
|
||||
</div>
|
||||
<p class="opt-desc">入睡半天手脚捂不热,稍有微风吹拂即觉寒凉</p>
|
||||
</div>
|
||||
@@ -484,7 +360,7 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">冷热适中 (体温感应平衡)</span>
|
||||
<span class="opt-tag">恒湿52% · 舒适35.5°C</span>
|
||||
<span class="opt-tag">冷热适中</span>
|
||||
</div>
|
||||
<p class="opt-desc">体感舒适度正常,对温湿度耐受度均衡</p>
|
||||
</div>
|
||||
@@ -560,7 +436,7 @@ onMounted(() => {
|
||||
<span class="step-num">02</span>
|
||||
<h3>运动情况板块 (必选)</h3>
|
||||
</div>
|
||||
<p class="section-subtitle">深度关联:肌肉放松硬度分区、夜间床垫自适应调校</p>
|
||||
<p class="section-subtitle">了解日常活动频率和身体疲劳感受</p>
|
||||
|
||||
<!-- Q1: 频率 -->
|
||||
<div class="question-block">
|
||||
@@ -683,7 +559,7 @@ onMounted(() => {
|
||||
<span class="step-num">03</span>
|
||||
<h3>饮食情况板块 (必选)</h3>
|
||||
</div>
|
||||
<p class="section-subtitle">深度关联:除湿机紫外线 UV 杀菌深度净化、晚间空气流通</p>
|
||||
<p class="section-subtitle">了解三餐、夜宵和饮水习惯</p>
|
||||
|
||||
<!-- Q1: 规律性 -->
|
||||
<div class="question-block">
|
||||
@@ -761,9 +637,9 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">经常吃夜宵 (睡前2小时内进食)</span>
|
||||
<span class="opt-tag tag-uv">开启 UV 紫外深度消杀</span>
|
||||
<span class="opt-tag">经常晚间进食</span>
|
||||
</div>
|
||||
<p class="opt-desc">胃肠负担大、卧房空气异味残留,需强化除湿机空气净化循环</p>
|
||||
<p class="opt-desc">睡前进食可能影响休息,可逐步调整进食时间</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -773,9 +649,9 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">偶尔吃夜宵 (每周 1–2 次加班聚餐)</span>
|
||||
<span class="opt-tag tag-uv">按需 UV 杀菌</span>
|
||||
<span class="opt-tag">偶尔晚间进食</span>
|
||||
</div>
|
||||
<p class="opt-desc">偶尔晚间进食,需要适度空气清新与恒湿防护</p>
|
||||
<p class="opt-desc">偶尔因加班或聚餐改变晚餐时间</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -785,9 +661,9 @@ onMounted(() => {
|
||||
>
|
||||
<div class="opt-head">
|
||||
<span class="opt-title">坚决不吃夜宵 (睡前3–4小时禁食)</span>
|
||||
<span class="opt-tag">常规静音</span>
|
||||
<span class="opt-tag">较少夜宵</span>
|
||||
</div>
|
||||
<p class="opt-desc">良好消化作息,卧房保持超静音柔风环境</p>
|
||||
<p class="opt-desc">通常能给晚餐和入睡留出间隔</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -824,44 +700,6 @@ onMounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 实时个性化设备参数生成结果卡片 -->
|
||||
<section class="card-section result-preview-card">
|
||||
<div class="preview-header">
|
||||
<div class="preview-badge">算法推演</div>
|
||||
<h4>专属全套智能家具启动参数预览</h4>
|
||||
</div>
|
||||
<p class="preview-intro">根据上方填写的体征与三个板块选项,智能中枢已为您定制如下启动预设:</p>
|
||||
|
||||
<div class="preset-items-grid">
|
||||
<div class="preset-pill">
|
||||
<span class="label">除湿机恒湿</span>
|
||||
<strong>{{ liveSceneConfig.targetHumidity }}% 湿度 · {{ liveSceneConfig.fanLevel }}</strong>
|
||||
</div>
|
||||
<div class="preset-pill">
|
||||
<span class="label">除湿机UV消杀</span>
|
||||
<strong :class="{ 'highlight-uv': liveSceneConfig.enableUv }">
|
||||
{{ liveSceneConfig.enableUv ? '深度紫外线开启' : '关闭保静音' }}
|
||||
</strong>
|
||||
</div>
|
||||
<div class="preset-pill">
|
||||
<span class="label">氛围助眠灯光</span>
|
||||
<strong>{{ liveSceneConfig.lightBrightnessPercent }}% 亮度 · {{ liveSceneConfig.lightColorTemperatureKelvin }}K 暖光</strong>
|
||||
</div>
|
||||
<div class="preset-pill">
|
||||
<span class="label">Xiaomi Sound Pro</span>
|
||||
<strong>{{ liveSceneConfig.enableSpeakerSleepMode ? '开启睡眠模式' : '关闭睡眠模式' }} · {{ liveSceneConfig.speakerVolumePercent }}% 音量</strong>
|
||||
</div>
|
||||
<div class="preset-pill">
|
||||
<span class="label">8H 智能睡眠床垫</span>
|
||||
<strong>左右加热 {{ liveSceneConfig.mattressHeatingLevel }} 档 · {{ liveSceneConfig.mattressMode }}</strong>
|
||||
</div>
|
||||
<div class="preset-pill">
|
||||
<span class="label">智能香氛机</span>
|
||||
<strong>开启香氛与柔光 · 浓度由设备端预设</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<div class="submit-action-bar">
|
||||
<VanButton
|
||||
@@ -873,7 +711,7 @@ onMounted(() => {
|
||||
:loading="submitting"
|
||||
@click="handleSave"
|
||||
>
|
||||
保存评估并应用智能家具参数
|
||||
保存健康评估
|
||||
</VanButton>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1197,11 +1035,6 @@ onMounted(() => {
|
||||
background: rgba(192, 105, 39, 0.1);
|
||||
}
|
||||
|
||||
.tag-uv {
|
||||
color: #7b42b5;
|
||||
background: rgba(123, 66, 181, 0.1);
|
||||
}
|
||||
|
||||
.opt-desc {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
@@ -1209,71 +1042,6 @@ onMounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* 算法推演预览卡片 */
|
||||
.result-preview-card {
|
||||
background: linear-gradient(145deg, #123e37, #09332c);
|
||||
color: #ffffff;
|
||||
border: 1px solid rgba(214, 166, 87, 0.3);
|
||||
}
|
||||
|
||||
.preview-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.preview-badge {
|
||||
background: #d6a657;
|
||||
color: #123e37;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.preview-header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
color: #f7ede1;
|
||||
}
|
||||
|
||||
.preview-intro {
|
||||
margin: 0 0 14px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.preset-items-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-pill {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.preset-pill .label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.preset-pill strong {
|
||||
color: #ffefcf;
|
||||
}
|
||||
|
||||
.highlight-uv {
|
||||
color: #dfb2ff !important;
|
||||
}
|
||||
|
||||
.submit-action-bar {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
+23
-10
@@ -41,6 +41,11 @@ async function load() {
|
||||
}
|
||||
|
||||
async function executeScene(scene: 'sleep' | 'all-off') {
|
||||
if (scene === 'sleep' && !dashboard.value?.tcmResult?.hasCompleted) {
|
||||
showToast('请先完成中医四诊,生成体质倾向')
|
||||
router.push('/tcm-assessment')
|
||||
return
|
||||
}
|
||||
sceneRunning.value = scene
|
||||
try {
|
||||
const result = scene === 'sleep'
|
||||
@@ -82,7 +87,7 @@ onMounted(load)
|
||||
<section class="hero-card">
|
||||
<p class="eyebrow">今日健康建议</p>
|
||||
<h2>{{ dashboard?.currentPlan?.title || '尚未完成健康评估' }}</h2>
|
||||
<p>{{ dashboard?.currentPlan?.summary || '完成健康评估后,系统会自动生成个人健康方案与家居启动参数。' }}</p>
|
||||
<p>{{ dashboard?.currentPlan?.summary || '完成健康评估后,系统会整理您的基本情况并生成日常健康建议。' }}</p>
|
||||
<van-button
|
||||
v-if="!dashboard?.currentPlan"
|
||||
round
|
||||
@@ -99,21 +104,26 @@ onMounted(load)
|
||||
<div class="guide-badge">重要提醒</div>
|
||||
<div class="guide-body">
|
||||
<div class="guide-text">
|
||||
<h3>先填写个人基本信息与身体评估</h3>
|
||||
<p>尚未进行身体及睡眠质量、运动情况、饮食情况三大维度评估。完成评估后将为您专属定制全套智能家居启动参数!</p>
|
||||
<h3>完善个人基本信息与健康评估</h3>
|
||||
<p>尚未记录身体及睡眠质量、运动情况、饮食情况。健康评估仅用于了解基本情况;设备启动参数由中医四诊体质结果生成。</p>
|
||||
</div>
|
||||
<button class="guide-btn" type="button">去评估 ></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 个性化智能家居启动参数概览 (已完成评估) -->
|
||||
<section v-else class="section-card personalized-startup-card">
|
||||
<!-- 智能家居启动参数仅来源于中医四诊体质结果 -->
|
||||
<section class="section-card personalized-startup-card">
|
||||
<div class="section-title">
|
||||
<span>专属舒睡启动参数</span>
|
||||
<span>体质专属舒睡启动参数</span>
|
||||
</div>
|
||||
<p class="startup-card-desc">依据您的体征评估与冷热、承托偏好,智能中枢已生成专属预设:</p>
|
||||
<p class="startup-card-desc">
|
||||
{{ dashboard?.tcmResult?.hasCompleted
|
||||
? `依据${dashboard.tcmResult.constitutionName}倾向生成基础预设;床垫加热看重庆天气,软硬度看 BMI。`
|
||||
: '先完成中医四诊并生成体质倾向,才可一键开启专属场景。' }}
|
||||
<a v-if="!dashboard?.tcmResult?.hasCompleted" href="/tcm-assessment" @click.prevent="router.push('/tcm-assessment')">去完成四诊 ›</a>
|
||||
</p>
|
||||
|
||||
<div class="startup-params-tags">
|
||||
<div v-if="dashboard?.tcmResult?.hasCompleted" class="startup-params-tags">
|
||||
<div class="startup-param-badge">
|
||||
<span class="lbl">恒湿目标</span>
|
||||
<strong>{{ dashboard?.sceneConfig?.targetHumidity || 55 }}% · {{ dashboard?.sceneConfig?.fanLevel || '1挡' }}</strong>
|
||||
@@ -128,19 +138,22 @@ onMounted(load)
|
||||
</div>
|
||||
<div class="startup-param-badge">
|
||||
<span class="lbl">8H 床垫</span>
|
||||
<strong>左右加热 {{ dashboard?.sceneConfig?.mattressHeatingLevel ?? 1 }} 档 · {{ dashboard?.sceneConfig?.mattressMode || '智能适配' }}</strong>
|
||||
<strong>左右加热 {{ dashboard?.mattressComfort?.heatingLevel ?? 0 }} 档 · 软硬度{{ dashboard?.mattressComfort?.firmness || '待填写 BMI' }}</strong>
|
||||
</div>
|
||||
<div class="startup-param-badge" v-if="dashboard?.sceneConfig?.enableUv">
|
||||
<span class="lbl">除湿消杀</span>
|
||||
<strong style="color: #7b42b5;">UV 深度消杀已开启</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="dashboard?.tcmResult?.hasCompleted" class="startup-card-desc">
|
||||
{{ dashboard?.mattressComfort?.weatherReason }};{{ dashboard?.mattressComfort?.firmnessReason }}
|
||||
</p>
|
||||
|
||||
<div class="startup-scene-actions">
|
||||
<button
|
||||
class="startup-action-primary"
|
||||
type="button"
|
||||
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
|
||||
:disabled="!dashboard?.tcmResult?.hasCompleted || !homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
|
||||
@click="executeScene('sleep')"
|
||||
>
|
||||
{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启' }}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
Button as VanButton,
|
||||
showConfirmDialog,
|
||||
showFailToast,
|
||||
showLoadingToast,
|
||||
showSuccessToast,
|
||||
@@ -13,12 +14,16 @@ import {
|
||||
type HomeAssistantDiscoveredDevice,
|
||||
type HomeAssistantLightTransition,
|
||||
type HomeAssistantSpeakerDemo,
|
||||
type HomeAssistantStatus
|
||||
type HomeAssistantStatus,
|
||||
type MattressComfort,
|
||||
type TcmAssessmentResult
|
||||
} from '../services/health'
|
||||
|
||||
const router = useRouter()
|
||||
const loading = ref(true)
|
||||
const haStatus = ref<HomeAssistantStatus>()
|
||||
const tcmResult = ref<TcmAssessmentResult>()
|
||||
const mattressComfort = ref<MattressComfort>()
|
||||
const sceneRunning = ref<'sleep' | 'all-off'>()
|
||||
const settingParam = ref<string | null>(null)
|
||||
const switchingDeviceId = ref<string | null>(null)
|
||||
@@ -52,6 +57,16 @@ const parameterLabels: Record<string, string> = {
|
||||
mattress_left_mode: '床垫左侧承托模式',
|
||||
mattress_right_mode: '床垫右侧承托模式'
|
||||
}
|
||||
const mattressFirmnessOptions = ['最软', '很软', '稍软', '稍硬', '很硬', '最硬']
|
||||
const mattressFirmnessZones = [
|
||||
{ key: 'left_shoulder', label: '左肩' }, { key: 'right_shoulder', label: '右肩' },
|
||||
{ key: 'left_back', label: '左背' }, { key: 'right_back', label: '右背' },
|
||||
{ key: 'left_waist', label: '左腰' }, { key: 'right_waist', label: '右腰' },
|
||||
{ key: 'left_lumbar', label: '左腰椎' }, { key: 'right_lumbar', label: '右腰椎' },
|
||||
{ key: 'left_hip', label: '左臀' }, { key: 'right_hip', label: '右臀' },
|
||||
{ key: 'left_leg', label: '左腿' }, { key: 'right_leg', label: '右腿' }
|
||||
]
|
||||
const massagePartOptions = ['无', '背部', '腰部', '牵引', '腿部', '全身']
|
||||
|
||||
function setFeedback(type: 'success' | 'fail', message: string) {
|
||||
actionFeedback.value = { type, message }
|
||||
@@ -104,7 +119,11 @@ const localParams = reactive({
|
||||
mattressLeftHeating: 1,
|
||||
mattressRightHeating: 1,
|
||||
mattressLeftMode: '智能适配',
|
||||
mattressRightMode: '智能适配'
|
||||
mattressRightMode: '智能适配',
|
||||
mattressFirmness: {} as Record<string, string>,
|
||||
mattressMassageLeftPart: '无',
|
||||
mattressMassageRightPart: '无',
|
||||
mattressMassageLevel: '轻柔'
|
||||
})
|
||||
|
||||
function clamp(val: number, min: number, max: number): number {
|
||||
@@ -123,7 +142,15 @@ function numberValue(value: unknown): number | undefined {
|
||||
async function loadStatus(notify = false) {
|
||||
loading.value = true
|
||||
try {
|
||||
haStatus.value = await healthApi.homeAssistantStatus()
|
||||
const [status, dashboard] = await Promise.all([
|
||||
healthApi.homeAssistantStatus(),
|
||||
healthApi.dashboard().catch(() => undefined)
|
||||
])
|
||||
haStatus.value = status
|
||||
if (dashboard) {
|
||||
tcmResult.value = dashboard.tcmResult
|
||||
mattressComfort.value = dashboard.mattressComfort
|
||||
}
|
||||
const controls = haStatus.value?.controlParameters ?? {}
|
||||
const humidifier = controls.humidity
|
||||
const humidity = numberValue(humidifier?.attributes?.humidity)
|
||||
@@ -135,7 +162,7 @@ async function loadStatus(notify = false) {
|
||||
const brightness = numberValue(controls.brightness?.attributes?.brightness)
|
||||
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 = clamp(kelvin, 2200, 6500)
|
||||
if (kelvin !== undefined) localParams.lightColorTemp = clamp(kelvin, 2200, 5000)
|
||||
|
||||
const volume = numberValue(controls.volume?.attributes?.volume_level)
|
||||
if (volume !== undefined) localParams.speakerVolume = clamp(Math.round(volume * 100), 0, 100)
|
||||
@@ -152,6 +179,14 @@ async function loadStatus(notify = false) {
|
||||
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
|
||||
for (const zone of mattressFirmnessZones) {
|
||||
const state = controls[`mattress_firmness_${zone.key}`]?.state
|
||||
if (state && state !== 'unavailable' && state !== 'unknown')
|
||||
localParams.mattressFirmness[zone.key] = state
|
||||
}
|
||||
if (controls.mattress_massage_left_part?.state) localParams.mattressMassageLeftPart = controls.mattress_massage_left_part.state
|
||||
if (controls.mattress_massage_right_part?.state) localParams.mattressMassageRightPart = controls.mattress_massage_right_part.state
|
||||
if (controls.mattress_massage_level?.state) localParams.mattressMassageLevel = controls.mattress_massage_level.state
|
||||
await syncLightTransitionStatus()
|
||||
try {
|
||||
speakerDemo.value = await healthApi.speakerDemoStatus()
|
||||
@@ -350,7 +385,8 @@ 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 label = parameterLabels[paramType]
|
||||
|| (paramType.startsWith('mattress_firmness_') ? '床垫分区软硬度' : '设备参数')
|
||||
const loadingToast = showLoadingToast({
|
||||
message: `正在设置${label}…`,
|
||||
forbidClick: true,
|
||||
@@ -395,8 +431,31 @@ async function setMattressMode(side: 'left' | 'right', mode: string) {
|
||||
await applyParam(device.deviceId, 'mattress_right_mode', mode)
|
||||
}
|
||||
|
||||
async function setMattressSelect(parameterType: string, event: Event) {
|
||||
const device = mattressDevice.value
|
||||
if (!device) return
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (parameterType.startsWith('mattress_massage_') && value !== '无') {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '确认启动床垫按摩',
|
||||
message: '按摩或牵引可能让床垫立即动作,请确认设备周围安全后继续。'
|
||||
})
|
||||
} catch {
|
||||
await loadStatus()
|
||||
return
|
||||
}
|
||||
}
|
||||
await applyParam(device.deviceId, parameterType, value)
|
||||
}
|
||||
|
||||
// 一键场景
|
||||
async function executeScene(scene: 'sleep' | 'all-off') {
|
||||
if (scene === 'sleep' && !tcmResult.value?.hasCompleted) {
|
||||
showFailToast('请先完成中医四诊')
|
||||
router.push('/tcm-assessment')
|
||||
return
|
||||
}
|
||||
sceneRunning.value = scene
|
||||
const loadingToast = showLoadingToast({
|
||||
message: scene === 'sleep' ? '正在启动舒睡场景…' : '正在关闭场景设备…',
|
||||
@@ -476,13 +535,13 @@ onUnmounted(() => {
|
||||
<div class="scene-actions-grid">
|
||||
<button
|
||||
class="scene-btn sleep-btn"
|
||||
:disabled="operationBusy"
|
||||
:disabled="operationBusy || !tcmResult?.hasCompleted"
|
||||
@click="executeScene('sleep')"
|
||||
>
|
||||
<div class="s-icon">🌙</div>
|
||||
<div class="s-text">
|
||||
<strong>{{ sceneRunning === 'sleep' ? '正在启动舒睡模式…' : '一键启动全屋舒睡模式' }}</strong>
|
||||
<small>协同控制除湿、香氛、灯光、音箱与床垫</small>
|
||||
<small>{{ tcmResult?.hasCompleted ? `按${tcmResult.constitutionName}倾向协同控制设备` : '请先完成中医四诊,生成体质倾向' }}</small>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@@ -498,6 +557,9 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<button v-if="!tcmResult?.hasCompleted" class="tcm-guide-button" type="button" @click="router.push('/tcm-assessment')">
|
||||
去完成中医四诊,解锁一键启动 ›
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div
|
||||
@@ -677,7 +739,7 @@ onUnmounted(() => {
|
||||
<VanSlider
|
||||
v-model="localParams.lightColorTemp"
|
||||
:min="2200"
|
||||
:max="6500"
|
||||
:max="5000"
|
||||
:step="50"
|
||||
:disabled="operationBusy"
|
||||
active-color="#c87f2b"
|
||||
@@ -685,7 +747,7 @@ onUnmounted(() => {
|
||||
/>
|
||||
<div class="slider-scale">
|
||||
<span>2200K 超暖橙光 (利于褪黑素)</span>
|
||||
<span>6500K 清爽冷白光</span>
|
||||
<span>5000K 清爽白光</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -903,6 +965,15 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div class="params-body">
|
||||
<div class="mattress-recommendation">
|
||||
<strong>重庆天气与 BMI 床垫建议</strong>
|
||||
<p>{{ mattressComfort?.weatherReason || '天气建议读取中' }}</p>
|
||||
<p>{{ mattressComfort?.firmnessReason || 'BMI 建议读取中' }}</p>
|
||||
<small v-if="mattressComfort?.weatherAvailable">
|
||||
重庆室外 {{ mattressComfort.outdoorTemperatureCelsius }}℃ · 体感 {{ mattressComfort.feelsLikeCelsius }}℃
|
||||
</small>
|
||||
<small>自动场景使用上述建议;下面可手动调整,加热滑块属于手动覆盖。</small>
|
||||
</div>
|
||||
<!-- 左侧加热档位 -->
|
||||
<div class="param-row">
|
||||
<div class="param-header">
|
||||
@@ -978,6 +1049,46 @@ onUnmounted(() => {
|
||||
>{{ mode }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="mattress-advanced">
|
||||
<summary>分区软硬度与按摩调节</summary>
|
||||
<p class="mattress-mode-note">软硬度按左、右各 6 个部位独立设置;BMI 只给出演示起点,并非医学建议。选择按摩部位可能让床垫立即动作。</p>
|
||||
<div v-for="zone in mattressFirmnessZones" :key="zone.key" class="mattress-select-row">
|
||||
<label :for="`firmness-${zone.key}`">{{ zone.label }}软硬度</label>
|
||||
<select :id="`firmness-${zone.key}`"
|
||||
:value="localParams.mattressFirmness[zone.key] || ''"
|
||||
:disabled="operationBusy || !mattressDevice.online"
|
||||
@change="setMattressSelect(`mattress_firmness_${zone.key}`, $event)">
|
||||
<option value="" disabled>读取中</option>
|
||||
<option v-for="level in mattressFirmnessOptions" :key="level" :value="level">{{ level }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mattress-select-row">
|
||||
<label for="massage-left-part">左侧按摩部位</label>
|
||||
<select id="massage-left-part" :value="localParams.mattressMassageLeftPart"
|
||||
:disabled="operationBusy || !mattressDevice.online"
|
||||
@change="setMattressSelect('mattress_massage_left_part', $event)">
|
||||
<option v-for="part in massagePartOptions" :key="part" :value="part">{{ part }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mattress-select-row">
|
||||
<label for="massage-right-part">右侧按摩部位</label>
|
||||
<select id="massage-right-part" :value="localParams.mattressMassageRightPart"
|
||||
:disabled="operationBusy || !mattressDevice.online"
|
||||
@change="setMattressSelect('mattress_massage_right_part', $event)">
|
||||
<option v-for="part in massagePartOptions" :key="part" :value="part">{{ part }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mattress-select-row">
|
||||
<label for="massage-level">按摩力度</label>
|
||||
<select id="massage-level" :value="localParams.mattressMassageLevel"
|
||||
:disabled="operationBusy || !mattressDevice.online"
|
||||
@change="setMattressSelect('mattress_massage_level', $event)">
|
||||
<option value="轻柔">轻柔</option>
|
||||
<option value="强劲">强劲</option>
|
||||
</select>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1367,6 +1478,25 @@ onUnmounted(() => {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.mattress-recommendation {
|
||||
padding: 12px;
|
||||
border: 1px solid #cde3dc;
|
||||
border-radius: 12px;
|
||||
background: #f3faf7;
|
||||
color: #174d40;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.mattress-recommendation strong { font-size: 13px; }
|
||||
.mattress-recommendation p { margin: 7px 0 0; }
|
||||
.mattress-recommendation small { display: block; margin-top: 6px; color: #6a8479; }
|
||||
.mattress-advanced { border-top: 1px solid #e8efea; padding-top: 12px; }
|
||||
.mattress-advanced summary { cursor: pointer; color: #0c5045; font-weight: 700; font-size: 14px; }
|
||||
.mattress-advanced .mattress-mode-note { margin: 12px 0; }
|
||||
.mattress-select-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 9px 0; border-bottom: 1px solid #edf1ee; }
|
||||
.mattress-select-row label { color: #556c64; font-size: 12px; }
|
||||
.mattress-select-row select { width: 48%; min-height: 36px; border: 1px solid #d9e6de; border-radius: 9px; background: #fff; padding: 5px 8px; color: #174d40; font-size: 12px; }
|
||||
|
||||
.light-mode-panel {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
@@ -1557,4 +1687,16 @@ onUnmounted(() => {
|
||||
color: #72847d;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tcm-guide-button {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, .35);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, .12);
|
||||
color: #fff;
|
||||
min-height: 40px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
</style>
|
||||
|
||||
+28
-10
@@ -53,7 +53,7 @@ const ageDisplay = computed(() => {
|
||||
return age
|
||||
})
|
||||
|
||||
// 根据评估答案提取体质标签
|
||||
// 健康评估只展示生活习惯画像;体质由中医四诊独立判定。
|
||||
const profileTags = computed(() => {
|
||||
const ans = assessment.value?.answers
|
||||
if (!ans || !assessment.value?.hasCompletedAssessment) return []
|
||||
@@ -61,25 +61,25 @@ const profileTags = computed(() => {
|
||||
const list: { label: string; desc: string; type: 'gold' | 'blue' | 'green' | 'purple' }[] = []
|
||||
|
||||
if (ans.temperaturePreference === 'heat_fear') {
|
||||
list.push({ label: '体感燥热偏温', desc: '除湿机清爽48% · 床垫低档加热', type: 'blue' })
|
||||
list.push({ label: '夜间偏热感', desc: '自述夜间较容易感到闷热', type: 'blue' })
|
||||
} else if (ans.temperaturePreference === 'cold_fear') {
|
||||
list.push({ label: '体质畏寒怕冷', desc: '除湿机温润58% · 床垫加强加热', type: 'gold' })
|
||||
list.push({ label: '夜间偏冷感', desc: '自述睡眠时较容易感到寒冷', type: 'gold' })
|
||||
} else {
|
||||
list.push({ label: '冷热适中平衡', desc: '标准舒适温湿度', type: 'green' })
|
||||
list.push({ label: '冷热感受适中', desc: '自述夜间温度感受较平稳', type: 'green' })
|
||||
}
|
||||
|
||||
if (ans.fallingAsleepSpeed === 'hard') {
|
||||
list.push({ label: '睡前敏感易失眠', desc: '2200K超暖暗光 · 音箱睡眠模式', type: 'purple' })
|
||||
list.push({ label: '入睡较慢', desc: '可关注睡前作息和放松习惯', type: 'purple' })
|
||||
} else if (ans.fallingAsleepSpeed === 'fast') {
|
||||
list.push({ label: '快速深度睡眠', desc: '自然转场柔光', type: 'green' })
|
||||
list.push({ label: '入睡较快', desc: '通常能较快进入睡眠', type: 'green' })
|
||||
}
|
||||
|
||||
if (ans.muscleFatigueArea === 'waist_back' || ans.muscleFatigueArea === 'back_neck') {
|
||||
list.push({ label: '脊柱腰背劳损', desc: '床垫启用智能腰托模式', type: 'gold' })
|
||||
list.push({ label: '腰背疲劳感', desc: '自述腰背或颈部容易疲劳', type: 'gold' })
|
||||
}
|
||||
|
||||
if (ans.lateNightEating === 'often' || ans.lateNightEating === 'sometimes' || ans.flavorPreference === 'heavy') {
|
||||
list.push({ label: '晚间进食空气净化', desc: '除湿机UV紫外消杀已启用', type: 'purple' })
|
||||
list.push({ label: '关注晚间饮食', desc: '可逐步调整夜宵与重口味习惯', type: 'purple' })
|
||||
}
|
||||
|
||||
return list
|
||||
@@ -174,7 +174,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>您尚未完成身体健康评估,全屋智能家具当前使用出厂默认参数运行。</p>
|
||||
<p>您尚未完成身体健康评估;一键舒睡还需完成中医四诊,生成体质倾向。</p>
|
||||
<VanButton
|
||||
type="primary"
|
||||
size="small"
|
||||
@@ -187,6 +187,24 @@ onMounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profile-card">
|
||||
<div class="card-head">
|
||||
<h3>中医四诊体质倾向</h3>
|
||||
<span class="status-indicator" :class="{ done: dashboard?.tcmResult?.hasCompleted }">
|
||||
{{ dashboard?.tcmResult?.hasCompleted ? '已生成' : '未评估' }}
|
||||
</span>
|
||||
</div>
|
||||
<template v-if="dashboard?.tcmResult?.hasCompleted">
|
||||
<p class="plan-summary-text">{{ dashboard.tcmResult.constitutionName }}{{ dashboard.tcmResult.secondaryName ? ` · 兼${dashboard.tcmResult.secondaryName}` : '' }}</p>
|
||||
<p class="plan-summary-text">{{ dashboard.tcmResult.summary }}</p>
|
||||
<p class="plan-summary-text">香型建议:{{ dashboard.tcmResult.aromaRecommendation }};音乐建议:{{ dashboard.tcmResult.musicRecommendation }}</p>
|
||||
</template>
|
||||
<p v-else class="plan-summary-text">完成四诊后,才能一键启动体质相关的设备预设。</p>
|
||||
<VanButton type="primary" size="small" round color="#0c5045" @click="router.push('/tcm-assessment')">
|
||||
{{ dashboard?.tcmResult?.hasCompleted ? '重新评估体质' : '去完成四诊' }}
|
||||
</VanButton>
|
||||
</section>
|
||||
|
||||
<!-- 当前健康方案 -->
|
||||
<section class="profile-card" v-if="dashboard?.currentPlan">
|
||||
<div class="card-head">
|
||||
@@ -225,7 +243,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<div class="menu-text">
|
||||
<strong>重新进行健康评估</strong>
|
||||
<small>更新身体指标,重新智能推导家具启动参数</small>
|
||||
<small>更新身体指标与睡眠、运动、饮食情况</small>
|
||||
</div>
|
||||
</div>
|
||||
<span class="arrow">></span>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { showFailToast, showSuccessToast } from 'vant'
|
||||
import AppTabBar from '../components/AppTabBar.vue'
|
||||
import { healthApi, type TcmAssessmentResult } from '../services/health'
|
||||
|
||||
type Question = { key: string; title: string; options: { value: string; label: string }[] }
|
||||
const sections: { title: string; hint: string; questions: Question[] }[] = [
|
||||
{
|
||||
title: '望诊', hint: '观察面色、形体与精神状态', questions: [
|
||||
{ key: 'face', title: '面色', options: [
|
||||
{ value: 'rosy', label: '红润' }, { value: 'pale', label: '苍白' },
|
||||
{ value: 'sallow', label: '萎黄' }, { value: 'flushed', label: '潮红' }, { value: 'dusky', label: '青暗' }
|
||||
] },
|
||||
{ key: 'body', title: '体型', options: [
|
||||
{ value: 'balanced', label: '匀称' }, { value: 'heavy', label: '偏胖' },
|
||||
{ value: 'thin', label: '偏瘦' }, { value: 'abdominal', label: '腹部肥胖' }
|
||||
] },
|
||||
{ key: 'spirit', title: '神志', options: [
|
||||
{ value: 'energetic', label: '有神' }, { value: 'low', label: '少神' }, { value: 'tired', label: '倦怠' }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '闻诊', hint: '感受声音与气息', questions: [
|
||||
{ key: 'voice', title: '声音', options: [
|
||||
{ value: 'clear', label: '洪亮' }, { value: 'normal', label: '平和' }, { value: 'weak', label: '低沉' }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '问诊', hint: '选择最接近近期日常状态的答案', questions: [
|
||||
{ key: 'coldHeat', title: '寒热倾向', options: [
|
||||
{ value: 'neutral', label: '无明显倾向' }, { value: 'cold', label: '怕冷喜暖' }, { value: 'hot', label: '怕热喜凉' }
|
||||
] },
|
||||
{ key: 'sweating', title: '汗出', options: [
|
||||
{ value: 'normal', label: '正常' }, { value: 'easy', label: '易汗' }, { value: 'little', label: '少汗' }
|
||||
] },
|
||||
{ key: 'mouth', title: '口味', options: [
|
||||
{ value: 'normal', label: '正常' }, { value: 'bland', label: '口淡' },
|
||||
{ value: 'bitter', label: '口苦' }, { value: 'dry', label: '口干' }
|
||||
] },
|
||||
{ key: 'stool', title: '大便', options: [
|
||||
{ value: 'normal', label: '正常' }, { value: 'dry', label: '偏干' },
|
||||
{ value: 'loose', label: '偏稀' }, { value: 'sticky', label: '黏滞不爽' }
|
||||
] },
|
||||
{ key: 'sleep', title: '睡眠', options: [
|
||||
{ value: 'good', label: '可' }, { value: 'difficult', label: '入睡难' },
|
||||
{ value: 'waking', label: '易醒' }, { value: 'dreams', label: '多梦' }
|
||||
] },
|
||||
{ key: 'emotion', title: '情志', options: [
|
||||
{ value: 'steady', label: '平稳' }, { value: 'anxious', label: '易焦虑' },
|
||||
{ value: 'depressed', label: '易抑郁' }, { value: 'irritable', label: '急躁' }
|
||||
] },
|
||||
{ key: 'allergy', title: '敏感或过敏倾向', options: [
|
||||
{ value: 'none', label: '无明显倾向' }, { value: 'often', label: '经常出现' }
|
||||
] }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '切诊', hint: '不了解脉象可选择“不了解”', questions: [
|
||||
{ key: 'pulse', title: '脉象特征', options: [
|
||||
{ value: 'normal', label: '平和' }, { value: 'wiry', label: '弦' },
|
||||
{ value: 'slippery', label: '滑' }, { value: 'thin', label: '细' },
|
||||
{ value: 'rough', label: '涩' }, { value: 'deep', label: '沉' },
|
||||
{ value: 'floating', label: '浮' }, { value: 'weak', label: '弱' },
|
||||
{ value: 'unknown', label: '不了解' }
|
||||
] }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const answers = reactive<Record<string, string>>({})
|
||||
const notes = reactive<Record<string, string>>({ tongue: '', odor: '', breath: '', pulseNote: '' })
|
||||
const result = ref<TcmAssessmentResult>()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const questions = sections.flatMap(section => section.questions)
|
||||
const answeredCount = computed(() => questions.filter(question => answers[question.key]).length)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const saved = await healthApi.getTcmAssessment()
|
||||
if (saved.hasCompleted) {
|
||||
result.value = saved
|
||||
Object.assign(answers, saved.answers)
|
||||
Object.assign(notes, saved.notes)
|
||||
}
|
||||
} catch (error) {
|
||||
showFailToast(error instanceof Error ? error.message : '四诊记录加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function submit() {
|
||||
const missing = questions.find(question => !answers[question.key])
|
||||
if (missing) {
|
||||
showFailToast(`请先选择“${missing.title}”`)
|
||||
document.getElementById(`tcm-${missing.key}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
result.value = await healthApi.saveTcmAssessment({
|
||||
answers: { ...answers }, notes: { ...notes }
|
||||
})
|
||||
showSuccessToast('体质倾向已生成,下次一键启动将使用新参数')
|
||||
document.getElementById('tcm-result')?.scrollIntoView({ behavior: 'smooth' })
|
||||
} catch (error) {
|
||||
showFailToast(error instanceof Error ? error.message : '生成失败,请稍后重试')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="tcm-page">
|
||||
<header class="tcm-header">
|
||||
<p class="eyebrow">EVERGREEN · FOUR DIAGNOSES</p>
|
||||
<h1>中医四诊</h1>
|
||||
<p>填写望、闻、问、切的观察信息,生成九大体质中的演示倾向与家居参数。</p>
|
||||
<div class="progress-row"><span>已完成 {{ answeredCount }}/{{ questions.length }} 项</span><span>{{ result?.hasCompleted ? '已生成结果' : '待生成' }}</span></div>
|
||||
</header>
|
||||
|
||||
<section v-if="result?.hasCompleted" id="tcm-result" class="tcm-card result-card">
|
||||
<div class="result-top"><span>当前体质倾向</span><small>演示参考 · 非医学诊断</small></div>
|
||||
<h2>{{ result.constitutionName }}</h2>
|
||||
<p v-if="result.secondaryName">次要倾向:{{ result.secondaryName }}</p>
|
||||
<p>{{ result.summary }}</p>
|
||||
<div class="recommendations">
|
||||
<div><span>香型建议</span><strong>{{ result.aromaRecommendation }}</strong></div>
|
||||
<div><span>音乐风格建议</span><strong>{{ result.musicRecommendation }}</strong></div>
|
||||
<div><span>室温建议</span><strong>{{ result.roomTemperatureRecommendation }}</strong></div>
|
||||
</div>
|
||||
<p class="capability-note">香型需人工更换,音乐风格暂不能自动选择,室温仅供参考;一键启动只下发当前已支持的设备参数。</p>
|
||||
<div class="parameter-preview">
|
||||
<span>一键启动预设</span>
|
||||
<p>湿度 {{ result.sceneConfig.targetHumidity }}% · 灯光 {{ result.sceneConfig.lightColorTemperatureKelvin }}K</p>
|
||||
<p>香氛{{ result.sceneConfig.enableFragrance ? '开启' : '关闭' }} · 音箱睡眠模式 {{ result.sceneConfig.speakerVolumePercent }}% 音量</p>
|
||||
<p>床垫加热按重庆冬季天气决定;软硬度按健康档案 BMI 给出可手动修改的演示起点。</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="loading" class="tcm-card">正在读取四诊记录…</div>
|
||||
<template v-else>
|
||||
<section v-for="section in sections" :key="section.title" class="tcm-card">
|
||||
<div class="section-heading"><h2>{{ section.title }}</h2><p>{{ section.hint }}</p></div>
|
||||
<div v-for="question in section.questions" :id="`tcm-${question.key}`" :key="question.key" class="question">
|
||||
<h3>{{ question.title }} <small>必选</small></h3>
|
||||
<div class="choice-grid">
|
||||
<button v-for="option in question.options" :key="option.value" type="button"
|
||||
:class="{ selected: answers[question.key] === option.value }"
|
||||
:aria-pressed="answers[question.key] === option.value"
|
||||
@click="answers[question.key] = option.value">{{ option.label }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<label v-if="section.title === '望诊'" class="note-label">舌象补充(选填)<textarea v-model="notes.tongue" maxlength="300" placeholder="例如舌色、舌苔等;不了解可不填" /></label>
|
||||
<template v-if="section.title === '闻诊'">
|
||||
<label class="note-label">气味补充(选填)<textarea v-model="notes.odor" maxlength="300" placeholder="例如是否有明显气味" /></label>
|
||||
<label class="note-label">气息补充(选填)<textarea v-model="notes.breath" maxlength="300" placeholder="例如气息是否平稳" /></label>
|
||||
</template>
|
||||
<label v-if="section.title === '切诊'" class="note-label">切诊备注(选填)<textarea v-model="notes.pulseNote" maxlength="300" placeholder="如有专业测量结果可补充" /></label>
|
||||
</section>
|
||||
<button class="submit-button" type="button" :disabled="saving" @click="submit">
|
||||
{{ saving ? '正在生成…' : result?.hasCompleted ? '重新评估并更新参数' : '生成我的体质与家居参数' }}
|
||||
</button>
|
||||
<p class="tcm-disclaimer">本问卷为内部演示的固定评分规则,不等同于专业体质辨识量表,不用于诊断或治疗。</p>
|
||||
</template>
|
||||
<AppTabBar />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tcm-page { max-width: 480px; margin: 0 auto; padding: 18px 16px calc(100px + env(safe-area-inset-bottom)); color: #173d35; box-sizing: border-box; }
|
||||
.tcm-header { padding: 18px 8px 20px; }
|
||||
.eyebrow { color: #a37941; font-size: 11px; font-weight: 700; letter-spacing: .14em; }
|
||||
.tcm-header h1 { font-size: 26px; margin: 5px 0 8px; }
|
||||
.tcm-header > p:not(.eyebrow) { color: #71837b; font-size: 13px; line-height: 1.65; }
|
||||
.progress-row { display: flex; justify-content: space-between; color: #527169; font-size: 12px; margin-top: 18px; }
|
||||
.tcm-card { background: #fff; border: 1px solid #e9eee9; box-shadow: 0 8px 26px rgba(18, 62, 55, .06); border-radius: 20px; padding: 20px; margin-bottom: 15px; }
|
||||
.section-heading { display: flex; justify-content: space-between; gap: 12px; align-items: baseline; border-bottom: 1px solid #edf1ee; padding-bottom: 12px; }
|
||||
.section-heading h2 { margin: 0; font-size: 19px; }
|
||||
.section-heading p { margin: 0; color: #86958d; font-size: 11px; text-align: right; }
|
||||
.question { padding: 15px 0; border-bottom: 1px solid #f0f2f0; scroll-margin-top: 80px; }
|
||||
.question h3 { font-size: 14px; margin: 0 0 10px; }
|
||||
.question small { color: #a88b5c; font-size: 10px; font-weight: 400; margin-left: 5px; }
|
||||
.choice-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.choice-grid button { min-height: 39px; border: 1px solid #e4e9e5; border-radius: 11px; background: #f9fbf9; color: #496158; font-size: 13px; }
|
||||
.choice-grid button.selected { border-color: #0c5045; background: #0c5045; color: #fff; font-weight: 700; }
|
||||
.note-label { display: block; font-size: 13px; font-weight: 600; margin-top: 15px; }
|
||||
.note-label textarea { display: block; width: 100%; min-height: 68px; resize: vertical; box-sizing: border-box; margin-top: 8px; border: 1px solid #e4e9e5; border-radius: 11px; padding: 10px; font: inherit; font-weight: 400; color: #173d35; }
|
||||
.result-card { background: linear-gradient(150deg, #123e37, #0b5144); color: #fff; scroll-margin-top: 16px; }
|
||||
.result-top { display: flex; justify-content: space-between; font-size: 12px; opacity: .8; }
|
||||
.result-card h2 { font-size: 27px; margin: 14px 0 8px; }
|
||||
.result-card p { font-size: 12px; line-height: 1.6; }
|
||||
.recommendations { display: grid; gap: 7px; margin: 16px 0; }
|
||||
.recommendations div { display: flex; justify-content: space-between; gap: 10px; padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,.17); font-size: 12px; }
|
||||
.recommendations span { opacity: .7; }
|
||||
.recommendations strong { text-align: right; }
|
||||
.capability-note { opacity: .78; }
|
||||
.parameter-preview { background: rgba(255,255,255,.1); border-radius: 12px; padding: 12px; margin-top: 16px; }
|
||||
.parameter-preview span { font-size: 12px; font-weight: 700; }
|
||||
.parameter-preview p { margin: 6px 0 0; }
|
||||
.submit-button { width: 100%; min-height: 49px; border: 0; border-radius: 25px; background: #0c5045; color: #fff; font-weight: 700; font-size: 15px; }
|
||||
.submit-button:disabled { opacity: .55; }
|
||||
.tcm-disclaimer { color: #8a9890; font-size: 11px; line-height: 1.6; text-align: center; margin: 14px 12px 0; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user