This commit is contained in:
fuenmao
2026-09-23 18:19:47 +08:00
parent efd71f99f3
commit aecfd2a254
8 changed files with 491 additions and 282 deletions
+19
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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">去评估 &gt;</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' ? '正在开启…' : '一键开启' }}
+151 -9
View File
@@ -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
View File
@@ -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">&gt;</span>
+209
View File
@@ -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>