267 lines
8.7 KiB
Vue
267 lines
8.7 KiB
Vue
<template>
|
||
<page-login
|
||
:announcement-board="announcementBoard"
|
||
:branding="branding"
|
||
:form="form"
|
||
:header="header"
|
||
:illustration="illustration"
|
||
:layout="formPositionLayout"
|
||
:mobile-announcement="mobileAnnouncement"
|
||
:toolbar="toolbar"
|
||
:with-announcement="withAnnouncement"
|
||
@captcha-change="handleCaptchaChange"
|
||
@captcha-refresh="handleCaptchaRefresh"
|
||
@change-locale="handleChangeLocale"
|
||
@forgot-password="handleForgotPassword"
|
||
@select-announcement="handleSelectAnnouncement"
|
||
@submit="onLogin"
|
||
@toggle-layout="handleToggleLayout"
|
||
/>
|
||
|
||
<v-dialog v-model="dialogVisible" width="360">
|
||
<v-card>
|
||
<v-card-title>{{ dialogTitle }}</v-card-title>
|
||
<v-card-text>{{ dialogMessage }}</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
|
||
{{ t('common.ok') }}
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
|
||
<v-dialog v-model="announcementDialogVisible" max-width="720">
|
||
<v-card>
|
||
<v-card-title class="text-h6">
|
||
{{ selectedAnnouncement?.title }}
|
||
</v-card-title>
|
||
<v-card-subtitle class="pt-2">
|
||
{{ selectedAnnouncement?.date }} ・ {{ selectedAnnouncement?.school }}
|
||
</v-card-subtitle>
|
||
<v-card-text class="text-body-1">
|
||
{{ selectedAnnouncementDetail }}
|
||
</v-card-text>
|
||
<v-card-actions>
|
||
<v-spacer />
|
||
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
|
||
關閉
|
||
</v-btn>
|
||
</v-card-actions>
|
||
</v-card>
|
||
</v-dialog>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import HyakkaouAcademyImage from '@/assets/logo.png'
|
||
import PageLogin from '@/components/PageLogin.vue'
|
||
import {
|
||
type LoginAnnouncementListItem,
|
||
useLoginAnnouncements,
|
||
} from '@/composables/useLoginAnnouncements'
|
||
import { useLoginCaptcha } from '@/composables/useLoginCaptcha'
|
||
import { useAuthStore } from '@/stores/auth'
|
||
import { useMenuStore } from '@/stores/menu'
|
||
import { useSnackbarStore } from '@/stores/snackbar'
|
||
|
||
type LayoutType = 'side-left' | 'side-right' | 'card'
|
||
|
||
// i18n
|
||
const { t, locale } = useI18n()
|
||
const router = useRouter()
|
||
const route = useRoute()
|
||
const authStore = useAuthStore()
|
||
const menuStore = useMenuStore()
|
||
const snackbarStore = useSnackbarStore()
|
||
|
||
// 語系選項
|
||
const locales = ['zh-TW', 'en-US']
|
||
|
||
// 插圖圖片來源
|
||
const illustrationImage = ref(HyakkaouAcademyImage)
|
||
|
||
// 功能開關與版型
|
||
const formPositionLayout = ref<LayoutType>('side-left')
|
||
// 是否啟用公告
|
||
const withAnnouncement = ref(true)
|
||
const withForgotPassword = ref(true)
|
||
const withRememberAccount = ref(true)
|
||
|
||
// 功能開關:是否啟用驗證碼
|
||
const withCaptcha = ref(true)
|
||
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
|
||
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
|
||
const {
|
||
boardConfig: announcementBoard,
|
||
mobileAnnouncementConfig: mobileAnnouncement,
|
||
selectedAnnouncement,
|
||
selectedAnnouncementDetail,
|
||
} = loginAnnouncements
|
||
|
||
// 文字內容(i18n)
|
||
const title = computed(() => t('pages.login.title'))
|
||
const organization = computed(() => t('pages.login.organization'))
|
||
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
|
||
const passwPlaceholder = computed(() => t('pages.login.passwPlaceholder'))
|
||
const illustrationTitle = computed(() => t('pages.login.illustrationTitle'))
|
||
const illustrationDescription = computed(() => t('pages.login.illustrationDescription'))
|
||
const welcomeText = computed(() => t('pages.login.welcomeText'))
|
||
const welcomeDescription = computed(() => t('pages.login.welcomeDescription'))
|
||
const rememberMeLabel = computed(() => t('pages.login.rememberMeLabel'))
|
||
const forgotPasswordText = computed(() => t('pages.login.forgotPasswordText'))
|
||
const submitText = computed(() => t('pages.login.submitText'))
|
||
const verifyText = computed(() => t('pages.login.verifyText'))
|
||
const captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
|
||
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
|
||
|
||
// 連結與儲存設定
|
||
// 忘記密碼連結(由 form.forgotPassword 設定)
|
||
const forgotPasswordHref = ref('/forgot-password')
|
||
const forgotPasswordTarget = ref<string | undefined>(undefined)
|
||
// 記住帳號的 localStorage key
|
||
const rememberStorageKey = ref('login.remember.username')
|
||
// 驗證與對話框狀態
|
||
const dialogVisible = ref(false)
|
||
const dialogTitle = ref('')
|
||
const dialogMessage = ref('')
|
||
const announcementDialogVisible = ref(false)
|
||
|
||
// 內容組合(傳入 PageLogin)
|
||
const branding = computed(() => ({
|
||
title: title.value,
|
||
organization: organization.value,
|
||
}))
|
||
|
||
const illustration = computed(() => ({
|
||
image: illustrationImage.value,
|
||
title: illustrationTitle.value,
|
||
description: illustrationDescription.value,
|
||
}))
|
||
|
||
const header = computed(() => ({
|
||
welcomeText: welcomeText.value,
|
||
welcomeDescription: welcomeDescription.value,
|
||
}))
|
||
|
||
// 表單區塊設定(含功能開關)
|
||
const form = computed(() => ({
|
||
accPlaceholder: accPlaceholder.value,
|
||
passwPlaceholder: passwPlaceholder.value,
|
||
rememberMeLabel: rememberMeLabel.value,
|
||
submitText: submitText.value,
|
||
verifyText: verifyText.value,
|
||
captchaPlaceholder: captchaPlaceholder.value,
|
||
refreshTitle: refreshTitle.value,
|
||
rememberStorageKey: rememberStorageKey.value,
|
||
withForgotPassword: withForgotPassword.value,
|
||
withRememberAccount: withRememberAccount.value,
|
||
// 功能開關:是否顯示驗證碼
|
||
withCaptcha: withCaptcha.value,
|
||
captcha: loginCaptcha.formCaptcha.value,
|
||
captchaValue: loginCaptcha.captchaValue.value,
|
||
captchaLoading: loginCaptcha.captchaLoading.value,
|
||
captchaErrorMessage: loginCaptcha.captchaErrorMessage.value ?? '',
|
||
captchaVerified: false,
|
||
forgotPassword: {
|
||
text: forgotPasswordText.value,
|
||
href: forgotPasswordHref.value,
|
||
target: forgotPasswordTarget.value,
|
||
},
|
||
}))
|
||
|
||
// 右上工具列設定(含顯示開關)
|
||
const toolbar = computed(() => ({
|
||
// 功能開關:是否顯示語系切換工具列
|
||
show: true,
|
||
locale: locale.value,
|
||
locales,
|
||
}))
|
||
|
||
// 事件處理
|
||
function handleForgotPassword(e: MouseEvent) {
|
||
if (!withForgotPassword.value) return
|
||
|
||
console.log('Forgot Password Click:', e)
|
||
}
|
||
|
||
function handleChangeLocale(nextLocale: string) {
|
||
locale.value = nextLocale
|
||
localStorage.setItem('locale', nextLocale)
|
||
}
|
||
|
||
async function handleCaptchaRefresh() {
|
||
await loginCaptcha.refreshCaptcha().catch(() => undefined)
|
||
}
|
||
|
||
function handleCaptchaChange(value: string) {
|
||
loginCaptcha.setCaptchaValue(value)
|
||
}
|
||
|
||
function handleToggleLayout() {
|
||
const layoutOrder: LayoutType[] = ['side-left', 'side-right', 'card']
|
||
const currentIndex = layoutOrder.indexOf(formPositionLayout.value)
|
||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % layoutOrder.length
|
||
formPositionLayout.value = layoutOrder[nextIndex] ?? 'side-left'
|
||
}
|
||
|
||
function handleSelectAnnouncement(item: LoginAnnouncementListItem) {
|
||
loginAnnouncements.selectById(item.id)
|
||
announcementDialogVisible.value = true
|
||
}
|
||
|
||
async function onLogin(data: Record<string, unknown>) {
|
||
if (withCaptcha.value && !loginCaptcha.captchaValue.value) {
|
||
dialogTitle.value = t('common.notice')
|
||
dialogMessage.value = t('pages.login.alert.verifyRequired')
|
||
dialogVisible.value = true
|
||
return
|
||
}
|
||
|
||
try {
|
||
dialogTitle.value = t('common.notice')
|
||
|
||
const inputUserId = String(data.username ?? '').trim()
|
||
const inputPassword = String(data.password ?? '').trim()
|
||
const isDev = import.meta.env.DEV
|
||
const devDefaultUserId = String(import.meta.env.VITE_DEV_DEFAULT_USER_ID ?? '').trim()
|
||
const devDefaultPassword = String(import.meta.env.VITE_DEV_DEFAULT_PASSWORD ?? '').trim()
|
||
const userId = isDev && !inputUserId ? devDefaultUserId : inputUserId
|
||
const password = isDev && !inputPassword ? devDefaultPassword : inputPassword
|
||
|
||
await authStore.login({
|
||
UserID: userId,
|
||
Password: password,
|
||
captcha: loginCaptcha.getLoginCaptchaPayload(),
|
||
})
|
||
|
||
menuStore.getMenu(authStore.user?.id ?? '')
|
||
|
||
// menuStore.getFavorite(authStore.user?.id ?? '')
|
||
|
||
snackbarStore.show({
|
||
message: t('pages.login.alert.loginSuccess'),
|
||
color: 'success',
|
||
timeout: 2000,
|
||
location: 'top right',
|
||
variant: 'flat',
|
||
})
|
||
|
||
const redirect = (route.query.redirect as string) || '/'
|
||
await router.push(redirect.startsWith('/') ? redirect : '/')
|
||
} catch (error) {
|
||
console.error('Login error:', error)
|
||
dialogTitle.value = t('common.notice')
|
||
dialogMessage.value = t('pages.login.alert.loginFailed')
|
||
dialogVisible.value = true
|
||
}
|
||
}
|
||
|
||
onMounted(() => {
|
||
void loginAnnouncements.load()
|
||
void loginCaptcha.loadCaptcha().catch(() => undefined)
|
||
})
|
||
</script>
|