feat: refactor layouts and login components
This commit is contained in:
@@ -1,45 +0,0 @@
|
||||
<template>
|
||||
<div class="text-center w-100">
|
||||
<div class="text-body-2">
|
||||
{{ props.promptText }}
|
||||
<a
|
||||
class="text-primary text-decoration-none font-weight-bold ml-1"
|
||||
:href="props.href || '#'"
|
||||
:target="props.target"
|
||||
@click="handleClick"
|
||||
>
|
||||
{{ props.linkText }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
promptText: {
|
||||
type: String,
|
||||
default: '還沒有帳號?',
|
||||
},
|
||||
linkText: {
|
||||
type: String,
|
||||
default: '註冊帳號',
|
||||
},
|
||||
href: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
target: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['click'])
|
||||
|
||||
function handleClick(e: MouseEvent) {
|
||||
emit('click', e)
|
||||
if (!props.href) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,194 +0,0 @@
|
||||
<template>
|
||||
<v-card class="w-100 h-100 d-flex flex-column bg-transparent pa-2 pa-lg-4" elevation="3">
|
||||
<v-card-title class="text-h6 text-lg-h5 font-weight-bold text-accent mb-4">{{
|
||||
title
|
||||
}}</v-card-title>
|
||||
|
||||
<v-tabs v-model="activeTab" class="mb-3" color="primary" density="comfortable">
|
||||
<v-tab v-for="tab in normalizedTabs" :key="tab.value" :value="tab.value">
|
||||
{{ tab.label }}
|
||||
</v-tab>
|
||||
</v-tabs>
|
||||
|
||||
<div class="announcement-content mb-3">
|
||||
<v-table v-if="!isSystemTab" density="comfortable" fixed-header height="300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="text-left">{{ dateHeader }}</th>
|
||||
<th class="text-left">{{ schoolHeader }}</th>
|
||||
<th class="text-left">{{ titleHeader }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in pageItems" :key="item.id">
|
||||
<td class="text-no-wrap">{{ item.date }}</td>
|
||||
<td class="text-no-wrap">{{ item.school }}</td>
|
||||
<td>
|
||||
<v-btn
|
||||
class="px-0 text-none justify-start"
|
||||
color="primary"
|
||||
variant="text"
|
||||
@click="emit('select-announcement', item)"
|
||||
>
|
||||
{{ item.title }}
|
||||
</v-btn>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="pageItems.length === 0">
|
||||
<td class="text-center text-medium-emphasis py-6" :colspan="3">{{ emptyText }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
|
||||
<v-list v-else class="rounded border overflow-y-auto h-100" density="comfortable" lines="two">
|
||||
<v-list-item v-for="item in systemPageItems" :key="item.id" border="b">
|
||||
<v-list-item-title class="text-h6 mb-2">
|
||||
{{ 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="systemPageItems.length === 0" class="h-100">
|
||||
<v-list-item-title class="text-center text-medium-emphasis">{{
|
||||
emptyText
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-space-between align-center mt-auto pt-3">
|
||||
<span class="text-caption text-medium-emphasis">
|
||||
{{ paginationLabel }} {{ totalItems }}
|
||||
</span>
|
||||
<v-pagination v-model="page" density="comfortable" :length="pageCount" rounded="circle" />
|
||||
</div>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
interface AnnouncementTab {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AnnouncementItem {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
}
|
||||
|
||||
interface SystemAnnouncementItem {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
tabs?: AnnouncementTab[]
|
||||
items?: AnnouncementItem[]
|
||||
systemAnnouncements?: SystemAnnouncementItem[]
|
||||
allTabLabel?: string
|
||||
itemsPerPage?: number
|
||||
dateHeader?: string
|
||||
schoolHeader?: string
|
||||
titleHeader?: string
|
||||
emptyText?: string
|
||||
paginationLabel?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '學校甄選簡章公告區',
|
||||
tabs: () => [{ label: '全部', value: '__all__' }],
|
||||
items: () => [],
|
||||
systemAnnouncements: () => [],
|
||||
allTabLabel: '全部',
|
||||
itemsPerPage: 5,
|
||||
dateHeader: '公告時間',
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
emptyText: '目前沒有公告資料',
|
||||
paginationLabel: '總筆數:',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'select-announcement', item: AnnouncementItem): void
|
||||
}>()
|
||||
|
||||
const allTabValue = '__all__'
|
||||
const systemTabValue = '__system__'
|
||||
|
||||
const systemTab = computed<AnnouncementTab>(() => ({
|
||||
label: '系統公告',
|
||||
value: systemTabValue,
|
||||
}))
|
||||
|
||||
const normalizedTabs = computed<AnnouncementTab[]>(() => {
|
||||
const baseTabs =
|
||||
props.tabs.length > 0 ? props.tabs : [{ label: props.allTabLabel, value: allTabValue }]
|
||||
if (baseTabs.some((tab) => tab.value === systemTabValue)) return baseTabs
|
||||
return [...baseTabs, systemTab.value]
|
||||
})
|
||||
|
||||
const activeTab = ref(normalizedTabs.value[0]?.value ?? allTabValue)
|
||||
const page = ref(1)
|
||||
const isSystemTab = computed(() => activeTab.value === systemTabValue)
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (activeTab.value === allTabValue) return props.items
|
||||
return props.items.filter((item) => item.tab === activeTab.value)
|
||||
})
|
||||
|
||||
const totalItems = computed(() => {
|
||||
if (isSystemTab.value) return props.systemAnnouncements.length
|
||||
return filteredItems.value.length
|
||||
})
|
||||
|
||||
const pageCount = computed(() => {
|
||||
const size = Math.max(1, props.itemsPerPage)
|
||||
return Math.max(1, Math.ceil(totalItems.value / size))
|
||||
})
|
||||
|
||||
const pageItems = computed(() => {
|
||||
const size = Math.max(1, props.itemsPerPage)
|
||||
const start = (page.value - 1) * size
|
||||
return filteredItems.value.slice(start, start + size)
|
||||
})
|
||||
|
||||
const systemPageItems = computed<SystemAnnouncementItem[]>(() => {
|
||||
const size = Math.max(1, props.itemsPerPage)
|
||||
const start = (page.value - 1) * size
|
||||
return props.systemAnnouncements.slice(start, start + size)
|
||||
})
|
||||
|
||||
watch(
|
||||
normalizedTabs,
|
||||
(tabs) => {
|
||||
if (tabs.some((tab) => tab.value === activeTab.value)) return
|
||||
activeTab.value = tabs[0]?.value ?? allTabValue
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(activeTab, () => {
|
||||
page.value = 1
|
||||
})
|
||||
|
||||
watch(pageCount, (count) => {
|
||||
if (page.value <= count) return
|
||||
page.value = count
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.announcement-content {
|
||||
height: 300px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +0,0 @@
|
||||
<template>
|
||||
<div class="d-flex align-center">
|
||||
<span class="text-h5 font-weight-bold text-primary">{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
title: {
|
||||
type: String,
|
||||
default: 'Login PageS',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -1,152 +0,0 @@
|
||||
<template>
|
||||
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
|
||||
<v-text-field
|
||||
v-model="username"
|
||||
bg-color="surface"
|
||||
class="mb-6 mb-md-4"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
:placeholder="props.accPlaceholder"
|
||||
variant="outlined"
|
||||
></v-text-field>
|
||||
|
||||
<v-text-field
|
||||
v-model="password"
|
||||
:append-inner-icon="showPassword ? mdiEyeOff : mdiEye"
|
||||
bg-color="surface"
|
||||
class="mb-6 mb-md-4"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
hide-details
|
||||
:placeholder="props.passwPlaceholder"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
variant="outlined"
|
||||
@click:append-inner="showPassword = !showPassword"
|
||||
></v-text-field>
|
||||
|
||||
<slot name="verify"></slot>
|
||||
|
||||
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
|
||||
<v-checkbox
|
||||
v-model="rememberMe"
|
||||
color="primary"
|
||||
density="compact"
|
||||
hide-details
|
||||
:label="props.rememberMeLabel"
|
||||
></v-checkbox>
|
||||
<a
|
||||
class="text-body-2 text-primary text-decoration-none"
|
||||
:href="props.forgotPasswordHref || '#'"
|
||||
:target="props.forgotPasswordTarget"
|
||||
@click="handleForgotPasswordClick"
|
||||
>
|
||||
{{ props.forgotPasswordText }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
block
|
||||
class="mb-6 font-weight-bold"
|
||||
color="primary"
|
||||
elevation="0"
|
||||
height="48"
|
||||
size="large"
|
||||
type="submit"
|
||||
>
|
||||
{{ props.submitText }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiEye, mdiEyeOff } from '@mdi/js'
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const rememberMe = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
passwPlaceholder: {
|
||||
type: String,
|
||||
default: '請輸入6位數密碼',
|
||||
},
|
||||
accPlaceholder: {
|
||||
type: String,
|
||||
default: '請輸入帳號',
|
||||
},
|
||||
rememberMeLabel: {
|
||||
type: String,
|
||||
default: '記住帳號',
|
||||
},
|
||||
forgotPasswordText: {
|
||||
type: String,
|
||||
default: '忘記密碼?',
|
||||
},
|
||||
forgotPasswordHref: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
forgotPasswordTarget: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
submitText: {
|
||||
type: String,
|
||||
default: '登入',
|
||||
},
|
||||
rememberStorageKey: {
|
||||
type: String,
|
||||
default: 'sklogin.remember.username',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['submit', 'forgot-password'])
|
||||
|
||||
onMounted(() => {
|
||||
const saved = localStorage.getItem(props.rememberStorageKey)
|
||||
if (saved) {
|
||||
username.value = saved
|
||||
rememberMe.value = true
|
||||
}
|
||||
})
|
||||
|
||||
watch([rememberMe, username], ([nextRemember, nextUsername]) => {
|
||||
if (!nextRemember) {
|
||||
localStorage.removeItem(props.rememberStorageKey)
|
||||
return
|
||||
}
|
||||
|
||||
if (!nextUsername) {
|
||||
localStorage.removeItem(props.rememberStorageKey)
|
||||
return
|
||||
}
|
||||
|
||||
localStorage.setItem(props.rememberStorageKey, nextUsername)
|
||||
})
|
||||
|
||||
function handleForgotPasswordClick(e: MouseEvent) {
|
||||
emit('forgot-password', e)
|
||||
if (!props.forgotPasswordHref) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.v-field--variant-outlined) {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:deep(.v-btn) {
|
||||
text-transform: none;
|
||||
border-radius: 8px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
:deep(.v-checkbox .v-label) {
|
||||
font-size: 14px;
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<div class="login-header-wrapper">
|
||||
<h2 class="text-h5 text-primary font-weight-bold mb-2">{{ props.welcomeText }}</h2>
|
||||
<p class="text-subtitle-1 text-secondary">{{ props.welcomeDescription }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const props = defineProps({
|
||||
welcomeText: {
|
||||
type: String,
|
||||
default: '歡迎回來 👋🏻',
|
||||
},
|
||||
welcomeDescription: {
|
||||
type: String,
|
||||
default: '請輸入您的帳號密碼進行登入',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
.login-header-wrapper {
|
||||
height: 140px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="illustration-container d-flex flex-column align-center justify-center fill-height px-8"
|
||||
>
|
||||
<div class="illustration-wrapper mb-8 w-100 d-flex justify-center">
|
||||
<v-img
|
||||
v-if="image"
|
||||
aspect-ratio="16/9"
|
||||
contain
|
||||
max-width="600"
|
||||
:src="image"
|
||||
width="100%"
|
||||
></v-img>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<h1 class="text-h4 font-weight-bold text-secondary mb-4">{{ title }}</h1>
|
||||
<p class="text-body-1 text-secondary">{{ description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
image: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '這是一個標題',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '這是一個副標題',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.illustration-container {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,82 +0,0 @@
|
||||
<template>
|
||||
<div class="d-flex justify-end py-0 py-sm-2">
|
||||
<v-btn
|
||||
class="d-none d-md-block"
|
||||
color="grey-darken-1"
|
||||
:icon="mdiPaletteOutline"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="toggleTheme"
|
||||
></v-btn>
|
||||
<v-menu location="bottom end">
|
||||
<template #activator="{ props: menuActivatorProps }">
|
||||
<v-btn
|
||||
v-bind="menuActivatorProps"
|
||||
color="grey-darken-1"
|
||||
:icon="mdiTranslate"
|
||||
size="small"
|
||||
variant="text"
|
||||
></v-btn>
|
||||
</template>
|
||||
<v-list density="compact">
|
||||
<v-list-item
|
||||
v-for="localeOption in localeOptions"
|
||||
:key="localeOption"
|
||||
:active="localeOption === props.locale"
|
||||
@click="handleSelectLocale(localeOption)"
|
||||
>
|
||||
<v-list-item-title>{{ localeLabels[localeOption] ?? localeOption }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiPaletteOutline, mdiTranslate } from '@mdi/js'
|
||||
import { computed } from 'vue'
|
||||
import { useTheme } from 'vuetify'
|
||||
import { getNextThemeName } from '@/utils/theme'
|
||||
|
||||
interface Props {
|
||||
locale?: string
|
||||
locales?: string[]
|
||||
localeLabels?: Record<string, string>
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
locale: 'zh-TW',
|
||||
locales: () => ['zh-TW', 'en-US'],
|
||||
localeLabels: () => ({
|
||||
'en-US': 'English',
|
||||
'zh-TW': '中文',
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits(['change-locale', 'toggle-layout'])
|
||||
|
||||
const theme = useTheme()
|
||||
|
||||
const availableThemeNames = computed(() =>
|
||||
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||
)
|
||||
|
||||
function toggleTheme() {
|
||||
const names = availableThemeNames.value
|
||||
if (names.length === 0) return
|
||||
|
||||
const current = theme.global.name.value
|
||||
const next = getNextThemeName(names, current)
|
||||
if (!next) return
|
||||
theme.change(next)
|
||||
}
|
||||
|
||||
const localeOptions = computed(() =>
|
||||
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
|
||||
)
|
||||
|
||||
function handleSelectLocale(locale: string) {
|
||||
if (locale === props.locale) return
|
||||
emit('change-locale', locale)
|
||||
}
|
||||
</script>
|
||||
@@ -1,118 +0,0 @@
|
||||
<template>
|
||||
<v-sheet v-bind="$attrs" class="verify-container mb-6 mb-md-4" color="transparent">
|
||||
<div v-if="loading && !captchaImage" class="d-flex justify-center align-center py-4">
|
||||
<v-progress-circular color="primary" indeterminate></v-progress-circular>
|
||||
</div>
|
||||
|
||||
<div v-else class="d-flex align-center gap-2">
|
||||
<!-- Captcha Image and Refresh -->
|
||||
<div
|
||||
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2"
|
||||
:title="props.refreshTitle"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
|
||||
<v-icon class="ms-2" color="grey" :icon="mdiRefresh"></v-icon>
|
||||
</div>
|
||||
|
||||
<!-- Input and Verify -->
|
||||
<v-text-field
|
||||
v-model="inputCode"
|
||||
:append-inner-icon="props.verified ? mdiCheckCircle : undefined"
|
||||
bg-color="surface"
|
||||
class="flex-grow-1"
|
||||
color="primary"
|
||||
density="compact"
|
||||
:disabled="props.verified"
|
||||
:error="!!errorMsg"
|
||||
hide-details
|
||||
:placeholder="props.captchaPlaceholder"
|
||||
variant="outlined"
|
||||
>
|
||||
<template v-if="props.verified" #append-inner>
|
||||
<v-icon color="success" :icon="mdiCheckCircle" />
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="text-caption text-error mt-1">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiCheckCircle, mdiRefresh } from '@mdi/js'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface CaptchaPayload {
|
||||
imgUrl?: string
|
||||
id?: string
|
||||
tokenValue?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
captcha?: CaptchaPayload
|
||||
modelValue?: string
|
||||
loading?: boolean
|
||||
errorMessage?: string
|
||||
verified?: boolean
|
||||
verifyText?: string
|
||||
captchaPlaceholder?: string
|
||||
refreshTitle?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
captcha: undefined,
|
||||
modelValue: '',
|
||||
loading: false,
|
||||
errorMessage: '',
|
||||
verified: false,
|
||||
verifyText: '驗證',
|
||||
captchaPlaceholder: '驗證碼',
|
||||
refreshTitle: '點擊刷新驗證碼',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const captchaImage = computed(() => props.captcha?.imgUrl ?? '')
|
||||
|
||||
const inputCode = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (val: string) => emit('update:modelValue', val),
|
||||
})
|
||||
|
||||
const errorMsg = computed(() => props.errorMessage)
|
||||
|
||||
const loading = computed(() => props.loading)
|
||||
|
||||
function handleRefresh() {
|
||||
if (props.verified) return
|
||||
emit('refresh')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.verify-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha-wrapper {
|
||||
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
height: 40px;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.captcha-img {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user