533 lines
15 KiB
Vue
533 lines
15 KiB
Vue
<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
|
|
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"
|
|
@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-1">
|
|
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"
|
|
@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-1">
|
|
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
|
</div>
|
|
</v-card>
|
|
</v-row>
|
|
|
|
<v-bottom-sheet 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 './base/login/LoginAnnouncementBoard.vue'
|
|
import LoginBrand from './base/login/LoginBrand.vue'
|
|
import LoginForm from './base/login/LoginForm.vue'
|
|
import LoginHeader from './base/login/LoginHeader.vue'
|
|
import LoginToolBar from './base/login/LoginToolBar.vue'
|
|
import LoginVerify from './base/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
|
|
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'
|
|
branding: BrandingConfig
|
|
illustration: IllustrationConfig
|
|
announcementBoard: AnnouncementBoardConfig
|
|
mobileAnnouncement: MobileAnnouncementConfig
|
|
header: HeaderConfig
|
|
form: FormConfig
|
|
toolbar: ToolBarConfig
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
layout: 'side-left',
|
|
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',
|
|
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.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>
|