feat: draft

This commit is contained in:
skytek_xinliang
2026-06-22 10:59:41 +08:00
commit c2d4c06e7e
38 changed files with 7505 additions and 0 deletions
+711
View File
@@ -0,0 +1,711 @@
<template>
<Layout>
<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" />
<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>
</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-row class="z-index-1 position-relative text-center" dense>
<v-col cols="3">
<div class="text-caption text-white-70">肌力</div>
<div class="text-subtitle-2 font-weight-black">
{{ store.radarData.strength }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">平衡</div>
<div class="text-subtitle-2 font-weight-black">
{{ store.radarData.balance }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">敏捷</div>
<div class="text-subtitle-2 font-weight-black">
{{ store.radarData.agility }}
</div>
</v-col>
<v-col cols="3">
<div class="text-caption text-white-70">心肺</div>
<div class="text-subtitle-2 font-weight-black">
{{ store.radarData.cardio }}
</div>
</v-col>
</v-row>
</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>
<p class="text-caption text-grey mb-4">
綜合 11 項現場普測數值計算得出藍色為同齡正常參考值區間
</p>
<!-- 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>
</v-card>
<!-- 下一步行動引導 (根據量測進度動態顯示) -->
<h3 class="text-subtitle-2 font-weight-black text-primary mb-3">下一步行動推薦</h3>
<!-- 情況 1普測量測正在進行中 -->
<v-card
v-if="isIncompleteMeasurement"
class="rounded-xl elevation-3 mb-4 transition-card text-left"
color="surface"
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"
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">
<v-card class="rounded-xl">
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
<v-icon class="mr-2" icon="mdi-dumbbell" />
本週運動處方明細
</v-card-title>
<v-card-text class="pt-4 text-body-1">
<h4 class="font-weight-bold text-primary mb-2">每週訓練任務進度</h4>
<div class="mb-4">
<div class="d-flex justify-space-between align-center text-caption mb-1">
<span>任務完成度 (2/7堂課)</span>
<span>28%</span>
</div>
<v-progress-linear color="secondary" height="6" model-value="28" rounded />
</div>
<!-- 動作說明清單 -->
<v-list class="pa-0" density="comfortable">
<v-list-item class="bg-teal-lighten-5 rounded-lg mb-2 py-2">
<template #prepend>
<v-icon color="secondary" icon="mdi-numeric-1-circle" size="24" />
</template>
<v-list-item-title class="font-weight-bold">下肢肌力坐站起立訓練</v-list-item-title>
<v-list-item-subtitle class="text-caption mt-1">
每週 2 每次 3 每組 10-12 <br />
<span class="text-error font-weight-bold">* 扶手保護站起時吐氣</span>
</v-list-item-subtitle>
</v-list-item>
<v-list-item class="bg-teal-lighten-5 rounded-lg mb-2 py-2">
<template #prepend>
<v-icon color="secondary" icon="mdi-numeric-2-circle" size="24" />
</template>
<v-list-item-title class="font-weight-bold">靜態平衡扶椅單腳站立</v-list-item-title>
<v-list-item-subtitle class="text-caption mt-1">
每週 3 左右腳各 30 重複 3 <br />
<span class="text-grey-darken-1">手輕扶椅背確保安全</span>
</v-list-item-subtitle>
</v-list-item>
<v-list-item class="bg-teal-lighten-5 rounded-lg py-2">
<template #prepend>
<v-icon color="secondary" icon="mdi-numeric-3-circle" size="24" />
</template>
<v-list-item-title class="font-weight-bold">心肺耐力每日快走</v-list-item-title>
<v-list-item-subtitle class="text-caption mt-1">
每日 20-30 分鐘達到微喘但仍可說話的強度<br />
<span class="text-error font-weight-bold">* 若感覺頭暈或膝蓋疼痛請暫停</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions class="justify-end px-4 pb-4">
<v-btn
class="rounded-lg px-4"
color="primary"
variant="flat"
@click="prescriptionDialog = false"
>確認關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<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-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>
</div>
<v-card class="rounded-xl elevation-3 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">
<v-icon icon="mdi-clipboard-text-outline" />
</v-avatar>
<div class="text-left">
<div class="text-subtitle-1 font-weight-black text-primary">先做線上健康自評</div>
<div class="text-caption text-grey"> 3 分鐘完成取得初步健康風險分級</div>
</div>
</div>
<v-btn
block
class="rounded-lg font-weight-bold mb-3"
color="primary"
prepend-icon="mdi-arrow-right-circle"
to="/self-assessment"
variant="flat"
>
開始健康自評
</v-btn>
<v-btn
block
class="rounded-lg font-weight-bold"
color="primary"
prepend-icon="mdi-login"
to="/login"
variant="outlined"
>
登入健康平台
</v-btn>
</v-card-text>
</v-card>
<v-card class="rounded-xl elevation-1 border-accent-light" color="surface">
<v-card-text class="pa-4 d-flex align-center text-left">
<v-icon class="mr-3" color="primary" icon="mdi-lock-outline" size="28" />
<div>
<div class="font-weight-bold text-grey-darken-4">登入後可使用完整功能</div>
<div class="text-caption text-grey">
普測預約現場報到量測紀錄與運動處方需要登入狀態
</div>
</div>
</v-card-text>
</v-card>
</template>
</template>
</Layout>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import Layout from '@/components/layout/DefaultLayout.vue'
import { useAppStore } from '@/stores/app'
const store = useAppStore()
const prescriptionDialog = ref(false)
const todayTimeString = computed(() => {
const now = new Date()
return `${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 '待加強 (有衰弱與跌倒風險)'
})
// 每週處方文字
const currentWeeklyPrescription = computed(() => {
if (store.healthScore >= 80) {
return '本週處方:1次肌力循環 + 1次平衡課 + 每日20分鐘快走'
} else if (store.healthScore >= 65) {
return '本週處方:2次肌力循環 + 1次平衡課 + 每日20分鐘快走'
} else {
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>
.passport-card {
background: linear-gradient(135deg, #1d7063 0%, #145248 100%);
border: 1px solid rgba(71, 194, 166, 0.3);
box-shadow: 0 8px 16px rgba(20, 82, 72, 0.25) !important;
}
.bg-circle-1 {
position: absolute;
width: 150px;
height: 150px;
border-radius: 50%;
background: rgba(71, 194, 166, 0.15);
top: -50px;
right: -50px;
z-index: 0;
}
.bg-circle-2 {
position: absolute;
width: 100px;
height: 100px;
border-radius: 50%;
background: rgba(230, 105, 38, 0.1);
bottom: -30px;
left: -20px;
z-index: 0;
}
.z-index-1 {
z-index: 1;
}
.bg-white-20 {
background-color: rgba(255, 255, 255, 0.2);
}
.bg-white-10 {
background-color: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.05);
}
.text-white-70 {
color: rgba(255, 255, 255, 0.7) !important;
}
.text-accent-light {
color: #8cebd2 !important;
}
.score-disk {
background: radial-gradient(circle, #e66926 0%, #cc561b 100%);
border: 3px solid #ffffff;
width: 72px;
height: 72px;
border-radius: 50%;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
flex-shrink: 0;
}
.score-val {
font-size: 1.75rem;
font-weight: 900;
line-height: 1;
color: #ffffff;
}
.score-unit {
font-size: 0.65rem;
font-weight: bold;
color: rgba(255, 255, 255, 0.8);
}
.border-accent-light {
border: 1px solid rgba(71, 194, 166, 0.2) !important;
}
.transition-card {
transition: all 0.25s ease;
border: 1px solid rgba(0, 0, 0, 0.08);
}
.transition-card:hover {
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>