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:
@@ -9,7 +9,7 @@
|
||||
- `layouts/`:App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`。
|
||||
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`。
|
||||
|
||||
`PageMaint.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/` 頂層。
|
||||
`MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/` 頂層。
|
||||
|
||||
## 規則
|
||||
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
<template>
|
||||
<v-container class="pa-0" fluid>
|
||||
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
|
||||
<v-sheet
|
||||
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
|
||||
color="surface"
|
||||
>
|
||||
<v-avatar color="primary" size="52" variant="tonal">
|
||||
<span class="text-h5">👋</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組
|
||||
</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
<section class="d-flex flex-column">
|
||||
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
|
||||
<!--
|
||||
使用 v-data-iterator 保留一致的列表輸出結構,
|
||||
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架。
|
||||
-->
|
||||
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
|
||||
<!--
|
||||
Vuetify 會把原始資料包進 wrapper,
|
||||
這裡統一解包可避免模板層散落型別判斷。
|
||||
-->
|
||||
<template #default="{ items }">
|
||||
<v-row density="compact">
|
||||
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
|
||||
<v-card
|
||||
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
|
||||
variant="outlined"
|
||||
@click="emit('news', resolveNewsItem(wrapped))"
|
||||
>
|
||||
<v-sheet class="news-badge">
|
||||
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
|
||||
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
|
||||
</v-sheet>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex flex-wrap align-center font-weight-bold">
|
||||
{{ resolveNewsItem(wrapped).title }}
|
||||
<v-chip
|
||||
v-if="resolveNewsItem(wrapped).isNew"
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
>
|
||||
NEW
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-2">
|
||||
{{ resolveNewsItem(wrapped).desc }}
|
||||
</div>
|
||||
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon size="14" :icon="mdiFolderOutline" />
|
||||
<span>{{ resolveNewsItem(wrapped).dept }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon size="14" :icon="mdiEyeOutline" />
|
||||
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-iterator>
|
||||
</section>
|
||||
|
||||
<v-card
|
||||
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
|
||||
color="secondary"
|
||||
rounded="xl"
|
||||
variant="tonal"
|
||||
@click="emit('message-center')"
|
||||
>
|
||||
<div class="d-flex align-center ga-4">
|
||||
<v-avatar color="secondary" size="44" variant="flat">
|
||||
<span class="text-h6">✉️</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
|
||||
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-body-2 font-weight-medium">查看全部 →</div>
|
||||
</v-card>
|
||||
|
||||
<section class="d-flex flex-column pb-4">
|
||||
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
|
||||
<v-row class="mt-2" density="compact">
|
||||
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
|
||||
<v-card
|
||||
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
|
||||
variant="outlined"
|
||||
@click="emit('quick', item)"
|
||||
>
|
||||
<div class="text-h5">{{ item.icon }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
這個 dialog 只做消息內容呈現,
|
||||
開關狀態仍交給 view 管理,避免頁面元件自行持有流程狀態。
|
||||
-->
|
||||
<v-dialog
|
||||
:model-value="props.isNewsDialogOpen"
|
||||
max-width="640"
|
||||
@update:model-value="emit('update:isNewsDialogOpen', $event)"
|
||||
>
|
||||
<v-card v-if="props.selectedNews">
|
||||
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
|
||||
{{ props.selectedNews.title }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
|
||||
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
|
||||
{{ props.selectedNews.dept }} · {{ props.selectedNews.views }} 次瀏覽
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="pt-4">
|
||||
{{ props.selectedNews.desc }}
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
|
||||
關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
|
||||
|
||||
interface NewsItem {
|
||||
id: number
|
||||
date: string
|
||||
month: string
|
||||
title: string
|
||||
desc: string
|
||||
dept: string
|
||||
views: string
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
interface QuickItem {
|
||||
icon: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
newsItems: NewsItem[]
|
||||
quickItems: QuickItem[]
|
||||
selectedNews: NewsItem | null
|
||||
isNewsDialogOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
news: [item: NewsItem]
|
||||
'message-center': []
|
||||
quick: [item: QuickItem]
|
||||
'update:isNewsDialogOpen': [value: boolean]
|
||||
}>()
|
||||
|
||||
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
|
||||
function resolveNewsItem(wrapped: unknown): NewsItem {
|
||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||
return (wrapped as { raw: NewsItem }).raw
|
||||
}
|
||||
|
||||
return wrapped as NewsItem
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.news-item {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.news-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
|
||||
.news-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
min-height: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.news-badge-date {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.news-badge-month {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -1,548 +0,0 @@
|
||||
<template>
|
||||
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||
<!-- Side Layouts -->
|
||||
<v-row
|
||||
v-if="props.layout !== 'card'"
|
||||
class="fill-height"
|
||||
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||
no-gutters
|
||||
>
|
||||
<!-- Illustration Column -->
|
||||
<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="props.branding.title" />
|
||||
</div>
|
||||
<v-sheet
|
||||
v-if="props.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="props.announcementBoard.allTabLabel"
|
||||
:date-header="props.announcementBoard.dateHeader"
|
||||
:empty-text="props.announcementBoard.emptyText"
|
||||
:items="props.announcementBoard.items"
|
||||
:items-per-page="props.announcementBoard.itemsPerPage"
|
||||
:pagination-label="props.announcementBoard.paginationLabel"
|
||||
:school-header="props.announcementBoard.schoolHeader"
|
||||
:system-announcements="props.announcementBoard.systemAnnouncements"
|
||||
:tabs="props.announcementBoard.tabs"
|
||||
:title="props.announcementBoard.title"
|
||||
:title-header="props.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"
|
||||
>
|
||||
{{ props.mobileAnnouncement.viewAllText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-banner>
|
||||
</div>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.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="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-sm-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
:with-forgot-password="props.form.withForgotPassword"
|
||||
:with-remember-account="props.form.withRememberAccount"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.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() }} {{ props.branding.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Card Layout (Centered) -->
|
||||
<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="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
||||
elevation="10"
|
||||
max-width="450"
|
||||
width="100%"
|
||||
>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-md-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
:with-forgot-password="props.form.withForgotPassword"
|
||||
:with-remember-account="props.form.withRememberAccount"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.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() }} {{ props.branding.organization }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-row>
|
||||
|
||||
<v-bottom-sheet
|
||||
v-if="props.withAnnouncement"
|
||||
v-model="mobileAnnouncementSheetVisible"
|
||||
class="d-sm-none"
|
||||
>
|
||||
<v-card rounded="t-xl">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold">
|
||||
{{ props.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>{{ props.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">
|
||||
{{ props.mobileAnnouncement.closeText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-bottom-sheet>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiBullhornVariantOutline } from '@mdi/js'
|
||||
import { computed, ref } from 'vue'
|
||||
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
|
||||
import LoginBrand from './login/LoginBrand.vue'
|
||||
import LoginForm from './login/LoginForm.vue'
|
||||
import LoginHeader from './login/LoginHeader.vue'
|
||||
import LoginToolBar from './login/LoginToolBar.vue'
|
||||
import LoginVerify from './login/LoginVerify.vue'
|
||||
|
||||
interface BrandingConfig {
|
||||
title?: string
|
||||
organization?: string
|
||||
}
|
||||
|
||||
interface IllustrationConfig {
|
||||
image?: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface HeaderConfig {
|
||||
welcomeText?: string
|
||||
welcomeDescription?: string
|
||||
}
|
||||
|
||||
interface AnnouncementTabConfig {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AnnouncementItemConfig {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
}
|
||||
|
||||
interface AnnouncementBoardConfig {
|
||||
title?: string
|
||||
tabs?: AnnouncementTabConfig[]
|
||||
items?: AnnouncementItemConfig[]
|
||||
systemAnnouncements?: {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}[]
|
||||
allTabLabel?: string
|
||||
itemsPerPage?: number
|
||||
dateHeader?: string
|
||||
schoolHeader?: string
|
||||
titleHeader?: string
|
||||
emptyText?: string
|
||||
paginationLabel?: string
|
||||
}
|
||||
|
||||
interface MobileAnnouncementConfig {
|
||||
items?: {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}[]
|
||||
show?: boolean
|
||||
viewAllText?: string
|
||||
listTitle?: string
|
||||
closeText?: string
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
interface ForgotPasswordConfig {
|
||||
text?: string
|
||||
href?: string
|
||||
target?: string
|
||||
}
|
||||
|
||||
interface FormConfig {
|
||||
accPlaceholder?: string
|
||||
passwPlaceholder?: string
|
||||
rememberMeLabel?: string
|
||||
submitText?: string
|
||||
rememberStorageKey?: string
|
||||
withForgotPassword?: boolean
|
||||
withRememberAccount?: boolean
|
||||
withCaptcha?: boolean
|
||||
captcha?: {
|
||||
imgUrl?: string
|
||||
id?: string
|
||||
tokenValue?: string
|
||||
}
|
||||
captchaValue?: string
|
||||
captchaLoading?: boolean
|
||||
captchaErrorMessage?: string
|
||||
captchaVerified?: boolean
|
||||
verifyText?: string
|
||||
captchaPlaceholder?: string
|
||||
refreshTitle?: string
|
||||
forgotPassword: ForgotPasswordConfig
|
||||
}
|
||||
|
||||
interface ToolBarConfig {
|
||||
show?: boolean
|
||||
locale?: string
|
||||
locales?: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
layout: 'side-left' | 'side-right' | 'card'
|
||||
withAnnouncement?: boolean
|
||||
branding: BrandingConfig
|
||||
illustration: IllustrationConfig
|
||||
announcementBoard: AnnouncementBoardConfig
|
||||
mobileAnnouncement: MobileAnnouncementConfig
|
||||
header: HeaderConfig
|
||||
form: FormConfig
|
||||
toolbar: ToolBarConfig
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'side-left',
|
||||
withAnnouncement: true,
|
||||
branding: () => ({
|
||||
title: 'Skyteck Login',
|
||||
organization: 'school',
|
||||
}),
|
||||
illustration: () => ({
|
||||
image: null,
|
||||
title: 'Login',
|
||||
description: 'Login to your account',
|
||||
}),
|
||||
announcementBoard: () => ({
|
||||
title: '學校公告區',
|
||||
tabs: [
|
||||
{ label: '全部', value: '__all__' },
|
||||
{ label: '國中', value: 'junior' },
|
||||
{ label: '高中', value: 'senior' },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'announcement-1',
|
||||
date: '2024-03-19',
|
||||
school: '市立實踐國中',
|
||||
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||
tab: 'junior',
|
||||
},
|
||||
],
|
||||
systemAnnouncements: [],
|
||||
allTabLabel: '全部',
|
||||
itemsPerPage: 5,
|
||||
dateHeader: '公告時間',
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
emptyText: '目前沒有公告資料',
|
||||
paginationLabel: '總筆數:',
|
||||
}),
|
||||
mobileAnnouncement: () => ({
|
||||
items: [],
|
||||
show: false,
|
||||
viewAllText: '查看全部',
|
||||
listTitle: '系統公告',
|
||||
closeText: '關閉',
|
||||
emptyText: '目前沒有公告',
|
||||
}),
|
||||
header: () => ({
|
||||
welcomeText: 'Welcome back 👋🏻',
|
||||
welcomeDescription: 'Please enter your account password to login',
|
||||
}),
|
||||
form: () => ({
|
||||
accPlaceholder: '請輸入帳號',
|
||||
passwPlaceholder: '請輸入密碼',
|
||||
rememberMeLabel: '記住帳號',
|
||||
submitText: '登入',
|
||||
rememberStorageKey: 'sklogin.remember.username',
|
||||
withForgotPassword: true,
|
||||
withRememberAccount: true,
|
||||
withCaptcha: true,
|
||||
captcha: undefined,
|
||||
captchaValue: '',
|
||||
captchaLoading: false,
|
||||
captchaErrorMessage: '',
|
||||
captchaVerified: false,
|
||||
verifyText: '驗證',
|
||||
captchaPlaceholder: '驗證碼',
|
||||
refreshTitle: '點擊刷新驗證碼',
|
||||
forgotPassword: {
|
||||
text: '忘記密碼?',
|
||||
href: '',
|
||||
target: undefined,
|
||||
},
|
||||
}),
|
||||
toolbar: () => ({
|
||||
show: true,
|
||||
locale: 'zh-TW',
|
||||
locales: ['zh-TW', 'en-US'],
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'submit',
|
||||
'change-locale',
|
||||
'forgot-password',
|
||||
'captcha-refresh',
|
||||
'captcha-change',
|
||||
'toggle-layout',
|
||||
'select-announcement',
|
||||
])
|
||||
|
||||
const mobileAnnouncementSheetVisible = ref(false)
|
||||
|
||||
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
|
||||
|
||||
const showMobileAnnouncementBanner = computed(() => {
|
||||
if (!props.withAnnouncement) return false
|
||||
if (props.mobileAnnouncement.show === false) return false
|
||||
return mobileAnnouncementItems.value.length > 0
|
||||
})
|
||||
|
||||
const mobileAnnouncementBannerText = computed(() => {
|
||||
return mobileAnnouncementItems.value[0]?.content ?? ''
|
||||
})
|
||||
|
||||
const layoutClass = computed(() => {
|
||||
return `layout-${props.layout}`
|
||||
})
|
||||
|
||||
function handleLogin(formData: Record<string, unknown>) {
|
||||
emit('submit', formData)
|
||||
}
|
||||
|
||||
function handleCaptchaRefresh() {
|
||||
emit('captcha-refresh')
|
||||
}
|
||||
|
||||
function handleCaptchaChange(value: string) {
|
||||
emit('captcha-change', value)
|
||||
}
|
||||
|
||||
function handleChangeLocale(nextLocale: string) {
|
||||
emit('change-locale', nextLocale)
|
||||
}
|
||||
|
||||
function handleToggleLayout() {
|
||||
emit('toggle-layout')
|
||||
}
|
||||
|
||||
function handleForgotPassword(e: MouseEvent) {
|
||||
emit('forgot-password', e)
|
||||
}
|
||||
|
||||
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
|
||||
emit('select-announcement', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<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;
|
||||
}
|
||||
|
||||
/* Specific styles for side-right to flip border */
|
||||
.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