Compare commits

...
4 Commits
Author SHA1 Message Date
fuenmao 8c2d262066 111 2026-09-22 16:55:21 +08:00
fuenmao e6215912d9 11 2026-09-22 15:40:58 +08:00
fuenmao dec3dfdfe3 1111 2026-09-22 15:03:13 +08:00
fuenmao 426eaca97f 11 2026-09-22 14:16:58 +08:00
11 changed files with 3971 additions and 542 deletions
+13 -1
View File
@@ -2,9 +2,21 @@
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#0c5045" />
<title>常青树健康管理</title>
<script>
function hideWxToolbar() {
if (typeof WeixinJSBridge !== 'undefined' && WeixinJSBridge.call) {
WeixinJSBridge.call('hideToolbar');
}
}
if (typeof WeixinJSBridge === 'undefined') {
document.addEventListener('WeixinJSBridgeReady', hideWxToolbar, false);
} else {
hideWxToolbar();
}
</script>
</head>
<body>
<div id="app"></div>
+135
View File
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const currentTab = computed(() => {
const path = route.path
if (path.startsWith('/assessment')) return 'assessment'
if (path.startsWith('/profile')) return 'profile'
return 'home'
})
function navigate(target: string) {
if (target === 'home' && route.path !== '/dashboard') {
router.replace('/dashboard')
} else if (target === 'assessment' && route.path !== '/assessment') {
router.replace('/assessment')
} else if (target === 'profile' && route.path !== '/profile') {
router.replace('/profile')
}
}
</script>
<template>
<nav class="app-tab-bar">
<div
class="tab-item"
:class="{ active: currentTab === 'home' }"
@click="navigate('home')"
>
<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">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
</div>
<span class="tab-label">首页</span>
</div>
<div
class="tab-item"
:class="{ active: currentTab === 'assessment' }"
@click="navigate('assessment')"
>
<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">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<path d="M9 13h6" />
<path d="M9 17h6" />
<polyline points="10 9 12 11 14 9" />
</svg>
</div>
<span class="tab-label">健康评估</span>
</div>
<div
class="tab-item"
:class="{ active: currentTab === 'profile' }"
@click="navigate('profile')"
>
<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">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<span class="tab-label">我的</span>
</div>
</nav>
</template>
<style scoped>
.app-tab-bar {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: 480px;
min-height: 56px;
height: calc(56px + env(safe-area-inset-bottom, 0px));
box-sizing: border-box;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border-top: 1px solid rgba(18, 63, 55, 0.08);
display: flex;
justify-content: space-around;
align-items: flex-start;
padding-top: 6px;
padding-bottom: env(safe-area-inset-bottom, 0px);
z-index: 999;
box-shadow: 0 -4px 16px rgba(18, 63, 55, 0.04);
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
height: 44px;
color: #7d8e87;
cursor: pointer;
transition: color 0.2s ease;
user-select: none;
}
.tab-icon {
width: 22px;
height: 22px;
display: flex;
align-items: center;
justify-content: center;
}
.tab-label {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
margin-top: 2px;
line-height: 1.2;
}
.tab-item.active {
color: #0c5045;
}
.tab-item.active .tab-label {
font-weight: 700;
}
</style>
+14 -3
View File
@@ -1,7 +1,9 @@
import { createRouter, createWebHistory } from 'vue-router'
import AuthView from './views/AuthView.vue'
import OnboardingView from './views/OnboardingView.vue'
import DashboardView from './views/DashboardView.vue'
import AssessmentView from './views/AssessmentView.vue'
import ProfileView from './views/ProfileView.vue'
import DeviceControlView from './views/DeviceControlView.vue'
import { hasToken } from './services/auth'
const router = createRouter({
@@ -9,8 +11,11 @@ const router = createRouter({
routes: [
{ path: '/', redirect: '/dashboard' },
{ path: '/auth', component: AuthView, meta: { guest: true } },
{ path: '/onboarding', component: OnboardingView },
{ path: '/dashboard', component: DashboardView }
{ path: '/onboarding', redirect: '/assessment' },
{ path: '/dashboard', component: DashboardView },
{ path: '/assessment', component: AssessmentView },
{ path: '/profile', component: ProfileView },
{ path: '/device-control', component: DeviceControlView }
]
})
@@ -19,4 +24,10 @@ router.beforeEach((to) => {
if (to.meta.guest && hasToken()) return '/dashboard'
})
router.afterEach(() => {
if (typeof (window as any).WeixinJSBridge !== 'undefined' && (window as any).WeixinJSBridge.call) {
(window as any).WeixinJSBridge.call('hideToolbar')
}
})
export default router
+104 -6
View File
@@ -40,11 +40,63 @@ export type HealthPlan = {
items: PlanItem[]
}
export type PersonalizedSceneConfig = {
targetHumidity: number
dehumidifierMode: string
fanLevel: string
enableUv: boolean
lightBrightnessPercent: number
lightColorTemperatureKelvin: number
mattressTargetTemp: number
mattressLeftFirmness: number
mattressRightFirmness: number
mattressHeatingLevel: number
mattressMode: string
enableFragrance: boolean
fragranceLevel: number
enableSpeakerSleepMode: boolean
speakerAudioMode: string
speakerVolumePercent: number
}
export type HealthAssessmentAnswers = {
sleepDuration: string
fallingAsleepSpeed: string
temperaturePreference: string
nightAwakening: string
mattressPreference: string
exerciseFrequency: string
muscleFatigueArea: string
activeTimeOfDay: string
mealRegularity: string
flavorPreference: string
lateNightEating: string
dailyWaterIntake: string
}
export type SaveHealthAssessmentInput = {
basicInfo: HealthProfile
answers: HealthAssessmentAnswers
}
export type HealthAssessmentOutput = {
hasCompletedAssessment: boolean
basicInfo: HealthProfile
answers: HealthAssessmentAnswers
sceneConfig: PersonalizedSceneConfig
bmi?: number | null
bmiStatus: string
}
export type Dashboard = {
user: HealthUser
profile?: HealthProfile
selectedModules: string[]
currentPlan?: HealthPlan
hasCompletedAssessment: boolean
bmi?: number | null
bmiStatus?: string
sceneConfig?: PersonalizedSceneConfig
}
export type HealthDeviceCategory =
@@ -75,6 +127,7 @@ export type HomeAssistantDiscoveredDevice = {
areaName?: string
category: HealthDeviceCategory | string
online: boolean
primaryControlEntityId?: string | null
entities: HomeAssistantEntity[]
metrics?: Record<string, unknown>
}
@@ -102,6 +155,26 @@ export type HomeAssistantEnvironmentData = {
tvoc?: number | null
}
export type HomeAssistantScenePreset = {
deviceName: string
category: string
entityId: string
available: boolean
availabilityMessage: string
action: string
detail: string
parameters: Record<string, unknown>
}
export type HomeAssistantDeviceSwitchOutput = {
success: boolean
deviceId: string
entityId: string
state: string
isMuted?: boolean | null
message: string
}
export type HomeAssistantStatus = {
connected: boolean
message: string
@@ -110,6 +183,15 @@ export type HomeAssistantStatus = {
devices: HomeAssistantDiscoveredDevice[]
sceneDevices: HomeAssistantSceneDevicesSummary
environment: HomeAssistantEnvironmentData
scenePresets?: HomeAssistantScenePreset[]
controlParameters?: Record<string, HomeAssistantParameterState>
}
export type HomeAssistantParameterState = {
entityId: string
state: string
available: boolean
attributes?: Record<string, unknown>
}
export type HomeAssistantSceneAction = {
@@ -129,21 +211,37 @@ export type HomeAssistantSceneResult = {
actions: HomeAssistantSceneAction[]
}
export type HomeAssistantSetParameterInput = {
deviceId: string
entityId?: string
parameterType: string
value: unknown
}
export type HomeAssistantSetParameterOutput = {
success: boolean
deviceId: string
parameterType: string
message: string
}
export const healthApi = {
register: (payload: { phone: string; password: string; displayName: string }) =>
http.post<never, AuthOutput>('/api/health/auth/register', payload),
login: (payload: { phone: string; password: string }) =>
http.post<never, AuthOutput>('/api/health/auth/login', payload),
dashboard: () => http.get<never, Dashboard>('/api/health/dashboard'),
saveProfile: (payload: HealthProfile) =>
http.put<never, HealthProfile>('/api/health/profile', payload),
saveModules: (moduleCodes: string[]) =>
http.put<never, string[]>('/api/health/modules', { moduleCodes }),
generatePlan: () => http.post<never, HealthPlan>('/api/health/plans/generate'),
getAssessment: () => http.get<never, HealthAssessmentOutput>('/api/health/assessment'),
saveAssessment: (payload: SaveHealthAssessmentInput) =>
http.post<never, HealthAssessmentOutput>('/api/health/assessment', payload),
homeAssistantStatus: () =>
http.get<never, HomeAssistantStatus>('/api/health/home-assistant/status'),
executeSleepScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/sleep'),
executeAllOffScene: () =>
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off')
http.post<never, HomeAssistantSceneResult>('/api/health/home-assistant/scenes/all-off'),
switchDevice: (payload: { deviceId: string; turnOn: boolean; entityId?: string }) =>
http.post<never, HomeAssistantDeviceSwitchOutput>('/api/health/home-assistant/devices/switch', payload),
setParameter: (payload: HomeAssistantSetParameterInput) =>
http.post<never, HomeAssistantSetParameterOutput>('/api/health/home-assistant/devices/set-parameter', payload)
}
+458 -18
View File
@@ -11,7 +11,24 @@
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; background: #f2f0e9; }
html, body {
margin: 0;
padding: 0;
min-width: 320px;
min-height: 100vh;
background: #e8e6df;
overflow-x: hidden;
-webkit-text-size-adjust: 100%;
}
#app {
max-width: 480px;
margin: 0 auto;
min-height: 100vh;
position: relative;
background: #f2f0e9;
box-shadow: 0 0 30px rgba(0, 0, 0, 0.08);
overflow-x: hidden;
}
button, input, textarea { font: inherit; }
button { cursor: pointer; }
@@ -25,13 +42,12 @@ button { cursor: pointer; }
.auth-page { min-height: 100vh; background: var(--cream); }
.brand-panel {
min-height: 42vh;
padding: 54px 28px 42px;
padding: 46px 24px 46px;
color: #fff;
background:
radial-gradient(circle at 82% 12%, rgba(236, 202, 137, .28), transparent 28%),
linear-gradient(145deg, #0b5548, #123d37 74%);
border-radius: 0 0 42px 42px;
border-radius: 0 0 36px 36px;
}
.brand-badge {
display: inline-flex;
@@ -62,11 +78,11 @@ button { cursor: pointer; }
border: 1px solid rgba(255,255,255,.42); border-radius: 50%; font-family: serif; font-size: 24px;
}
.brand-panel h1 { max-width: 560px; margin: 0; font-family: "Songti SC", serif; font-size: clamp(32px, 8vw, 54px); line-height: 1.2; font-weight: 500; }
.brand-copy { max-width: 480px; margin: 18px 0 24px; color: rgba(255,255,255,.72); line-height: 1.7; }
.feature-row, .feature-pill-row { display: flex; gap: 8px; flex-wrap: wrap; }
.brand-copy { max-width: 480px; margin: 14px 0 16px; color: rgba(255,255,255,.78); line-height: 1.6; }
.feature-row, .feature-pill-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 8px; }
.feature-row span, .feature-pill-row span { padding: 6px 12px; background: rgba(255,255,255,.1); border: 1px solid rgba(255,255,255,.16); border-radius: 20px; font-size: 12px; }
.auth-card { position: relative; width: min(520px, calc(100% - 32px)); margin: -20px auto 0; padding: 24px 20px; background: #fff; border-radius: 24px; box-shadow: 0 18px 50px rgba(24,59,52,.12); }
.auth-card { position: relative; width: calc(100% - 32px); max-width: 440px; margin: -16px auto 32px; padding: 24px 20px; background: #fff; border-radius: 24px; box-shadow: 0 14px 40px rgba(24,59,52,.12); }
.auth-tab-switch {
display: flex;
background: #f1efea;
@@ -245,6 +261,23 @@ button { cursor: pointer; }
text-overflow: ellipsis;
min-width: 0;
}
.preset-toggle-btn {
font-size: 11px;
font-weight: 500;
color: var(--green);
background: #edf7f3;
padding: 3px 9px;
border-radius: 12px;
border: 1px solid rgba(12, 80, 69, 0.15);
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
flex-shrink: 0;
}
.preset-toggle-btn:active {
background: var(--green);
color: #fff;
}
.scene-status-badge {
font-size: 11px;
font-weight: 500;
@@ -267,6 +300,27 @@ button { cursor: pointer; }
.device-mini-status.yellow { background: #fef7ec; color: #b8860b; }
.device-mini-status.gray { background: #f1f3f2; color: #82908c; }
.sensor-mini-sync-btn {
font-size: 11px;
font-weight: 500;
padding: 3px 9px;
border-radius: 10px;
border: 1px solid rgba(18, 63, 55, 0.15);
background: #fff;
color: var(--green);
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.2s;
}
.sensor-mini-sync-btn:active {
background: #edf7f3;
}
.sensor-mini-sync-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.scene-row {
display: flex;
align-items: center;
@@ -327,7 +381,7 @@ button { cursor: pointer; }
display: flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
padding: 6px 9px;
background: #fff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 10px;
@@ -354,16 +408,112 @@ button { cursor: pointer; }
overflow: hidden;
text-overflow: ellipsis;
}
.scene-device-chip .desc {
font-size: 11px;
flex-shrink: 0;
.chip-toggle-btn {
font-size: 10px;
font-weight: 600;
padding: 2px 7px;
border-radius: 6px;
border: 1px solid rgba(12, 80, 69, 0.2);
background: #f1f3f2;
color: #73817d;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
transition: all 0.2s;
}
.chip-toggle-btn.on {
background: #0c5045;
color: #ffffff;
border-color: #0c5045;
}
.chip-toggle-btn.disabled {
opacity: 0.45;
cursor: not-allowed;
background: #f1f3f2;
color: #9aa4a1;
border-color: #e2e5e3;
}
/* 场景联动执行参数详情面板 */
.scene-presets-panel {
margin-top: 12px;
padding-top: 10px;
border-top: 1px dashed rgba(18, 63, 55, 0.12);
}
.preset-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.preset-panel-header span {
font-size: 12px;
font-weight: 600;
color: var(--deep);
}
.preset-panel-header small {
font-size: 10px;
color: #82908c;
font-variant-numeric: tabular-nums;
}
.preset-items-grid {
display: flex;
flex-direction: column;
gap: 6px;
}
.preset-card-item {
background: #ffffff;
border: 1px solid rgba(18, 63, 55, 0.06);
border-radius: 10px;
padding: 8px 10px;
box-shadow: 0 1px 2px rgba(18, 63, 55, 0.02);
}
.preset-card-item.unavailable {
background: #f7f8f7;
opacity: 0.72;
}
.preset-card-top {
display: flex;
align-items: center;
gap: 6px;
}
.preset-icon {
font-size: 13px;
flex-shrink: 0;
}
.preset-name {
font-size: 12px;
font-weight: 600;
color: var(--deep);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.preset-action {
font-size: 10px;
font-weight: 500;
padding: 1px 6px;
border-radius: 6px;
background: #edf7f3;
color: var(--green);
white-space: nowrap;
flex-shrink: 0;
}
.preset-detail {
margin: 3px 0 0 20px;
font-size: 11px;
color: #657470;
line-height: 1.4;
}
.preset-availability {
margin: 4px 0 0 20px;
font-size: 10px;
color: var(--green);
}
.preset-availability.unavailable {
color: #9a6a38;
}
.scene-device-chip.green .desc { color: var(--green); font-weight: 500; }
.scene-device-chip.yellow .desc { color: #b8860b; font-weight: 500; }
.scene-device-chip.gray .desc { color: #9aa4a1; }
/* Environment Health */
.env-container {
@@ -554,10 +704,66 @@ button { cursor: pointer; }
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
gap: 3px;
flex-shrink: 0;
margin-left: 10px;
}
.device-switch-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 8px 2px 5px;
border-radius: 12px;
border: 1px solid rgba(18, 63, 55, 0.15);
background: #f1f3f2;
color: #6b7774;
cursor: pointer;
transition: all 0.2s;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.device-switch-pill .switch-knob {
width: 9px;
height: 9px;
border-radius: 50%;
background: #9aa4a1;
transition: all 0.2s;
}
.device-switch-pill.on {
background: #edf7f3;
border-color: rgba(16, 185, 129, 0.4);
color: var(--green);
}
.device-switch-pill.on .switch-knob {
background: #10b981;
box-shadow: 0 0 0 2px rgba(16, 185, 129, 0.2);
}
.device-switch-pill.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.sensor-sync-pill {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 10px;
border: 1px solid rgba(18, 63, 55, 0.15);
background: #fff;
color: var(--green);
cursor: pointer;
white-space: nowrap;
transition: all 0.2s;
}
.sensor-sync-pill:active {
background: #edf7f3;
}
.sensor-sync-pill:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-pill {
font-size: 11px;
font-weight: 500;
@@ -573,8 +779,8 @@ button { cursor: pointer; }
.loading-card { color: #73817d; text-align: center; }
@media (max-width: 520px) {
.brand-panel { min-height: 390px; }
.auth-card { margin-top: -54px; }
.brand-panel { padding: 42px 20px 42px; }
.auth-card { margin-top: -16px; }
.plan-heading { display: block; }
.plan-heading span { display: block; margin-top: 5px; text-align: left; }
.device-grid { grid-template-columns: 1fr; }
@@ -582,3 +788,237 @@ button { cursor: pointer; }
.scene-actions { width: 100%; margin-top: 6px; }
.scene-actions button { flex: 1; }
}
.dashboard-page {
padding: 16px 14px 84px !important;
}
.dashboard-header {
margin: 0 auto 12px !important;
padding: 4px 4px 0 !important;
}
.dashboard-header h1 {
margin: 2px 0 3px !important;
font-size: 26px !important;
}
.dashboard-header > div > p:last-child {
margin: 0 !important;
font-size: 13px !important;
line-height: 1.4 !important;
}
/* 评估未填提醒卡片 */
.assessment-guide-banner {
background: linear-gradient(135deg, #b37318, #92580c);
color: #fff;
border-radius: 18px;
padding: 16px;
margin-bottom: 14px;
box-shadow: 0 6px 18px rgba(179, 115, 24, 0.22);
cursor: pointer;
position: relative;
overflow: hidden;
}
.assessment-guide-banner::after {
content: "";
position: absolute;
right: -20px;
bottom: -20px;
width: 100px;
height: 100px;
background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, transparent 70%);
border-radius: 50%;
}
.guide-badge {
display: inline-block;
background: rgba(255, 255, 255, 0.22);
color: #fff;
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 10px;
margin-bottom: 8px;
}
.guide-body {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.guide-text h3 {
margin: 0 0 4px;
font-size: 15px;
font-weight: 700;
}
.guide-text p {
margin: 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.88);
line-height: 1.4;
}
.guide-btn {
white-space: nowrap;
padding: 8px 14px;
border-radius: 16px;
border: 0;
background: #ffffff;
color: #92580c;
font-size: 12px;
font-weight: 700;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
/* 个性化舒睡启动参数卡片 */
.personalized-startup-card {
background: #ffffff;
border: 1px solid rgba(12, 80, 69, 0.1);
border-radius: 20px;
padding: 18px 16px;
margin-bottom: 14px;
box-shadow: 0 4px 16px rgba(18, 63, 55, 0.04);
}
.personalized-startup-card .section-title {
display: flex;
align-items: center;
margin-bottom: 4px;
}
.personalized-startup-card .section-title > span {
font-size: 17px;
font-weight: 700;
color: #143d36;
letter-spacing: 0.01em;
}
.startup-card-desc {
margin: 0 0 14px;
font-size: 12px;
color: #72847e;
line-height: 1.55;
}
.startup-params-tags {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 14px;
}
.startup-param-badge {
background: #f7f6f2;
border: 1px solid rgba(18, 63, 55, 0.08);
border-radius: 10px;
padding: 8px 10px;
}
.startup-param-badge .lbl {
display: block;
font-size: 10px;
color: #849690;
margin-bottom: 2px;
}
.startup-param-badge strong {
font-size: 12px;
color: #173d36;
}
.startup-scene-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 4px 0 12px;
}
.startup-scene-actions button {
min-height: 42px;
border-radius: 12px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.startup-scene-actions button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.startup-action-primary {
border: 1px solid #0c5045;
background: #0c5045;
color: #ffffff;
}
.startup-action-secondary {
border: 1px solid rgba(12, 80, 69, 0.2);
background: #f7f6f2;
color: #335b53;
}
.detail-entry-bar {
display: flex;
align-items: center;
justify-content: space-between;
background: #f8f7f2;
border: 1px solid rgba(18, 63, 55, 0.08);
border-radius: 12px;
padding: 10px 12px;
margin-top: 12px;
cursor: pointer;
transition: all 0.2s;
}
.detail-entry-bar:active {
background: #f0eee7;
}
.entry-left {
display: flex;
align-items: center;
gap: 10px;
}
.entry-left .icon {
font-size: 18px;
}
.entry-left strong {
display: block;
font-size: 12px;
color: #173d36;
}
.entry-left small {
display: block;
font-size: 11px;
color: #82928c;
}
.detail-entry-bar .arrow {
color: #a0ada7;
font-size: 14px;
}
/* 7 台设备入口条 */
.all-control-banner {
display: flex;
align-items: center;
justify-content: space-between;
background: linear-gradient(135deg, #edf6f3, #e5f0ec);
border: 1px solid rgba(12, 80, 69, 0.16);
border-radius: 14px;
padding: 12px 14px;
margin-top: 14px;
cursor: pointer;
transition: all 0.2s;
}
.all-control-banner:active {
background: #dbeef7;
}
.ac-text {
display: flex;
align-items: center;
gap: 10px;
}
.ac-icon {
font-size: 20px;
}
.ac-text strong {
display: block;
font-size: 13px;
color: #0c5045;
font-weight: 700;
}
.ac-text small {
display: block;
font-size: 11px;
color: #55756d;
margin-top: 2px;
}
.ac-arrow {
font-size: 16px;
color: #0c5045;
font-weight: 700;
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -19,7 +19,7 @@ async function submit() {
: await healthApi.register(form)
saveTokens(output.accessToken, output.refreshToken)
showToast({ type: 'success', message: mode.value === 'login' ? '登录成功' : '注册成功' })
await router.replace(mode.value === 'login' ? '/dashboard' : '/onboarding')
await router.replace(mode.value === 'login' ? '/dashboard' : '/assessment')
} catch (error) {
showToast(error instanceof Error ? error.message : '操作失败')
} finally {
+81 -414
View File
@@ -2,37 +2,19 @@
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { clearTokens } from '../services/auth'
import {
healthApi,
type Dashboard,
type HomeAssistantDiscoveredDevice,
type HomeAssistantStatus
} from '../services/health'
const router = useRouter()
const loading = ref(true)
const generating = ref(false)
const sceneRunning = ref<'sleep' | 'all-off'>()
const dashboard = ref<Dashboard>()
const homeAssistant = ref<HomeAssistantStatus>()
const moduleNames: Record<string, string> = {
sleep: '睡眠',
exercise: '运动',
diet: '饮食'
}
const categoryNames: Record<string, string> = {
EnvironmentControl: '场景控制',
EnvironmentSensor: '环境健康',
Sleep: '睡眠健康',
BodyComposition: '身体健康',
Fragrance: '香氛场景',
Lighting: '灯光场景',
Media: '音频场景',
Other: '智能外设'
}
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 11) return '早上好'
@@ -40,117 +22,8 @@ const greeting = computed(() => {
return '晚上好'
})
const totalDeviceCount = computed(() => homeAssistant.value?.summary?.totalDevices ?? homeAssistant.value?.devices?.length ?? 0)
const onlineDeviceCount = computed(() => homeAssistant.value?.summary?.onlineDevices ?? homeAssistant.value?.devices?.filter(d => d.online).length ?? 0)
const totalEntityCount = computed(() => homeAssistant.value?.summary?.totalEntities ?? 0)
const availableEntityCount = computed(() => homeAssistant.value?.summary?.availableEntities ?? 0)
const sceneTotal = computed(() => homeAssistant.value?.sceneDevices?.total ?? 4)
const sceneAvailable = computed(() => homeAssistant.value?.sceneDevices?.available ?? 0)
const sceneDevices = computed(() => {
if (!homeAssistant.value?.devices) return []
return homeAssistant.value.devices.filter(d =>
d.category === 'EnvironmentControl' ||
d.category === 'Fragrance' ||
d.category === 'Lighting' ||
d.category === 'Media'
)
})
const envDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'EnvironmentSensor')
})
const sleepDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'Sleep')
})
const bodyDevice = computed(() => {
return homeAssistant.value?.devices?.find(d => d.category === 'BodyComposition')
})
function getDeviceStatusType(device: HomeAssistantDiscoveredDevice): 'green' | 'yellow' | 'gray' {
if (!device.online) return 'gray'
if (device.category === 'Media') {
const hasMedia = device.entities.some(e => e.domain === 'media_player' && e.available)
return hasMedia ? 'green' : 'yellow'
}
const hasAvailable = device.entities.some(e => e.available)
return hasAvailable ? 'green' : 'yellow'
}
function getDeviceStatusText(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'gray') return '设备离线'
if (type === 'yellow') {
if (device.category === 'Media') return '在线 · 播放功能暂不可用'
return '在线 · 部分功能受限'
}
if (device.category === 'EnvironmentControl') {
const humidity = device.metrics?.humidity
return humidity ? `在线 · 当前湿度 ${humidity}%` : '在线 · 运行正常'
}
return '在线 · 运行正常'
}
function getSceneDeviceShortName(device: HomeAssistantDiscoveredDevice): string {
if (device.category === 'EnvironmentControl') return '除湿机 Max'
if (device.category === 'Fragrance') return '智能香氛机'
if (device.category === 'Lighting') return 'Lemesh 色温灯'
if (device.category === 'Media') return 'Sound Pro 音箱'
return device.name
}
function getSceneDeviceStatus(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'green') {
if (device.category === 'EnvironmentControl' && device.metrics?.humidity) {
return `${device.metrics.humidity}%`
}
return '已就绪'
}
if (type === 'yellow') return '受限'
return '离线'
}
function getDeviceShortBadgeText(device: HomeAssistantDiscoveredDevice | undefined): string {
if (!device) return '未接入'
const type = getDeviceStatusType(device)
if (type === 'green') return '设备在线'
if (type === 'yellow') return '部分受限'
return '设备离线'
}
function getDeviceListBadgeText(device: HomeAssistantDiscoveredDevice): string {
const type = getDeviceStatusType(device)
if (type === 'green') return '在线'
if (type === 'yellow') return '受限'
return '离线'
}
function getDeviceSubModel(device: HomeAssistantDiscoveredDevice | undefined, fallback: string): string {
if (!device) return fallback
if (device.category === 'EnvironmentSensor') return '罗威亚检测仪'
if (device.category === 'Sleep') return '8H 睡眠床垫'
if (device.category === 'BodyComposition') return '米家体脂秤'
return device.name
}
function getTemperatureTag(temp: number | undefined | null): string {
if (temp == null) return '待采集'
if (temp < 18) return '偏凉'
if (temp <= 26) return '适宜'
return '偏暖'
}
function getHumidityTag(hum: number | undefined | null): string {
if (hum == null) return '待采集'
if (hum < 40) return '干燥'
if (hum <= 60) return '舒适'
if (hum <= 70) return '稍潮'
return '潮湿'
}
async function load() {
loading.value = true
try {
@@ -182,19 +55,6 @@ async function executeScene(scene: 'sleep' | 'all-off') {
}
}
async function generate() {
generating.value = true
try {
await healthApi.generatePlan()
await load()
showToast({ type: 'success', message: '方案已更新' })
} catch (error) {
showToast(error instanceof Error ? error.message : '生成失败')
} finally {
generating.value = false
}
}
function logout() {
clearTokens()
router.replace('/auth')
@@ -221,295 +81,102 @@ onMounted(load)
<template v-else>
<section class="hero-card">
<p class="eyebrow">今日健康建议</p>
<h2>{{ dashboard?.currentPlan?.title || '尚未生成健康方案' }}</h2>
<p>{{ dashboard?.currentPlan?.summary || '完成健康板块选择后,即可生成第一版个人参数。' }}</p>
<div class="tag-row">
<span v-for="code in dashboard?.selectedModules" :key="code">{{ moduleNames[code] || code }}</span>
</div>
<h2>{{ dashboard?.currentPlan?.title || '尚未完成健康评估' }}</h2>
<p>{{ dashboard?.currentPlan?.summary || '完成健康评估后,系统会自动生成个人健康方案与家居启动参数。' }}</p>
<van-button
v-if="!dashboard?.currentPlan"
round
color="#f1c37a"
text-color="#183b34"
:loading="generating"
@click="generate"
@click="router.replace('/assessment')"
>
立即生成
去健康评估
</van-button>
</section>
<!-- 未完成评估提示横幅 -->
<section v-if="!dashboard?.hasCompletedAssessment" class="assessment-guide-banner" @click="router.replace('/assessment')">
<div class="guide-badge">重要提醒</div>
<div class="guide-body">
<div class="guide-text">
<h3>先填写个人基本信息与身体评估</h3>
<p>尚未进行身体及睡眠质量、运动情况、饮食情况三大维度评估。完成评估后将为您专属定制全套智能家居启动参数!</p>
</div>
<button class="guide-btn" type="button">去评估 &gt;</button>
</div>
</section>
<!-- 个性化智能家居启动参数概览 (已完成评估) -->
<section v-else class="section-card personalized-startup-card">
<div class="section-title">
<span>专属舒睡启动参数</span>
</div>
<p class="startup-card-desc">依据您的体征评估与冷热、承托偏好,智能中枢已生成专属预设:</p>
<div class="startup-params-tags">
<div class="startup-param-badge">
<span class="lbl">恒湿目标</span>
<strong>{{ dashboard?.sceneConfig?.targetHumidity || 55 }}% · {{ dashboard?.sceneConfig?.fanLevel || '1挡' }}</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠灯光</span>
<strong>{{ dashboard?.sceneConfig?.lightBrightnessPercent || 20 }}% · {{ dashboard?.sceneConfig?.lightColorTemperatureKelvin || 2700 }}K</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">助眠音箱</span>
<strong>{{ dashboard?.sceneConfig?.enableSpeakerSleepMode ? '开启睡眠模式' : '关闭睡眠模式' }} · {{ dashboard?.sceneConfig?.speakerVolumePercent || 15 }}%</strong>
</div>
<div class="startup-param-badge">
<span class="lbl">8H 床垫</span>
<strong>左右加热 {{ dashboard?.sceneConfig?.mattressHeatingLevel ?? 1 }} 档 · {{ dashboard?.sceneConfig?.mattressMode || '智能适配' }}</strong>
</div>
<div class="startup-param-badge" v-if="dashboard?.sceneConfig?.enableUv">
<span class="lbl">除湿消杀</span>
<strong style="color: #7b42b5;">UV 深度消杀已开启</strong>
</div>
</div>
<div class="startup-scene-actions">
<button
class="startup-action-primary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '正在开启…' : '一键开启' }}
</button>
<button
class="startup-action-secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '正在关闭…' : '一键关闭' }}
</button>
</div>
<div class="detail-entry-bar" @click="router.push('/device-control')">
<div class="entry-left">
<span class="icon">⚙️</span>
<div>
<strong>进入设备详情与独立参数调节</strong>
<small>显示每个设备在线状态,可单独开关与精细滑块调节</small>
</div>
</div>
<span class="arrow">&gt;</span>
</div>
</section>
<section class="metric-strip">
<div><small>睡眠目标</small><strong>7–8<em>h</em></strong></div>
<div><small>活动目标</small><strong>30<em>min</em></strong></div>
<div><small>方案版本</small><strong>V{{ dashboard?.currentPlan?.version || 0 }}</strong></div>
</section>
<section class="section-card">
<div class="section-title"><span>我的健康板块</span><button type="button" @click="router.push('/onboarding')">调整</button></div>
<article v-for="item in dashboard?.currentPlan?.items" :key="item.moduleCode" class="plan-item">
<div class="plan-index">{{ item.moduleCode === 'sleep' ? '☾' : item.moduleCode === 'exercise' ? '↗' : '◒' }}</div>
<div>
<div class="plan-heading"><strong>{{ item.title }}</strong><span>{{ item.target }}</span></div>
<p>{{ item.advice }}</p>
</div>
</article>
</section>
<section class="section-card smart-card">
<!-- 顶部连接状态与总览 -->
<div class="section-title">
<span>健康空间</span>
<small class="status-dot" :class="{ online: homeAssistant?.connected }">
{{ homeAssistant?.connected
? `已连接 · ${onlineDeviceCount}/${totalDeviceCount} 在线`
: (homeAssistant?.message || '正在连接 Home Assistant') }}
</small>
</div>
<!-- 汇总胶囊条 -->
<div v-if="homeAssistant?.connected" class="ha-summary-bar">
<div class="summary-item">
<span>在线设备</span>
<strong>{{ onlineDeviceCount }}<em>/{{ totalDeviceCount }}</em></strong>
</div>
<div class="summary-item">
<span>可用实体</span>
<strong>{{ availableEntityCount }}<em>/{{ totalEntityCount }}</em></strong>
</div>
<div class="summary-item">
<span>场景就绪</span>
<strong>{{ sceneAvailable }}<em>/{{ sceneTotal }}</em></strong>
</div>
</div>
<!-- 1. 场景控制板块 -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌙</span>
<strong>睡眠场景控制</strong>
</div>
<span class="scene-status-badge">场景 {{ sceneAvailable }}/{{ sceneTotal }} 就绪</span>
</div>
<div class="scene-row">
<div class="scene-info">
<strong>舒睡模式</strong>
<p>除湿 55% · 微风睡眠 · 暖光助眠 · 草本香氛</p>
</div>
<div class="scene-actions">
<button
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('sleep')"
>
{{ sceneRunning === 'sleep' ? '执行中…' : '一键舒睡' }}
</button>
<button
class="secondary"
type="button"
:disabled="!homeAssistant?.connected || sceneAvailable === 0 || !!sceneRunning"
@click="executeScene('all-off')"
>
{{ sceneRunning === 'all-off' ? '执行中…' : '全部关闭' }}
</button>
</div>
</div>
<!-- 场景关联的4个设备微卡片 -->
<div v-if="sceneDevices.length" class="scene-device-list">
<div
v-for="d in sceneDevices"
:key="d.deviceId"
class="scene-device-chip"
:class="getDeviceStatusType(d)"
>
<span class="dot"></span>
<span class="name">{{ getSceneDeviceShortName(d) }}</span>
<span class="desc">{{ getSceneDeviceStatus(d) }}</span>
</div>
</div>
</div>
<!-- 2. 环境健康板块 (空气检测仪) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🌿</span>
<strong>环境健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(envDevice, '罗威亚检测仪') }}</span>
</div>
<span v-if="envDevice" class="device-mini-status" :class="getDeviceStatusType(envDevice)">
{{ getDeviceShortBadgeText(envDevice) }}
</span>
</div>
<div class="env-container">
<!-- 基础温湿度:突出展示体感舒适 -->
<div class="env-primary-grid">
<div class="env-hero-card">
<div class="env-hero-header">
<span class="env-label">室内温度</span>
<span class="env-tag">{{ getTemperatureTag(homeAssistant?.environment?.temperature) }}</span>
</div>
<div class="env-hero-value">
<strong>{{ homeAssistant?.environment?.temperature ?? '—' }}</strong>
<em>°C</em>
</div>
</div>
<div class="env-hero-card">
<div class="env-hero-header">
<span class="env-label">室内湿度</span>
<span class="env-tag">{{ getHumidityTag(homeAssistant?.environment?.humidity) }}</span>
</div>
<div class="env-hero-value">
<strong>{{ homeAssistant?.environment?.humidity ?? '—' }}</strong>
<em>%</em>
</div>
</div>
</div>
<!-- 空气洁净与化学挥发指标:5项紧凑对称排布 -->
<div class="env-secondary-grid">
<div class="env-metric-item">
<small>PM2.5</small>
<strong>{{ homeAssistant?.environment?.pm25 ?? '—' }}</strong>
<em>μg/m³</em>
</div>
<div class="env-metric-item">
<small>PM10</small>
<strong>{{ homeAssistant?.environment?.pm10 ?? '—' }}</strong>
<em>μg/m³</em>
</div>
<div class="env-metric-item">
<small>CO₂</small>
<strong>{{ homeAssistant?.environment?.co2 ?? '—' }}</strong>
<em>ppm</em>
</div>
<div class="env-metric-item">
<small>甲醛</small>
<strong>{{ homeAssistant?.environment?.hcho ?? '—' }}</strong>
<em>mg/m³</em>
</div>
<div class="env-metric-item">
<small>TVOC</small>
<strong>{{ homeAssistant?.environment?.tvoc ?? '—' }}</strong>
<em>mg/m³</em>
</div>
</div>
</div>
</div>
<!-- 3. 睡眠健康板块 (8H 智能床垫) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">🛏️</span>
<strong>睡眠健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(sleepDevice, '8H 睡眠床垫') }}</span>
</div>
<span v-if="sleepDevice" class="device-mini-status" :class="getDeviceStatusType(sleepDevice)">
{{ getDeviceShortBadgeText(sleepDevice) }}
</span>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>在床监测</small>
<strong>{{ sleepDevice?.metrics?.sleepStatus || (sleepDevice?.online ? '实时监测' : '待同步') }}</strong>
</div>
<div class="feature-item">
<small>左侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.leftFirmness || '5 档 · 适中' }}</strong>
</div>
<div class="feature-item">
<small>右侧软硬度</small>
<strong>{{ sleepDevice?.metrics?.rightFirmness || '6 档 · 偏硬' }}</strong>
</div>
<div class="feature-item">
<small>智能调温</small>
<strong>{{ sleepDevice?.online ? '温控已就绪' : '待接入' }}</strong>
</div>
</div>
</div>
<!-- 4. 身体健康板块 (体脂秤) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">⚖️</span>
<strong>身体健康</strong>
<span class="subcard-model">{{ getDeviceSubModel(bodyDevice, '米家体脂秤') }}</span>
</div>
<span v-if="bodyDevice" class="device-mini-status" :class="getDeviceStatusType(bodyDevice)">
{{ getDeviceShortBadgeText(bodyDevice) }}
</span>
</div>
<div class="device-feature-row">
<div class="feature-item">
<small>体脂秤状态</small>
<strong>{{ bodyDevice?.online ? '称重即同步' : '待同步' }}</strong>
</div>
<div class="feature-item">
<small>最新体重</small>
<strong>{{ bodyDevice?.metrics?.weight || '68.5' }}<em>kg</em></strong>
</div>
<div class="feature-item">
<small>体脂率</small>
<strong>{{ bodyDevice?.metrics?.bodyFatRate || '18.2' }}<em>%</em></strong>
</div>
<div class="feature-item">
<small>测量算法</small>
<strong>BIA 深度测量</strong>
</div>
</div>
</div>
<!-- 5. 全部智能设备 (7 台设备列表) -->
<div class="smart-subcard">
<div class="subcard-header">
<div class="subcard-title">
<span class="subcard-icon">📱</span>
<strong>全部智能设备</strong>
<span class="subcard-model">({{ totalDeviceCount }} 台)</span>
</div>
<small style="color: #82908c; font-size: 11px;">自动发现实时同步</small>
</div>
<div v-if="homeAssistant?.devices?.length" class="all-device-list">
<article
v-for="item in homeAssistant.devices"
:key="item.deviceId"
class="device-card-item"
:class="getDeviceStatusType(item)"
>
<div class="device-card-main">
<span class="device-status-dot" :class="getDeviceStatusType(item)"></span>
<div class="device-card-info">
<div class="device-card-top">
<strong>{{ item.name }}</strong>
<span class="category-tag">{{ categoryNames[item.category] || item.category }}</span>
</div>
<p class="device-card-meta">
<span>{{ item.areaName || '睡眠空间' }}</span>
<span>· {{ item.model }}</span>
</p>
</div>
</div>
<div class="device-card-extra">
<span class="status-pill" :class="getDeviceStatusType(item)">
{{ getDeviceListBadgeText(item) }}
</span>
<small class="entity-count">{{ item.entities.filter(e => e.available).length }}/{{ item.entities.length }} 实体</small>
</div>
</article>
</div>
<div v-else style="padding: 16px; text-align: center; color: #82908c; font-size: 12px;">
暂无已发现的设备
</div>
</div>
</section>
<p class="medical-note">健康建议仅用于日常管理演示,不构成医疗诊断或治疗意见。</p>
</template>
<!-- 固定底部导航栏 -->
<AppTabBar />
</main>
</template>
File diff suppressed because it is too large Load Diff
-99
View File
@@ -1,99 +0,0 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, Field as VanField, showToast } from 'vant'
import { healthApi } from '../services/health'
const router = useRouter()
const loading = ref(false)
const selected = ref<string[]>(['sleep', 'exercise', 'diet'])
const modules = [
{ code: 'sleep', icon: '☾', title: '睡眠质量', copy: '环境与节律深睡' },
{ code: 'exercise', icon: '↗', title: '运动情况', copy: '日常活动与活力' },
{ code: 'diet', icon: '◒', title: '饮食情况', copy: '三餐热量与偏好' }
]
const profile = reactive({
gender: '',
heightCm: undefined as number | undefined,
weightKg: undefined as number | undefined,
averageSleepHours: undefined as number | undefined,
dailyExerciseMinutes: undefined as number | undefined,
dietPreference: '',
healthGoal: ''
})
function toggle(code: string) {
selected.value = selected.value.includes(code)
? selected.value.filter((item) => item !== code)
: [...selected.value, code]
}
async function submit() {
if (!selected.value.length) {
showToast('请至少选择一个健康板块')
return
}
loading.value = true
try {
await Promise.all([
healthApi.saveModules(selected.value),
healthApi.saveProfile(profile)
])
await healthApi.generatePlan()
showToast({ type: 'success', message: '专属方案已生成' })
await router.replace('/dashboard')
} catch (error) {
showToast(error instanceof Error ? error.message : '保存失败')
} finally {
loading.value = false
}
}
</script>
<template>
<main class="page-shell">
<header class="page-header">
<p class="eyebrow">PERSONAL SETUP · 01</p>
<h1>今天,想先关注什么?</h1>
<p>选择健康板块,系统将结合您的基础信息生成第一版个人参数。</p>
</header>
<section class="section-card">
<div class="section-title"><span>健康板块</span><small>可多选,已选 {{ selected.length }}/3</small></div>
<div class="module-grid">
<button
v-for="item in modules"
:key="item.code"
class="module-card"
:class="{ selected: selected.includes(item.code) }"
type="button"
@click="toggle(item.code)"
>
<span class="module-icon">{{ item.icon }}</span>
<strong>{{ item.title }}</strong>
<small>{{ item.copy }}</small>
<i>{{ selected.includes(item.code) ? '✓ 已选' : '+ 选择' }}</i>
</button>
</div>
</section>
<section class="section-card form-card">
<div class="section-title"><span>身体基线数据</span><small>用于生成参数</small></div>
<div class="two-column">
<van-field v-model="profile.gender" label="性别" label-width="40px" placeholder="男 / 女" />
<van-field v-model.number="profile.heightCm" type="number" label="身高" label-width="40px" placeholder="厘米(cm)" />
<van-field v-model.number="profile.weightKg" type="number" label="体重" label-width="40px" placeholder="公斤(kg)" />
<van-field v-model.number="profile.averageSleepHours" type="number" label="睡眠" label-width="40px" placeholder="小时/天" />
</div>
<van-field v-model.number="profile.dailyExerciseMinutes" type="number" label="日均活动" label-width="70px" placeholder="平均每天中等活动时长(分钟)" />
<van-field v-model="profile.dietPreference" label="饮食偏好" label-width="70px" placeholder="例如:少油清淡、偏面食、少糖" />
<van-field v-model="profile.healthGoal" label="健康目标" label-width="70px" type="textarea" rows="2" placeholder="例如:希望改善晚上入睡困难、提升精力" />
</section>
<div class="sticky-action">
<van-button block round color="#0c5045" :loading="loading" @click="submit">
生成我的健康方案
</van-button>
</div>
</main>
</template>
+638
View File
@@ -0,0 +1,638 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { clearTokens } from '../services/auth'
import { healthApi, type Dashboard, type HealthAssessmentOutput } from '../services/health'
const router = useRouter()
const loading = ref(true)
const dashboard = ref<Dashboard>()
const assessment = ref<HealthAssessmentOutput>()
async function loadData() {
loading.value = true
try {
const [dashRes, assessRes] = await Promise.all([
healthApi.dashboard(),
healthApi.getAssessment().catch(() => undefined)
])
dashboard.value = dashRes
assessment.value = assessRes
} catch (err) {
showToast(err instanceof Error ? err.message : '加载失败')
} finally {
loading.value = false
}
}
const bmiDisplay = computed(() => {
if (dashboard.value?.bmi) {
return {
val: dashboard.value.bmi,
status: dashboard.value.bmiStatus || '正常'
}
}
if (assessment.value?.bmi) {
return {
val: assessment.value.bmi,
status: assessment.value.bmiStatus || '正常'
}
}
return null
})
const ageDisplay = computed(() => {
const raw = dashboard.value?.profile?.birthday || assessment.value?.basicInfo.birthday
if (!raw) return undefined
const birthday = new Date(raw)
const today = new Date()
let age = today.getFullYear() - birthday.getFullYear()
if (today < new Date(today.getFullYear(), birthday.getMonth(), birthday.getDate())) age--
return age
})
// 根据评估答案提取体质标签
const profileTags = computed(() => {
const ans = assessment.value?.answers
if (!ans || !assessment.value?.hasCompletedAssessment) return []
const list: { label: string; desc: string; type: 'gold' | 'blue' | 'green' | 'purple' }[] = []
if (ans.temperaturePreference === 'heat_fear') {
list.push({ label: '体感燥热偏温', desc: '除湿机清爽48% · 床垫低档加热', type: 'blue' })
} else if (ans.temperaturePreference === 'cold_fear') {
list.push({ label: '体质畏寒怕冷', desc: '除湿机温润58% · 床垫加强加热', type: 'gold' })
} else {
list.push({ label: '冷热适中平衡', desc: '标准舒适温湿度', type: 'green' })
}
if (ans.fallingAsleepSpeed === 'hard') {
list.push({ label: '睡前敏感易失眠', desc: '2200K超暖暗光 · 音箱睡眠模式', type: 'purple' })
} else if (ans.fallingAsleepSpeed === 'fast') {
list.push({ label: '快速深度睡眠', desc: '自然转场柔光', type: 'green' })
}
if (ans.muscleFatigueArea === 'waist_back' || ans.muscleFatigueArea === 'back_neck') {
list.push({ label: '脊柱腰背劳损', desc: '床垫启用智能腰托模式', type: 'gold' })
}
if (ans.lateNightEating === 'often' || ans.lateNightEating === 'sometimes' || ans.flavorPreference === 'heavy') {
list.push({ label: '晚间进食空气净化', desc: '除湿机UV紫外消杀已启用', type: 'purple' })
}
return list
})
function handleLogout() {
showConfirmDialog({
title: '确认退出',
message: '确定要退出当前账号登录吗?'
}).then(() => {
clearTokens()
showToast('已安全退出')
router.replace('/auth')
}).catch(() => {})
}
onMounted(() => {
loadData()
})
</script>
<template>
<div class="profile-page">
<!-- 顶部用户卡片 -->
<header class="profile-header">
<div class="user-row">
<div class="avatar-box">
<span>{{ (dashboard?.user.displayName || '客').slice(0, 1) }}</span>
</div>
<div class="user-info">
<h2>{{ dashboard?.user.displayName || '尊贵会员' }}</h2>
<p class="phone-text">{{ dashboard?.user.phone || '健康管理账号' }}</p>
<span class="user-badge">常青树 · 智能健康睡眠会员</span>
</div>
</div>
</header>
<main class="profile-content">
<!-- 身体指标基线档案 -->
<section class="profile-card">
<div class="card-head">
<h3>身体基线档案</h3>
<button class="edit-btn" @click="router.replace('/assessment')">编辑指标</button>
</div>
<div class="metrics-grid">
<div class="metric-item">
<span class="m-label">性别</span>
<strong class="m-val">{{ dashboard?.profile?.gender || assessment?.basicInfo.gender || '已保密' }}</strong>
</div>
<div class="metric-item">
<span class="m-label">身高</span>
<strong class="m-val">{{ dashboard?.profile?.heightCm || assessment?.basicInfo.heightCm || '--' }} <small>cm</small></strong>
</div>
<div class="metric-item">
<span class="m-label">年龄</span>
<strong class="m-val">{{ ageDisplay ?? '--' }} <small>岁</small></strong>
</div>
<div class="metric-item">
<span class="m-label">体重</span>
<strong class="m-val">{{ dashboard?.profile?.weightKg || assessment?.basicInfo.weightKg || '--' }} <small>kg</small></strong>
</div>
<div class="metric-item">
<span class="m-label">BMI 指数</span>
<strong class="m-val" :class="{ highlight: bmiDisplay }">
{{ bmiDisplay?.val || '--' }}
</strong>
<span class="m-status-tag" v-if="bmiDisplay">{{ bmiDisplay.status }}</span>
</div>
</div>
</section>
<!-- 专属评估画像标签 -->
<section class="profile-card">
<div class="card-head">
<h3>健康评估画像与专属策略</h3>
<span class="status-indicator" :class="{ done: assessment?.hasCompletedAssessment }">
{{ assessment?.hasCompletedAssessment ? '已完成评估' : '未评估' }}
</span>
</div>
<div v-if="assessment?.hasCompletedAssessment && profileTags.length > 0" class="tags-container">
<div
v-for="(tag, idx) in profileTags"
:key="idx"
class="profile-tag-item"
:class="'theme-' + tag.type"
>
<div class="tag-title">{{ tag.label }}</div>
<div class="tag-desc">{{ tag.desc }}</div>
</div>
</div>
<div v-else class="empty-state">
<p>您尚未完成身体健康评估,全屋智能家具当前使用出厂默认参数运行。</p>
<VanButton
type="primary"
size="small"
round
color="#0c5045"
@click="router.replace('/assessment')"
>
立即进行健康评估
</VanButton>
</div>
</section>
<!-- 当前健康方案 -->
<section class="profile-card" v-if="dashboard?.currentPlan">
<div class="card-head">
<h3>当前执行方案</h3>
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}.0</span>
</div>
<p class="plan-summary-text">{{ dashboard.currentPlan.summary }}</p>
<div class="plan-items-list">
<div
v-for="(item, i) in dashboard.currentPlan.items"
:key="i"
class="plan-row"
>
<div class="plan-row-head">
<span class="dot"></span>
<strong>{{ item.title }}</strong>
</div>
<p class="plan-target">目标:{{ item.target }}</p>
<p class="plan-advice">指导:{{ item.advice }}</p>
</div>
</div>
</section>
<!-- 快捷功能入口与操作菜单 -->
<section class="action-card-menu">
<div class="menu-item" @click="router.replace('/assessment')">
<div class="menu-left">
<div class="menu-icon icon-assess">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
</svg>
</div>
<div class="menu-text">
<strong>重新进行健康评估</strong>
<small>更新身体指标,重新智能推导家具启动参数</small>
</div>
</div>
<span class="arrow">&gt;</span>
</div>
<div class="menu-item" @click="router.push('/device-control')">
<div class="menu-left">
<div class="menu-icon icon-device">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2">
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<line x1="9" y1="1" x2="9" y2="4" />
<line x1="15" y1="1" x2="15" y2="4" />
<line x1="9" y1="20" x2="9" y2="23" />
<line x1="15" y1="20" x2="15" y2="23" />
</svg>
</div>
<div class="menu-text">
<strong>设备详情与参数调节</strong>
<small>查看7台设备状态、独立开关与精细参数设置</small>
</div>
</div>
<span class="arrow">&gt;</span>
</div>
</section>
<!-- 退出登录按钮 -->
<div class="logout-wrap">
<button class="logout-btn" @click="handleLogout">
退出登录
</button>
</div>
</main>
<!-- 固定底部导航栏 -->
<AppTabBar />
</div>
</template>
<style scoped>
.profile-page {
padding-bottom: 90px;
background: #f2f0e9;
min-height: 100vh;
}
.profile-header {
background: linear-gradient(135deg, #0c5045, #123d36);
color: #fff;
padding: 34px 20px 28px;
border-radius: 0 0 32px 32px;
box-shadow: 0 10px 30px rgba(12, 80, 69, 0.12);
}
.user-row {
display: flex;
align-items: center;
gap: 16px;
}
.avatar-box {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #d6a657, #b88636);
color: #fff;
display: grid;
place-items: center;
font-size: 26px;
font-family: serif;
font-weight: 700;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
border: 2px solid rgba(255, 255, 255, 0.4);
}
.user-info h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 700;
}
.phone-text {
margin: 0 0 6px;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
}
.user-badge {
display: inline-block;
font-size: 11px;
background: rgba(255, 255, 255, 0.15);
color: #ffe6ba;
padding: 2px 8px;
border-radius: 12px;
}
.profile-content {
padding: 16px 14px;
}
.profile-card {
background: #ffffff;
border-radius: 20px;
padding: 18px 16px;
margin-bottom: 14px;
box-shadow: 0 4px 18px rgba(18, 63, 55, 0.04);
border: 1px solid rgba(18, 63, 55, 0.06);
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.card-head h3 {
margin: 0;
font-size: 15px;
color: #123f37;
font-weight: 700;
}
.edit-btn {
border: 0;
background: transparent;
color: #0c5045;
font-size: 12px;
font-weight: 600;
}
.status-indicator {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #f0f0ed;
color: #88948e;
}
.status-indicator.done {
background: #eef6f3;
color: #0c5045;
font-weight: 600;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.metric-item {
background: #f8f7f3;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.m-label {
font-size: 11px;
color: #798a83;
margin-bottom: 4px;
}
.m-val {
font-size: 15px;
color: #173d36;
font-weight: 700;
}
.m-val small {
font-size: 10px;
color: #8fa099;
}
.m-val.highlight {
color: #0c5045;
font-size: 16px;
}
.m-status-tag {
font-size: 10px;
color: #0c5045;
background: rgba(12, 80, 69, 0.1);
padding: 1px 6px;
border-radius: 8px;
margin-top: 4px;
font-weight: 600;
}
/* 标签列表 */
.tags-container {
display: flex;
flex-direction: column;
gap: 8px;
}
.profile-tag-item {
border-radius: 12px;
padding: 10px 12px;
border: 1px solid;
}
.profile-tag-item.theme-gold {
background: #fcf9f2;
border-color: rgba(214, 166, 87, 0.3);
}
.profile-tag-item.theme-gold .tag-title {
color: #9c6c19;
}
.profile-tag-item.theme-blue {
background: #f3f8fd;
border-color: rgba(66, 153, 225, 0.3);
}
.profile-tag-item.theme-blue .tag-title {
color: #2b6cb0;
}
.profile-tag-item.theme-green {
background: #f1f8f5;
border-color: rgba(12, 80, 69, 0.2);
}
.profile-tag-item.theme-green .tag-title {
color: #0c5045;
}
.profile-tag-item.theme-purple {
background: #f8f4fd;
border-color: rgba(159, 122, 234, 0.3);
}
.profile-tag-item.theme-purple .tag-title {
color: #6b46c1;
}
.tag-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 2px;
}
.tag-desc {
font-size: 11px;
color: #60736c;
}
.empty-state {
text-align: center;
padding: 16px 10px;
}
.empty-state p {
margin: 0 0 12px;
font-size: 12px;
color: #84968f;
line-height: 1.5;
}
/* 方案卡片 */
.version-tag {
font-size: 11px;
color: #0c5045;
background: #eef6f3;
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
}
.plan-summary-text {
margin: 0 0 14px;
font-size: 12px;
color: #556c64;
line-height: 1.6;
}
.plan-items-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-row {
background: #f9f8f4;
border-radius: 12px;
padding: 10px 12px;
}
.plan-row-head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.plan-row-head .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #0c5045;
}
.plan-row-head strong {
font-size: 13px;
color: #173e37;
}
.plan-target, .plan-advice {
margin: 2px 0;
font-size: 12px;
color: #697c75;
line-height: 1.4;
}
/* 菜单导航 */
.action-card-menu {
background: #ffffff;
border-radius: 20px;
overflow: hidden;
margin-bottom: 16px;
border: 1px solid rgba(18, 63, 55, 0.06);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(18, 63, 55, 0.05);
cursor: pointer;
transition: background 0.2s;
}
.menu-item:last-child {
border-bottom: none;
}
.menu-item:active {
background: #f8f7f3;
}
.menu-left {
display: flex;
align-items: center;
gap: 12px;
}
.menu-icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: grid;
place-items: center;
}
.icon-assess {
background: #eef7f3;
color: #0c5045;
}
.icon-device {
background: #fbf5e8;
color: #b07e26;
}
.menu-text strong {
display: block;
font-size: 13px;
color: #183e37;
}
.menu-text small {
display: block;
font-size: 11px;
color: #7b8e88;
margin-top: 2px;
}
.arrow {
color: #b3bfb9;
font-size: 14px;
}
.logout-wrap {
margin-top: 24px;
}
.logout-btn {
width: 100%;
padding: 13px 0;
border-radius: 14px;
border: 1px solid rgba(220, 70, 70, 0.25);
background: #ffffff;
color: #d64545;
font-size: 14px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
}
.logout-btn:active {
background: #fff5f5;
}
</style>