7b0cfe4448
Split the login page into smaller reusable components for branding, toolbar, header, form, announcements, and mobile layout behavior. This keeps the view responsible for orchestration while moving UI sections into focused components. Update page creation docs to reflect the simplified flow where views render sections/items directly and composables coordinate store/service access when needed.refactor(login): compose page from focused login components Split the login page into smaller reusable components for branding, toolbar, header, form, announcements, and mobile layout behavior. This keeps the view responsible for orchestration while moving UI sections into focused components. Update page creation docs to reflect the simplified flow where views render sections/items directly and composables coordinate store/service access when needed.
531 lines
18 KiB
Vue
531 lines
18 KiB
Vue
<script setup lang="ts">
|
|
import { mdiBullhornVariantOutline } from '@mdi/js'
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import { useI18n } from 'vue-i18n'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import HyakkaouAcademyImage from '@/assets/logo.png'
|
|
import LoginAnnouncementBoard from '@/components/login/LoginAnnouncementBoard.vue'
|
|
import LoginBrand from '@/components/login/LoginBrand.vue'
|
|
import LoginForm from '@/components/login/LoginForm.vue'
|
|
import LoginHeader from '@/components/login/LoginHeader.vue'
|
|
import LoginToolBar from '@/components/login/LoginToolBar.vue'
|
|
import LoginVerify from '@/components/login/LoginVerify.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'
|
|
|
|
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
|
|
|
|
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'))
|
|
|
|
const forgotPasswordHref = ref('/forgot-password')
|
|
const forgotPasswordTarget = ref<string | undefined>(undefined)
|
|
const rememberStorageKey = ref('login.remember.username')
|
|
const dialogVisible = ref(false)
|
|
const dialogTitle = ref('')
|
|
const dialogMessage = ref('')
|
|
const announcementDialogVisible = ref(false)
|
|
|
|
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,
|
|
}))
|
|
|
|
const mobileAnnouncementSheetVisible = ref(false)
|
|
const mobileAnnouncementItems = computed(() => mobileAnnouncement.value.items ?? [])
|
|
const showMobileAnnouncementBanner = computed(() => {
|
|
if (!withAnnouncement.value) return false
|
|
if (mobileAnnouncement.value.show === false) return false
|
|
return mobileAnnouncementItems.value.length > 0
|
|
})
|
|
const mobileAnnouncementBannerText = computed(() => {
|
|
return mobileAnnouncementItems.value[0]?.content ?? ''
|
|
})
|
|
const layoutClass = computed(() => `layout-${formPositionLayout.value}`)
|
|
|
|
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 ?? '')
|
|
|
|
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>
|
|
|
|
<template>
|
|
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
|
<v-row
|
|
v-if="formPositionLayout !== 'card'"
|
|
class="fill-height"
|
|
:class="{ 'flex-row-reverse': formPositionLayout === 'side-right' }"
|
|
no-gutters
|
|
>
|
|
<v-col
|
|
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
|
|
cols="12"
|
|
lg="8"
|
|
sm="6"
|
|
>
|
|
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
|
<LoginBrand :title="branding.title" />
|
|
</div>
|
|
<v-sheet
|
|
v-if="withAnnouncement"
|
|
class="board-wrapper pa-2 pa-lg-0"
|
|
color="rgba(var(--v-theme-surface), 0.8)"
|
|
elevation="0"
|
|
max-width="680"
|
|
rounded="lg"
|
|
width="100%"
|
|
>
|
|
<LoginAnnouncementBoard
|
|
:all-tab-label="announcementBoard.allTabLabel"
|
|
:date-header="announcementBoard.dateHeader"
|
|
:empty-text="announcementBoard.emptyText"
|
|
:items="announcementBoard.items"
|
|
:items-per-page="announcementBoard.itemsPerPage"
|
|
:pagination-label="announcementBoard.paginationLabel"
|
|
:school-header="announcementBoard.schoolHeader"
|
|
:system-announcements="announcementBoard.systemAnnouncements"
|
|
:tabs="announcementBoard.tabs"
|
|
:title="announcementBoard.title"
|
|
:title-header="announcementBoard.titleHeader"
|
|
@select-announcement="handleSelectAnnouncement"
|
|
/>
|
|
</v-sheet>
|
|
</v-col>
|
|
|
|
<v-col
|
|
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
|
|
cols="12"
|
|
lg="4"
|
|
sm="6"
|
|
>
|
|
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
|
|
<v-banner
|
|
class="d-sm-none mb-2"
|
|
density="comfortable"
|
|
lines="one"
|
|
:mobile="false"
|
|
:stacked="false"
|
|
>
|
|
<template #prepend>
|
|
<v-slide-x-transition appear>
|
|
<div class="mobile-banner-icon-wrap d-flex align-center">
|
|
<v-icon
|
|
class="mobile-banner-icon"
|
|
color="primary"
|
|
size="small"
|
|
:icon="mdiBullhornVariantOutline"
|
|
/>
|
|
</div>
|
|
</v-slide-x-transition>
|
|
</template>
|
|
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
|
<template #actions>
|
|
<v-btn
|
|
class="text-none"
|
|
color="primary"
|
|
size="small"
|
|
variant="text"
|
|
@click="mobileAnnouncementSheetVisible = true"
|
|
>
|
|
{{ mobileAnnouncement.viewAllText }}
|
|
</v-btn>
|
|
</template>
|
|
</v-banner>
|
|
</div>
|
|
<LoginToolBar
|
|
v-if="toolbar.show"
|
|
:locale="toolbar.locale"
|
|
:locales="toolbar.locales"
|
|
@change-locale="handleChangeLocale"
|
|
@toggle-layout="handleToggleLayout"
|
|
/>
|
|
<div
|
|
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
|
|
>
|
|
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
|
<LoginBrand :title="branding.title" />
|
|
</div>
|
|
<LoginHeader
|
|
class="d-none d-sm-block"
|
|
:welcome-description="header.welcomeDescription"
|
|
:welcome-text="header.welcomeText"
|
|
/>
|
|
<LoginForm
|
|
:acc-placeholder="form.accPlaceholder"
|
|
:forgot-password-href="form.forgotPassword.href"
|
|
:forgot-password-target="form.forgotPassword.target"
|
|
:forgot-password-text="form.forgotPassword.text"
|
|
:passw-placeholder="form.passwPlaceholder"
|
|
:remember-me-label="form.rememberMeLabel"
|
|
:remember-storage-key="form.rememberStorageKey"
|
|
:submit-text="form.submitText"
|
|
:with-forgot-password="form.withForgotPassword"
|
|
:with-remember-account="form.withRememberAccount"
|
|
@forgot-password="handleForgotPassword"
|
|
@submit="onLogin"
|
|
>
|
|
<template v-if="form.withCaptcha" #verify>
|
|
<LoginVerify
|
|
:captcha="form.captcha"
|
|
:captcha-placeholder="form.captchaPlaceholder"
|
|
:error-message="form.captchaErrorMessage"
|
|
:loading="form.captchaLoading"
|
|
:model-value="form.captchaValue"
|
|
:refresh-title="form.refreshTitle"
|
|
:verified="form.captchaVerified"
|
|
:verify-text="form.verifyText"
|
|
@refresh="handleCaptchaRefresh"
|
|
@update:model-value="handleCaptchaChange"
|
|
/>
|
|
</template>
|
|
</LoginForm>
|
|
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
|
|
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
|
|
</div>
|
|
</div>
|
|
</v-col>
|
|
</v-row>
|
|
|
|
<v-row
|
|
v-else
|
|
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
|
|
no-gutters
|
|
>
|
|
<v-card
|
|
class="rounded-lg"
|
|
:class="toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
|
elevation="10"
|
|
max-width="450"
|
|
width="100%"
|
|
>
|
|
<LoginToolBar
|
|
v-if="toolbar.show"
|
|
:locale="toolbar.locale"
|
|
:locales="toolbar.locales"
|
|
@change-locale="handleChangeLocale"
|
|
@toggle-layout="handleToggleLayout"
|
|
/>
|
|
<div class="d-flex justify-center mb-6 mb-md-4">
|
|
<LoginBrand :title="branding.title" />
|
|
</div>
|
|
<LoginHeader
|
|
class="d-none d-md-block"
|
|
:welcome-description="header.welcomeDescription"
|
|
:welcome-text="header.welcomeText"
|
|
/>
|
|
<LoginForm
|
|
:acc-placeholder="form.accPlaceholder"
|
|
:forgot-password-href="form.forgotPassword.href"
|
|
:forgot-password-target="form.forgotPassword.target"
|
|
:forgot-password-text="form.forgotPassword.text"
|
|
:passw-placeholder="form.passwPlaceholder"
|
|
:remember-me-label="form.rememberMeLabel"
|
|
:remember-storage-key="form.rememberStorageKey"
|
|
:submit-text="form.submitText"
|
|
:with-forgot-password="form.withForgotPassword"
|
|
:with-remember-account="form.withRememberAccount"
|
|
@forgot-password="handleForgotPassword"
|
|
@submit="onLogin"
|
|
>
|
|
<template v-if="form.withCaptcha" #verify>
|
|
<LoginVerify
|
|
:captcha="form.captcha"
|
|
:captcha-placeholder="form.captchaPlaceholder"
|
|
:error-message="form.captchaErrorMessage"
|
|
:loading="form.captchaLoading"
|
|
:model-value="form.captchaValue"
|
|
:refresh-title="form.refreshTitle"
|
|
:verified="form.captchaVerified"
|
|
:verify-text="form.verifyText"
|
|
@refresh="handleCaptchaRefresh"
|
|
@update:model-value="handleCaptchaChange"
|
|
/>
|
|
</template>
|
|
</LoginForm>
|
|
<div class="mt-8 text-center text-caption text-grey-darken-2">
|
|
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
|
|
</div>
|
|
</v-card>
|
|
</v-row>
|
|
|
|
<v-bottom-sheet
|
|
v-if="withAnnouncement"
|
|
v-model="mobileAnnouncementSheetVisible"
|
|
class="d-sm-none"
|
|
>
|
|
<v-card rounded="t-xl">
|
|
<v-card-title class="text-subtitle-1 font-weight-bold">
|
|
{{ mobileAnnouncement.listTitle }}
|
|
</v-card-title>
|
|
<v-list lines="two">
|
|
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
|
|
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
|
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
|
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span
|
|
>{{ item.createdAt }}
|
|
</v-list-item-subtitle>
|
|
</v-list-item>
|
|
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
|
<v-list-item-title>{{ mobileAnnouncement.emptyText }}</v-list-item-title>
|
|
</v-list-item>
|
|
</v-list>
|
|
<v-card-actions class="justify-end">
|
|
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
|
|
{{ mobileAnnouncement.closeText }}
|
|
</v-btn>
|
|
</v-card-actions>
|
|
</v-card>
|
|
</v-bottom-sheet>
|
|
</v-sheet>
|
|
|
|
<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>
|
|
|
|
<style scoped>
|
|
:deep(.v-banner__prepend) {
|
|
align-self: center;
|
|
margin-inline-end: 16px;
|
|
}
|
|
|
|
:deep(.v-banner-actions) {
|
|
align-self: center;
|
|
}
|
|
|
|
.mobile-banner-icon {
|
|
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
|
|
transform-origin: center;
|
|
}
|
|
|
|
@keyframes mobile-banner-breathe {
|
|
0%,
|
|
100% {
|
|
opacity: 0.9;
|
|
transform: scale(1);
|
|
}
|
|
|
|
50% {
|
|
opacity: 1;
|
|
transform: scale(1.08);
|
|
}
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.mobile-banner-icon {
|
|
animation: none;
|
|
}
|
|
}
|
|
|
|
.illustration-panel {
|
|
background: linear-gradient(
|
|
135deg,
|
|
rgb(var(--v-theme-background)) 0%,
|
|
rgb(var(--v-theme-surface)) 100%
|
|
);
|
|
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
|
}
|
|
|
|
.login-form-wrapper {
|
|
max-width: 450px;
|
|
margin: 0 auto;
|
|
width: 100%;
|
|
}
|
|
|
|
.login-header-height {
|
|
height: 120px;
|
|
}
|
|
|
|
.layout-side-right .illustration-panel {
|
|
border-right: none;
|
|
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
|
}
|
|
</style>
|