fix: classname
This commit is contained in:
@@ -61,12 +61,12 @@
|
|||||||
class="test-item d-flex align-center justify-space-between rounded-lg px-3"
|
class="test-item d-flex align-center justify-space-between rounded-lg px-3"
|
||||||
>
|
>
|
||||||
<div class="mr-3">
|
<div class="mr-3">
|
||||||
<div class="text-body-2 font-weight-bold">
|
<div class="text-body-medium font-weight-bold">
|
||||||
{{ item.fitnessName }}
|
{{ item.fitnessName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-body-1 font-weight-black text-no-wrap"
|
class="text-body-large font-weight-black text-no-wrap"
|
||||||
:class="item.fitnessScore === null ? 'text-grey' : 'text-primary'"
|
:class="item.fitnessScore === null ? 'text-grey' : 'text-primary'"
|
||||||
>
|
>
|
||||||
{{ item.fitnessScore ?? "無資料" }}
|
{{ item.fitnessScore ?? "無資料" }}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const isOpen = defineModel<boolean>({ required: true });
|
|||||||
個資同意與資料授權
|
個資同意與資料授權
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text class="pt-4 text-body-1">
|
<v-card-text class="pt-4 text-body-large">
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ defineProps<{ title: string; description?: string }>();
|
|||||||
<v-card-title class="text-center">{{ title }}</v-card-title>
|
<v-card-title class="text-center">{{ title }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<slot name="description">
|
<slot name="description">
|
||||||
<p v-if="description" class="text-body-1 mb-5">
|
<p v-if="description" class="text-body-large mb-5">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
</slot>
|
</slot>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title>
|
<v-app-bar-title class="font-weight-bold flex-grow-1">
|
||||||
|
運動玩轉健康力
|
||||||
|
</v-app-bar-title>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<div class="d-flex align-center">
|
<div class="d-flex align-center">
|
||||||
@@ -19,7 +21,10 @@
|
|||||||
variant="text"
|
variant="text"
|
||||||
@click="isLargeText = !isLargeText"
|
@click="isLargeText = !isLargeText"
|
||||||
>
|
>
|
||||||
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
|
<v-icon
|
||||||
|
:color="isLargeText ? 'accent' : 'white'"
|
||||||
|
icon="mdi-format-size"
|
||||||
|
/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -61,7 +66,9 @@
|
|||||||
</v-avatar>
|
</v-avatar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
|
<v-list-item-subtitle class="text-white-50"
|
||||||
|
>未登入訪客</v-list-item-subtitle
|
||||||
|
>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
@@ -74,7 +81,12 @@
|
|||||||
<v-list-item-title>大字體模式</v-list-item-title>
|
<v-list-item-title>大字體模式</v-list-item-title>
|
||||||
|
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
|
<v-switch
|
||||||
|
v-model="isLargeText"
|
||||||
|
color="secondary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
|
|
||||||
@@ -191,11 +203,16 @@
|
|||||||
</v-navigation-drawer>
|
</v-navigation-drawer>
|
||||||
|
|
||||||
<!-- 主要內容區 -->
|
<!-- 主要內容區 -->
|
||||||
<v-main class="bg-white min-vh-100" :class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }">
|
<v-main
|
||||||
|
class="bg-white min-vh-100"
|
||||||
|
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
||||||
|
>
|
||||||
<v-container class="px-4 py-2">
|
<v-container class="px-4 py-2">
|
||||||
<v-alert
|
<v-alert
|
||||||
v-if="
|
v-if="
|
||||||
store.isLoggedIn && store.requiresEmailCompletion && route.name !== 'complete-email'
|
store.isLoggedIn &&
|
||||||
|
store.requiresEmailCompletion &&
|
||||||
|
route.name !== 'complete-email'
|
||||||
"
|
"
|
||||||
class="mb-2"
|
class="mb-2"
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -206,11 +223,16 @@
|
|||||||
<span class="text-title-small">
|
<span class="text-title-small">
|
||||||
{{
|
{{
|
||||||
EMAIL_COMPLETION_REQUIRED
|
EMAIL_COMPLETION_REQUIRED
|
||||||
? '請先註冊 E-Mail,才能繼續使用平台。'
|
? "請先註冊 E-Mail,才能繼續使用平台。"
|
||||||
: '尚未設定 E-Mail,前往註冊以接收重要通知。'
|
: "尚未設定 E-Mail,前往註冊以接收重要通知。"
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<v-btn color="warning" size="small" :to="emailCompletionLocation" variant="flat">
|
<v-btn
|
||||||
|
color="warning"
|
||||||
|
size="small"
|
||||||
|
:to="emailCompletionLocation"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
前往補登
|
前往補登
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -230,7 +252,9 @@
|
|||||||
tag="div"
|
tag="div"
|
||||||
>
|
>
|
||||||
<v-btn to="/" value="home">
|
<v-btn to="/" value="home">
|
||||||
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
|
<v-icon :color="getBottomNavIconColor('home')"
|
||||||
|
>mdi-view-dashboard</v-icon
|
||||||
|
>
|
||||||
<span class="bottom-nav-text">首頁</span>
|
<span class="bottom-nav-text">首頁</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
@@ -246,13 +270,25 @@
|
|||||||
<span class="bottom-nav-text">自評</span>
|
<span class="bottom-nav-text">自評</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn v-if="CHECKIN_ENABLED && store.isLoggedIn" to="/checkin" value="checkin">
|
<v-btn
|
||||||
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
|
v-if="CHECKIN_ENABLED && store.isLoggedIn"
|
||||||
|
to="/checkin"
|
||||||
|
value="checkin"
|
||||||
|
>
|
||||||
|
<v-icon :color="getBottomNavIconColor('checkin')"
|
||||||
|
>mdi-qrcode-scan</v-icon
|
||||||
|
>
|
||||||
<span class="bottom-nav-text">量測</span>
|
<span class="bottom-nav-text">量測</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn v-if="APPOINTMENT_ENABLED && store.isLoggedIn" to="/appointment" value="appointment">
|
<v-btn
|
||||||
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
|
v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
|
||||||
|
to="/appointment"
|
||||||
|
value="appointment"
|
||||||
|
>
|
||||||
|
<v-icon :color="getBottomNavIconColor('appointment')"
|
||||||
|
>mdi-calendar-check</v-icon
|
||||||
|
>
|
||||||
<span class="bottom-nav-text">預約</span>
|
<span class="bottom-nav-text">預約</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-bottom-navigation>
|
</v-bottom-navigation>
|
||||||
@@ -260,12 +296,14 @@
|
|||||||
<!-- 隱私授權對話框 -->
|
<!-- 隱私授權對話框 -->
|
||||||
<v-dialog v-model="showPrivacy" max-width="500">
|
<v-dialog v-model="showPrivacy" max-width="500">
|
||||||
<v-card class="rounded-xl">
|
<v-card class="rounded-xl">
|
||||||
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
<v-card-title
|
||||||
|
class="bg-primary text-white font-weight-bold d-flex align-center"
|
||||||
|
>
|
||||||
<v-icon class="mr-2" icon="mdi-shield-check" />
|
<v-icon class="mr-2" icon="mdi-shield-check" />
|
||||||
個資同意與資料授權
|
個資同意與資料授權
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text class="pt-4 text-body-1">
|
<v-card-text class="pt-4 text-body-large">
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
||||||
</p>
|
</p>
|
||||||
@@ -293,8 +331,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from "vue";
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
BODY_COMPOSITION_ENABLED,
|
BODY_COMPOSITION_ENABLED,
|
||||||
@@ -302,53 +340,54 @@ import {
|
|||||||
CHECKIN_ENABLED,
|
CHECKIN_ENABLED,
|
||||||
EMAIL_COMPLETION_REQUIRED,
|
EMAIL_COMPLETION_REQUIRED,
|
||||||
SELF_ASSESSMENT_ENABLED,
|
SELF_ASSESSMENT_ENABLED,
|
||||||
} from '@/config/features'
|
} from "@/config/features";
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from '@/stores/course'
|
import { useCourseStore } from "@/stores/course";
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
|
|
||||||
const drawer = ref(false)
|
const drawer = ref(false);
|
||||||
const activeTab = ref('home')
|
const activeTab = ref("home");
|
||||||
const showPrivacy = ref(false)
|
const showPrivacy = ref(false);
|
||||||
const emailCompletionLocation = computed(() => ({
|
const emailCompletionLocation = computed(() => ({
|
||||||
name: 'complete-email',
|
name: "complete-email",
|
||||||
query: {
|
query: {
|
||||||
redirect: route.name === 'complete-email' ? '/' : route.fullPath,
|
redirect: route.name === "complete-email" ? "/" : route.fullPath,
|
||||||
},
|
},
|
||||||
}))
|
}));
|
||||||
|
|
||||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||||
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
|
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||||
|
|
||||||
watch(isLargeText, (newVal) => {
|
watch(isLargeText, (newVal) => {
|
||||||
localStorage.setItem('mode-large-text', String(newVal))
|
localStorage.setItem("mode-large-text", String(newVal));
|
||||||
})
|
});
|
||||||
|
|
||||||
// 同步底部導航狀態與當前路由
|
// 同步底部導航狀態與當前路由
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => route.path,
|
||||||
(path) => {
|
(path) => {
|
||||||
if (path === '/') activeTab.value = 'home'
|
if (path === "/") activeTab.value = "home";
|
||||||
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
|
else if (path.startsWith("/self-assessment"))
|
||||||
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
|
activeTab.value = "self-assessment";
|
||||||
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
|
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
|
||||||
|
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true },
|
||||||
)
|
);
|
||||||
|
|
||||||
function getBottomNavIconColor(value: string) {
|
function getBottomNavIconColor(value: string) {
|
||||||
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
|
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await store.logout()
|
await store.logout();
|
||||||
courseStore.resetGroup()
|
courseStore.resetGroup();
|
||||||
await router.push('/login')
|
await router.push("/login");
|
||||||
drawer.value = false
|
drawer.value = false;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -370,8 +409,8 @@ async function handleLogout() {
|
|||||||
line-height: unset !important;
|
line-height: unset !important;
|
||||||
}
|
}
|
||||||
.v-card-text,
|
.v-card-text,
|
||||||
.text-body-1,
|
.text-body-large,
|
||||||
.text-body-2,
|
.text-body-medium,
|
||||||
p {
|
p {
|
||||||
font-size: 1.15rem !important;
|
font-size: 1.15rem !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
size="18"
|
size="18"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="text-body-2 text-grey-darken-3">
|
<div class="text-body-medium text-grey-darken-3">
|
||||||
<span class="font-weight-bold">{{ apt.date }}</span>
|
<span class="font-weight-bold">{{ apt.date }}</span>
|
||||||
<span class="ml-4 text-grey-darken-1">{{ apt.time }}</span>
|
<span class="ml-4 text-grey-darken-1">{{ apt.time }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
|
|
||||||
<!-- <div class="d-flex align-start mb-2">
|
<!-- <div class="d-flex align-start mb-2">
|
||||||
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" />
|
<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 class="text-body-medium text-grey-darken-3">{{ apt.location }}</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<div class="d-flex align-start mb-3">
|
<div class="d-flex align-start mb-3">
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
size="18"
|
size="18"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="text-body-2 text-grey-darken-3">
|
<div class="text-body-medium text-grey-darken-3">
|
||||||
剩餘名額:<span
|
剩餘名額:<span
|
||||||
class="font-weight-bold"
|
class="font-weight-bold"
|
||||||
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
||||||
|
|||||||
@@ -201,7 +201,7 @@
|
|||||||
報告計算完成!
|
報告計算完成!
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-body-2 text-grey-darken-2 mb-6">
|
<p class="text-body-medium text-grey-darken-2 mb-6">
|
||||||
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
|
|
||||||
<v-divider class="mb-3" />
|
<v-divider class="mb-3" />
|
||||||
|
|
||||||
<div class="text-body-2 text-grey-darken-3 whitespace-pre-line">
|
<div class="text-body-medium text-grey-darken-3 whitespace-pre-line">
|
||||||
{{ station.reference }}
|
{{ station.reference }}
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
@@ -1,54 +1,58 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { shallowRef } from 'vue'
|
import { shallowRef } from "vue";
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import EmailCompletionForm from '@/components/auth/EmailCompletionForm.vue'
|
import EmailCompletionForm from "@/components/auth/EmailCompletionForm.vue";
|
||||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||||
import { EMAIL_COMPLETION_REQUIRED } from '@/config/features'
|
import { EMAIL_COMPLETION_REQUIRED } from "@/config/features";
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from "@/stores/app";
|
||||||
import { getApiErrorStatus } from '@/utils/api-error'
|
import { getApiErrorStatus } from "@/utils/api-error";
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore();
|
||||||
const route = useRoute()
|
const route = useRoute();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const loading = shallowRef(false)
|
const loading = shallowRef(false);
|
||||||
const errorMessage = shallowRef('')
|
const errorMessage = shallowRef("");
|
||||||
|
|
||||||
function redirectTarget() {
|
function redirectTarget() {
|
||||||
const target = String(route.query.redirect ?? '')
|
const target = String(route.query.redirect ?? "");
|
||||||
if (!target.startsWith('/') || target.startsWith('//') || target.startsWith('/complete-email')) {
|
if (
|
||||||
return '/'
|
!target.startsWith("/") ||
|
||||||
|
target.startsWith("//") ||
|
||||||
|
target.startsWith("/complete-email")
|
||||||
|
) {
|
||||||
|
return "/";
|
||||||
}
|
}
|
||||||
|
|
||||||
return target
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSubmit(email: string) {
|
async function handleSubmit(email: string) {
|
||||||
errorMessage.value = ''
|
errorMessage.value = "";
|
||||||
loading.value = true
|
loading.value = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await store.updateEmail(email)
|
await store.updateEmail(email);
|
||||||
await router.replace(redirectTarget())
|
await router.replace(redirectTarget());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = getApiErrorStatus(error)
|
const status = getApiErrorStatus(error);
|
||||||
if (status === 409) {
|
if (status === 409) {
|
||||||
errorMessage.value = '使用者資料已更新,請重新整理後再試。'
|
errorMessage.value = "使用者資料已更新,請重新整理後再試。";
|
||||||
} else if (status === 422) {
|
} else if (status === 422) {
|
||||||
errorMessage.value = '請輸入正確且不超過 50 碼的 E-Mail。'
|
errorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
|
||||||
} else if (status !== 401) {
|
} else if (status !== 401) {
|
||||||
errorMessage.value = 'E-Mail 儲存失敗,請稍後再試。'
|
errorMessage.value = "E-Mail 儲存失敗,請稍後再試。";
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function skipForNow() {
|
async function skipForNow() {
|
||||||
if (EMAIL_COMPLETION_REQUIRED) {
|
if (EMAIL_COMPLETION_REQUIRED) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await router.replace(redirectTarget())
|
await router.replace(redirectTarget());
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -58,9 +62,13 @@ async function skipForNow() {
|
|||||||
<v-row justify="center" no-gutters>
|
<v-row justify="center" no-gutters>
|
||||||
<v-col cols="12" sm="8" md="6">
|
<v-col cols="12" sm="8" md="6">
|
||||||
<v-card class="rounded-xl mt-6" color="surface">
|
<v-card class="rounded-xl mt-6" color="surface">
|
||||||
<v-card-title class="text-primary font-weight-bold"> 註冊 E-Mail </v-card-title>
|
<v-card-title class="text-primary font-weight-bold">
|
||||||
|
註冊 E-Mail
|
||||||
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<p class="text-body-1 mb-5">填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。</p>
|
<p class="text-body-large mb-5">
|
||||||
|
填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。
|
||||||
|
</p>
|
||||||
|
|
||||||
<EmailCompletionForm
|
<EmailCompletionForm
|
||||||
:error-message="errorMessage"
|
:error-message="errorMessage"
|
||||||
|
|||||||
@@ -1,14 +1,21 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue'
|
import ForgotPasswordForm from "@/components/auth/ForgotPasswordForm.vue";
|
||||||
import PasswordRecoveryShell from '@/components/auth/PasswordRecoveryShell.vue'
|
import PasswordRecoveryShell from "@/components/auth/PasswordRecoveryShell.vue";
|
||||||
import { useForgotPassword } from '@/composables/useForgotPassword'
|
import { useForgotPassword } from "@/composables/useForgotPassword";
|
||||||
const recoverySteps = [
|
const recoverySteps = [
|
||||||
{ title: '輸入已註冊手機門號', icon: 'mdi-phone' },
|
{ title: "輸入已註冊手機門號", icon: "mdi-phone" },
|
||||||
{ title: '收起E-Mail,使用連結進行重設密碼', icon: 'mdi-email-outline' },
|
{ title: "收起E-Mail,使用連結進行重設密碼", icon: "mdi-email-outline" },
|
||||||
]
|
];
|
||||||
|
|
||||||
const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds, submit } =
|
const {
|
||||||
useForgotPassword()
|
accepted,
|
||||||
|
loading,
|
||||||
|
disabled,
|
||||||
|
errorMessage,
|
||||||
|
fieldErrors,
|
||||||
|
remainingSeconds,
|
||||||
|
submit,
|
||||||
|
} = useForgotPassword();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -16,7 +23,11 @@ const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds
|
|||||||
<template #description>
|
<template #description>
|
||||||
<v-sheet class="pa-4 mb-6" color="background" rounded="lg">
|
<v-sheet class="pa-4 mb-6" color="background" rounded="lg">
|
||||||
<ol aria-label="密碼重設流程" class="recovery-flow ma-0 pa-0">
|
<ol aria-label="密碼重設流程" class="recovery-flow ma-0 pa-0">
|
||||||
<li v-for="(step, index) in recoverySteps" :key="step.title" class="recovery-step">
|
<li
|
||||||
|
v-for="(step, index) in recoverySteps"
|
||||||
|
:key="step.title"
|
||||||
|
class="recovery-step"
|
||||||
|
>
|
||||||
<v-avatar
|
<v-avatar
|
||||||
class="recovery-step-icon"
|
class="recovery-step-icon"
|
||||||
color="primary"
|
color="primary"
|
||||||
@@ -28,7 +39,7 @@ const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds
|
|||||||
</v-avatar>
|
</v-avatar>
|
||||||
<div class="recovery-step-content">
|
<div class="recovery-step-content">
|
||||||
<span class="recovery-step-number text-primary">
|
<span class="recovery-step-number text-primary">
|
||||||
步驟 {{ String(index + 1).padStart(2, '0') }}
|
步驟 {{ String(index + 1).padStart(2, "0") }}
|
||||||
</span>
|
</span>
|
||||||
<p class="text-title-small text-high-emphasis ma-0">
|
<p class="text-title-small text-high-emphasis ma-0">
|
||||||
{{ step.title }}
|
{{ step.title }}
|
||||||
@@ -42,12 +53,17 @@ const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds
|
|||||||
<v-alert type="success" variant="tonal" role="status">
|
<v-alert type="success" variant="tonal" role="status">
|
||||||
如果帳號存在,系統將寄送密碼重設信件。
|
如果帳號存在,系統將寄送密碼重設信件。
|
||||||
</v-alert>
|
</v-alert>
|
||||||
<p class="mt-4 text-body-1">請查看信箱與垃圾郵件匣。</p>
|
<p class="mt-4 text-body-large">請查看信箱與垃圾郵件匣。</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-alert v-if="errorMessage" class="mb-4" type="error" variant="tonal" role="alert">{{
|
<v-alert
|
||||||
errorMessage
|
v-if="errorMessage"
|
||||||
}}</v-alert>
|
class="mb-4"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
role="alert"
|
||||||
|
>{{ errorMessage }}</v-alert
|
||||||
|
>
|
||||||
<ForgotPasswordForm
|
<ForgotPasswordForm
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -83,7 +99,7 @@ const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds
|
|||||||
left: 19px;
|
left: 19px;
|
||||||
width: 2px;
|
width: 2px;
|
||||||
background: rgba(var(--v-theme-primary), 0.18);
|
background: rgba(var(--v-theme-primary), 0.18);
|
||||||
content: '';
|
content: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
.recovery-step-icon {
|
.recovery-step-icon {
|
||||||
|
|||||||
@@ -170,62 +170,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card class="rounded-xl elevation-2 mb-4 py-4" color="surface">
|
<v-card class="rounded-xl mb-4" color="surface" variant="flat">
|
||||||
<v-card-text>
|
<v-card-text class="pa-5 pa-sm-6">
|
||||||
<v-row density="compact">
|
<div class="d-flex align-center ga-4 mb-6">
|
||||||
<v-col cols="12" md="6">
|
<v-avatar
|
||||||
|
class="flex-shrink-0"
|
||||||
|
color="primary"
|
||||||
|
rounded="lg"
|
||||||
|
size="48"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
icon="mdi-lock-open-outline"
|
||||||
|
size="26"
|
||||||
|
/>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
class="text-title-medium font-weight-bold text-high-emphasis mb-1"
|
||||||
|
>
|
||||||
|
登入後可使用完整功能
|
||||||
|
</h2>
|
||||||
|
<p class="text-body-small text-medium-emphasis ma-0">
|
||||||
|
使用手機門號登入;既有帳號可先建立密碼。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="rounded-lg font-weight-bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-login"
|
prepend-icon="mdi-login"
|
||||||
|
size="large"
|
||||||
to="/login"
|
to="/login"
|
||||||
variant="outlined"
|
variant="flat"
|
||||||
>
|
>
|
||||||
登入平台
|
登入平台
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="rounded-lg font-weight-bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-account-plus-outline"
|
prepend-icon="mdi-account-plus-outline"
|
||||||
|
size="large"
|
||||||
to="/register"
|
to="/register"
|
||||||
variant="text"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
既有帳號建立密碼
|
既有帳號建立密碼
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12">
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="rounded-lg"
|
|
||||||
color="primary"
|
|
||||||
prepend-icon="mdi-lock-reset"
|
|
||||||
to="/forgot-password"
|
|
||||||
variant="text"
|
|
||||||
>
|
|
||||||
忘記密碼
|
|
||||||
</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<div class="d-flex align-center mt-4 px-4">
|
|
||||||
<v-icon
|
|
||||||
class="mr-3"
|
|
||||||
color="primary"
|
|
||||||
icon="mdi-lock-outline"
|
|
||||||
size="28"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<v-divider />
|
||||||
<div class="font-weight-bold text-grey-darken-4">
|
|
||||||
登入後可使用完整功能
|
<div
|
||||||
</div>
|
class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
|
||||||
</div>
|
>
|
||||||
|
<span class="text-body-medium text-medium-emphasis"
|
||||||
|
>無法登入?</span
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-lock-reset"
|
||||||
|
to="/forgot-password"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
忘記密碼
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -244,10 +263,12 @@
|
|||||||
<v-avatar class="mb-4" color="primary" size="64">
|
<v-avatar class="mb-4" color="primary" size="64">
|
||||||
<v-icon color="white" icon="mdi-refresh-circle" size="34" />
|
<v-icon color="white" icon="mdi-refresh-circle" size="34" />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2">
|
<p
|
||||||
|
class="text-body-large font-weight-bold text-grey-darken-4 mb-2"
|
||||||
|
>
|
||||||
確定要重新計算目前的體適能評級嗎?
|
確定要重新計算目前的體適能評級嗎?
|
||||||
</p>
|
</p>
|
||||||
<p class="text-body-2 text-grey-darken-2 mb-0">
|
<p class="text-body-medium text-grey-darken-2 mb-0">
|
||||||
系統會依各測驗項目的最新成績重新計算分組。
|
系統會依各測驗項目的最新成績重新計算分組。
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -317,10 +338,10 @@
|
|||||||
<v-icon color="white" icon="mdi-calendar-check" size="34" />
|
<v-icon color="white" icon="mdi-calendar-check" size="34" />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
|
|
||||||
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2">
|
<p class="text-body-large font-weight-bold text-grey-darken-4 mb-2">
|
||||||
預約普測,建立您的體適能分組
|
預約普測,建立您的體適能分組
|
||||||
</p>
|
</p>
|
||||||
<p class="text-body-2 text-grey-darken-2 mb-0">
|
<p class="text-body-medium text-grey-darken-2 mb-0">
|
||||||
完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。
|
完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。
|
||||||
</p>
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|||||||
@@ -16,119 +16,137 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- 登入卡片 -->
|
<!-- 登入卡片 -->
|
||||||
<v-card class="rounded-lg mt-4" color="surface">
|
<v-card
|
||||||
<v-card-title> 登入 </v-card-title>
|
class="rounded-xl mt-4 text-left"
|
||||||
<v-card-text> 請輸入您的手機號碼與登入密碼 </v-card-text>
|
color="surface"
|
||||||
<v-alert
|
variant="flat"
|
||||||
v-if="route.query.passwordReset === 'success'"
|
>
|
||||||
class="mx-4 mb-4"
|
<v-card-title class="text-center">登入</v-card-title>
|
||||||
type="success"
|
<v-form @submit.prevent="handleLogin">
|
||||||
variant="tonal"
|
<div class="pa-5 pa-sm-6">
|
||||||
>
|
<v-alert
|
||||||
密碼已更新,請使用新密碼登入。
|
v-if="route.query.passwordReset === 'success'"
|
||||||
</v-alert>
|
class="mb-4"
|
||||||
<!-- 表單 -->
|
type="success"
|
||||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
variant="tonal"
|
||||||
<v-alert
|
role="status"
|
||||||
v-if="csrfRecoveryMessage"
|
>
|
||||||
class="mb-4"
|
密碼已更新,請使用新密碼登入。
|
||||||
density="compact"
|
</v-alert>
|
||||||
type="warning"
|
|
||||||
variant="tonal"
|
<v-alert
|
||||||
|
v-if="csrfRecoveryMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ csrfRecoveryMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="account"
|
||||||
|
autocomplete="tel-national"
|
||||||
|
inputmode="tel"
|
||||||
|
class="mb-5"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
label="手機號碼"
|
||||||
|
placeholder="例如:0912345678"
|
||||||
|
prepend-inner-icon="mdi-phone"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mb-6"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
label="登入密碼"
|
||||||
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
type="password"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold mb-3"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading || remainingSeconds > 0"
|
||||||
|
:loading="loading"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
remainingSeconds > 0
|
||||||
|
? `${remainingSeconds} 秒後再試`
|
||||||
|
: "登入健康平台"
|
||||||
|
}}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
prepend-icon="mdi-account-plus-outline"
|
||||||
|
size="large"
|
||||||
|
to="/register"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
既有帳號建立密碼
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
|
||||||
>
|
>
|
||||||
{{ csrfRecoveryMessage }}
|
<span class="text-body-medium text-medium-emphasis"
|
||||||
</v-alert>
|
>無法登入?</span
|
||||||
|
>
|
||||||
<v-alert
|
<v-btn
|
||||||
v-if="errorMessage"
|
class="rounded-lg"
|
||||||
class="mb-4"
|
color="primary"
|
||||||
density="compact"
|
:disabled="loading"
|
||||||
type="error"
|
prepend-icon="mdi-lock-reset"
|
||||||
variant="tonal"
|
to="/forgot-password"
|
||||||
>
|
variant="text"
|
||||||
{{ errorMessage }}
|
>
|
||||||
</v-alert>
|
忘記密碼
|
||||||
|
</v-btn>
|
||||||
<v-text-field
|
</div>
|
||||||
v-model="account"
|
|
||||||
class="mb-6"
|
|
||||||
color="primary"
|
|
||||||
hide-details="auto"
|
|
||||||
label="手機號碼"
|
|
||||||
placeholder="例如: 0910666888"
|
|
||||||
prepend-inner-icon="mdi-phone"
|
|
||||||
required
|
|
||||||
rounded="lg"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="password"
|
|
||||||
class="mb-6"
|
|
||||||
color="primary"
|
|
||||||
hide-details="auto"
|
|
||||||
label="登入密碼"
|
|
||||||
prepend-inner-icon="mdi-lock-outline"
|
|
||||||
required
|
|
||||||
rounded="lg"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
|
||||||
color="secondary"
|
|
||||||
:disabled="remainingSeconds > 0"
|
|
||||||
:loading="loading"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
remainingSeconds > 0
|
|
||||||
? `${remainingSeconds} 秒後再試`
|
|
||||||
: "登入健康平台"
|
|
||||||
}}
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="rounded-lg font-weight-bold mb-3"
|
|
||||||
color="primary"
|
|
||||||
to="/register"
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
既有帳號建立密碼
|
|
||||||
</v-btn>
|
|
||||||
<v-btn block color="primary" to="/forgot-password" variant="text">
|
|
||||||
忘記密碼
|
|
||||||
</v-btn>
|
|
||||||
</v-form>
|
</v-form>
|
||||||
<!-- <div class="d-flex align-center justify-center my-4">
|
|
||||||
<v-divider class="mr-3" />
|
|
||||||
<span class="text-caption text-grey font-weight-bold">或使用以下方式快捷登入</span>
|
|
||||||
<v-divider class="ml-3" />
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- LINE 一鍵登入 (高齡場景極度實用) -->
|
<v-card-actions class="justify-center px-5 py-4">
|
||||||
<!-- <v-btn
|
<span class="text-body-small text-medium-emphasis text-center">
|
||||||
block
|
|
||||||
class="rounded-lg font-weight-bold text-white mb-4 text-button d-flex align-center justify-center"
|
|
||||||
color="#06C755"
|
|
||||||
:loading="lineLoading"
|
|
||||||
size="large"
|
|
||||||
style="text-transform: none"
|
|
||||||
variant="flat"
|
|
||||||
@click="handleLineLogin"
|
|
||||||
>
|
|
||||||
<v-icon class="mr-2" icon="mdi-message-text" />
|
|
||||||
使用 LINE 帳號一鍵登入
|
|
||||||
</v-btn> -->
|
|
||||||
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<span class="text-body-small text-grey">
|
|
||||||
登入即表示您同意本平台的
|
登入即表示您同意本平台的
|
||||||
<a
|
<a
|
||||||
class="text-accent text-decoration-none font-weight-bold"
|
class="text-primary text-decoration-none font-weight-bold"
|
||||||
href="#"
|
href="#"
|
||||||
>
|
>
|
||||||
個資授權條款
|
個資授權條款
|
||||||
@@ -212,6 +230,8 @@ async function finishAuthentication() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
|
if (loading.value || remainingSeconds.value > 0) return;
|
||||||
|
|
||||||
const phone = normalizePhone(account.value);
|
const phone = normalizePhone(account.value);
|
||||||
|
|
||||||
if (!/^09\d{8}$/.test(phone)) {
|
if (!/^09\d{8}$/.test(phone)) {
|
||||||
@@ -258,41 +278,3 @@ async function _handleLineLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.glow-sweep-button {
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-sweep-button::after {
|
|
||||||
position: absolute;
|
|
||||||
top: -50%;
|
|
||||||
left: -70%;
|
|
||||||
width: 45%;
|
|
||||||
height: 200%;
|
|
||||||
background: linear-gradient(
|
|
||||||
120deg,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(255, 255, 255, 0.15) 35%,
|
|
||||||
rgba(255, 255, 255, 0.75) 50%,
|
|
||||||
rgba(255, 255, 255, 0.15) 65%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
content: "";
|
|
||||||
transform: rotate(18deg);
|
|
||||||
animation: glow-sweep 2.4s ease-in-out infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow-sweep {
|
|
||||||
0% {
|
|
||||||
left: -70%;
|
|
||||||
}
|
|
||||||
|
|
||||||
45%,
|
|
||||||
100% {
|
|
||||||
left: 125%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -3,275 +3,280 @@ import {
|
|||||||
createWebHistory,
|
createWebHistory,
|
||||||
isNavigationFailure,
|
isNavigationFailure,
|
||||||
NavigationFailureType,
|
NavigationFailureType,
|
||||||
} from 'vue-router'
|
} from "vue-router";
|
||||||
import Appointment from '@/pages/appointment.vue'
|
import Appointment from "@/pages/appointment.vue";
|
||||||
import BodyComposition from '@/pages/body-composition.vue'
|
import BodyComposition from "@/pages/body-composition.vue";
|
||||||
import Checkin from '@/pages/checkin.vue'
|
import Checkin from "@/pages/checkin.vue";
|
||||||
import CheckinDetail from '@/pages/checkinDetail.vue'
|
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||||
import CompleteEmail from '@/pages/complete-email.vue'
|
import CompleteEmail from "@/pages/complete-email.vue";
|
||||||
import Course from '@/pages/course.vue'
|
import Course from "@/pages/course.vue";
|
||||||
import Index from '@/pages/index.vue'
|
import Index from "@/pages/index.vue";
|
||||||
import Login from '@/pages/login.vue'
|
import Login from "@/pages/login.vue";
|
||||||
import Register from '@/pages/register.vue'
|
import Register from "@/pages/register.vue";
|
||||||
import ForgotPassword from '@/pages/forgot-password.vue'
|
import ForgotPassword from "@/pages/forgot-password.vue";
|
||||||
import ChangePassword from '@/pages/change-password.vue'
|
import ChangePassword from "@/pages/change-password.vue";
|
||||||
import {
|
import {
|
||||||
captureInitialPasswordResetToken,
|
captureInitialPasswordResetToken,
|
||||||
clearPasswordResetToken,
|
clearPasswordResetToken,
|
||||||
deliverPasswordResetToken,
|
deliverPasswordResetToken,
|
||||||
isPublicPasswordPath,
|
isPublicPasswordPath,
|
||||||
stagePasswordResetToken,
|
stagePasswordResetToken,
|
||||||
} from '@/utils/password-reset-token'
|
} from "@/utils/password-reset-token";
|
||||||
import SelfAssessment from '@/pages/self-assessment.vue'
|
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
BODY_COMPOSITION_ENABLED,
|
BODY_COMPOSITION_ENABLED,
|
||||||
CHECKIN_ENABLED,
|
CHECKIN_ENABLED,
|
||||||
EMAIL_COMPLETION_REQUIRED,
|
EMAIL_COMPLETION_REQUIRED,
|
||||||
} from '@/config/features'
|
} from "@/config/features";
|
||||||
import { useAppStore } from '@/stores/app'
|
import { useAppStore } from "@/stores/app";
|
||||||
import { useCourseStore } from '@/stores/course'
|
import { useCourseStore } from "@/stores/course";
|
||||||
import { getApiErrorStatus } from '@/utils/api-error'
|
import { getApiErrorStatus } from "@/utils/api-error";
|
||||||
|
|
||||||
// 在 router 啟動初始導航與任何 Session API 之前移除敏感 query。
|
// 在 router 啟動初始導航與任何 Session API 之前移除敏感 query。
|
||||||
captureInitialPasswordResetToken()
|
captureInitialPasswordResetToken();
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
scrollBehavior(to, _from, savedPosition) {
|
scrollBehavior(to, _from, savedPosition) {
|
||||||
if (savedPosition) {
|
if (savedPosition) {
|
||||||
return savedPosition
|
return savedPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.hash) {
|
if (to.hash) {
|
||||||
return { el: to.hash, top: 68 }
|
return { el: to.hash, top: 68 };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { left: 0, top: 0 }
|
return { left: 0, top: 0 };
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/",
|
||||||
name: 'home',
|
name: "home",
|
||||||
component: Index,
|
component: Index,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: "/login",
|
||||||
name: 'login',
|
name: "login",
|
||||||
component: Login,
|
component: Login,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/forgot-password',
|
path: "/forgot-password",
|
||||||
name: 'forgot-password',
|
name: "forgot-password",
|
||||||
component: ForgotPassword,
|
component: ForgotPassword,
|
||||||
meta: { title: '忘記密碼|運動玩轉健康力' },
|
meta: { title: "忘記密碼|運動玩轉健康力" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/change-password',
|
path: "/change-password",
|
||||||
name: 'change-password',
|
name: "change-password",
|
||||||
component: ChangePassword,
|
component: ChangePassword,
|
||||||
meta: { title: '重設密碼|運動玩轉健康力' },
|
meta: { title: "重設密碼|運動玩轉健康力" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register',
|
path: "/register",
|
||||||
name: 'register',
|
name: "register",
|
||||||
component: Register,
|
component: Register,
|
||||||
meta: { title: '註冊|運動玩轉健康力' },
|
meta: { title: "註冊|運動玩轉健康力" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/self-assessment',
|
path: "/self-assessment",
|
||||||
name: 'self-assessment',
|
name: "self-assessment",
|
||||||
component: SelfAssessment,
|
component: SelfAssessment,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/complete-email',
|
path: "/complete-email",
|
||||||
name: 'complete-email',
|
name: "complete-email",
|
||||||
component: CompleteEmail,
|
component: CompleteEmail,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
title: '註冊 E-Mail|運動玩轉健康力',
|
title: "註冊 E-Mail|運動玩轉健康力",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/profile',
|
path: "/profile",
|
||||||
redirect: { name: 'home' },
|
redirect: { name: "home" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/body-composition',
|
path: "/body-composition",
|
||||||
name: 'body-composition',
|
name: "body-composition",
|
||||||
component: BodyComposition,
|
component: BodyComposition,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresUserID: true,
|
requiresUserID: true,
|
||||||
title: '身體數值分析|運動玩轉健康力',
|
title: "身體數值分析|運動玩轉健康力",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/appointment',
|
path: "/appointment",
|
||||||
name: 'appointment',
|
name: "appointment",
|
||||||
component: Appointment,
|
component: Appointment,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/course',
|
path: "/course",
|
||||||
name: 'course',
|
name: "course",
|
||||||
component: Course,
|
component: Course,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
requiresFitnessGroup: true,
|
requiresFitnessGroup: true,
|
||||||
title: '課程建議|運動玩轉健康力',
|
title: "課程建議|運動玩轉健康力",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/checkin',
|
path: "/checkin",
|
||||||
name: 'checkin',
|
name: "checkin",
|
||||||
component: Checkin,
|
component: Checkin,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/checkin/:code',
|
path: "/checkin/:code",
|
||||||
name: 'checkin-detail',
|
name: "checkin-detail",
|
||||||
component: CheckinDetail,
|
component: CheckinDetail,
|
||||||
meta: { requiresAuth: true },
|
meta: { requiresAuth: true },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
});
|
||||||
|
|
||||||
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/
|
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
|
||||||
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/
|
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/;
|
||||||
const CHECKIN_PATH = /^\/checkin(?:\/|$)/
|
const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
||||||
|
|
||||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
if (to.name === 'change-password' && Object.hasOwn(to.query, 'token')) {
|
if (to.name === "change-password" && Object.hasOwn(to.query, "token")) {
|
||||||
stagePasswordResetToken(to.query.token)
|
stagePasswordResetToken(to.query.token);
|
||||||
const query = { ...to.query }
|
const query = { ...to.query };
|
||||||
delete query.token
|
delete query.token;
|
||||||
return { name: 'change-password', query, hash: to.hash, replace: true }
|
return { name: "change-password", query, hash: to.hash, replace: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 公開密碼流程不依賴 Profile,也不受強制補登 E-Mail 限制。
|
// 公開密碼流程不依賴 Profile,也不受強制補登 E-Mail 限制。
|
||||||
if (isPublicPasswordPath(to.path)) return
|
if (isPublicPasswordPath(to.path)) return;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
||||||
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
||||||
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
||||||
) {
|
) {
|
||||||
return { name: 'home' }
|
return { name: "home" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const store = useAppStore()
|
const store = useAppStore();
|
||||||
await store.initializeAuthentication()
|
await store.initializeAuthentication();
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||||
return {
|
return {
|
||||||
name: 'login',
|
name: "login",
|
||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresUserID && !store.userID.trim()) {
|
if (to.meta.requiresUserID && !store.userID.trim()) {
|
||||||
return {
|
return {
|
||||||
name: 'login',
|
name: "login",
|
||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
EMAIL_COMPLETION_REQUIRED &&
|
EMAIL_COMPLETION_REQUIRED &&
|
||||||
store.isLoggedIn &&
|
store.isLoggedIn &&
|
||||||
store.requiresEmailCompletion &&
|
store.requiresEmailCompletion &&
|
||||||
to.name !== 'complete-email'
|
to.name !== "complete-email"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
name: 'complete-email',
|
name: "complete-email",
|
||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (to.meta.requiresFitnessGroup) {
|
if (to.meta.requiresFitnessGroup) {
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await courseStore.ensureGroup()
|
await courseStore.ensureGroup();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (getApiErrorStatus(error) === 401 || !store.isLoggedIn) {
|
if (getApiErrorStatus(error) === 401 || !store.isLoggedIn) {
|
||||||
return {
|
return {
|
||||||
name: 'login',
|
name: "login",
|
||||||
query: { redirect: to.fullPath },
|
query: { redirect: to.fullPath },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (courseStore.groupMissing) {
|
if (courseStore.groupMissing) {
|
||||||
return {
|
return {
|
||||||
name: 'appointment',
|
name: "appointment",
|
||||||
query: { reason: 'missing-fitness-group' },
|
query: { reason: "missing-fitness-group" },
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 技術性錯誤不應被誤導成尚未分組,返回首頁讓使用者重試。
|
// 技術性錯誤不應被誤導成尚未分組,返回首頁讓使用者重試。
|
||||||
return { name: 'home' }
|
return { name: "home" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
router.afterEach((to, _from, failure) => {
|
router.afterEach((to, _from, failure) => {
|
||||||
if (!failure && to.name !== 'change-password') clearPasswordResetToken()
|
if (!failure && to.name !== "change-password") clearPasswordResetToken();
|
||||||
// 清理後的同路由導航可能被判定 duplicate,仍需移交新的 Token。
|
// 清理後的同路由導航可能被判定 duplicate,仍需移交新的 Token。
|
||||||
if (
|
if (
|
||||||
to.name === 'change-password' &&
|
to.name === "change-password" &&
|
||||||
(!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
|
(!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
|
||||||
) {
|
) {
|
||||||
deliverPasswordResetToken()
|
deliverPasswordResetToken();
|
||||||
}
|
}
|
||||||
document.title = typeof to.meta.title === 'string' ? to.meta.title : '運動玩轉健康力'
|
document.title =
|
||||||
})
|
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
||||||
|
});
|
||||||
|
|
||||||
// 共用 HTTP client 收到登入後 API 的 401 時,清除前端狀態並保留原頁面供登入後返回。
|
// 共用 HTTP client 收到登入後 API 的 401 時,清除前端狀態並保留原頁面供登入後返回。
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
window.addEventListener('auth-expired', () => {
|
window.addEventListener("auth-expired", () => {
|
||||||
const store = useAppStore()
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore();
|
||||||
const currentRoute = router.currentRoute.value
|
const currentRoute = router.currentRoute.value;
|
||||||
const wasLoggedIn = store.isLoggedIn
|
const wasLoggedIn = store.isLoggedIn;
|
||||||
store.clearAuthentication()
|
store.clearAuthentication();
|
||||||
courseStore.resetGroup()
|
courseStore.resetGroup();
|
||||||
|
|
||||||
if (wasLoggedIn && currentRoute.name !== 'login' && !isPublicPasswordPath(currentRoute.path)) {
|
if (
|
||||||
|
wasLoggedIn &&
|
||||||
|
currentRoute.name !== "login" &&
|
||||||
|
!isPublicPasswordPath(currentRoute.path)
|
||||||
|
) {
|
||||||
void router.replace({
|
void router.replace({
|
||||||
name: 'login',
|
name: "login",
|
||||||
query: { redirect: currentRoute.fullPath },
|
query: { redirect: currentRoute.fullPath },
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
|
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
|
||||||
window.addEventListener('csrf-recovery-required', () => {
|
window.addEventListener("csrf-recovery-required", () => {
|
||||||
const store = useAppStore()
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore()
|
const courseStore = useCourseStore();
|
||||||
const currentRoute = router.currentRoute.value
|
const currentRoute = router.currentRoute.value;
|
||||||
if (isPublicPasswordPath(currentRoute.path)) {
|
if (isPublicPasswordPath(currentRoute.path)) {
|
||||||
store.clearAuthentication()
|
store.clearAuthentication();
|
||||||
courseStore.resetGroup()
|
courseStore.resetGroup();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const redirect =
|
const redirect =
|
||||||
currentRoute.name === 'login'
|
currentRoute.name === "login"
|
||||||
? String(currentRoute.query.redirect ?? '')
|
? String(currentRoute.query.redirect ?? "")
|
||||||
: currentRoute.fullPath
|
: currentRoute.fullPath;
|
||||||
store.clearAuthentication()
|
store.clearAuthentication();
|
||||||
courseStore.resetGroup()
|
courseStore.resetGroup();
|
||||||
|
|
||||||
void router.replace({
|
void router.replace({
|
||||||
name: 'login',
|
name: "login",
|
||||||
query: {
|
query: {
|
||||||
...(redirect ? { redirect } : {}),
|
...(redirect ? { redirect } : {}),
|
||||||
csrf: 'refresh',
|
csrf: "refresh",
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default router
|
export default router;
|
||||||
|
|||||||
Reference in New Issue
Block a user