fix: classname

This commit is contained in:
skytek_xinliang
2026-10-06 09:55:30 +08:00
parent 12b067632f
commit 3211c3b78b
12 changed files with 471 additions and 400 deletions
@@ -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'"
+1 -1
View File
@@ -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 {
+58 -37
View File
@@ -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>
+128 -146
View File
@@ -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>
+122 -117
View File
@@ -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;