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.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.
This commit is contained in:
skytek_xinliang
2026-05-27 13:43:43 +08:00
parent 7b99087cbb
commit 7b0cfe4448
18 changed files with 614 additions and 1007 deletions
+342 -78
View File
@@ -1,63 +1,15 @@
<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 { 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 PageLogin from '@/components/PageLogin.vue'
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,
@@ -69,7 +21,6 @@ import { useSnackbarStore } from '@/stores/snackbar'
type LayoutType = 'side-left' | 'side-right' | 'card'
// i18n
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
@@ -77,21 +28,14 @@ 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 {
@@ -101,7 +45,6 @@ const {
selectedAnnouncementDetail,
} = loginAnnouncements
// 文字內容(i18n
const title = computed(() => t('pages.login.title'))
const organization = computed(() => t('pages.login.organization'))
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
@@ -117,19 +60,14 @@ 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,
@@ -146,7 +84,6 @@ const header = computed(() => ({
welcomeDescription: welcomeDescription.value,
}))
// 表單區塊設定(含功能開關)
const form = computed(() => ({
accPlaceholder: accPlaceholder.value,
passwPlaceholder: passwPlaceholder.value,
@@ -158,7 +95,6 @@ const form = computed(() => ({
rememberStorageKey: rememberStorageKey.value,
withForgotPassword: withForgotPassword.value,
withRememberAccount: withRememberAccount.value,
// 功能開關:是否顯示驗證碼
withCaptcha: withCaptcha.value,
captcha: loginCaptcha.formCaptcha.value,
captchaValue: loginCaptcha.captchaValue.value,
@@ -172,18 +108,26 @@ const form = computed(() => ({
},
}))
// 右上工具列設定(含顯示開關)
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)
}
@@ -239,8 +183,6 @@ async function onLogin(data: Record<string, unknown>) {
menuStore.getMenu(authStore.user?.id ?? '')
// menuStore.getFavorite(authStore.user?.id ?? '')
snackbarStore.show({
message: t('pages.login.alert.loginSuccess'),
color: 'success',
@@ -264,3 +206,325 @@ onMounted(() => {
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>