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:
+342
-78
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user