Compare commits
4
Commits
0f97d8cba3
...
8c2d262066
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c2d262066 | ||
|
|
e6215912d9 | ||
|
|
dec3dfdfe3 | ||
|
|
426eaca97f |
+13
-1
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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">去评估 ></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">></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
@@ -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>
|
||||
@@ -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">></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">></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>
|
||||
Reference in New Issue
Block a user