227 lines
5.8 KiB
TypeScript
227 lines
5.8 KiB
TypeScript
import { createRouter, createWebHistory } from "vue-router";
|
|
import Appointment from "@/pages/appointment.vue";
|
|
import BodyComposition from "@/pages/body-composition.vue";
|
|
import Checkin from "@/pages/checkin.vue";
|
|
import CheckinDetail from "@/pages/checkinDetail.vue";
|
|
import CompleteEmail from "@/pages/complete-email.vue";
|
|
import Course from "@/pages/course.vue";
|
|
import Index from "@/pages/index.vue";
|
|
import Login from "@/pages/login.vue";
|
|
import Register from "@/pages/register.vue";
|
|
import SelfAssessment from "@/pages/self-assessment.vue";
|
|
import {
|
|
APPOINTMENT_ENABLED,
|
|
BODY_COMPOSITION_ENABLED,
|
|
CHECKIN_ENABLED,
|
|
EMAIL_COMPLETION_REQUIRED,
|
|
} from "@/config/features";
|
|
import { useAppStore } from "@/stores/app";
|
|
import { useCourseStore } from "@/stores/course";
|
|
import { getApiErrorStatus } from "@/utils/api-error";
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(import.meta.env.BASE_URL),
|
|
scrollBehavior(to, _from, savedPosition) {
|
|
if (savedPosition) {
|
|
return savedPosition;
|
|
}
|
|
|
|
if (to.hash) {
|
|
return { el: to.hash, top: 68 };
|
|
}
|
|
|
|
return { left: 0, top: 0 };
|
|
},
|
|
routes: [
|
|
{
|
|
path: "/",
|
|
name: "home",
|
|
component: Index,
|
|
},
|
|
{
|
|
path: "/login",
|
|
name: "login",
|
|
component: Login,
|
|
},
|
|
{
|
|
path: "/register",
|
|
name: "register",
|
|
component: Register,
|
|
meta: { title: "註冊|運動玩轉健康力" },
|
|
},
|
|
{
|
|
path: "/self-assessment",
|
|
name: "self-assessment",
|
|
component: SelfAssessment,
|
|
meta: { requiresAuth: true },
|
|
},
|
|
{
|
|
path: "/complete-email",
|
|
name: "complete-email",
|
|
component: CompleteEmail,
|
|
meta: {
|
|
requiresAuth: true,
|
|
title: "補登 E-Mail|運動玩轉健康力",
|
|
},
|
|
},
|
|
{
|
|
path: "/profile",
|
|
redirect: { name: "home" },
|
|
},
|
|
{
|
|
path: "/body-composition",
|
|
name: "body-composition",
|
|
component: BodyComposition,
|
|
meta: {
|
|
requiresAuth: true,
|
|
requiresUserID: true,
|
|
title: "身體數值分析|運動玩轉健康力",
|
|
},
|
|
},
|
|
{
|
|
path: "/appointment",
|
|
name: "appointment",
|
|
component: Appointment,
|
|
meta: { requiresAuth: true },
|
|
},
|
|
{
|
|
path: "/course",
|
|
name: "course",
|
|
component: Course,
|
|
meta: {
|
|
requiresAuth: true,
|
|
requiresFitnessGroup: true,
|
|
title: "課程建議|運動玩轉健康力",
|
|
},
|
|
},
|
|
{
|
|
path: "/checkin",
|
|
name: "checkin",
|
|
component: Checkin,
|
|
meta: { requiresAuth: true },
|
|
},
|
|
{
|
|
path: "/checkin/:code",
|
|
name: "checkin-detail",
|
|
component: CheckinDetail,
|
|
meta: { requiresAuth: true },
|
|
},
|
|
],
|
|
});
|
|
|
|
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
|
|
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/;
|
|
const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
|
|
|
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
|
router.beforeEach(async (to) => {
|
|
if (
|
|
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
|
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
|
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
|
) {
|
|
return { name: "home" };
|
|
}
|
|
|
|
const store = useAppStore();
|
|
await store.initializeAuthentication();
|
|
|
|
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
|
return {
|
|
name: "login",
|
|
query: { redirect: to.fullPath },
|
|
};
|
|
}
|
|
|
|
if (to.meta.requiresUserID && !store.userID.trim()) {
|
|
return {
|
|
name: "login",
|
|
query: { redirect: to.fullPath },
|
|
};
|
|
}
|
|
|
|
if (
|
|
EMAIL_COMPLETION_REQUIRED &&
|
|
store.isLoggedIn &&
|
|
store.requiresEmailCompletion &&
|
|
to.name !== "complete-email"
|
|
) {
|
|
return {
|
|
name: "complete-email",
|
|
query: { redirect: to.fullPath },
|
|
};
|
|
}
|
|
|
|
if (to.meta.requiresFitnessGroup) {
|
|
const courseStore = useCourseStore();
|
|
|
|
try {
|
|
await courseStore.ensureGroup();
|
|
} catch (error) {
|
|
if (getApiErrorStatus(error) === 401 || !store.isLoggedIn) {
|
|
return {
|
|
name: "login",
|
|
query: { redirect: to.fullPath },
|
|
};
|
|
}
|
|
|
|
if (courseStore.groupMissing) {
|
|
return {
|
|
name: "appointment",
|
|
query: { reason: "missing-fitness-group" },
|
|
};
|
|
}
|
|
|
|
// 技術性錯誤不應被誤導成尚未分組,返回首頁讓使用者重試。
|
|
return { name: "home" };
|
|
}
|
|
}
|
|
});
|
|
|
|
router.afterEach((to) => {
|
|
document.title =
|
|
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
|
});
|
|
|
|
// 共用 HTTP client 收到登入後 API 的 401 時,清除前端狀態並保留原頁面供登入後返回。
|
|
if (typeof window !== "undefined") {
|
|
window.addEventListener("auth-expired", () => {
|
|
const store = useAppStore();
|
|
const courseStore = useCourseStore();
|
|
const currentRoute = router.currentRoute.value;
|
|
const wasLoggedIn = store.isLoggedIn;
|
|
store.clearAuthentication();
|
|
courseStore.resetGroup();
|
|
|
|
if (wasLoggedIn && currentRoute.name !== "login") {
|
|
void router.replace({
|
|
name: "login",
|
|
query: { redirect: currentRoute.fullPath },
|
|
});
|
|
}
|
|
});
|
|
|
|
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
|
|
window.addEventListener("csrf-recovery-required", () => {
|
|
const store = useAppStore();
|
|
const courseStore = useCourseStore();
|
|
const currentRoute = router.currentRoute.value;
|
|
const redirect =
|
|
currentRoute.name === "login"
|
|
? String(currentRoute.query.redirect ?? "")
|
|
: currentRoute.fullPath;
|
|
store.clearAuthentication();
|
|
courseStore.resetGroup();
|
|
|
|
void router.replace({
|
|
name: "login",
|
|
query: {
|
|
...(redirect ? { redirect } : {}),
|
|
csrf: "refresh",
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
export default router;
|