Files
Evergreen-Health-Mobile/src/views/ProfileView.vue
T
2026-11-13 14:03:38 +08:00

653 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Button as VanButton, showConfirmDialog, showToast } from 'vant'
import AppTabBar from '../components/AppTabBar.vue'
import { usePageRequest } from '../composables/usePageRequest'
import { ageFromBirthday } from '../services/formValidation'
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>()
const loadError = ref('')
const pageRequest = usePageRequest()
const logoutBusy = ref(false)
let active = true
async function loadData() {
const requestId = pageRequest.begin()
loading.value = true
loadError.value = ''
try {
const [dashRes, assessRes] = await Promise.all([
healthApi.dashboard(),
healthApi.getAssessment()
])
if (!pageRequest.isCurrent(requestId)) return
if (!dashRes || !assessRes) throw new Error('健康档案暂时无法读取,请重试')
dashboard.value = dashRes
assessment.value = assessRes
} catch (err) {
if (pageRequest.isCurrent(requestId)) loadError.value = err instanceof Error ? err.message : '加载失败,请重试'
} finally {
if (pageRequest.isCurrent(requestId)) loading.value = false
}
}
const bmiDisplay = computed(() => {
if (typeof dashboard.value?.bmi === 'number' && Number.isFinite(dashboard.value.bmi) && dashboard.value.bmi > 0) {
return {
val: dashboard.value.bmi,
status: dashboard.value.bmiStatus || '未提供分类'
}
}
if (typeof assessment.value?.bmi === 'number' && Number.isFinite(assessment.value.bmi) && assessment.value.bmi > 0) {
return {
val: assessment.value.bmi,
status: assessment.value.bmiStatus || '未提供分类'
}
}
return null
})
const ageDisplay = computed(() => {
const raw = dashboard.value?.profile?.birthday || assessment.value?.basicInfo.birthday
return ageFromBirthday(raw)
})
// 健康评估只展示生活习惯画像;体质由中医四诊独立判定。
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: '自述夜间较容易感到闷热', type: 'blue' })
} else if (ans.temperaturePreference === 'cold_fear') {
list.push({ label: '夜间偏冷感', desc: '自述睡眠时较容易感到寒冷', type: 'gold' })
} else if (ans.temperaturePreference === 'normal') {
list.push({ label: '冷热感受适中', desc: '自述夜间温度感受较平稳', type: 'green' })
}
if (ans.fallingAsleepSpeed === 'hard') {
list.push({ label: '入睡较慢', desc: '可关注睡前作息和放松习惯', 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: '可逐步调整夜宵与重口味习惯', type: 'purple' })
}
return list
})
function handleLogout() {
if (!active || logoutBusy.value) return
logoutBusy.value = true
showConfirmDialog({
title: '确认退出',
message: '确定要退出当前账号登录吗?'
}).then(() => {
if (!active) return
clearTokens()
showToast('已安全退出')
router.replace('/auth')
}).catch(() => {}).finally(() => { if (active) logoutBusy.value = false })
}
onMounted(() => {
loadData()
})
onUnmounted(() => { active = false })
</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 v-if="loading" class="profile-card page-load-state" role="status"><span class="page-loading-mark" aria-hidden="true"></span><p>正在读取您的健康档案…</p></section>
<section v-else-if="loadError" class="profile-card page-error-state" role="alert">
<h3>暂时无法读取健康档案</h3><p>{{ loadError }}</p><button class="retry-button" type="button" @click="loadData">重新加载</button>
</section>
<template v-else-if="dashboard">
<!-- 身体指标基线档案 -->
<section class="profile-card">
<div class="card-head">
<h3>身体基线档案</h3>
<button class="edit-btn" type="button" @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 bmi-metric">
<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="#0284c7"
@click="router.replace('/assessment')"
>
立即进行健康评估
</VanButton>
</div>
</section>
<section class="profile-card">
<div class="card-head">
<h3>中医四诊体质倾向</h3>
<span class="status-indicator" :class="{ done: dashboard?.tcmResult?.hasCompleted }">
{{ dashboard?.tcmResult?.hasCompleted ? '已生成' : '未评估' }}
</span>
</div>
<template v-if="dashboard?.tcmResult?.hasCompleted">
<p class="plan-summary-text">{{ dashboard.tcmResult.constitutionName }}{{ dashboard.tcmResult.secondaryName ? ` · 兼${dashboard.tcmResult.secondaryName}` : '' }}</p>
<p class="plan-summary-text">{{ dashboard.tcmResult.summary }}</p>
<p class="plan-summary-text">香型建议:{{ dashboard.tcmResult.aromaRecommendation }};音乐建议:{{ dashboard.tcmResult.musicRecommendation }}</p>
</template>
<p v-else class="plan-summary-text">完成四诊后,才能一键启动体质相关的设备预设。</p>
<VanButton type="primary" size="small" round color="#0284c7" @click="router.push('/tcm-assessment')">
{{ dashboard?.tcmResult?.hasCompleted ? '重新评估体质' : '去完成四诊' }}
</VanButton>
</section>
<!-- 当前健康方案 -->
<section class="profile-card" v-if="dashboard?.currentPlan">
<div class="card-head">
<h3>日常健康建议</h3>
<span class="version-tag">版本 v{{ dashboard.currentPlan.version }}</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">
<button class="menu-item" type="button" @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>
</button>
<button class="menu-item" type="button" @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>查看设备状态、快捷开关与完整参数</small>
</div>
</div>
<span class="arrow">&gt;</span>
</button>
</section>
</template>
<!-- 退出登录按钮 -->
<div class="logout-wrap">
<button class="logout-btn" type="button" :disabled="logoutBusy" @click="handleLogout">
退出登录
</button>
</div>
</main>
<!-- 固定底部导航栏 -->
<AppTabBar />
</div>
</template>
<style scoped>
.profile-page {
max-width: 480px;
margin: 0 auto;
padding: 20px var(--page-gutter, 16px) calc(88px + env(safe-area-inset-bottom));
background: linear-gradient(180deg, #bfe3fc 0%, #e0f2fe 22%, #f0f9ff 48%, #f8fafc 100%);
min-height: 100vh;
box-sizing: border-box;
}
.profile-header {
background: transparent;
color: #0f172a;
padding: 4px 4px 20px;
border-radius: 0;
box-shadow: none;
}
.user-row {
display: flex;
align-items: center;
gap: 16px;
}
.avatar-box {
width: 58px;
height: 58px;
border-radius: 50%;
background: #f0f9ff;
color: #0284c7;
display: grid;
place-items: center;
font-size: 24px;
font-weight: 700;
box-shadow: 0 2px 10px rgba(2, 132, 199, 0.12);
border: 2px solid #bae6fd;
}
.user-info h2 {
margin: 0 0 4px;
font-size: 20px;
font-weight: 700;
color: #0f172a;
}
.phone-text {
margin: 0 0 6px;
font-size: 12px;
color: #64748b;
}
.user-badge {
display: inline-block;
font-size: 11px;
background: #f0f9ff;
color: #0284c7;
border: 1px solid #bae6fd;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
}
.profile-content {
padding: 0;
}
.profile-card {
background: #ffffff;
border-radius: 16px;
padding: 20px 16px;
margin-bottom: 14px;
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f6;
}
.card-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.card-head h3 {
margin: 0;
font-size: 15px;
color: #0f172a;
font-weight: 700;
}
.edit-btn {
border: 0;
background: transparent;
color: #0284c7;
font-size: 12px;
font-weight: 600;
}
.status-indicator {
font-size: 11px;
padding: 2px 8px;
border-radius: 10px;
background: #f1f5f9;
color: #64748b;
}
.status-indicator.done {
background: #f0f9ff;
color: #0284c7;
border: 1px solid #bae6fd;
font-weight: 600;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.metric-item {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 10px 6px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.m-label {
font-size: 11px;
color: #64748b;
margin-bottom: 4px;
}
.m-val {
font-size: 15px;
color: #0f172a;
font-weight: 700;
}
.m-val small {
font-size: 10px;
color: #64748b;
}
.m-val.highlight {
color: #0284c7;
font-size: 16px;
}
.m-status-tag {
font-size: 10px;
color: #0284c7;
background: #f0f9ff;
border: 1px solid #bae6fd;
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: #fffbeb;
border-color: #fde68a;
}
.profile-tag-item.theme-gold .tag-title { color: #d97706; }
.profile-tag-item.theme-blue {
background: #f0f9ff;
border-color: #bae6fd;
}
.profile-tag-item.theme-blue .tag-title { color: #0284c7; }
.profile-tag-item.theme-green {
background: #ecfdf5;
border-color: #a7f3d0;
}
.profile-tag-item.theme-green .tag-title { color: #10b981; }
.profile-tag-item.theme-purple {
background: #faf5ff;
border-color: #e9d5ff;
}
.profile-tag-item.theme-purple .tag-title { color: #9333ea; }
.tag-title {
font-size: 13px;
font-weight: 700;
margin-bottom: 2px;
}
.tag-desc {
font-size: 11px;
color: #64748b;
}
.empty-state {
text-align: center;
padding: 16px 10px;
}
.empty-state p {
margin: 0 0 12px;
font-size: 12px;
color: #64748b;
line-height: 1.5;
}
.version-tag {
font-size: 11px;
color: #0284c7;
background: #f0f9ff;
border: 1px solid #bae6fd;
padding: 2px 8px;
border-radius: 8px;
font-weight: 600;
}
.plan-summary-text {
margin: 0 0 14px;
font-size: 12px;
color: #475569;
line-height: 1.6;
}
.plan-items-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.plan-row {
background: #f8fafc;
border: 1px solid #e2e8f0;
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: #0284c7;
}
.plan-row-head strong {
font-size: 13px;
color: #0f172a;
}
.plan-target, .plan-advice {
margin: 2px 0;
font-size: 12px;
color: #64748b;
line-height: 1.4;
}
.action-card-menu {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
margin-bottom: 16px;
border: 1px solid #eef2f6;
box-shadow: 0 4px 20px -2px rgba(15, 23, 42, 0.04);
}
.menu-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
cursor: pointer;
transition: background 0.2s;
}
.menu-item:last-child { border-bottom: none; }
.menu-item:active { background: #f8fafc; }
.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: #f0f9ff;
color: #0284c7;
}
.icon-device {
background: #f0fdf4;
color: #16a34a;
}
.menu-text strong {
display: block;
font-size: 13px;
color: #0f172a;
}
.menu-text small {
display: block;
font-size: 11px;
color: #64748b;
margin-top: 2px;
}
.arrow {
color: #94a3b8;
font-size: 14px;
}
.logout-wrap { margin-top: 24px; }
.logout-btn {
width: 100%;
padding: 13px 0;
border-radius: 14px;
border: 1px solid #fecaca;
background: #fff5f5;
color: #ef4444;
font-size: 14px;
font-weight: 600;
cursor: pointer;
}
.logout-btn:active { background: #fee2e2; }
</style>