feat: refactor layouts and login components

This commit is contained in:
skytek_xinliang
2026-03-30 15:04:27 +08:00
parent f7413111c0
commit 79b20ded3b
21 changed files with 159 additions and 210 deletions
@@ -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>
-14
View File
@@ -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>
-152
View File
@@ -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>
-24
View File
@@ -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>
-118
View File
@@ -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>