358 lines
10 KiB
Vue
358 lines
10 KiB
Vue
<template>
|
||
<Layout>
|
||
<template #main>
|
||
<v-card class="mb-6" variant="flat">
|
||
<v-card-title class="text-primary">預約</v-card-title>
|
||
|
||
<v-card-subtitle class="text-grey-darken-3">
|
||
報名免費體能普測,取得個人運動處方
|
||
</v-card-subtitle>
|
||
</v-card>
|
||
|
||
<v-alert
|
||
v-if="showMissingGroupNotice"
|
||
class="rounded-xl mb-6"
|
||
closable
|
||
icon="mdi-calendar-alert"
|
||
type="info"
|
||
variant="tonal"
|
||
@click:close="dismissMissingGroupNotice"
|
||
>
|
||
尚未取得您的體適能分組,請先預約並完成現場普測;分組建立後即可查看個人化課程建議。
|
||
</v-alert>
|
||
|
||
<!-- 活動分類選擇 -->
|
||
<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>
|
||
</div>
|
||
|
||
<v-row v-else>
|
||
<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">
|
||
<div class="d-flex justify-space-between align-center">
|
||
<span class="text-title-medium font-weight-bold text-primary">
|
||
<v-icon class="mr-1" icon="mdi-office-building" size="14" />
|
||
{{ apt.organizer }}
|
||
</span>
|
||
|
||
<v-chip
|
||
class="font-weight-black text-title-small"
|
||
:color="getStatusColor(apt.status)"
|
||
size="small"
|
||
variant="flat"
|
||
>
|
||
{{ apt.status }}
|
||
</v-chip>
|
||
</div>
|
||
</v-card-item>
|
||
|
||
<v-card-text>
|
||
<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"
|
||
/>
|
||
|
||
<div class="text-body-2 text-grey-darken-3">
|
||
<span class="font-weight-bold">{{ apt.date }}</span>
|
||
<span class="ml-4 text-grey-darken-1">{{ apt.time }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- <div class="d-flex align-start mb-2">
|
||
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" />
|
||
<div class="text-body-2 text-grey-darken-3">{{ apt.location }}</div>
|
||
</div> -->
|
||
|
||
<div class="d-flex align-start mb-3">
|
||
<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
|
||
>
|
||
位
|
||
</div>
|
||
</div>
|
||
|
||
<v-divider class="my-2" />
|
||
|
||
<!-- 量測項目標籤 -->
|
||
<!-- <div class="mb-2">
|
||
<div class="text-caption font-weight-bold text-grey mb-1">量測項目:</div>
|
||
|
||
<div class="d-flex flex-wrap gap-1">
|
||
<v-chip
|
||
v-for="item in apt.items"
|
||
:key="item"
|
||
class="font-weight-medium"
|
||
color="primary"
|
||
size="x-small"
|
||
variant="outlined"
|
||
>
|
||
{{ item }}
|
||
</v-chip>
|
||
</div>
|
||
</div> -->
|
||
</v-card-text>
|
||
|
||
<!-- 卡片按鈕操作 -->
|
||
<v-card-actions class="px-4 pb-4 pt-0">
|
||
<!-- 未報名狀態 -->
|
||
<template v-if="apt.status === '未報名'">
|
||
<v-btn
|
||
block
|
||
class="rounded-lg text-white font-weight-bold"
|
||
color="secondary"
|
||
:disabled="apt.slots === 0"
|
||
variant="flat"
|
||
@click="handleRegister(apt.id)"
|
||
>
|
||
{{ apt.slots === 0 ? "名額已滿" : "線上報名活動" }}
|
||
</v-btn>
|
||
</template>
|
||
|
||
<!-- 已報名狀態 -->
|
||
<template v-else-if="apt.status === '已報名'">
|
||
<v-row class="w-100">
|
||
<v-col cols="6">
|
||
<v-btn
|
||
block
|
||
class="rounded-lg text-white font-weight-bold"
|
||
color="primary"
|
||
variant="flat"
|
||
@click="showCheckinQRCode(apt)"
|
||
>
|
||
出示 QR Code
|
||
</v-btn>
|
||
</v-col>
|
||
|
||
<v-col cols="6">
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold"
|
||
color="error"
|
||
variant="outlined"
|
||
@click="handleCancel(apt.id)"
|
||
>
|
||
取消報名
|
||
</v-btn>
|
||
</v-col>
|
||
</v-row>
|
||
</template>
|
||
|
||
<!-- 已報到狀態 -->
|
||
<template v-else-if="apt.status === '已報到'">
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold"
|
||
color="primary"
|
||
to="/checkin"
|
||
variant="outlined"
|
||
>
|
||
進行現場量測 (已報到)
|
||
</v-btn>
|
||
</template>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-col>
|
||
</v-row>
|
||
|
||
<!-- 報到 QR Code 對話框 -->
|
||
<v-dialog v-model="qrcodeDialog" max-width="400">
|
||
<v-card class="rounded-xl overflow-hidden text-center">
|
||
<v-card-title class="bg-primary text-white font-weight-bold py-3">
|
||
現場報到 QR Code
|
||
</v-card-title>
|
||
|
||
<v-card-text class="pa-6">
|
||
<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>
|
||
|
||
<div
|
||
class="qrcode-wrapper mx-auto mb-4 elevation-3 rounded-lg pa-3 bg-white border d-flex justify-center align-center"
|
||
>
|
||
<QRCode :size="220" :value="selectedCheckinPayload" />
|
||
</div>
|
||
|
||
<div class="text-caption text-grey-darken-2 font-weight-bold mb-4">
|
||
姓名: {{ store.userProfile.name || "市民" }} 先生 | 電話:
|
||
{{ store.userProfile.phone }}
|
||
</div>
|
||
|
||
<!-- 模擬現場合對處,高齡測試快捷鍵 -->
|
||
<v-btn
|
||
block
|
||
class="rounded-lg font-weight-bold"
|
||
color="success"
|
||
prepend-icon="mdi-qrcode-scan"
|
||
variant="tonal"
|
||
@click="simulateOnSiteCheckin"
|
||
>
|
||
已由現場人員掃描報到
|
||
</v-btn>
|
||
</v-card-text>
|
||
|
||
<v-divider />
|
||
|
||
<v-card-actions class="pa-4 justify-center">
|
||
<v-btn
|
||
class="rounded-lg px-6"
|
||
color="primary"
|
||
variant="flat"
|
||
@click="qrcodeDialog = false"
|
||
>
|
||
關閉條碼
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</template>
|
||
</Layout>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, ref } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||
import QRCode from "@/components/QRCode.vue";
|
||
import { type Appointment, useAppStore } from "@/stores/app";
|
||
|
||
const store = useAppStore();
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
const filterTab = ref("all");
|
||
const showMissingGroupNotice = ref(
|
||
route.query.reason === "missing-fitness-group",
|
||
);
|
||
|
||
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 === "已報到",
|
||
);
|
||
}
|
||
return store.appointments;
|
||
});
|
||
|
||
const selectedCheckinPayload = computed(() => {
|
||
if (!selectedApt.value) {
|
||
return "https://hp.local/c";
|
||
}
|
||
|
||
return `https://hp.local/c/${selectedApt.value.id}`;
|
||
});
|
||
|
||
function getStatusColor(status: string) {
|
||
switch (status) {
|
||
case "未報名": {
|
||
return "grey-darken-1";
|
||
}
|
||
case "已報名": {
|
||
return "secondary";
|
||
}
|
||
case "已報到": {
|
||
return "success";
|
||
}
|
||
default: {
|
||
return "grey";
|
||
}
|
||
}
|
||
}
|
||
|
||
function handleRegister(id: string) {
|
||
store.registerAppointment(id);
|
||
}
|
||
|
||
function handleCancel(id: string) {
|
||
store.cancelAppointment(id);
|
||
}
|
||
|
||
async function dismissMissingGroupNotice() {
|
||
showMissingGroupNotice.value = false;
|
||
|
||
const query = { ...route.query };
|
||
delete query.reason;
|
||
await router.replace({ query });
|
||
}
|
||
|
||
function showCheckinQRCode(apt: Appointment) {
|
||
selectedApt.value = apt;
|
||
qrcodeDialog.value = true;
|
||
}
|
||
|
||
function simulateOnSiteCheckin() {
|
||
if (selectedApt.value) {
|
||
store.checkinAppointment(selectedApt.value.id);
|
||
qrcodeDialog.value = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.gap-1 {
|
||
gap: 4px;
|
||
}
|
||
.gap-2 {
|
||
gap: 8px;
|
||
}
|
||
|
||
.qrcode-wrapper {
|
||
position: relative;
|
||
width: 244px;
|
||
height: 244px;
|
||
overflow: hidden;
|
||
}
|
||
</style>
|