Files
skt-vuetify-templates/src/views/Login.vue
T
2026-05-22 10:43:17 +08:00

267 lines
8.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>