style: index page

This commit is contained in:
skytek_xinliang
2026-06-22 17:26:48 +08:00
parent f52f1a40f7
commit 11f309c54c
20 changed files with 870 additions and 1090 deletions
+130 -430
View File
@@ -3,341 +3,105 @@
<template #main>
<template v-if="store.isLoggedIn">
<!-- 頂部我的健康力護照卡片 (Taipei Masters Hub) -->
<v-card
class="passport-card rounded-xl text-white pa-5 elevation-8 mb-6 position-relative overflow-hidden"
>
<!-- 背景裝飾圓圈 -->
<div class="bg-circle-1" />
<div class="bg-circle-2" />
<v-card class="border-primary border-xl rounded-xl" variant="flat">
<v-row class="mt-4" no-gutters>
<v-col cols="8">
<v-card-title> {{ store.userProfile.name }} 先生 </v-card-title>
<v-card-subtitle> 資料更新於 {{ todayTimeString }} </v-card-subtitle>
</v-col>
<div class="d-flex justify-space-between align-center mb-6 z-index-1 position-relative">
<div class="d-flex align-center">
<v-avatar class="mr-3 border-white" color="accent" size="44">
<span class="text-white font-weight-black text-subtitle-1">{{
store.userProfile.name.substring(1)
}}</span>
</v-avatar>
<div>
<h2 class="text-subtitle-1 font-weight-black mb-0">
{{ store.userProfile.name }} 先生
</h2>
<span class="text-caption text-white-70">更新於今日 {{ todayTimeString }}</span>
<v-col class="d-flex justify-center" cols="4">
<!-- 健康力分數大圓盤 -->
<div class="score-disk d-flex flex-column justify-center align-center">
<span class="score-val">{{ displayedHealthScore }}</span>
<span class="score-unit"></span>
</div>
</div>
<!-- 健康力分數大圓盤 -->
<div class="score-disk d-flex flex-column justify-center align-center">
<span class="score-val">{{ store.healthScore }}</span>
<span class="score-unit"></span>
</div>
</div>
<!-- 功能儲備與運動處方簡介 -->
<div class="z-index-1 position-relative mb-4">
<div class="d-inline-flex align-center bg-white-20 px-3 py-1 rounded-pill mb-3">
<v-icon class="mr-1 text-accent-light" icon="mdi-heart-pulse" size="16" />
<span class="text-caption font-weight-bold">功能儲備{{ functionalReserve }}</span>
</div>
<div class="text-body-2 font-weight-medium bg-white-10 rounded-lg pa-3">
<div class="font-weight-black mb-1 text-accent-light d-flex align-center">
<v-icon class="mr-1" icon="mdi-notebook-check-outline" size="16" />
本週處方建議
</div>
{{ currentWeeklyPrescription }}
</div>
</div>
<v-divider class="bg-white-20 my-3 z-index-1 position-relative" />
</v-col>
</v-row>
<!-- 四項核心能力數值簡覽 -->
<v-row class="z-index-1 position-relative text-center" dense>
<v-col cols="3">
<div class="text-caption text-white-70">肌力</div>
<v-row class="mt-4" no-gutters>
<v-col class="text-center border pa-2" cols="3">
<div class="">肌力</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.strength }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">平衡</div>
<v-col class="text-center border pa-2" cols="3">
<div class="">平衡</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.balance }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">敏捷</div>
<v-col class="text-center border pa-2" cols="3">
<div class="">敏捷</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.agility }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">心肺</div>
<v-col class="text-center border pa-2" cols="3">
<div class="">心肺</div>
<div class="text-subtitle-2 font-weight-black">
<div class="font-weight-black">
{{ store.radarData.cardio }}
</div>
</v-col>
</v-row>
<!-- 功能儲備與運動處方簡介 -->
<v-card-text class="bg-primary">
<div class="font-weight-bold text-accent-light">
<v-icon icon="mdi-notebook-check-outline" size="16" />
運動處方建議
</div>
<p class="text-white mt-2">
{{ currentWeeklyPrescription }}
</p>
</v-card-text>
</v-card>
<!-- 中間功能指標與五維雷達圖 -->
<v-card class="rounded-xl elevation-3 border-accent-light mb-6 pa-4" color="surface">
<h3 class="text-subtitle-1 font-weight-black text-primary mb-2 d-flex align-center">
<v-icon class="mr-2" color="secondary" icon="mdi-radar" />
個人五維健康力指標
</h3>
<v-card class="border-xl border-primary rounded-xl mt-4" variant="flat">
<v-card-title> 個人五維健康力指標 </v-card-title>
<p class="text-caption text-grey mb-4">
綜合 11 項現場普測數值計算得出藍色為同齡正常參考值區間
</p>
<v-card-subtitle>
綜合 11 項現場普測數值計算得出<br />藍色為同齡正常參考值區間
</v-card-subtitle>
<!-- SVG 雷達圖不依賴任何外部圖表庫保證完美渲染 -->
<div class="d-flex justify-center my-2 position-relative">
<svg class="radar-svg" height="260" viewBox="0 0 120 120" width="260">
<!-- 5 個同心五邊形網格 (背景線) -->
<polygon
v-for="grid in [20, 40, 60, 80, 100]"
:key="grid"
fill="none"
:points="getPolygonPoints(grid)"
stroke="#E0E0E0"
stroke-width="0.3"
/>
<!-- 五個維度軸線 -->
<line
v-for="i in 5"
:key="`axis-${i}`"
stroke="#E0E0E0"
stroke-width="0.3"
x1="60"
:x2="getAxisEndPoint(i - 1).x"
y1="60"
:y2="getAxisEndPoint(i - 1).y"
/>
<!-- 同齡參考區間 (同齡常模基準設為 65 ) -->
<polygon
fill="rgba(33, 150, 243, 0.12)"
:points="getPolygonPoints(65)"
stroke="#2196F3"
stroke-dasharray="1 1"
stroke-width="0.5"
/>
<!-- 使用者真實數據填充面 -->
<polygon
fill="rgba(71, 194, 166, 0.35)"
:points="userDataPoints"
stroke="#1D7063"
stroke-width="1.2"
/>
<!-- 使用者數據頂點標記圓圈 -->
<circle
v-for="(pt, idx) in userDataCoordArray"
:key="`dot-${idx}`"
:cx="pt.x"
:cy="pt.y"
fill="#E66926"
r="1.5"
stroke="#FFFFFF"
stroke-width="0.5"
/>
<!-- 頂點文字標記 (Cardio, Strength, Balance, Flexibility, Agility) -->
<text
fill="#1D7063"
font-size="4"
font-weight="bold"
text-anchor="middle"
x="60"
y="8"
>
心肺 ({{ store.radarData.cardio }})
</text>
<text
fill="#1D7063"
font-size="4"
font-weight="bold"
text-anchor="start"
x="110"
y="44"
>
肌力 ({{ store.radarData.strength }})
</text>
<text
fill="#1D7063"
font-size="4"
font-weight="bold"
text-anchor="start"
x="94"
y="104"
>
平衡 ({{ store.radarData.balance }})
</text>
<text
fill="#1D7063"
font-size="4"
font-weight="bold"
text-anchor="end"
x="26"
y="104"
>
柔軟 ({{ store.radarData.flexibility }})
</text>
<text
fill="#1D7063"
font-size="4"
font-weight="bold"
text-anchor="end"
x="10"
y="44"
>
敏捷 ({{ store.radarData.agility }})
</text>
</svg>
<!-- 圖例標示 -->
<div class="radar-legend d-flex flex-column">
<div class="d-flex align-center text-caption mb-1">
<span class="legend-color bg-primary mr-1" />
<span>我的指標</span>
</div>
<div class="d-flex align-center text-caption">
<span class="legend-color bg-blue mr-1 border-dashed" />
<span>同齡參考</span>
</div>
</div>
</div>
<HealthRadarChart :radar-data="store.radarData" />
</v-card>
<!-- 下一步行動引導 (根據量測進度動態顯示) -->
<h3 class="text-subtitle-2 font-weight-black text-primary mb-3">下一步行動推薦</h3>
<ModuleRouterCard
description="完成線上自評,取得初步風險分級。"
icon="mdi-clipboard-text-search"
icon-color="accent"
title="自評"
to="/self-assessment"
/>
<!-- 情況 1普測量測正在進行中 -->
<v-card
v-if="isIncompleteMeasurement"
class="rounded-xl elevation-3 mb-4 transition-card text-left"
color="surface"
<ModuleRouterCard
description="完成數值紀錄,建立個人功能基線。"
icon="mdi-run-fast"
icon-color="accent"
title="量測"
to="/checkin"
>
<v-card-text class="pa-4 d-flex align-center justify-between">
<div class="d-flex align-center">
<v-avatar class="mr-3 text-white elevation-1" color="secondary" size="48">
<v-icon icon="mdi-run-fast" />
</v-avatar>
/>
<div>
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
繼續現場體能普測
</div>
<div class="text-caption text-grey">
目前已完成 {{ completedCount }} / 11 繼續完成登錄
</div>
</div>
</div>
<v-icon color="primary" icon="mdi-chevron-right" />
</v-card-text>
</v-card>
<!-- 情況 2量測全部完成提醒可以看運動處方任務 -->
<v-card
v-else
class="rounded-xl elevation-3 mb-4 transition-card text-left"
color="surface"
@click="prescriptionDialog = true"
>
<v-card-text class="pa-4 d-flex align-center justify-between">
<div class="d-flex align-center">
<v-avatar class="mr-3 text-white elevation-1" color="secondary" size="48">
<v-icon icon="mdi-calendar-multiselect" />
</v-avatar>
<div>
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
執行本週運動處方課程
</div>
<div class="text-caption text-grey">
依普測結果產生肌力 2 心肺 2 平衡 3
</div>
</div>
</div>
<v-icon color="primary" icon="mdi-chevron-right" />
</v-card-text>
</v-card>
<!-- 普測預約快捷卡片 -->
<v-card
class="rounded-xl elevation-3 mb-6 transition-card text-left"
color="surface"
<ModuleRouterCard
description="預訂首次量測或回測,追蹤健康狀態。"
icon="mdi-calendar-clock"
icon-color="accent"
title="預約"
to="/appointment"
>
<v-card-text class="pa-4 d-flex align-center justify-between">
<div class="d-flex align-center">
<v-avatar class="mr-3 text-white elevation-1" color="primary" size="48">
<v-icon icon="mdi-calendar-clock" />
</v-avatar>
<div>
<div class="font-weight-bold text-subtitle-2 text-grey-darken-4">
預約 30 秒坐站與平衡回測
</div>
<div class="text-caption text-grey">4 / 12 週定期回測動態追蹤體能進步狀態</div>
</div>
</div>
<v-icon color="primary" icon="mdi-chevron-right" />
</v-card-text>
</v-card>
<!-- 最近一次量測結果摘要 -->
<v-card class="rounded-xl elevation-3 mb-8 pa-4" color="surface">
<div class="d-flex justify-space-between align-center mb-3">
<h3 class="text-subtitle-1 font-weight-black text-primary d-flex align-center">
<v-icon class="mr-2" color="secondary" icon="mdi-file-eye-outline" />
最近量測數值摘要
</h3>
<v-btn
class="font-weight-bold px-0"
color="primary"
size="small"
to="/checkin"
variant="text"
>查看全部</v-btn>
</div>
<v-row dense>
<v-col v-for="item in recentItems" :key="item.name" class="mb-2" cols="6">
<div class="bg-grey-lighten-4 rounded-lg pa-3 h-100">
<div class="text-caption text-grey-darken-1 mb-1">{{ item.name }}</div>
<div class="text-body-1 font-weight-black text-grey-darken-4">
{{ item.value }}
</div>
</div>
</v-col>
</v-row>
</v-card>
/>
<!-- 運動處方對話框 -->
<v-dialog v-model="prescriptionDialog" max-width="500">
@@ -417,17 +181,15 @@
<template v-else>
<div class="text-center mb-6">
<v-avatar class="elevation-3 mb-4 border-accent-light" color="primary" size="72">
<v-icon color="accent" icon="mdi-heart-pulse" size="42" />
<v-icon color="red-accent-1" icon="mdi-heart-pulse" size="42" />
</v-avatar>
<h1 class="text-h5 font-weight-black text-primary mb-2">運動玩轉健康力</h1>
<p class="text-body-2 text-grey-darken-1 mb-0">
不登入也可以先完成線上健康自評登入後可預約普測與查看量測紀錄
<p class="text-body-medium text-grey-darken-1 mb-0">
不登入也可以先完成線上健康自評<br />登入後可預約普測與查看量測紀錄
</p>
</div>
<v-card class="rounded-xl elevation-3 border-accent-light mb-4" color="surface">
<v-card class="rounded-xl elevation-2 border-accent-light mb-4" color="surface">
<v-card-text class="pa-5">
<div class="d-flex align-center mb-4">
<v-avatar class="mr-3 text-white" color="secondary" size="48">
@@ -442,13 +204,13 @@
<v-btn
block
class="rounded-lg font-weight-bold mb-3"
color="primary"
prepend-icon="mdi-arrow-right-circle"
class="rounded-lg font-weight-bold elevation-1 mb-3"
color="secondary"
prepend-icon="mdi-clipboard-text-search"
to="/self-assessment"
variant="flat"
>
開始健康自評
開始自評
</v-btn>
<v-btn
@@ -459,7 +221,7 @@
to="/login"
variant="outlined"
>
登入健康平台
登入平台
</v-btn>
</v-card-text>
</v-card>
@@ -483,116 +245,81 @@
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import HealthRadarChart from '@/components/HealthRadarChart.vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import ModuleRouterCard from '@/components/ModuleRouterCard.vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
const prescriptionDialog = ref(false)
const displayedHealthScore = ref(1)
let healthScoreTimer: number | undefined
function animateHealthScore (targetScore: number) {
if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer)
}
if (targetScore <= 1) {
displayedHealthScore.value = targetScore
return
}
displayedHealthScore.value = 1
const duration = 900
const stepDuration = Math.max(12, Math.floor(duration / targetScore))
healthScoreTimer = window.setInterval(() => {
if (displayedHealthScore.value >= targetScore) {
window.clearInterval(healthScoreTimer)
healthScoreTimer = undefined
return
}
displayedHealthScore.value += 1
}, stepDuration)
}
onMounted(() => {
animateHealthScore(store.healthScore)
})
onBeforeUnmount(() => {
if (healthScoreTimer !== undefined) {
window.clearInterval(healthScoreTimer)
}
})
watch(
() => store.healthScore,
score => {
animateHealthScore(score)
},
)
const todayTimeString = computed(() => {
const now = new Date()
return `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
})
const date = [
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
].join('/')
const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`
// 功能儲備評估
const functionalReserve = computed(() => {
const score = store.healthScore
if (score >= 80) return '良好 (無衰弱風險)'
if (score >= 65) return '正常 (需持續運動維持)'
return '待加強 (有衰弱與跌倒風險)'
return `${date} ${time}`
})
// 每週處方文字
const currentWeeklyPrescription = computed(() => {
if (store.healthScore >= 80) {
return '本週處方:1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
return '1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
} else if (store.healthScore >= 65) {
return '本週處方:2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
return '2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
} else {
return '本週處方:3次防跌平衡引導 + 每日15分鐘扶椅坐站與慢走'
return '3次防跌平衡引導 + 每日15分鐘扶椅坐站與慢走'
}
})
// 檢測是否還有未完成的測量站
const isIncompleteMeasurement = computed(() => {
return store.completedStationsCount < store.totalStationsCount
})
const completedCount = computed(() => store.completedStationsCount)
// 最近一次量測結果摘要列表
const recentItems = computed(() => {
const m = store.measurements
const items = []
if (m.BloodPressure.completed) {
items.push({
name: '血壓量測',
value: `${m.BloodPressure.values.systolic}/${m.BloodPressure.values.diastolic} mmHg`,
})
}
if (m.BodyFat.completed) {
items.push({ name: '體組成 BMI', value: `${m.BodyFat.values.bmi}` })
}
if (m.GripStrength.completed) {
items.push({ name: '右手最大握力', value: `${m.GripStrength.values.rightHand} kg` })
} else {
items.push({ name: '左右握力', value: '未測量' })
}
if (m.OneLegTest.completed) {
items.push({
name: '單腳站立平衡',
value: `${Math.max(m.OneLegTest.values.leftSeconds, m.OneLegTest.values.rightSeconds)}`,
})
} else {
items.push({ name: '開眼單足立', value: '未測量' })
}
return items
})
// --- SVG 雷達圖點計算邏輯 ---
const cx = 60
const cy = 60
const r = 45
// 計算 5 角多邊形的頂點
// 頂點順序: i=0(心肺), i=1(肌力), i=2(平衡), i=3(柔軟), i=4(敏捷)
function getAxisEndPoint (i: number, radius = r) {
const angle = -Math.PI / 2 + (i * 2 * Math.PI) / 5
return {
x: cx + radius * Math.cos(angle),
y: cy + radius * Math.sin(angle),
}
}
// 根據百分比半徑繪製背景網格五邊形
function getPolygonPoints (percent: number) {
const radius = (percent / 100) * r
const pts = []
for (let i = 0; i < 5; i++) {
const pt = getAxisEndPoint(i, radius)
pts.push(`${pt.x},${pt.y}`)
}
return pts.join(' ')
}
// 使用者五維數據對應的雷達圖頂點座標
const userDataCoordArray = computed(() => {
const data = store.radarData
// 順序與頂點對應:
// 0: cardio (心肺), 1: strength (肌力), 2: balance (平衡), 3: flexibility (柔軟), 4: agility (敏捷)
const values = [data.cardio, data.strength, data.balance, data.flexibility, data.agility]
return values.map((val, i) => {
const radius = (Math.max(5, Math.min(100, val)) / 100) * r
return getAxisEndPoint(i, radius)
})
})
const userDataPoints = computed(() => {
return userDataCoordArray.value.map(pt => `${pt.x},${pt.y}`).join(' ')
})
</script>
<style scoped>
@@ -681,31 +408,4 @@ const userDataPoints = computed(() => {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.06);
}
/* 雷達圖圖例 */
.radar-legend {
position: absolute;
bottom: 0;
right: 0;
background-color: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(0, 0, 0, 0.05);
padding: 6px 8px;
border-radius: 8px;
}
.legend-color {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 2px;
}
.border-dashed {
border: 1px dashed #2196f3;
background-color: rgba(33, 150, 243, 0.12) !important;
}
.radar-svg text {
font-family: inherit;
}
</style>