chore: OXC Formatter
This commit is contained in:
@@ -10,19 +10,36 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 活動分類選擇 -->
|
||||
<v-tabs v-model="filterTab" class="mb-4 bg-white rounded-xl elevation-1" color="primary" grow>
|
||||
<v-tabs
|
||||
v-model="filterTab"
|
||||
class="mb-4 bg-white rounded-xl elevation-1"
|
||||
color="primary"
|
||||
grow
|
||||
>
|
||||
<v-tab value="all">全部活動</v-tab>
|
||||
<v-tab value="registered">我的報名</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<!-- 活動列表 -->
|
||||
<div v-if="filteredAppointments.length === 0" class="text-center py-12">
|
||||
<v-icon class="mb-3" color="grey-lighten-1" icon="mdi-calendar-blank" size="64" />
|
||||
<div class="text-title-small text-grey font-weight-bold">目前沒有相關活動</div>
|
||||
<v-icon
|
||||
class="mb-3"
|
||||
color="grey-lighten-1"
|
||||
icon="mdi-calendar-blank"
|
||||
size="64"
|
||||
/>
|
||||
<div class="text-title-small text-grey font-weight-bold">
|
||||
目前沒有相關活動
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<v-row v-else>
|
||||
<v-col v-for="apt in filteredAppointments" :key="apt.id" class="mb-4" cols="12">
|
||||
<v-col
|
||||
v-for="apt in filteredAppointments"
|
||||
:key="apt.id"
|
||||
class="mb-4"
|
||||
cols="12"
|
||||
>
|
||||
<v-card class="rounded-xl elevation-3" color="surface">
|
||||
<!-- 卡片頂部狀態條 -->
|
||||
<v-card-item class="bg-teal-lighten-5 py-4">
|
||||
@@ -44,12 +61,19 @@
|
||||
</v-card-item>
|
||||
|
||||
<v-card-text>
|
||||
<h2 class="text-title-large font-weight-bold text-grey-darken-4 mb-4">
|
||||
<h2
|
||||
class="text-title-large font-weight-bold text-grey-darken-4 mb-4"
|
||||
>
|
||||
{{ apt.title }}
|
||||
</h2>
|
||||
|
||||
<div class="d-flex align-start mb-2">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-calendar-clock" size="18" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-calendar-clock"
|
||||
size="18"
|
||||
/>
|
||||
|
||||
<div class="text-body-2 text-grey-darken-3">
|
||||
<span class="font-weight-bold">{{ apt.date }}</span>
|
||||
@@ -63,13 +87,19 @@
|
||||
</div> -->
|
||||
|
||||
<div class="d-flex align-start mb-3">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-human-queue" size="18" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-human-queue"
|
||||
size="18"
|
||||
/>
|
||||
|
||||
<div class="text-body-2 text-grey-darken-3">
|
||||
剩餘名額:<span
|
||||
class="font-weight-bold"
|
||||
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
||||
>{{ apt.slots }}</span>
|
||||
>{{ apt.slots }}</span
|
||||
>
|
||||
位
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,7 +137,7 @@
|
||||
variant="flat"
|
||||
@click="handleRegister(apt.id)"
|
||||
>
|
||||
{{ apt.slots === 0 ? '名額已滿' : '線上報名活動' }}
|
||||
{{ apt.slots === 0 ? "名額已滿" : "線上報名活動" }}
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
@@ -165,11 +195,15 @@
|
||||
</v-card-title>
|
||||
|
||||
<v-card-text class="pa-6">
|
||||
<h3 class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4">
|
||||
<h3
|
||||
class="text-subtitle-1 font-weight-bold mb-1 text-grey-darken-4"
|
||||
>
|
||||
{{ selectedApt?.title }}
|
||||
</h3>
|
||||
|
||||
<p class="text-caption text-grey mb-4">請在現場合對處出示此條碼進行報到</p>
|
||||
<p class="text-caption text-grey mb-4">
|
||||
請在現場合對處出示此條碼進行報到
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center"
|
||||
@@ -178,7 +212,8 @@
|
||||
</div>
|
||||
|
||||
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||||
姓名: {{ store.userProfile.name }} 先生 | 電話: {{ store.userProfile.phone }}
|
||||
姓名: {{ store.userProfile.name }} 先生 | 電話:
|
||||
{{ store.userProfile.phone }}
|
||||
</div>
|
||||
|
||||
<!-- 模擬現場合對處,高齡測試快捷鍵 -->
|
||||
@@ -213,66 +248,68 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import QRCode from '@/components/QRCode.vue'
|
||||
import { type Appointment, useAppStore } from '@/stores/app'
|
||||
import { computed, ref } from "vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import QRCode from "@/components/QRCode.vue";
|
||||
import { type Appointment, useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const filterTab = ref('all')
|
||||
const store = useAppStore();
|
||||
const filterTab = ref("all");
|
||||
|
||||
const qrcodeDialog = ref(false)
|
||||
const selectedApt = ref<Appointment | null>(null)
|
||||
const qrcodeDialog = ref(false);
|
||||
const selectedApt = ref<Appointment | null>(null);
|
||||
|
||||
const filteredAppointments = computed(() => {
|
||||
if (filterTab.value === 'registered') {
|
||||
return store.appointments.filter(a => a.status === '已報名' || a.status === '已報到')
|
||||
if (filterTab.value === "registered") {
|
||||
return store.appointments.filter(
|
||||
(a) => a.status === "已報名" || a.status === "已報到",
|
||||
);
|
||||
}
|
||||
return store.appointments
|
||||
})
|
||||
return store.appointments;
|
||||
});
|
||||
|
||||
const selectedCheckinPayload = computed(() => {
|
||||
if (!selectedApt.value) {
|
||||
return 'https://hp.local/c'
|
||||
return "https://hp.local/c";
|
||||
}
|
||||
|
||||
return `https://hp.local/c/${selectedApt.value.id}`
|
||||
})
|
||||
return `https://hp.local/c/${selectedApt.value.id}`;
|
||||
});
|
||||
|
||||
function getStatusColor (status: string) {
|
||||
function getStatusColor(status: string) {
|
||||
switch (status) {
|
||||
case '未報名': {
|
||||
return 'grey-darken-1'
|
||||
case "未報名": {
|
||||
return "grey-darken-1";
|
||||
}
|
||||
case '已報名': {
|
||||
return 'secondary'
|
||||
case "已報名": {
|
||||
return "secondary";
|
||||
}
|
||||
case '已報到': {
|
||||
return 'success'
|
||||
case "已報到": {
|
||||
return "success";
|
||||
}
|
||||
default: {
|
||||
return 'grey'
|
||||
return "grey";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleRegister (id: string) {
|
||||
store.registerAppointment(id)
|
||||
function handleRegister(id: string) {
|
||||
store.registerAppointment(id);
|
||||
}
|
||||
|
||||
function handleCancel (id: string) {
|
||||
store.cancelAppointment(id)
|
||||
function handleCancel(id: string) {
|
||||
store.cancelAppointment(id);
|
||||
}
|
||||
|
||||
function showCheckinQRCode (apt: Appointment) {
|
||||
selectedApt.value = apt
|
||||
qrcodeDialog.value = true
|
||||
function showCheckinQRCode(apt: Appointment) {
|
||||
selectedApt.value = apt;
|
||||
qrcodeDialog.value = true;
|
||||
}
|
||||
|
||||
function simulateOnSiteCheckin () {
|
||||
function simulateOnSiteCheckin() {
|
||||
if (selectedApt.value) {
|
||||
store.checkinAppointment(selectedApt.value.id)
|
||||
qrcodeDialog.value = false
|
||||
store.checkinAppointment(selectedApt.value.id);
|
||||
qrcodeDialog.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -10,31 +10,50 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 報到活動資訊 -->
|
||||
<v-card v-if="activeAppointment" class="rounded-xl elevation-3 mb-6" color="surface">
|
||||
<v-card
|
||||
v-if="activeAppointment"
|
||||
class="rounded-xl elevation-3 mb-6"
|
||||
color="surface"
|
||||
>
|
||||
<v-card-text>
|
||||
<div class="d-flex align-center justify-space-between mb-3">
|
||||
<span class="text-title-small font-weight-bold text-primary">
|
||||
<v-icon class="mr-1" icon="mdi-map-marker-radius" />
|
||||
現場檢測站:{{ activeAppointment.location.split(' ')[0] }}
|
||||
現場檢測站:{{ activeAppointment.location.split(" ")[0] }}
|
||||
</span>
|
||||
|
||||
<v-chip class="font-weight-black" color="success" size="small" variant="flat">
|
||||
<v-chip
|
||||
class="font-weight-black"
|
||||
color="success"
|
||||
size="small"
|
||||
variant="flat"
|
||||
>
|
||||
已報到
|
||||
</v-chip>
|
||||
</div>
|
||||
|
||||
<h2 class="text-title-large font-weight-black text-grey-darken-4 mb-2">
|
||||
<h2
|
||||
class="text-title-large font-weight-black text-grey-darken-4 mb-2"
|
||||
>
|
||||
{{ activeAppointment.title }}
|
||||
</h2>
|
||||
|
||||
<div class="text-title-small text-grey">日期:{{ activeAppointment.date }}</div>
|
||||
<div class="text-title-small text-grey">
|
||||
日期:{{ activeAppointment.date }}
|
||||
</div>
|
||||
|
||||
<!-- 量測總進度條 -->
|
||||
<div class="mt-4">
|
||||
<div class="d-flex justify-space-between align-center mb-1">
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-2">測量站點完成度</span>
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-2"
|
||||
>測量站點完成度</span
|
||||
>
|
||||
|
||||
<span class="text-title-small font-weight-bold text-primary">{{ completedCount }} / {{ totalCount }} ({{ progressPercent }}%)</span>
|
||||
<span class="text-title-small font-weight-bold text-primary"
|
||||
>{{ completedCount }} / {{ totalCount }} ({{
|
||||
progressPercent
|
||||
}}%)</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<v-progress-linear
|
||||
@@ -50,20 +69,31 @@
|
||||
</v-card>
|
||||
|
||||
<!-- 未報到提示 -->
|
||||
<v-card v-else class="rounded-xl elevation-3 pa-6 text-center mb-6" color="surface">
|
||||
<v-card
|
||||
v-else
|
||||
class="rounded-xl elevation-3 pa-6 text-center mb-6"
|
||||
color="surface"
|
||||
>
|
||||
<v-icon class="mb-3" color="warning" icon="mdi-qrcode-scan" size="64" />
|
||||
<h3 class="text-title-medium font-weight-bold mb-2">尚未在活動現場合對報到</h3>
|
||||
<h3 class="text-title-medium font-weight-bold mb-2">
|
||||
尚未在活動現場合對報到
|
||||
</h3>
|
||||
|
||||
<p class="text-body-medium text-grey mb-4">
|
||||
請先前往「活動預約」頁面,出示您的報到條碼給現場志工進行核對。
|
||||
</p>
|
||||
|
||||
<v-btn class="rounded-lg" color="primary" to="/appointment">前往查看預約活動</v-btn>
|
||||
<v-btn class="rounded-lg" color="primary" to="/appointment"
|
||||
>前往查看預約活動</v-btn
|
||||
>
|
||||
</v-card>
|
||||
|
||||
<!-- 快捷測試功能 (對 Demo 非常友善) -->
|
||||
|
||||
<div v-if="activeAppointment && completedCount < totalCount" class="d-flex justify-center">
|
||||
<div
|
||||
v-if="activeAppointment && completedCount < totalCount"
|
||||
class="d-flex justify-center"
|
||||
>
|
||||
<v-btn
|
||||
class="rounded-lg font-weight-bold text-white"
|
||||
color="secondary"
|
||||
@@ -75,13 +105,23 @@
|
||||
|
||||
<!-- 11 個量測關卡列表 -->
|
||||
<div v-if="activeAppointment" class="mb-6">
|
||||
<h3 class="text-title-medium font-weight-black text-primary px-4 mb-3">關卡列表</h3>
|
||||
<h3 class="text-title-medium font-weight-black text-primary px-4 mb-3">
|
||||
關卡列表
|
||||
</h3>
|
||||
|
||||
<v-row>
|
||||
<v-col v-for="(station, key) in store.measurements" :key="key" class="mb-2" cols="12">
|
||||
<v-col
|
||||
v-for="(station, key) in store.measurements"
|
||||
:key="key"
|
||||
class="mb-2"
|
||||
cols="12"
|
||||
>
|
||||
<v-card
|
||||
class="rounded-xl elevation-2 cursor-pointer transition-card"
|
||||
:class="{ 'border-success': station.completed, 'border-grey': !station.completed }"
|
||||
:class="{
|
||||
'border-success': station.completed,
|
||||
'border-grey': !station.completed,
|
||||
}"
|
||||
:to="`/checkin/${key}`"
|
||||
>
|
||||
<div class="d-flex align-center justify-space-between pa-4">
|
||||
@@ -92,7 +132,9 @@
|
||||
size="40"
|
||||
>
|
||||
<v-icon
|
||||
:icon="station.completed ? 'mdi-check' : getStationIcon(key)"
|
||||
:icon="
|
||||
station.completed ? 'mdi-check' : getStationIcon(key)
|
||||
"
|
||||
size="20"
|
||||
/>
|
||||
</v-avatar>
|
||||
@@ -100,14 +142,23 @@
|
||||
<div>
|
||||
<div
|
||||
class="font-weight-bold text-body-large"
|
||||
:class="station.completed ? 'text-success' : 'text-grey-darken-4'"
|
||||
:class="
|
||||
station.completed
|
||||
? 'text-success'
|
||||
: 'text-grey-darken-4'
|
||||
"
|
||||
>
|
||||
{{ station.name }}
|
||||
</div>
|
||||
<!-- 已完成,顯示數值 -->
|
||||
<div v-if="station.completed" class="text-title-small text-grey-darken-2">
|
||||
<div
|
||||
v-if="station.completed"
|
||||
class="text-title-small text-grey-darken-2"
|
||||
>
|
||||
{{ getMeasurementSummary(key, station.values) }}
|
||||
<span class="ml-2 text-grey-lighten-1">| {{ station.time?.split(' ')[1] }}</span>
|
||||
<span class="ml-2 text-grey-lighten-1"
|
||||
>| {{ station.time?.split(" ")[1] }}</span
|
||||
>
|
||||
</div>
|
||||
<!-- 未完成,顯示待測 -->
|
||||
<div v-else class="text-title-small text-grey-darken-1">
|
||||
@@ -146,7 +197,9 @@
|
||||
<v-icon color="white" icon="mdi-check-all" size="40" />
|
||||
</v-avatar>
|
||||
|
||||
<h2 class="text-h5 font-weight-black text-success mb-2">報告計算完成!</h2>
|
||||
<h2 class="text-h5 font-weight-black text-success mb-2">
|
||||
報告計算完成!
|
||||
</h2>
|
||||
|
||||
<p class="text-body-2 text-grey-darken-2 mb-6">
|
||||
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
||||
@@ -168,148 +221,151 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
const reportSuccessDialog = ref(false)
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
const reportSuccessDialog = ref(false);
|
||||
|
||||
// 尋找已報到的活動
|
||||
const activeAppointment = computed(() => {
|
||||
return store.appointments.find(a => a.status === '已報到')
|
||||
})
|
||||
return store.appointments.find((a) => a.status === "已報到");
|
||||
});
|
||||
|
||||
const completedCount = computed(() => store.completedStationsCount)
|
||||
const totalCount = computed(() => store.totalStationsCount)
|
||||
const progressPercent = computed(() => store.progressPercent)
|
||||
const completedCount = computed(() => store.completedStationsCount);
|
||||
const totalCount = computed(() => store.totalStationsCount);
|
||||
const progressPercent = computed(() => store.progressPercent);
|
||||
|
||||
function getStationIcon (code: string) {
|
||||
function getStationIcon(code: string) {
|
||||
switch (code) {
|
||||
case 'BloodPressure': {
|
||||
return 'mdi-heart-flash'
|
||||
case "BloodPressure": {
|
||||
return "mdi-heart-flash";
|
||||
}
|
||||
case 'BodyFat': {
|
||||
return 'mdi-scale-bathroom'
|
||||
case "BodyFat": {
|
||||
return "mdi-scale-bathroom";
|
||||
}
|
||||
case 'GripStrength': {
|
||||
return 'mdi-hand-back-left'
|
||||
case "GripStrength": {
|
||||
return "mdi-hand-back-left";
|
||||
}
|
||||
case 'Sit5x': {
|
||||
return 'mdi-human-male-height'
|
||||
case "Sit5x": {
|
||||
return "mdi-human-male-height";
|
||||
}
|
||||
case 'Sit30s': {
|
||||
return 'mdi-chair-school'
|
||||
case "Sit30s": {
|
||||
return "mdi-chair-school";
|
||||
}
|
||||
case 'StepTest': {
|
||||
return 'mdi-run-fast'
|
||||
case "StepTest": {
|
||||
return "mdi-run-fast";
|
||||
}
|
||||
case 'ArmTest': {
|
||||
return 'mdi-arm-flex'
|
||||
case "ArmTest": {
|
||||
return "mdi-arm-flex";
|
||||
}
|
||||
case 'BackTest': {
|
||||
return 'mdi-human-stretching'
|
||||
case "BackTest": {
|
||||
return "mdi-human-stretching";
|
||||
}
|
||||
case 'SitReachTest': {
|
||||
return 'mdi-human-stretching'
|
||||
case "SitReachTest": {
|
||||
return "mdi-human-stretching";
|
||||
}
|
||||
case 'UpGo8ft': {
|
||||
return 'mdi-navigation'
|
||||
case "UpGo8ft": {
|
||||
return "mdi-navigation";
|
||||
}
|
||||
case 'OneLegTest': {
|
||||
return 'mdi-scale-balance'
|
||||
case "OneLegTest": {
|
||||
return "mdi-scale-balance";
|
||||
}
|
||||
default: {
|
||||
return 'mdi-checkbox-blank-circle-outline'
|
||||
return "mdi-checkbox-blank-circle-outline";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMeasurementSummary (code: string, vals: Record<string, any>) {
|
||||
function getMeasurementSummary(code: string, vals: Record<string, any>) {
|
||||
switch (code) {
|
||||
case 'BloodPressure': {
|
||||
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`
|
||||
case "BloodPressure": {
|
||||
return `收縮壓: ${vals.systolic} / 舒張壓: ${vals.diastolic} mmHg | 心率: ${vals.heartRate} bpm`;
|
||||
}
|
||||
case 'BodyFat': {
|
||||
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`
|
||||
case "BodyFat": {
|
||||
return `BMI: ${vals.bmi} | 體脂率: ${vals.bodyFatPct}%`;
|
||||
}
|
||||
case 'GripStrength': {
|
||||
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`
|
||||
case "GripStrength": {
|
||||
return `左手: ${vals.leftHand} kg | 右手: ${vals.rightHand} kg`;
|
||||
}
|
||||
case 'Sit5x': {
|
||||
return `耗時: ${vals.durationSeconds} 秒`
|
||||
case "Sit5x": {
|
||||
return `耗時: ${vals.durationSeconds} 秒`;
|
||||
}
|
||||
case 'Sit30s': {
|
||||
return `次數: ${vals.count} 次`
|
||||
case "Sit30s": {
|
||||
return `次數: ${vals.count} 次`;
|
||||
}
|
||||
case 'StepTest': {
|
||||
return `踏步: ${vals.count} 次`
|
||||
case "StepTest": {
|
||||
return `踏步: ${vals.count} 次`;
|
||||
}
|
||||
case 'ArmTest': {
|
||||
return `屈舉: ${vals.count} 次`
|
||||
case "ArmTest": {
|
||||
return `屈舉: ${vals.count} 次`;
|
||||
}
|
||||
case 'BackTest': {
|
||||
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`
|
||||
case "BackTest": {
|
||||
return `左手: ${vals.leftDistanceCm} cm | 右手: ${vals.rightDistanceCm} cm`;
|
||||
}
|
||||
case 'SitReachTest': {
|
||||
return `伸展: ${vals.distanceCm} cm`
|
||||
case "SitReachTest": {
|
||||
return `伸展: ${vals.distanceCm} cm`;
|
||||
}
|
||||
case 'UpGo8ft': {
|
||||
return `耗時: ${vals.durationSeconds} 秒`
|
||||
case "UpGo8ft": {
|
||||
return `耗時: ${vals.durationSeconds} 秒`;
|
||||
}
|
||||
case 'OneLegTest': {
|
||||
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds} 秒`
|
||||
case "OneLegTest": {
|
||||
return `左腳: ${vals.leftSeconds} 秒 | 右腳: ${vals.rightSeconds} 秒`;
|
||||
}
|
||||
default: {
|
||||
return '數據登錄成功'
|
||||
return "數據登錄成功";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 模擬一鍵完成所有測量 (對 Demo 极為有用)
|
||||
function simulateAllMeasurements () {
|
||||
function simulateAllMeasurements() {
|
||||
// 自動將剩餘的 9 站填入合理健康的隨機值
|
||||
const m = store.measurements
|
||||
const m = store.measurements;
|
||||
|
||||
if (!m.GripStrength.completed) {
|
||||
store.saveMeasurement('GripStrength', { leftHand: 32, rightHand: 34 })
|
||||
store.saveMeasurement("GripStrength", { leftHand: 32, rightHand: 34 });
|
||||
}
|
||||
if (!m.Sit5x.completed) {
|
||||
store.saveMeasurement('Sit5x', { durationSeconds: 8.5 })
|
||||
store.saveMeasurement("Sit5x", { durationSeconds: 8.5 });
|
||||
}
|
||||
if (!m.Sit30s.completed) {
|
||||
store.saveMeasurement('Sit30s', { count: 18 })
|
||||
store.saveMeasurement("Sit30s", { count: 18 });
|
||||
}
|
||||
if (!m.StepTest.completed) {
|
||||
store.saveMeasurement('StepTest', { count: 92 })
|
||||
store.saveMeasurement("StepTest", { count: 92 });
|
||||
}
|
||||
if (!m.ArmTest.completed) {
|
||||
store.saveMeasurement('ArmTest', { count: 20 })
|
||||
store.saveMeasurement("ArmTest", { count: 20 });
|
||||
}
|
||||
if (!m.BackTest.completed) {
|
||||
store.saveMeasurement('BackTest', { leftDistanceCm: 2, rightDistanceCm: 3 })
|
||||
store.saveMeasurement("BackTest", {
|
||||
leftDistanceCm: 2,
|
||||
rightDistanceCm: 3,
|
||||
});
|
||||
}
|
||||
if (!m.SitReachTest.completed) {
|
||||
store.saveMeasurement('SitReachTest', { distanceCm: 14 })
|
||||
store.saveMeasurement("SitReachTest", { distanceCm: 14 });
|
||||
}
|
||||
if (!m.UpGo8ft.completed) {
|
||||
store.saveMeasurement('UpGo8ft', { durationSeconds: 5.8 })
|
||||
store.saveMeasurement("UpGo8ft", { durationSeconds: 5.8 });
|
||||
}
|
||||
if (!m.OneLegTest.completed) {
|
||||
store.saveMeasurement('OneLegTest', { leftSeconds: 24, rightSeconds: 26 })
|
||||
store.saveMeasurement("OneLegTest", { leftSeconds: 24, rightSeconds: 26 });
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport () {
|
||||
store.generateFinalReport()
|
||||
reportSuccessDialog.value = true
|
||||
function generateReport() {
|
||||
store.generateFinalReport();
|
||||
reportSuccessDialog.value = true;
|
||||
}
|
||||
|
||||
function goToDashboard () {
|
||||
reportSuccessDialog.value = false
|
||||
router.push('/')
|
||||
function goToDashboard() {
|
||||
reportSuccessDialog.value = false;
|
||||
router.push("/");
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,22 +14,36 @@
|
||||
</v-btn>
|
||||
|
||||
<!-- 關卡詳細卡片 -->
|
||||
<v-card v-if="station" class="rounded-xl elevation-3 mb-6" color="surface">
|
||||
<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-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>
|
||||
<h1 class="text-title-large font-weight-black">
|
||||
{{ station.name }}
|
||||
</h1>
|
||||
|
||||
<span class="text-title-medium text-white">第 {{ currentStationIndex }} 站 / 共 11 站</span>
|
||||
<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="text-title-small font-weight-bold mb-2">
|
||||
請出示 QR Code 給量測機掃描
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-center mb-2">
|
||||
<QRCode :size="160" :value="pidQrPayload" />
|
||||
@@ -88,32 +102,57 @@
|
||||
{{ machineStatusText }}
|
||||
</v-btn>
|
||||
|
||||
<div class="text-title-small text-grey-darken-1">{{ machineStatusHint }}</div>
|
||||
<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>
|
||||
<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">
|
||||
<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">
|
||||
<div
|
||||
class="text-body-large font-weight-bold text-grey-darken-3"
|
||||
>
|
||||
{{ field.label }}
|
||||
</div>
|
||||
|
||||
<div class="text-title-small text-grey">由量測機上傳後自動更新</div>
|
||||
<div class="text-title-small text-grey">
|
||||
由量測機上傳後自動更新
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-right">
|
||||
<div class="text-title-large font-weight-black text-primary">
|
||||
<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 class="text-title-small text-grey">
|
||||
{{ field.unit }}
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -138,8 +177,14 @@
|
||||
</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-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>
|
||||
@@ -155,390 +200,408 @@
|
||||
</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'
|
||||
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()
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useAppStore();
|
||||
|
||||
type MachineStatus = 'waiting' | 'measuring' | 'updated'
|
||||
type MachineStatus = "waiting" | "measuring" | "updated";
|
||||
|
||||
const code = computed(() => route.params.code as string)
|
||||
const machineStatus = ref<MachineStatus>('waiting')
|
||||
const mockTimers: number[] = []
|
||||
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
|
||||
name: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
reference: string;
|
||||
fields: Array<{
|
||||
key: string
|
||||
label: string
|
||||
unit: string
|
||||
placeholder: string
|
||||
defaultValue: number
|
||||
}>
|
||||
key: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
placeholder: string;
|
||||
defaultValue: number;
|
||||
}>;
|
||||
}
|
||||
> = {
|
||||
BloodPressure: {
|
||||
name: '血壓與心率量測',
|
||||
icon: 'mdi-heart-flash',
|
||||
name: "血壓與心率量測",
|
||||
icon: "mdi-heart-flash",
|
||||
description:
|
||||
'請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。',
|
||||
"請在放鬆狀態下靜坐 5 分鐘,手臂平放於桌面上,將袖帶綁在手肘上方兩公分處,按下測量鈕。檢測員請錄入血壓儀數值。",
|
||||
reference:
|
||||
'• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。',
|
||||
"• 收縮壓 (高壓): 120-139 mmHg (正常偏高),超過 140 mmHg 為高血壓警告。\n• 舒張壓 (低壓): 80-89 mmHg,超過 90 mmHg 為高血壓警告。\n• 靜止心率: 60-100 bpm。",
|
||||
fields: [
|
||||
{
|
||||
key: 'systolic',
|
||||
label: '收縮壓 (高壓)',
|
||||
unit: 'mmHg',
|
||||
placeholder: '如: 125',
|
||||
key: "systolic",
|
||||
label: "收縮壓 (高壓)",
|
||||
unit: "mmHg",
|
||||
placeholder: "如: 125",
|
||||
defaultValue: 120,
|
||||
},
|
||||
{
|
||||
key: 'diastolic',
|
||||
label: '舒張壓 (低壓)',
|
||||
unit: 'mmHg',
|
||||
placeholder: '如: 80',
|
||||
key: "diastolic",
|
||||
label: "舒張壓 (低壓)",
|
||||
unit: "mmHg",
|
||||
placeholder: "如: 80",
|
||||
defaultValue: 80,
|
||||
},
|
||||
{ key: 'heartRate', label: '安靜心率', unit: 'bpm', placeholder: '如: 72', defaultValue: 70 },
|
||||
{
|
||||
key: "heartRate",
|
||||
label: "安靜心率",
|
||||
unit: "bpm",
|
||||
placeholder: "如: 72",
|
||||
defaultValue: 70,
|
||||
},
|
||||
],
|
||||
},
|
||||
BodyFat: {
|
||||
name: '體組成分析 (BMI & 體脂)',
|
||||
icon: 'mdi-scale-bathroom',
|
||||
description: '請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。',
|
||||
name: "體組成分析 (BMI & 體脂)",
|
||||
icon: "mdi-scale-bathroom",
|
||||
description:
|
||||
"請脫鞋站在體脂計上,雙手握緊金屬把手。本站測量體重、BMI 與體脂肪率。",
|
||||
reference:
|
||||
'• 高齡者 BMI 正常區間: 20.0 - 26.9 (高齡適度豐腴有助於功能儲備)。\n• 體脂肪率正常區間:\n - 男性: 15% - 25% (超過 25% 為肥胖)\n - 女性: 20% - 30% (超過 30% 為肥胖)',
|
||||
"• 高齡者 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',
|
||||
key: "bmi",
|
||||
label: "BMI 身體質量指數",
|
||||
unit: "kg/m²",
|
||||
placeholder: "如: 22.5",
|
||||
defaultValue: 22,
|
||||
},
|
||||
{
|
||||
key: 'bodyFatPct',
|
||||
label: '體脂肪率',
|
||||
unit: '%',
|
||||
placeholder: '如: 24.0',
|
||||
key: "bodyFatPct",
|
||||
label: "體脂肪率",
|
||||
unit: "%",
|
||||
placeholder: "如: 24.0",
|
||||
defaultValue: 25,
|
||||
},
|
||||
],
|
||||
},
|
||||
GripStrength: {
|
||||
name: '左右手握力測驗 (肌力)',
|
||||
icon: 'mdi-hand-back-left',
|
||||
name: "左右手握力測驗 (肌力)",
|
||||
icon: "mdi-hand-back-left",
|
||||
description:
|
||||
'受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。',
|
||||
"受測者自然站立,手持握力器呈 90 度或自然下垂,全力緊握。左右手各測兩次,取最大值錄入。評估上肢最大肌力。",
|
||||
reference:
|
||||
'• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。',
|
||||
"• 高齡男性正常標準: 大於 28 公斤。\n• 高齡女性正常標準: 大於 18 公斤。\n• 數值過低可能代表肌少症前期,需加強抗阻力運動。",
|
||||
fields: [
|
||||
{
|
||||
key: 'leftHand',
|
||||
label: '左手最大握力',
|
||||
unit: 'kg',
|
||||
placeholder: '如: 30',
|
||||
key: "leftHand",
|
||||
label: "左手最大握力",
|
||||
unit: "kg",
|
||||
placeholder: "如: 30",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
key: 'rightHand',
|
||||
label: '右手最大握力',
|
||||
unit: 'kg',
|
||||
placeholder: '如: 32',
|
||||
key: "rightHand",
|
||||
label: "右手最大握力",
|
||||
unit: "kg",
|
||||
placeholder: "如: 32",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
Sit5x: {
|
||||
name: '5次坐站測試 (下肢肌力)',
|
||||
icon: 'mdi-human-male-height',
|
||||
name: "5次坐站測試 (下肢肌力)",
|
||||
icon: "mdi-human-male-height",
|
||||
description:
|
||||
'受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。',
|
||||
"受測者雙手抱胸,坐在無扶手椅子上。聽到口令後,以最快速度完成「站起-坐下」5 次。記錄完成的秒數。",
|
||||
reference:
|
||||
'• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。',
|
||||
"• 65 歲以上長者正常標準: 小於 12 秒完成。\n• 若超過 15 秒,表示下肢肌力明顯不足,跌倒風險增高,建議加入股四頭肌訓練。",
|
||||
fields: [
|
||||
{
|
||||
key: 'durationSeconds',
|
||||
label: '5次坐站完成時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 9.8',
|
||||
key: "durationSeconds",
|
||||
label: "5次坐站完成時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 9.8",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
Sit30s: {
|
||||
name: '30秒椅子坐立 (肌肉耐力)',
|
||||
icon: 'mdi-chair-school',
|
||||
name: "30秒椅子坐立 (肌肉耐力)",
|
||||
icon: "mdi-chair-school",
|
||||
description:
|
||||
'雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。',
|
||||
"雙手抱胸交叉於肩前,坐在高度約 43 公分的椅子中間。聽到開始口令後,30 秒內全力完成「完全站立且直立-坐回椅子」的次數。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。',
|
||||
"• 65-69 歲正常標準:\n - 男性: 12 - 18 次\n - 女性: 11 - 17 次\n• 此項目量測長者下肢肌肉耐力,是日常行走和樓梯攀爬的基礎。",
|
||||
fields: [
|
||||
{ key: 'count', label: '30秒內坐立次數', unit: '次', placeholder: '如: 14', defaultValue: 0 },
|
||||
{
|
||||
key: "count",
|
||||
label: "30秒內坐立次數",
|
||||
unit: "次",
|
||||
placeholder: "如: 14",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
StepTest: {
|
||||
name: '2分鐘原地抬膝踏步 (心肺耐力)',
|
||||
icon: 'mdi-run-fast',
|
||||
name: "2分鐘原地抬膝踏步 (心肺耐力)",
|
||||
icon: "mdi-run-fast",
|
||||
description:
|
||||
'量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。',
|
||||
"量測大轉子與髕骨中點高度,在牆上做記號。受測者聽到開始後,原地踏步,大腿必須抬高至記號高度。計算 2 分鐘內「右膝」抬起的次數。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。',
|
||||
"• 65-69 歲正常標準:\n - 男性: 86 - 116 次\n - 女性: 73 - 107 次\n• 評估長者有氧心肺適能,是維持長途行走體力的指標。",
|
||||
fields: [
|
||||
{
|
||||
key: 'count',
|
||||
label: '2分鐘右膝抬起次數',
|
||||
unit: '次',
|
||||
placeholder: '如: 80',
|
||||
key: "count",
|
||||
label: "2分鐘右膝抬起次數",
|
||||
unit: "次",
|
||||
placeholder: "如: 80",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
ArmTest: {
|
||||
name: '30秒肱二頭肌手臂屈舉 (上肢肌耐力)',
|
||||
icon: 'mdi-arm-flex',
|
||||
name: "30秒肱二頭肌手臂屈舉 (上肢肌耐力)",
|
||||
icon: "mdi-arm-flex",
|
||||
description:
|
||||
'坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。',
|
||||
"坐在椅子上,優勢手持啞鈴 (男性 8 磅 / 女性 5 磅) 自然下垂。聽到開始後,30 秒內以最快速度完成前臂彎舉與放下的次數。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。',
|
||||
"• 65-69 歲正常標準:\n - 男性 (8磅): 15 - 21 次\n - 女性 (5磅): 13 - 19 次\n• 評估上肢日常拿取重物、做家事所需的肌肉耐力。",
|
||||
fields: [
|
||||
{
|
||||
key: 'count',
|
||||
label: '30秒手臂屈舉次數',
|
||||
unit: '次',
|
||||
placeholder: '如: 16',
|
||||
key: "count",
|
||||
label: "30秒手臂屈舉次數",
|
||||
unit: "次",
|
||||
placeholder: "如: 16",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
BackTest: {
|
||||
name: '抓背測驗 (上肢關節柔軟度)',
|
||||
icon: 'mdi-human-stretching',
|
||||
name: "抓背測驗 (上肢關節柔軟度)",
|
||||
icon: "mdi-human-stretching",
|
||||
description:
|
||||
'一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。',
|
||||
"一隻手從肩上向後下方伸,另一隻手從腰部往後上方伸,雙手中指儘量靠近並重疊。量測兩中指間距離 (重疊為正,未觸碰為負)。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。',
|
||||
"• 65-69 歲正常標準:\n - 男性: -16 至 -2 公分\n - 女性: -8 至 +3 公分\n• 用於評估肩膀關節的靈活性,攸關穿衣、梳頭等日常自理動作。",
|
||||
fields: [
|
||||
{
|
||||
key: 'leftDistanceCm',
|
||||
label: '左手在上時兩指間距 (可為負值)',
|
||||
unit: '公分',
|
||||
placeholder: '如: -5 或 2',
|
||||
key: "leftDistanceCm",
|
||||
label: "左手在上時兩指間距 (可為負值)",
|
||||
unit: "公分",
|
||||
placeholder: "如: -5 或 2",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
key: 'rightDistanceCm',
|
||||
label: '右手在上時兩指間距 (可為負值)',
|
||||
unit: '公分',
|
||||
placeholder: '如: -3 或 4',
|
||||
key: "rightDistanceCm",
|
||||
label: "右手在上時兩指間距 (可為負值)",
|
||||
unit: "公分",
|
||||
placeholder: "如: -3 或 4",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
SitReachTest: {
|
||||
name: '坐姿體前彎 (下肢與腰部柔軟度)',
|
||||
icon: 'mdi-human-stretching',
|
||||
name: "坐姿體前彎 (下肢與腰部柔軟度)",
|
||||
icon: "mdi-human-stretching",
|
||||
description:
|
||||
'坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。',
|
||||
"坐在椅子邊緣,一腳伸直,腳尖勾起,另一腳彎曲平放。吸氣後呼氣,雙手重疊慢慢向前伸觸碰腳尖。測量中指端與腳尖距離。",
|
||||
reference:
|
||||
'• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。',
|
||||
"• 65-69 歲正常標準:\n - 男性: -8 至 +8 公分\n - 女性: -3 至 +12 公分\n• 用於評估大腿後側肌群與腰部的柔軟度,柔軟度佳可有效減緩腰酸背痛並預防拉傷。",
|
||||
fields: [
|
||||
{
|
||||
key: 'distanceCm',
|
||||
label: '前彎中指與腳尖距離',
|
||||
unit: '公分',
|
||||
placeholder: '如: 5 (超過腳尖為正)',
|
||||
key: "distanceCm",
|
||||
label: "前彎中指與腳尖距離",
|
||||
unit: "公分",
|
||||
placeholder: "如: 5 (超過腳尖為正)",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
UpGo8ft: {
|
||||
name: '2.44公尺起立繞物 (動態平衡與敏捷)',
|
||||
icon: 'mdi-navigation',
|
||||
name: "2.44公尺起立繞物 (動態平衡與敏捷)",
|
||||
icon: "mdi-navigation",
|
||||
description:
|
||||
'坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。',
|
||||
"坐在椅子上,前方 2.44 公尺 (8英尺) 處放置角錐。聽到開始口令後,起身快步繞過角錐並走回椅子坐下。記錄完成秒數。",
|
||||
reference:
|
||||
'• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。',
|
||||
"• 65 歲以上長者正常標準: 小於 8.5 秒完成。\n• 若大於 9.0 秒,代表敏捷度與動態平衡功能衰退,日常過馬路或避開障礙物時跌倒風險顯著升高。",
|
||||
fields: [
|
||||
{
|
||||
key: 'durationSeconds',
|
||||
label: '起立繞物完成時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 6.2',
|
||||
key: "durationSeconds",
|
||||
label: "起立繞物完成時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 6.2",
|
||||
defaultValue: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
OneLegTest: {
|
||||
name: '開眼單足立 (靜態平衡力)',
|
||||
icon: 'mdi-scale-balance',
|
||||
name: "開眼單足立 (靜態平衡力)",
|
||||
icon: "mdi-scale-balance",
|
||||
description:
|
||||
'受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。',
|
||||
"受測者雙手叉腰,一腳抬離地面,開始計時。當支撐腳移動、雙手離開腰部或懸空腳觸地時停止。左右腳各測一次,取最大秒數。",
|
||||
reference:
|
||||
'• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。',
|
||||
"• 65 歲以上正常標準: 大於 15 秒。\n• 低於 10 秒代表靜態平衡力嚴重退化,在日常站立、穿褲子或滑倒時缺乏瞬間反應力。",
|
||||
fields: [
|
||||
{
|
||||
key: 'leftSeconds',
|
||||
label: '左腳站立時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 18',
|
||||
key: "leftSeconds",
|
||||
label: "左腳站立時間",
|
||||
unit: "秒",
|
||||
placeholder: "如: 18",
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
key: 'rightSeconds',
|
||||
label: '右腳站立時間',
|
||||
unit: '秒',
|
||||
placeholder: '如: 20',
|
||||
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 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 === "waiting") {
|
||||
return "等待掃描QR Code";
|
||||
}
|
||||
|
||||
if (machineStatus.value === 'measuring') {
|
||||
return '量測完成,資料上傳中'
|
||||
if (machineStatus.value === "measuring") {
|
||||
return "量測完成,資料上傳中";
|
||||
}
|
||||
|
||||
return '伺服器已同步數據'
|
||||
})
|
||||
return "伺服器已同步數據";
|
||||
});
|
||||
|
||||
const machineStatusHint = computed(() => {
|
||||
if (machineStatus.value === 'waiting') {
|
||||
return '按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。'
|
||||
if (machineStatus.value === "waiting") {
|
||||
return "按下開始模擬後,量測機會掃描 QR Code 取得使用者 PID。";
|
||||
}
|
||||
|
||||
if (machineStatus.value === 'measuring') {
|
||||
return '設備正在將本站量測值送往後端伺服器,請稍候。'
|
||||
if (machineStatus.value === "measuring") {
|
||||
return "設備正在將本站量測值送往後端伺服器,請稍候。";
|
||||
}
|
||||
|
||||
return `更新時間:${currentMeasurement.value?.time ?? '--'}`
|
||||
})
|
||||
return `更新時間:${currentMeasurement.value?.time ?? "--"}`;
|
||||
});
|
||||
|
||||
// 計算當前關卡在 11 關中的順序
|
||||
const currentStationIndex = computed(() => {
|
||||
const keys = Object.keys(stationsSpec)
|
||||
return keys.indexOf(code.value) + 1
|
||||
})
|
||||
const keys = Object.keys(stationsSpec);
|
||||
return keys.indexOf(code.value) + 1;
|
||||
});
|
||||
|
||||
watch(
|
||||
code,
|
||||
newCode => {
|
||||
(newCode) => {
|
||||
if (stationsSpec[newCode]) {
|
||||
resetMockMeasurementFlow()
|
||||
resetMockMeasurementFlow();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearMockTimers()
|
||||
})
|
||||
clearMockTimers();
|
||||
});
|
||||
|
||||
function displayMeasurementValue (fieldKey: string) {
|
||||
if (machineStatus.value !== 'updated') {
|
||||
return '--'
|
||||
function displayMeasurementValue(fieldKey: string) {
|
||||
if (machineStatus.value !== "updated") {
|
||||
return "--";
|
||||
}
|
||||
|
||||
return currentMeasurement.value?.values[fieldKey] ?? '--'
|
||||
return currentMeasurement.value?.values[fieldKey] ?? "--";
|
||||
}
|
||||
|
||||
function goToNextStation () {
|
||||
const keys = Object.keys(stationsSpec)
|
||||
const currentIndex = keys.indexOf(code.value)
|
||||
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}`)
|
||||
const nextCode = keys[currentIndex + 1];
|
||||
router.push(`/checkin/${nextCode}`);
|
||||
} else {
|
||||
router.push('/checkin')
|
||||
router.push("/checkin");
|
||||
}
|
||||
}
|
||||
|
||||
function handleMachineStatusClick () {
|
||||
startMockMeasurementFlow(code.value)
|
||||
function handleMachineStatusClick() {
|
||||
startMockMeasurementFlow(code.value);
|
||||
}
|
||||
|
||||
function startMockMeasurementFlow (stationCode: string) {
|
||||
clearMockTimers()
|
||||
machineStatus.value = 'measuring'
|
||||
function startMockMeasurementFlow(stationCode: string) {
|
||||
clearMockTimers();
|
||||
machineStatus.value = "measuring";
|
||||
|
||||
mockTimers.push(
|
||||
window.setTimeout(() => {
|
||||
store.saveMeasurement(stationCode, createMockMeasurementValues(stationCode))
|
||||
machineStatus.value = 'updated'
|
||||
store.saveMeasurement(
|
||||
stationCode,
|
||||
createMockMeasurementValues(stationCode),
|
||||
);
|
||||
machineStatus.value = "updated";
|
||||
}, 1500),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function resetMockMeasurementFlow () {
|
||||
clearMockTimers()
|
||||
machineStatus.value = 'waiting'
|
||||
function resetMockMeasurementFlow() {
|
||||
clearMockTimers();
|
||||
machineStatus.value = "waiting";
|
||||
}
|
||||
|
||||
function clearMockTimers () {
|
||||
function clearMockTimers() {
|
||||
while (mockTimers.length > 0) {
|
||||
window.clearTimeout(mockTimers.pop())
|
||||
window.clearTimeout(mockTimers.pop());
|
||||
}
|
||||
}
|
||||
|
||||
function createMockMeasurementValues (stationCode: string) {
|
||||
function createMockMeasurementValues(stationCode: string) {
|
||||
switch (stationCode) {
|
||||
case 'BloodPressure': {
|
||||
return { systolic: 128, diastolic: 82, heartRate: 74 }
|
||||
case "BloodPressure": {
|
||||
return { systolic: 128, diastolic: 82, heartRate: 74 };
|
||||
}
|
||||
case 'BodyFat': {
|
||||
return { bmi: 23.4, bodyFatPct: 24.8 }
|
||||
case "BodyFat": {
|
||||
return { bmi: 23.4, bodyFatPct: 24.8 };
|
||||
}
|
||||
case 'GripStrength': {
|
||||
return { leftHand: 31, rightHand: 34 }
|
||||
case "GripStrength": {
|
||||
return { leftHand: 31, rightHand: 34 };
|
||||
}
|
||||
case 'Sit5x': {
|
||||
return { durationSeconds: 8.7 }
|
||||
case "Sit5x": {
|
||||
return { durationSeconds: 8.7 };
|
||||
}
|
||||
case 'Sit30s': {
|
||||
return { count: 17 }
|
||||
case "Sit30s": {
|
||||
return { count: 17 };
|
||||
}
|
||||
case 'StepTest': {
|
||||
return { count: 94 }
|
||||
case "StepTest": {
|
||||
return { count: 94 };
|
||||
}
|
||||
case 'ArmTest': {
|
||||
return { count: 19 }
|
||||
case "ArmTest": {
|
||||
return { count: 19 };
|
||||
}
|
||||
case 'BackTest': {
|
||||
return { leftDistanceCm: 2, rightDistanceCm: 3 }
|
||||
case "BackTest": {
|
||||
return { leftDistanceCm: 2, rightDistanceCm: 3 };
|
||||
}
|
||||
case 'SitReachTest': {
|
||||
return { distanceCm: 13 }
|
||||
case "SitReachTest": {
|
||||
return { distanceCm: 13 };
|
||||
}
|
||||
case 'UpGo8ft': {
|
||||
return { durationSeconds: 5.9 }
|
||||
case "UpGo8ft": {
|
||||
return { durationSeconds: 5.9 };
|
||||
}
|
||||
case 'OneLegTest': {
|
||||
return { leftSeconds: 23, rightSeconds: 26 }
|
||||
case "OneLegTest": {
|
||||
return { leftSeconds: 23, rightSeconds: 26 };
|
||||
}
|
||||
default: {
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,24 @@
|
||||
<template #main>
|
||||
<!-- 頁面標題:刻意縮小,把版面讓給下方的組別 -->
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-calendar-week" size="18" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-calendar-week"
|
||||
size="18"
|
||||
/>
|
||||
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-1">課程建議</span>
|
||||
<span class="text-title-small font-weight-bold text-grey-darken-1"
|
||||
>課程建議</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 體適能組別:本頁所有內容都依它而定,因此獨立成一張顯眼的卡片 -->
|
||||
<v-card class="rounded-xl mb-6 overflow-hidden" elevation="1" variant="flat">
|
||||
<v-card
|
||||
class="rounded-xl mb-6 overflow-hidden"
|
||||
elevation="1"
|
||||
variant="flat"
|
||||
>
|
||||
<v-card-item class="pa-4" :class="`bg-${store.groupMeta.color}`">
|
||||
<template #prepend>
|
||||
<v-avatar class="group-avatar" size="60">
|
||||
@@ -22,7 +33,9 @@
|
||||
</template>
|
||||
|
||||
<template #title>
|
||||
<span class="text-h5 font-weight-black text-white">{{ store.groupMeta.label }}</span>
|
||||
<span class="text-h5 font-weight-black text-white">{{
|
||||
store.groupMeta.label
|
||||
}}</span>
|
||||
</template>
|
||||
</v-card-item>
|
||||
|
||||
@@ -61,7 +74,12 @@
|
||||
|
||||
<!-- 載入中 -->
|
||||
<template v-if="store.loading">
|
||||
<v-skeleton-loader v-for="n in 3" :key="n" class="rounded-xl mb-4" type="article" />
|
||||
<v-skeleton-loader
|
||||
v-for="n in 3"
|
||||
:key="n"
|
||||
class="rounded-xl mb-4"
|
||||
type="article"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 載入失敗 -->
|
||||
@@ -75,7 +93,9 @@
|
||||
{{ store.error }}
|
||||
|
||||
<template #append>
|
||||
<v-btn size="small" variant="text" @click="store.loadAll()">重試</v-btn>
|
||||
<v-btn size="small" variant="text" @click="store.loadAll()"
|
||||
>重試</v-btn
|
||||
>
|
||||
</template>
|
||||
</v-alert>
|
||||
|
||||
@@ -94,8 +114,15 @@
|
||||
<template v-else>
|
||||
<!-- 課程說明:對應 doc/課程說明.md -->
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-card-title class="text-title-medium font-weight-bold text-primary d-flex align-center">
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-book-open-variant" size="20" />
|
||||
<v-card-title
|
||||
class="text-title-medium font-weight-bold text-primary d-flex align-center"
|
||||
>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-book-open-variant"
|
||||
size="20"
|
||||
/>
|
||||
課程說明
|
||||
</v-card-title>
|
||||
|
||||
@@ -103,7 +130,9 @@
|
||||
|
||||
<v-expansion-panels v-model="openPanels" multiple variant="accordion">
|
||||
<v-expansion-panel>
|
||||
<v-expansion-panel-title class="font-weight-bold">族群特徵</v-expansion-panel-title>
|
||||
<v-expansion-panel-title class="font-weight-bold"
|
||||
>族群特徵</v-expansion-panel-title
|
||||
>
|
||||
|
||||
<v-expansion-panel-text>
|
||||
<p class="text-body-medium text-grey-darken-2 mb-3">
|
||||
@@ -115,7 +144,11 @@
|
||||
:key="index"
|
||||
class="d-flex align-start ga-3 mb-2"
|
||||
>
|
||||
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
|
||||
<v-icon
|
||||
color="secondary"
|
||||
:icon="`mdi-numeric-${index + 1}-circle`"
|
||||
size="20"
|
||||
/>
|
||||
<div class="text-body-medium">{{ item }}</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
@@ -132,12 +165,18 @@
|
||||
:key="index"
|
||||
class="d-flex align-start ga-3 mb-4"
|
||||
>
|
||||
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
|
||||
<v-icon
|
||||
color="secondary"
|
||||
:icon="`mdi-numeric-${index + 1}-circle`"
|
||||
size="20"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="font-weight-bold">{{ item.title }}</div>
|
||||
|
||||
<div class="text-body-medium text-grey-darken-2 mt-1">{{ item.detail }}</div>
|
||||
<div class="text-body-medium text-grey-darken-2 mt-1">
|
||||
{{ item.detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
@@ -154,7 +193,11 @@
|
||||
:key="index"
|
||||
class="d-flex align-start ga-3 mb-2"
|
||||
>
|
||||
<v-icon color="secondary" :icon="`mdi-numeric-${index + 1}-circle`" size="20" />
|
||||
<v-icon
|
||||
color="secondary"
|
||||
:icon="`mdi-numeric-${index + 1}-circle`"
|
||||
size="20"
|
||||
/>
|
||||
<div class="text-body-medium">{{ item }}</div>
|
||||
</div>
|
||||
</v-expansion-panel-text>
|
||||
@@ -164,12 +207,21 @@
|
||||
|
||||
<!-- 運動時間建議:對應 doc/運動時間建議.png -->
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-expansion-panels v-model="durationTablePanel" flat variant="accordion">
|
||||
<v-expansion-panels
|
||||
v-model="durationTablePanel"
|
||||
flat
|
||||
variant="accordion"
|
||||
>
|
||||
<v-expansion-panel value="duration">
|
||||
<v-expansion-panel-title
|
||||
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
|
||||
>
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-timer-outline" size="20" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-timer-outline"
|
||||
size="20"
|
||||
/>
|
||||
運動時間建議
|
||||
</v-expansion-panel-title>
|
||||
|
||||
@@ -179,9 +231,15 @@
|
||||
<v-table class="course-table" density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day-col bg-primary text-white text-center">星期</th>
|
||||
<th class="bg-primary text-white text-center">運動內容</th>
|
||||
<th class="time-col bg-primary text-white text-center">時間</th>
|
||||
<th class="day-col bg-primary text-white text-center">
|
||||
星期
|
||||
</th>
|
||||
<th class="bg-primary text-white text-center">
|
||||
運動內容
|
||||
</th>
|
||||
<th class="time-col bg-primary text-white text-center">
|
||||
時間
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -191,14 +249,18 @@
|
||||
:key="row.day"
|
||||
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
|
||||
>
|
||||
<td class="day-col text-center font-weight-bold text-primary">
|
||||
<td
|
||||
class="day-col text-center font-weight-bold text-primary"
|
||||
>
|
||||
{{ DAY_NAMES[row.day - 1] }}
|
||||
</td>
|
||||
|
||||
<td class="text-body-medium text-wrap">{{ row.content }}</td>
|
||||
<td class="text-body-medium text-wrap">
|
||||
{{ row.content }}
|
||||
</td>
|
||||
|
||||
<td class="time-col text-center text-body-medium">
|
||||
{{ row.minutes === null ? '—' : `${row.minutes} 分` }}
|
||||
{{ row.minutes === null ? "—" : `${row.minutes} 分` }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -209,12 +271,21 @@
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-expansion-panels v-model="scheduleTablePanel" flat variant="accordion">
|
||||
<v-expansion-panels
|
||||
v-model="scheduleTablePanel"
|
||||
flat
|
||||
variant="accordion"
|
||||
>
|
||||
<v-expansion-panel value="schedule">
|
||||
<v-expansion-panel-title
|
||||
class="course-table-panel-title text-title-medium font-weight-bold text-primary"
|
||||
>
|
||||
<v-icon class="mr-2" color="primary" icon="mdi-timer-outline" size="20" />
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="primary"
|
||||
icon="mdi-timer-outline"
|
||||
size="20"
|
||||
/>
|
||||
運動課表
|
||||
</v-expansion-panel-title>
|
||||
|
||||
@@ -224,7 +295,9 @@
|
||||
<v-table class="course-table" density="comfortable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="day-col bg-primary text-white text-center">星期</th>
|
||||
<th class="day-col bg-primary text-white text-center">
|
||||
星期
|
||||
</th>
|
||||
|
||||
<th
|
||||
v-for="slot in visibleSlots"
|
||||
@@ -244,7 +317,9 @@
|
||||
:key="row.day"
|
||||
:class="{ 'bg-teal-lighten-5': row.day === todayDay }"
|
||||
>
|
||||
<td class="day-col text-center font-weight-bold text-primary">
|
||||
<td
|
||||
class="day-col text-center font-weight-bold text-primary"
|
||||
>
|
||||
{{ DAY_NAMES[row.day - 1] }}
|
||||
</td>
|
||||
|
||||
@@ -253,8 +328,13 @@
|
||||
:key="slot.key"
|
||||
class="slot-cell text-body-medium text-wrap"
|
||||
>
|
||||
<div class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3">
|
||||
<v-avatar class="slot-avatar flex-shrink-0" rounded="lg">
|
||||
<div
|
||||
class="d-flex flex-column flex-md-row align-center ga-1 ga-md-3"
|
||||
>
|
||||
<v-avatar
|
||||
class="slot-avatar flex-shrink-0"
|
||||
rounded="lg"
|
||||
>
|
||||
<v-img
|
||||
:alt="`${DAY_NAMES[row.day - 1]}${slot.label}課表插圖`"
|
||||
:src="scheduleCardImage(row.day, slot.key)"
|
||||
@@ -273,7 +353,10 @@
|
||||
</v-card>
|
||||
|
||||
<v-card class="rounded-lg elevation-1 mt-4 overflow-hidden">
|
||||
<v-img :alt="`${store.groupMeta.label}課表示意圖`" :src="groupIllustration" />
|
||||
<v-img
|
||||
:alt="`${store.groupMeta.label}課表示意圖`"
|
||||
:src="groupIllustration"
|
||||
/>
|
||||
</v-card>
|
||||
</template>
|
||||
</template>
|
||||
@@ -281,102 +364,124 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { FitnessGroup, TimeSlot, YesNo } from '@/api/types'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import highIllustration from '@/assets/course/high.png'
|
||||
import lowIllustration from '@/assets/course/low.png'
|
||||
import mediumIllustration from '@/assets/course/medium.png'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { COURSE_DETAIL } from '@/data/courseDetail'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import type { FitnessGroup, TimeSlot, YesNo } from "@/api/types";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import highIllustration from "@/assets/course/high.png";
|
||||
import lowIllustration from "@/assets/course/low.png";
|
||||
import mediumIllustration from "@/assets/course/medium.png";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { COURSE_DETAIL } from "@/data/courseDetail";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
|
||||
const store = useCourseStore()
|
||||
const store = useCourseStore();
|
||||
|
||||
// 運動時間建議與課程說明的靜態內容,依組別對應
|
||||
const detail = computed(() => COURSE_DETAIL[store.group])
|
||||
const detail = computed(() => COURSE_DETAIL[store.group]);
|
||||
|
||||
// 課程說明預設只展開第一個面板(族群特徵)
|
||||
const openPanels = ref([0])
|
||||
const openPanels = ref([0]);
|
||||
|
||||
// 兩張表格預設展開,使用者可分別收合以減少頁面長度。
|
||||
const durationTablePanel = ref<string | null>('duration')
|
||||
const scheduleTablePanel = ref<string | null>('schedule')
|
||||
const durationTablePanel = ref<string | null>("duration");
|
||||
const scheduleTablePanel = ref<string | null>("schedule");
|
||||
|
||||
const GROUP_ILLUSTRATIONS: Record<FitnessGroup, string> = {
|
||||
low: lowIllustration,
|
||||
medium: mediumIllustration,
|
||||
high: highIllustration,
|
||||
}
|
||||
};
|
||||
|
||||
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group])
|
||||
const groupIllustration = computed(() => GROUP_ILLUSTRATIONS[store.group]);
|
||||
|
||||
// 時段篩選功能尚未開放,UI 先隱藏;之後要開放時把這個常數改回 true 即可
|
||||
const SHOW_SLOT_FILTER = false
|
||||
const SHOW_SLOT_FILTER = false;
|
||||
|
||||
const DAY_NAMES = ['一', '二', '三', '四', '五', '六', '日']
|
||||
const DAY_NAMES = ["一", "二", "三", "四", "五", "六", "日"];
|
||||
|
||||
// 課表小卡:檔名為「星期-時段」,逐一匯入 21 張圖並依 day/slot 查表取用
|
||||
const scheduleCardModules = import.meta.glob<{ default: string }>(
|
||||
'../assets/course/schedule/*.png',
|
||||
"../assets/course/schedule/*.png",
|
||||
{ eager: true },
|
||||
)
|
||||
);
|
||||
|
||||
const SCHEDULE_CARD_DAY_KEYS = [
|
||||
'monday',
|
||||
'tuesday',
|
||||
'wednesday',
|
||||
'thursday',
|
||||
'friday',
|
||||
'saturday',
|
||||
'sunday',
|
||||
]
|
||||
"monday",
|
||||
"tuesday",
|
||||
"wednesday",
|
||||
"thursday",
|
||||
"friday",
|
||||
"saturday",
|
||||
"sunday",
|
||||
];
|
||||
const SCHEDULE_CARD_SLOT_KEYS: Record<TimeSlot, string> = {
|
||||
morning: 'morning',
|
||||
noon: 'noon',
|
||||
night: 'evening',
|
||||
}
|
||||
morning: "morning",
|
||||
noon: "noon",
|
||||
night: "evening",
|
||||
};
|
||||
|
||||
const SCHEDULE_CARDS: Record<string, string> = Object.fromEntries(
|
||||
Object.entries(scheduleCardModules).map(([path, module]) => [
|
||||
path.replace(/^.*\/|\.png$/g, ''),
|
||||
path.replace(/^.*\/|\.png$/g, ""),
|
||||
module.default,
|
||||
]),
|
||||
)
|
||||
);
|
||||
|
||||
function scheduleCardImage (day: number, slot: TimeSlot): string {
|
||||
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`
|
||||
return SCHEDULE_CARDS[key]
|
||||
function scheduleCardImage(day: number, slot: TimeSlot): string {
|
||||
const key = `${SCHEDULE_CARD_DAY_KEYS[day - 1]}-${SCHEDULE_CARD_SLOT_KEYS[slot]}`;
|
||||
return SCHEDULE_CARDS[key];
|
||||
}
|
||||
|
||||
const SLOTS: { key: TimeSlot, label: string, icon: string, headerClass: string }[] = [
|
||||
{ key: 'morning', label: '早上', icon: 'mdi-weather-sunset-up', headerClass: 'bg-deep-orange' },
|
||||
{ key: 'noon', label: '中午', icon: 'mdi-white-balance-sunny', headerClass: 'bg-amber-darken-2' },
|
||||
{ key: 'night', label: '晚上', icon: 'mdi-weather-night', headerClass: 'bg-deep-purple' },
|
||||
]
|
||||
const SLOTS: {
|
||||
key: TimeSlot;
|
||||
label: string;
|
||||
icon: string;
|
||||
headerClass: string;
|
||||
}[] = [
|
||||
{
|
||||
key: "morning",
|
||||
label: "早上",
|
||||
icon: "mdi-weather-sunset-up",
|
||||
headerClass: "bg-deep-orange",
|
||||
},
|
||||
{
|
||||
key: "noon",
|
||||
label: "中午",
|
||||
icon: "mdi-white-balance-sunny",
|
||||
headerClass: "bg-amber-darken-2",
|
||||
},
|
||||
{
|
||||
key: "night",
|
||||
label: "晚上",
|
||||
icon: "mdi-weather-night",
|
||||
headerClass: "bg-deep-purple",
|
||||
},
|
||||
];
|
||||
|
||||
// 依目前選取的時段決定表格要顯示哪幾欄,順序固定為早上/中午/晚上
|
||||
const visibleSlots = computed(() => SLOTS.filter(slot => store.selectedSlots.includes(slot.key)))
|
||||
const visibleSlots = computed(() =>
|
||||
SLOTS.filter((slot) => store.selectedSlots.includes(slot.key)),
|
||||
);
|
||||
|
||||
// 今天對應的星期幾,換算成與 CourseRow.day 相同的規則(1 = 週一 … 7 = 週日)
|
||||
const todayDay = computed(() => {
|
||||
const jsDay = new Date().getDay()
|
||||
return jsDay === 0 ? 7 : jsDay
|
||||
})
|
||||
const jsDay = new Date().getDay();
|
||||
return jsDay === 0 ? 7 : jsDay;
|
||||
});
|
||||
|
||||
// v-btn-toggle 以陣列表達勾選狀態,store 則以 Y/N 保存,兩者在此橋接
|
||||
const selectedSlots = computed<TimeSlot[]>({
|
||||
get: () => store.selectedSlots,
|
||||
set: value => {
|
||||
set: (value) => {
|
||||
for (const slot of SLOTS) {
|
||||
const next: YesNo = value.includes(slot.key) ? 'Y' : 'N'
|
||||
store.setSlot(slot.key, next)
|
||||
const next: YesNo = value.includes(slot.key) ? "Y" : "N";
|
||||
store.setSlot(slot.key, next);
|
||||
}
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
store.loadAll()
|
||||
})
|
||||
store.loadAll();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -7,12 +7,16 @@
|
||||
<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-card-subtitle>
|
||||
資料更新於 {{ todayTimeString }}
|
||||
</v-card-subtitle>
|
||||
</v-col>
|
||||
|
||||
<v-col class="d-flex justify-center" cols="4">
|
||||
<!-- 健康力分數大圓盤 -->
|
||||
<div class="score-disk d-flex flex-column justify-center align-center">
|
||||
<div
|
||||
class="score-disk d-flex flex-column justify-center align-center"
|
||||
>
|
||||
<span class="score-val">{{ displayedHealthScore }}</span>
|
||||
<span class="score-unit">分</span>
|
||||
</div>
|
||||
@@ -56,7 +60,11 @@
|
||||
|
||||
<!-- 課程建議入口 -->
|
||||
|
||||
<v-list-item append-icon="mdi-chevron-right" class="bg-primary py-4" to="/course">
|
||||
<v-list-item
|
||||
append-icon="mdi-chevron-right"
|
||||
class="bg-primary py-4"
|
||||
to="/course"
|
||||
>
|
||||
<v-list-item-title class="font-weight-bold">
|
||||
<v-icon icon="mdi-notebook-check-outline" size="16" />
|
||||
課程建議
|
||||
@@ -158,10 +166,17 @@
|
||||
</v-btn>
|
||||
|
||||
<div class="d-flex align-center mt-4">
|
||||
<v-icon class="mr-3" color="primary" icon="mdi-lock-outline" size="28" />
|
||||
<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="font-weight-bold text-grey-darken-4">
|
||||
登入後可使用完整功能
|
||||
</div>
|
||||
|
||||
<div class="text-title-small text-grey">
|
||||
普測預約、現場報到、量測紀錄與運動處方。
|
||||
@@ -184,70 +199,70 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import BrandAvatar from '@/components/BrandAvatar.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'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import BrandAvatar from "@/components/BrandAvatar.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 displayedHealthScore = ref(1)
|
||||
let healthScoreTimer: number | undefined
|
||||
const store = useAppStore();
|
||||
const displayedHealthScore = ref(1);
|
||||
let healthScoreTimer: number | undefined;
|
||||
|
||||
function animateHealthScore (targetScore: number) {
|
||||
function animateHealthScore(targetScore: number) {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer)
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
|
||||
if (targetScore <= 1) {
|
||||
displayedHealthScore.value = targetScore
|
||||
return
|
||||
displayedHealthScore.value = targetScore;
|
||||
return;
|
||||
}
|
||||
|
||||
displayedHealthScore.value = 1
|
||||
displayedHealthScore.value = 1;
|
||||
|
||||
const duration = 900
|
||||
const stepDuration = Math.max(12, Math.floor(duration / targetScore))
|
||||
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
|
||||
window.clearInterval(healthScoreTimer);
|
||||
healthScoreTimer = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
displayedHealthScore.value += 1
|
||||
}, stepDuration)
|
||||
displayedHealthScore.value += 1;
|
||||
}, stepDuration);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
animateHealthScore(store.healthScore)
|
||||
})
|
||||
animateHealthScore(store.healthScore);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (healthScoreTimer !== undefined) {
|
||||
window.clearInterval(healthScoreTimer)
|
||||
window.clearInterval(healthScoreTimer);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
watch(
|
||||
() => store.healthScore,
|
||||
score => {
|
||||
animateHealthScore(score)
|
||||
(score) => {
|
||||
animateHealthScore(score);
|
||||
},
|
||||
)
|
||||
);
|
||||
|
||||
const todayTimeString = computed(() => {
|
||||
const now = new Date()
|
||||
const now = new Date();
|
||||
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')}`
|
||||
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")}`;
|
||||
|
||||
return `${date} ${time}`
|
||||
})
|
||||
return `${date} ${time}`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,40 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import coverImage from '@/assets/introduction/functional _fitness_all_ages/00_cover.jpg'
|
||||
import overviewImage01 from '@/assets/introduction/functional _fitness_all_ages/01_overview.jpg'
|
||||
import methodsImage from '@/assets/introduction/functional _fitness_all_ages/02_methods.jpg'
|
||||
import resultsImage from '@/assets/introduction/functional _fitness_all_ages/03_results.jpg'
|
||||
import overviewImage02 from '@/assets/introduction/functional _fitness_all_ages/04_overview.jpg'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import coverImage from "@/assets/introduction/functional _fitness_all_ages/00_cover.jpg";
|
||||
import overviewImage01 from "@/assets/introduction/functional _fitness_all_ages/01_overview.jpg";
|
||||
import methodsImage from "@/assets/introduction/functional _fitness_all_ages/02_methods.jpg";
|
||||
import resultsImage from "@/assets/introduction/functional _fitness_all_ages/03_results.jpg";
|
||||
import overviewImage02 from "@/assets/introduction/functional _fitness_all_ages/04_overview.jpg";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
|
||||
const slides = [
|
||||
{
|
||||
title: '封面',
|
||||
title: "封面",
|
||||
src: coverImage,
|
||||
},
|
||||
{
|
||||
title: '研究總覽',
|
||||
title: "研究總覽",
|
||||
src: overviewImage01,
|
||||
},
|
||||
{
|
||||
title: '方法與指標',
|
||||
title: "方法與指標",
|
||||
src: methodsImage,
|
||||
},
|
||||
{
|
||||
title: '主要結果',
|
||||
title: "主要結果",
|
||||
src: resultsImage,
|
||||
},
|
||||
{
|
||||
title: '研究總覽',
|
||||
title: "研究總覽",
|
||||
src: overviewImage02,
|
||||
},
|
||||
]
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Layout>
|
||||
<template #main>
|
||||
<v-card variant="flat">
|
||||
<v-card-title class="font-weight-bold text-primary d-flex align-center py-0">
|
||||
<v-card-title
|
||||
class="font-weight-bold text-primary d-flex align-center py-0"
|
||||
>
|
||||
<v-icon class="mr-4" color="accent" icon="mdi-information-outline" />
|
||||
計畫簡介
|
||||
</v-card-title>
|
||||
|
||||
@@ -7,8 +7,12 @@
|
||||
<v-card class="bg-transparent" variant="flat">
|
||||
<BrandAvatar />
|
||||
|
||||
<v-card-title class="text-primary font-weight-bold">運動玩轉健康力</v-card-title>
|
||||
<v-card-subtitle class="text-primary">Taipei Masters Hub</v-card-subtitle>
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>運動玩轉健康力</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-primary"
|
||||
>Taipei Masters Hub</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
|
||||
<!-- 登入卡片 -->
|
||||
@@ -85,7 +89,10 @@
|
||||
<v-card-actions class="justify-center">
|
||||
<span class="text-body-small text-grey">
|
||||
登入即表示您同意本平台的
|
||||
<a class="text-accent text-decoration-none font-weight-bold" href="#">
|
||||
<a
|
||||
class="text-accent text-decoration-none font-weight-bold"
|
||||
href="#"
|
||||
>
|
||||
個資授權條款
|
||||
</a>
|
||||
</span>
|
||||
@@ -103,83 +110,87 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HTTPError } from 'ky'
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BrandAvatar from '@/components/BrandAvatar.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { HTTPError } from "ky";
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const router = useRouter()
|
||||
const store = useAppStore();
|
||||
const router = useRouter();
|
||||
|
||||
const account = ref('')
|
||||
const loading = ref(false)
|
||||
const lineLoading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const account = ref("");
|
||||
const loading = ref(false);
|
||||
const lineLoading = ref(false);
|
||||
const errorMessage = ref("");
|
||||
|
||||
/** 後端要求純數字、09 開頭、共 10 碼;允許使用者輸入時夾雜連字號或空白 */
|
||||
function normalizePhone (value: string) {
|
||||
return value.replace(/\D/g, '')
|
||||
function normalizePhone(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
interface ErrorResponseBody {
|
||||
/** 一般錯誤格式,例如 401 手機門號不存在 */
|
||||
message?: string
|
||||
message?: string;
|
||||
/** 驗證失敗時(400)後端走 ASP.NET Core 的 ProblemDetails 格式 */
|
||||
errors?: Record<string, string[]>
|
||||
errors?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
/** 優先取用後端回應 body 的錯誤訊息,取不到時退回通用錯誤訊息 */
|
||||
function resolveErrorMessage (error: unknown): string {
|
||||
function resolveErrorMessage(error: unknown): string {
|
||||
// ky 建立 HTTPError 時已經把 body 讀進 error.data,body 已被消耗,
|
||||
// 不能再呼叫 error.response.json()(會丟 "Body has already been read")
|
||||
if (error instanceof HTTPError && error.data && typeof error.data === 'object') {
|
||||
const body = error.data as ErrorResponseBody
|
||||
if (
|
||||
error instanceof HTTPError &&
|
||||
error.data &&
|
||||
typeof error.data === "object"
|
||||
) {
|
||||
const body = error.data as ErrorResponseBody;
|
||||
|
||||
if (body.message) {
|
||||
return body.message
|
||||
return body.message;
|
||||
}
|
||||
|
||||
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0]
|
||||
const firstFieldError = Object.values(body.errors ?? {})[0]?.[0];
|
||||
if (firstFieldError) {
|
||||
return firstFieldError
|
||||
return firstFieldError;
|
||||
}
|
||||
}
|
||||
|
||||
return error instanceof Error ? error.message : '登入失敗,請稍後再試。'
|
||||
return error instanceof Error ? error.message : "登入失敗,請稍後再試。";
|
||||
}
|
||||
|
||||
async function handleLogin () {
|
||||
const phone = normalizePhone(account.value)
|
||||
async function handleLogin() {
|
||||
const phone = normalizePhone(account.value);
|
||||
|
||||
if (!/^09\d{8}$/.test(phone)) {
|
||||
errorMessage.value = '請輸入正確的手機門號格式(09 開頭,共 10 碼)'
|
||||
return
|
||||
errorMessage.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)";
|
||||
return;
|
||||
}
|
||||
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
errorMessage.value = "";
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await store.login(phone)
|
||||
router.push('/')
|
||||
await store.login(phone);
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
errorMessage.value = resolveErrorMessage(error);
|
||||
} finally {
|
||||
loading.value = false
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function _handleLineLogin () {
|
||||
lineLoading.value = true
|
||||
async function _handleLineLogin() {
|
||||
lineLoading.value = true;
|
||||
|
||||
try {
|
||||
await store.login('0910666888') // LINE 模擬登入預設手機
|
||||
router.push('/')
|
||||
await store.login("0910666888"); // LINE 模擬登入預設手機
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
errorMessage.value = resolveErrorMessage(error)
|
||||
errorMessage.value = resolveErrorMessage(error);
|
||||
} finally {
|
||||
lineLoading.value = false
|
||||
lineLoading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -204,7 +215,7 @@ async function _handleLineLogin () {
|
||||
rgba(255, 255, 255, 0.15) 65%,
|
||||
transparent 100%
|
||||
);
|
||||
content: '';
|
||||
content: "";
|
||||
transform: rotate(18deg);
|
||||
animation: glow-sweep 2.4s ease-in-out infinite;
|
||||
pointer-events: none;
|
||||
@@ -220,5 +231,4 @@ async function _handleLineLogin () {
|
||||
left: 125%;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -11,11 +11,20 @@
|
||||
|
||||
<!-- 進度指示器 -->
|
||||
<v-row class="mb-6 justify-center">
|
||||
<v-col v-for="(stepName, idx) in stepNames" :key="idx" class="text-center" cols="3">
|
||||
<v-col
|
||||
v-for="(stepName, idx) in stepNames"
|
||||
:key="idx"
|
||||
class="text-center"
|
||||
cols="3"
|
||||
>
|
||||
<v-avatar
|
||||
class="text-white font-weight-bold mb-1 elevation-1"
|
||||
:color="
|
||||
currentStep > idx ? 'primary' : currentStep === idx ? 'secondary' : 'grey-lighten-2'
|
||||
currentStep > idx
|
||||
? 'primary'
|
||||
: currentStep === idx
|
||||
? 'secondary'
|
||||
: 'grey-lighten-2'
|
||||
"
|
||||
size="28"
|
||||
>
|
||||
@@ -43,8 +52,14 @@
|
||||
<v-card-text class="py-2">
|
||||
<!-- 步驟 1:基本資料 -->
|
||||
<div v-if="currentStep === 0">
|
||||
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
|
||||
<v-icon class="mr-2" color="secondary" icon="mdi-account-circle" />
|
||||
<h2
|
||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||
>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="secondary"
|
||||
icon="mdi-account-circle"
|
||||
/>
|
||||
基本資料確認
|
||||
</h2>
|
||||
|
||||
@@ -140,19 +155,31 @@
|
||||
|
||||
<!-- 步驟 2:活動量與自評 -->
|
||||
<div v-if="currentStep === 1">
|
||||
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
|
||||
<h2
|
||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||
>
|
||||
<v-icon class="mr-2" color="secondary" icon="mdi-walk" />
|
||||
活動習慣與想要改善的能力
|
||||
</h2>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
1. 最近 7 天,您每週進行大運動幾次?
|
||||
</p>
|
||||
|
||||
<v-radio-group v-model="assessmentForm.exerciseFrequency" class="mb-4" color="primary">
|
||||
<v-radio-group
|
||||
v-model="assessmentForm.exerciseFrequency"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
>
|
||||
<v-card
|
||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="assessmentForm.exerciseFrequency === '0' ? 'secondary' : 'grey-lighten-1'"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === '0'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '0'"
|
||||
>
|
||||
@@ -161,7 +188,11 @@
|
||||
|
||||
<v-card
|
||||
class="mb-2 rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="assessmentForm.exerciseFrequency === '1-2' ? 'secondary' : 'grey-lighten-1'"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === '1-2'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '1-2'"
|
||||
>
|
||||
@@ -170,7 +201,11 @@
|
||||
|
||||
<v-card
|
||||
class="rounded-lg pa-1 cursor-pointer transition-card"
|
||||
:color="assessmentForm.exerciseFrequency === '3+' ? 'secondary' : 'grey-lighten-1'"
|
||||
:color="
|
||||
assessmentForm.exerciseFrequency === '3+'
|
||||
? 'secondary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="assessmentForm.exerciseFrequency = '3+'"
|
||||
>
|
||||
@@ -178,7 +213,9 @@
|
||||
</v-card>
|
||||
</v-radio-group>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
2. 您目前最想改善的身體功能? (可複選)
|
||||
</p>
|
||||
|
||||
@@ -186,16 +223,25 @@
|
||||
<v-col v-for="imp in improvementsOptions" :key="imp" cols="6">
|
||||
<v-card
|
||||
class="rounded-lg py-2 px-1 text-center cursor-pointer transition-card"
|
||||
:class="{ 'bg-teal-lighten-5': assessmentForm.wantedImprovements.includes(imp) }"
|
||||
:class="{
|
||||
'bg-teal-lighten-5':
|
||||
assessmentForm.wantedImprovements.includes(imp),
|
||||
}"
|
||||
:color="
|
||||
assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey-lighten-1'
|
||||
assessmentForm.wantedImprovements.includes(imp)
|
||||
? 'primary'
|
||||
: 'grey-lighten-1'
|
||||
"
|
||||
variant="outlined"
|
||||
@click="toggleImprovement(imp)"
|
||||
>
|
||||
<v-icon
|
||||
class="mb-1"
|
||||
:color="assessmentForm.wantedImprovements.includes(imp) ? 'primary' : 'grey'"
|
||||
:color="
|
||||
assessmentForm.wantedImprovements.includes(imp)
|
||||
? 'primary'
|
||||
: 'grey'
|
||||
"
|
||||
:icon="getImprovementIcon(imp)"
|
||||
/>
|
||||
|
||||
@@ -207,21 +253,36 @@
|
||||
|
||||
<!-- 步驟 3:身體狀況自評 -->
|
||||
<div v-if="currentStep === 2">
|
||||
<h2 class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center">
|
||||
<v-icon class="mr-2" color="secondary" icon="mdi-alert-decagram" />
|
||||
<h2
|
||||
class="text-title-medium font-weight-bold text-primary mb-4 d-flex align-center"
|
||||
>
|
||||
<v-icon
|
||||
class="mr-2"
|
||||
color="secondary"
|
||||
icon="mdi-alert-decagram"
|
||||
/>
|
||||
身體限制與慢性病史
|
||||
</h2>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
1. 過去一年是否有過跌倒史?
|
||||
</p>
|
||||
|
||||
<v-radio-group v-model="assessmentForm.hasFalls" class="mb-4" color="primary" inline>
|
||||
<v-radio-group
|
||||
v-model="assessmentForm.hasFalls"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
inline
|
||||
>
|
||||
<v-radio label="無跌倒" :value="false" />
|
||||
<v-radio label="有跌倒過" :value="true" />
|
||||
</v-radio-group>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
2. 目前身體關節疼痛狀況?
|
||||
</p>
|
||||
|
||||
@@ -234,7 +295,9 @@
|
||||
variant="outlined"
|
||||
/>
|
||||
|
||||
<p class="text-body-medium font-weight-bold text-grey-darken-2 mb-2">
|
||||
<p
|
||||
class="text-body-medium font-weight-bold text-grey-darken-2 mb-2"
|
||||
>
|
||||
3. 您有哪些慢性疾病? (可複選)
|
||||
</p>
|
||||
|
||||
@@ -254,8 +317,16 @@
|
||||
|
||||
<!-- 步驟 4:自評結果 -->
|
||||
<div v-if="currentStep === 3" class="text-center py-4">
|
||||
<v-avatar class="elevation-2 mb-4" :color="getResultColor(resultLevel)" size="72">
|
||||
<v-icon color="white" :icon="getResultIcon(resultLevel)" size="40" />
|
||||
<v-avatar
|
||||
class="elevation-2 mb-4"
|
||||
:color="getResultColor(resultLevel)"
|
||||
size="72"
|
||||
>
|
||||
<v-icon
|
||||
color="white"
|
||||
:icon="getResultIcon(resultLevel)"
|
||||
size="40"
|
||||
/>
|
||||
</v-avatar>
|
||||
|
||||
<h2
|
||||
@@ -265,15 +336,26 @@
|
||||
自評分級:{{ resultLevel }}
|
||||
</h2>
|
||||
|
||||
<p class="text-title-small text-grey-darken-2 mb-6">您的健康自評報告已產生</p>
|
||||
<p class="text-title-small text-grey-darken-2 mb-6">
|
||||
您的健康自評報告已產生
|
||||
</p>
|
||||
|
||||
<v-card class="rounded-xl pa-4 text-left mb-6" color="grey-lighten-4" variant="flat">
|
||||
<div class="font-weight-bold mb-2 text-primary d-flex align-center">
|
||||
<v-card
|
||||
class="rounded-xl pa-4 text-left mb-6"
|
||||
color="grey-lighten-4"
|
||||
variant="flat"
|
||||
>
|
||||
<div
|
||||
class="font-weight-bold mb-2 text-primary d-flex align-center"
|
||||
>
|
||||
<v-icon class="mr-1" icon="mdi-text-box-search" />
|
||||
系統評估建議:
|
||||
</div>
|
||||
|
||||
<p v-if="resultLevel === '低風險'" class="text-body-medium text-grey-darken-3 mb-2">
|
||||
<p
|
||||
v-if="resultLevel === '低風險'"
|
||||
class="text-body-medium text-grey-darken-3 mb-2"
|
||||
>
|
||||
您的身體功能與日常活動習慣良好。建議您每週繼續保持 3
|
||||
次以上的運動,並可以預約普測活動以獲得精準的五維健康指標與運動處方。
|
||||
</p>
|
||||
@@ -289,7 +371,9 @@
|
||||
檢測到您有多項慢性病或中重度疼痛,運動時請特別留意。建議您先向您的家庭醫師或社區據點諮詢,並在運動普測現場合對您的健康狀態,以確保運動安全。
|
||||
</p>
|
||||
|
||||
<div class="font-weight-bold text-body-medium text-secondary mt-3">
|
||||
<div
|
||||
class="font-weight-bold text-body-medium text-secondary mt-3"
|
||||
>
|
||||
* 禁忌提醒:若收縮壓 >
|
||||
160mmHg、有關節強烈疼痛或暈眩時,請立即暫停所有體能檢測與訓練!
|
||||
</div>
|
||||
@@ -364,13 +448,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { computed, reactive, ref } from "vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
|
||||
const store = useAppStore()
|
||||
const currentStep = ref(0)
|
||||
const stepNames = ['基本資料', '功能狀態', '健康限制', '自評結果']
|
||||
const store = useAppStore();
|
||||
const currentStep = ref(0);
|
||||
const stepNames = ["基本資料", "功能狀態", "健康限制", "自評結果"];
|
||||
|
||||
const profileForm = reactive({
|
||||
name: store.userProfile.name,
|
||||
@@ -380,7 +464,7 @@ const profileForm = reactive({
|
||||
height: store.userProfile.height,
|
||||
weight: store.userProfile.weight,
|
||||
consent: store.userProfile.consent,
|
||||
})
|
||||
});
|
||||
|
||||
const assessmentForm = reactive({
|
||||
exerciseFrequency: store.selfAssessment.exerciseFrequency,
|
||||
@@ -388,91 +472,105 @@ const assessmentForm = reactive({
|
||||
hasFalls: store.selfAssessment.hasFalls,
|
||||
painLevel: store.selfAssessment.painLevel,
|
||||
chronicDiseases: [...store.selfAssessment.chronicDiseases],
|
||||
})
|
||||
});
|
||||
|
||||
const resultLevel = ref(store.selfAssessment.scoreLevel)
|
||||
const resultLevel = ref(store.selfAssessment.scoreLevel);
|
||||
|
||||
const improvementsOptions = ['肌力', '平衡', '心肺', '柔軟度', '敏捷', '體脂/BMI']
|
||||
const diseaseOptions = ['高血壓', '糖尿病', '高血脂', '心臟病', '關節退化', '骨質疏鬆']
|
||||
const improvementsOptions = [
|
||||
"肌力",
|
||||
"平衡",
|
||||
"心肺",
|
||||
"柔軟度",
|
||||
"敏捷",
|
||||
"體脂/BMI",
|
||||
];
|
||||
const diseaseOptions = [
|
||||
"高血壓",
|
||||
"糖尿病",
|
||||
"高血脂",
|
||||
"心臟病",
|
||||
"關節退化",
|
||||
"骨質疏鬆",
|
||||
];
|
||||
|
||||
const birthdayDate = computed<Date | null>({
|
||||
get: () => parseDateString(profileForm.birthday),
|
||||
set: value => {
|
||||
profileForm.birthday = formatDateString(value)
|
||||
set: (value) => {
|
||||
profileForm.birthday = formatDateString(value);
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
function parseDateString (value: string) {
|
||||
const [year, month, day] = value.split('-').map(Number)
|
||||
function parseDateString(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
if (!year || !month || !day) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(year, month - 1, day)
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function formatDateString (value: Date | null) {
|
||||
function formatDateString(value: Date | null) {
|
||||
if (!value) {
|
||||
return ''
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = value.getFullYear()
|
||||
const month = String(value.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(value.getDate()).padStart(2, '0')
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}`
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function getImprovementIcon (imp: string) {
|
||||
function getImprovementIcon(imp: string) {
|
||||
switch (imp) {
|
||||
case '肌力': {
|
||||
return 'mdi-arm-flex'
|
||||
case "肌力": {
|
||||
return "mdi-arm-flex";
|
||||
}
|
||||
case '平衡': {
|
||||
return 'mdi-scale-balance'
|
||||
case "平衡": {
|
||||
return "mdi-scale-balance";
|
||||
}
|
||||
case '心肺': {
|
||||
return 'mdi-heart-pulse'
|
||||
case "心肺": {
|
||||
return "mdi-heart-pulse";
|
||||
}
|
||||
case '柔軟度': {
|
||||
return 'mdi-human-handsup'
|
||||
case "柔軟度": {
|
||||
return "mdi-human-handsup";
|
||||
}
|
||||
case '敏捷': {
|
||||
return 'mdi-run-fast'
|
||||
case "敏捷": {
|
||||
return "mdi-run-fast";
|
||||
}
|
||||
case '體脂/BMI': {
|
||||
return 'mdi-calculator'
|
||||
case "體脂/BMI": {
|
||||
return "mdi-calculator";
|
||||
}
|
||||
default: {
|
||||
return 'mdi-help-circle'
|
||||
return "mdi-help-circle";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleImprovement (imp: string) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(imp)
|
||||
function toggleImprovement(imp: string) {
|
||||
const index = assessmentForm.wantedImprovements.indexOf(imp);
|
||||
if (index === -1) {
|
||||
assessmentForm.wantedImprovements.push(imp)
|
||||
assessmentForm.wantedImprovements.push(imp);
|
||||
} else {
|
||||
assessmentForm.wantedImprovements.splice(index, 1)
|
||||
assessmentForm.wantedImprovements.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToPageTop () {
|
||||
window.scrollTo({ top: 0 })
|
||||
function scrollToPageTop() {
|
||||
window.scrollTo({ top: 0 });
|
||||
}
|
||||
|
||||
function goToPreviousStep () {
|
||||
currentStep.value -= 1
|
||||
scrollToPageTop()
|
||||
function goToPreviousStep() {
|
||||
currentStep.value -= 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function goToNextStep () {
|
||||
currentStep.value += 1
|
||||
scrollToPageTop()
|
||||
function goToNextStep() {
|
||||
currentStep.value += 1;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function handleSubmit () {
|
||||
function handleSubmit() {
|
||||
// 儲存至 store
|
||||
store.updateProfile({
|
||||
name: profileForm.name,
|
||||
@@ -482,7 +580,7 @@ function handleSubmit () {
|
||||
height: profileForm.height,
|
||||
weight: profileForm.weight,
|
||||
consent: profileForm.consent,
|
||||
})
|
||||
});
|
||||
|
||||
store.submitSelfAssessment({
|
||||
exerciseFrequency: assessmentForm.exerciseFrequency,
|
||||
@@ -490,24 +588,24 @@ function handleSubmit () {
|
||||
hasFalls: assessmentForm.hasFalls,
|
||||
painLevel: assessmentForm.painLevel,
|
||||
chronicDiseases: assessmentForm.chronicDiseases,
|
||||
})
|
||||
});
|
||||
|
||||
// 取得計算後的結果
|
||||
resultLevel.value = store.selfAssessment.scoreLevel
|
||||
currentStep.value = 3
|
||||
scrollToPageTop()
|
||||
resultLevel.value = store.selfAssessment.scoreLevel;
|
||||
currentStep.value = 3;
|
||||
scrollToPageTop();
|
||||
}
|
||||
|
||||
function getResultColor (lvl: string) {
|
||||
if (lvl === '低風險') return 'success'
|
||||
if (lvl === '需追蹤') return 'warning'
|
||||
return 'error'
|
||||
function getResultColor(lvl: string) {
|
||||
if (lvl === "低風險") return "success";
|
||||
if (lvl === "需追蹤") return "warning";
|
||||
return "error";
|
||||
}
|
||||
|
||||
function getResultIcon (lvl: string) {
|
||||
if (lvl === '低風險') return 'mdi-check-decagram'
|
||||
if (lvl === '需追蹤') return 'mdi-alert-decagram'
|
||||
return 'mdi-alert-octagon'
|
||||
function getResultIcon(lvl: string) {
|
||||
if (lvl === "低風險") return "mdi-check-decagram";
|
||||
if (lvl === "需追蹤") return "mdi-alert-decagram";
|
||||
return "mdi-alert-octagon";
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user