Files
sports-empower-hub/app/citizen-frontend/src/pages/checkinDetail.vue
T
2026-06-23 15:01:49 +08:00

553 lines
17 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.
<template>
<Layout>
<template #main>
<!-- 頂部導航 -->
<v-btn
class="mb-4 font-weight-bold px-0"
color="primary"
prepend-icon="mdi-arrow-left"
size="large"
to="/checkin"
variant="text"
>
返回量測清單
</v-btn>
<!-- 關卡詳細卡片 -->
<v-card v-if="station" class="rounded-xl elevation-3 mb-6" color="surface">
<v-card-item class="bg-primary text-white py-4">
<div class="d-flex align-center mb-4">
<v-avatar class="mr-3 text-white elevation-1 flex-shrink-0" color="accent" size="48">
<v-icon :icon="station.icon" size="24" />
</v-avatar>
<div>
<h1 class="text-title-large font-weight-black">{{ station.name }}</h1>
<span class="text-title-medium text-white"> {{ currentStationIndex }} / 11 </span>
</div>
</div>
<div class="checkin-qr-panel rounded-lg pa-4 text-center">
<div class="text-title-small font-weight-bold mb-2">請出示 QR Code 給量測機掃描</div>
<div class="d-flex justify-center mb-2">
<QRCode :size="160" :value="pidQrPayload" />
</div>
</div>
</v-card-item>
<v-card-text class="py-5">
<!-- 關卡簡介 -->
<div
class="bg-teal-lighten-5 rounded-lg pa-3 mb-6 text-body-medium text-primary-darken-1"
>
<div class="font-weight-bold mb-1 d-flex align-center">
<v-icon class="mr-1" icon="mdi-information-outline" size="18" />
檢測說明
</div>
{{ station.description }}
</div>
<v-card class="rounded-lg mb-4" color="grey-lighten-4" variant="flat">
<v-btn v-if="machineStatus === 'waiting'" block @click="handleMachineStatusClick">
狀態模擬
</v-btn>
<v-card-text class="pa-4">
<div class="d-flex align-center">
<v-progress-circular
v-if="machineStatus !== 'updated'"
class="mr-3"
color="secondary"
indeterminate
size="32"
/>
<v-icon
v-else
class="mr-3"
color="success"
icon="mdi-cloud-check"
size="32"
/>
<div>
<v-btn
class="rounded-lg font-weight-bold mb-1"
color="primary"
:disabled="machineStatus !== 'waiting'"
size="small"
variant="flat"
>
{{ machineStatusText }}
</v-btn>
<div class="text-title-small text-grey-darken-1">{{ machineStatusHint }}</div>
</div>
</div>
</v-card-text>
</v-card>
<h3 class="text-title-medium font-weight-bold text-grey-darken-3 mb-3">量測數據</h3>
<v-row>
<v-col v-for="field in station.fields" :key="field.key" class="mb-3" cols="12">
<v-card class="rounded-lg border-accent-light" color="surface" variant="flat">
<v-card-text class="pa-4 d-flex justify-space-between align-center">
<div>
<div class="text-body-large font-weight-bold text-grey-darken-3">
{{ field.label }}
</div>
<div class="text-title-small text-grey">由量測機上傳後自動更新</div>
</div>
<div class="text-right">
<div class="text-title-large font-weight-black text-primary">
{{ displayMeasurementValue(field.key) }}
</div>
<div class="text-title-small text-grey">{{ field.unit }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<v-btn
block
class="rounded-lg font-weight-bold text-white elevation-2 mt-2"
color="secondary"
:disabled="machineStatus !== 'updated'"
size="large"
@click="goToNextStation"
>
進入下一站
</v-btn>
</v-card-text>
</v-card>
<!-- 參考值與說明卡片 -->
<v-card v-if="station" class="rounded-xl elevation-2 pa-4" color="surface">
<div class="d-flex align-center mb-3 text-primary font-weight-bold text-subtitle-2">
<v-icon class="mr-1" icon="mdi-heart-plus-outline" />
65 歲以上高齡正常參考標準
</div>
<v-divider class="mb-3" />
<div class="text-body-2 text-grey-darken-3 whitespace-pre-line">
{{ station.reference }}
</div>
</v-card>
</template>
</Layout>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Layout from '@/components/layout/DefaultLayout.vue'
import QRCode from '@/components/QRCode.vue'
import { useAppStore } from '@/stores/app'
const route = useRoute()
const router = useRouter()
const store = useAppStore()
type MachineStatus = 'waiting' | 'measuring' | 'updated'
const code = computed(() => route.params.code as string)
const machineStatus = ref<MachineStatus>('waiting')
const mockTimers: number[] = []
// 各個測量站點的詳細規格定義
const stationsSpec: Record<
string,
{
name: string
icon: string
description: string
reference: string
fields: Array<{
key: string
label: string
unit: string
placeholder: string
defaultValue: number
}>
}
> = {
BloodPressure: {
name: '血壓與心率量測',
icon: 'mdi-heart-flash',
description:
'請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。',
reference:
'• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。',
fields: [
{
key: 'systolic',
label: '收縮壓 (高壓)',
unit: 'mmHg',
placeholder: '如: 125',
defaultValue: 120,
},
{
key: 'diastolic',
label: '舒張壓 (低壓)',
unit: 'mmHg',
placeholder: '如: 80',
defaultValue: 80,
},
{ key: 'heartRate', label: '安靜心率', unit: 'bpm', placeholder: '如: 72', defaultValue: 70 },
],
},
BodyFat: {
name: '體組成分析 (BMI & 體脂)',
icon: 'mdi-scale-bathroom',
description: '請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。',
reference:
'• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)',
fields: [
{
key: 'bmi',
label: 'BMI 身體質量指數',
unit: 'kg/m²',
placeholder: '如: 22.5',
defaultValue: 22,
},
{
key: 'bodyFatPct',
label: '體脂肪率',
unit: '%',
placeholder: '如: 24.0',
defaultValue: 25,
},
],
},
GripStrength: {
name: '左右手握力測驗 (肌力)',
icon: 'mdi-hand-back-left',
description:
'受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。',
reference:
'• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。',
fields: [
{
key: 'leftHand',
label: '左手最大握力',
unit: 'kg',
placeholder: '如: 30',
defaultValue: 0,
},
{
key: 'rightHand',
label: '右手最大握力',
unit: 'kg',
placeholder: '如: 32',
defaultValue: 0,
},
],
},
Sit5x: {
name: '5次坐站測試 (下肢肌力)',
icon: 'mdi-human-male-height',
description:
'受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。',
reference:
'• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。',
fields: [
{
key: 'durationSeconds',
label: '5次坐站完成時間',
unit: '秒',
placeholder: '如: 9.8',
defaultValue: 0,
},
],
},
Sit30s: {
name: '30秒椅子坐立 (肌肉耐力)',
icon: 'mdi-chair-school',
description:
'雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。',
reference:
'• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。',
fields: [
{ key: 'count', label: '30秒內坐立次數', unit: '次', placeholder: '如: 14', defaultValue: 0 },
],
},
StepTest: {
name: '2分鐘原地抬膝踏步 (心肺耐力)',
icon: 'mdi-run-fast',
description:
'量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。',
reference:
'• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。',
fields: [
{
key: 'count',
label: '2分鐘右膝抬起次數',
unit: '次',
placeholder: '如: 80',
defaultValue: 0,
},
],
},
ArmTest: {
name: '30秒肱二頭肌手臂屈舉 (上肢肌耐力)',
icon: 'mdi-arm-flex',
description:
'坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。',
reference:
'• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。',
fields: [
{
key: 'count',
label: '30秒手臂屈舉次數',
unit: '次',
placeholder: '如: 16',
defaultValue: 0,
},
],
},
BackTest: {
name: '抓背測驗 (上肢關節柔軟度)',
icon: 'mdi-human-stretching',
description:
'一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。',
reference:
'• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。',
fields: [
{
key: 'leftDistanceCm',
label: '左手在上時兩指間距 (可為負值)',
unit: '公分',
placeholder: '如: -5 或 2',
defaultValue: 0,
},
{
key: 'rightDistanceCm',
label: '右手在上時兩指間距 (可為負值)',
unit: '公分',
placeholder: '如: -3 或 4',
defaultValue: 0,
},
],
},
SitReachTest: {
name: '坐姿體前彎 (下肢與腰部柔軟度)',
icon: 'mdi-human-stretching',
description:
'坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。',
reference:
'• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。',
fields: [
{
key: 'distanceCm',
label: '前彎中指與腳尖距離',
unit: '公分',
placeholder: '如: 5 (超過腳尖為正)',
defaultValue: 0,
},
],
},
UpGo8ft: {
name: '2.44公尺起立繞物 (動態平衡與敏捷)',
icon: 'mdi-navigation',
description:
'坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。',
reference:
'• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。',
fields: [
{
key: 'durationSeconds',
label: '起立繞物完成時間',
unit: '秒',
placeholder: '如: 6.2',
defaultValue: 0,
},
],
},
OneLegTest: {
name: '開眼單足立 (靜態平衡力)',
icon: 'mdi-scale-balance',
description:
'受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。',
reference:
'• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。',
fields: [
{
key: 'leftSeconds',
label: '左腳站立時間',
unit: '秒',
placeholder: '如: 18',
defaultValue: 0,
},
{
key: 'rightSeconds',
label: '右腳站立時間',
unit: '秒',
placeholder: '如: 20',
defaultValue: 0,
},
],
},
}
const station = computed(() => stationsSpec[code.value])
const currentMeasurement = computed(() => store.measurements[code.value])
const userPid = computed(() => `PID-${store.userProfile.phone.replace(/\D/g, '')}`)
const pidQrPayload = computed(() => `hp:pid:${userPid.value}`)
const machineStatusText = computed(() => {
if (machineStatus.value === 'waiting') {
return '等待掃描QR Code'
}
if (machineStatus.value === 'measuring') {
return '量測完成,資料上傳中'
}
return '伺服器已同步數據'
})
const machineStatusHint = computed(() => {
if (machineStatus.value === 'waiting') {
return '按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。'
}
if (machineStatus.value === 'measuring') {
return '設備正在將本站量測值送往後端伺服器,請稍候。'
}
return `更新時間:${currentMeasurement.value?.time ?? '--'}`
})
// 計算當前關卡在 11 關中的順序
const currentStationIndex = computed(() => {
const keys = Object.keys(stationsSpec)
return keys.indexOf(code.value) + 1
})
watch(
code,
newCode => {
if (stationsSpec[newCode]) {
resetMockMeasurementFlow()
}
},
{ immediate: true },
)
onBeforeUnmount(() => {
clearMockTimers()
})
function displayMeasurementValue (fieldKey: string) {
if (machineStatus.value !== 'updated') {
return '--'
}
return currentMeasurement.value?.values[fieldKey] ?? '--'
}
function goToNextStation () {
const keys = Object.keys(stationsSpec)
const currentIndex = keys.indexOf(code.value)
if (currentIndex !== -1 && currentIndex < keys.length - 1) {
const nextCode = keys[currentIndex + 1]
router.push(`/checkin/${nextCode}`)
} else {
router.push('/checkin')
}
}
function handleMachineStatusClick () {
startMockMeasurementFlow(code.value)
}
function startMockMeasurementFlow (stationCode: string) {
clearMockTimers()
machineStatus.value = 'measuring'
mockTimers.push(
window.setTimeout(() => {
store.saveMeasurement(stationCode, createMockMeasurementValues(stationCode))
machineStatus.value = 'updated'
}, 1500),
)
}
function resetMockMeasurementFlow () {
clearMockTimers()
machineStatus.value = 'waiting'
}
function clearMockTimers () {
while (mockTimers.length > 0) {
window.clearTimeout(mockTimers.pop())
}
}
function createMockMeasurementValues (stationCode: string) {
switch (stationCode) {
case 'BloodPressure': {
return { systolic: 128, diastolic: 82, heartRate: 74 }
}
case 'BodyFat': {
return { bmi: 23.4, bodyFatPct: 24.8 }
}
case 'GripStrength': {
return { leftHand: 31, rightHand: 34 }
}
case 'Sit5x': {
return { durationSeconds: 8.7 }
}
case 'Sit30s': {
return { count: 17 }
}
case 'StepTest': {
return { count: 94 }
}
case 'ArmTest': {
return { count: 19 }
}
case 'BackTest': {
return { leftDistanceCm: 2, rightDistanceCm: 3 }
}
case 'SitReachTest': {
return { distanceCm: 13 }
}
case 'UpGo8ft': {
return { durationSeconds: 5.9 }
}
case 'OneLegTest': {
return { leftSeconds: 23, rightSeconds: 26 }
}
default: {
return {}
}
}
}
</script>
<style scoped>
.border-accent-light {
border: 1px solid rgba(71, 194, 166, 0.2) !important;
}
.whitespace-pre-line {
white-space: pre-line;
line-height: 1.6;
}
.checkin-qr-panel {
background: rgba(255, 255, 255, 0.14);
border: 1px solid rgba(255, 255, 255, 0.22);
}
</style>