Refactor MasterDetailMntC.vue for improved readability and consistency

This commit is contained in:
skytek_xinliang
2026-03-30 09:18:55 +08:00
parent 7591ecd062
commit 16b58fbf7a
66 changed files with 2071 additions and 777 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ The following example assumes a component located at `src/components/MyComponent
</template>
<script lang="ts" setup>
//
//
</script>
```
@@ -30,6 +30,6 @@ When your template is rendered, the component's import will automatically be inl
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
import MyComponent from '@/components/MyComponent.vue'
</script>
```
+7 -5
View File
@@ -179,7 +179,9 @@ const emit = defineEmits([
'toggle-expand',
])
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
function normalizeOptions(
options: Array<string | number | { title: string; value: string | number }>
) {
return options.map((o) => {
if (typeof o === 'string' || typeof o === 'number') {
return { title: String(o), value: o }
@@ -203,7 +205,7 @@ const addSubParentItem = ref<DeptItem | null>(null)
const addSubDraftItem = ref<Record<string, unknown> | null>(null)
const selectedItem = ref<DeptItem | null>(null)
function openAddSub (item: DeptItem) {
function openAddSub(item: DeptItem) {
addSubParentItem.value = item
addSubDraftItem.value = {
name: '',
@@ -213,12 +215,12 @@ function openAddSub (item: DeptItem) {
addSubDialogOpen.value = true
}
function openEdit (item: DeptItem) {
function openEdit(item: DeptItem) {
selectedItem.value = item
editDialogOpen.value = true
}
function onAddSubSubmit (payload: Record<string, unknown>) {
function onAddSubSubmit(payload: Record<string, unknown>) {
if (!addSubParentItem.value) return
const newItem: DeptItem = {
id: Date.now(),
@@ -231,7 +233,7 @@ function onAddSubSubmit (payload: Record<string, unknown>) {
emit('add-sub', addSubParentItem.value, newItem)
}
function onEditSubmit (updated: Record<string, unknown>) {
function onEditSubmit(updated: Record<string, unknown>) {
emit('edit', updated)
}
+141 -62
View File
@@ -2,76 +2,129 @@
<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>
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">
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%">
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"
: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" />
: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">
<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">
<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" />
<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">
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" />
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">
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" />
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"
: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">
: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" />
: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">
@@ -82,33 +135,58 @@ class="d-none d-sm-block" :welcome-description="props.header.welcomeDescription"
</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-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%">
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" />
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" />
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"
: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">
: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" />
: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">
@@ -126,7 +204,8 @@ class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
<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 }}
{{ 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">
@@ -363,34 +442,33 @@ const layoutClass = computed(() => {
return `layout-${props.layout}`
})
function handleLogin (formData: Record<string, unknown>) {
function handleLogin(formData: Record<string, unknown>) {
emit('submit', formData)
}
function handleCaptchaRefresh () {
function handleCaptchaRefresh() {
emit('captcha-refresh')
}
function handleCaptchaChange (value: string) {
function handleCaptchaChange(value: string) {
emit('captcha-change', value)
}
function handleChangeLocale (nextLocale: string) {
function handleChangeLocale(nextLocale: string) {
emit('change-locale', nextLocale)
}
function handleToggleLayout () {
function handleToggleLayout() {
emit('toggle-layout')
}
function handleForgotPassword (e: MouseEvent) {
function handleForgotPassword(e: MouseEvent) {
emit('forgot-password', e)
}
function handleSelectAnnouncement (item: AnnouncementItemConfig) {
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
emit('select-announcement', item)
}
</script>
<style scoped>
@@ -409,7 +487,6 @@ function handleSelectAnnouncement (item: AnnouncementItemConfig) {
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
@@ -429,9 +506,11 @@ function handleSelectAnnouncement (item: AnnouncementItemConfig) {
}
.illustration-panel {
background: linear-gradient(135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%);
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);
}
+6 -4
View File
@@ -143,7 +143,9 @@ const emit = defineEmits(['edit', 'refresh', 'settings', 'toggle-expand'])
const editDialogOpen = ref(false)
const selectedItem = ref<MenuItem | null>(null)
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
function normalizeOptions(
options: Array<string | number | { title: string; value: string | number }>
) {
return options.map((o) => {
if (typeof o === 'string' || typeof o === 'number') {
return { title: String(o), value: o }
@@ -161,12 +163,12 @@ const resolvedStatusEnabledValue = computed(() => {
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
function openEdit (item: MenuItem) {
function openEdit(item: MenuItem) {
selectedItem.value = item
editDialogOpen.value = true
}
function onEditSubmit (updated: Record<string, unknown>) {
function onEditSubmit(updated: Record<string, unknown>) {
emit('edit', updated)
}
@@ -187,7 +189,7 @@ const formattedHeaders = computed(() => [
},
])
function getPermissionColor (permission: string) {
function getPermissionColor(permission: string) {
switch (permission) {
case '管理員': {
return 'primary'
+6 -6
View File
@@ -107,7 +107,7 @@ const dialogModel = computed({
set: (v: boolean) => emit('update:modelValue', v),
})
function normalizeOptions (options: Array<Option | string | number>) {
function normalizeOptions(options: Array<Option | string | number>) {
return options.map((o) => {
if (typeof o === 'string' || typeof o === 'number') {
return { title: String(o), value: o }
@@ -121,7 +121,7 @@ const normalizedPermissionOptions = computed(() => normalizeOptions(props.permis
const form = reactive<GenericRecord>({})
function resetForm (next: GenericRecord) {
function resetForm(next: GenericRecord) {
for (const key of Object.keys(form)) {
delete form[key]
}
@@ -129,7 +129,7 @@ function resetForm (next: GenericRecord) {
}
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
function getDefaultPermission (): OptionValue | '' {
function getDefaultPermission(): OptionValue | '' {
return normalizedPermissionOptions.value[0]?.value ?? ''
}
@@ -153,7 +153,7 @@ const formPermission = computed<OptionValue | ''>({
},
})
function syncFromItem () {
function syncFromItem() {
const item = props.item ?? {}
resetForm({ ...item })
@@ -182,12 +182,12 @@ watch(
}
)
function handleCancel () {
function handleCancel() {
emit('cancel')
dialogModel.value = false
}
function handleSubmit () {
function handleSubmit() {
emit('submit', { ...form })
if (props.closeOnSubmit) {
+9 -3
View File
@@ -30,7 +30,13 @@
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
<template #activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" density="comfortable" icon variant="text" @click="$emit('refresh')">
<v-btn
v-bind="activatorProps"
density="comfortable"
icon
variant="text"
@click="$emit('refresh')"
>
<v-icon :icon="mdiRefresh" />
</v-btn>
</template>
@@ -146,7 +152,7 @@ const selectAllIndeterminate = computed(() => {
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
})
function toggleSelectAll (checked: unknown) {
function toggleSelectAll(checked: unknown) {
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
const nonSettingsKeys = current.filter((k) => !allSettingsKeys.value.includes(k))
@@ -156,7 +162,7 @@ function toggleSelectAll (checked: unknown) {
)
}
function updateSettingsSelectedKeys (value: unknown) {
function updateSettingsSelectedKeys(value: unknown) {
emit('update:settingsSelectedKeys', Array.isArray(value) ? value : [])
}
</script>
+1 -1
View File
@@ -85,7 +85,7 @@ watch(
{ immediate: true }
)
function toggleExpand (id: string | number) {
function toggleExpand(id: string | number) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
@@ -36,7 +36,7 @@ const props = defineProps({
const emit = defineEmits(['click'])
function handleClick (e: MouseEvent) {
function handleClick(e: MouseEvent) {
emit('click', e)
if (!props.href) {
e.preventDefault()
@@ -1,6 +1,8 @@
<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-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">
@@ -23,8 +25,11 @@
<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)">
class="px-0 text-none justify-start"
color="primary"
variant="text"
@click="emit('select-announcement', item)"
>
{{ item.title }}
</v-btn>
</td>
@@ -41,17 +46,18 @@ class="px-0 text-none justify-start" color="primary" variant="text"
{{ 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 }}
{{ 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-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 }}
@@ -125,7 +131,8 @@ const systemTab = computed<AnnouncementTab>(() => ({
}))
const normalizedTabs = computed<AnnouncementTab[]>(() => {
const baseTabs = props.tabs.length > 0 ? props.tabs : [{ label: props.allTabLabel, value: allTabValue }]
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]
})
+41 -11
View File
@@ -1,29 +1,59 @@
<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-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'"
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>
@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>
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">
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">
<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>
@@ -96,7 +126,7 @@ watch([rememberMe, username], ([nextRemember, nextUsername]) => {
localStorage.setItem(props.rememberStorageKey, nextUsername)
})
function handleForgotPasswordClick (e: MouseEvent) {
function handleForgotPasswordClick(e: MouseEvent) {
emit('forgot-password', e)
if (!props.forgotPasswordHref) {
e.preventDefault()
+4 -4
View File
@@ -6,7 +6,8 @@
:icon="mdiPaletteOutline"
size="small"
variant="text"
@click="toggleTheme"></v-btn>
@click="toggleTheme"
></v-btn>
<v-menu location="bottom end">
<template #activator="{ props: menuActivatorProps }">
<v-btn
@@ -60,7 +61,7 @@ const availableThemeNames = computed(() =>
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
)
function toggleTheme () {
function toggleTheme() {
const names = availableThemeNames.value
if (names.length === 0) return
@@ -74,9 +75,8 @@ const localeOptions = computed(() =>
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
)
function handleSelectLocale (locale: string) {
function handleSelectLocale(locale: string) {
if (locale === props.locale) return
emit('change-locale', locale)
}
</script>
+17 -6
View File
@@ -7,17 +7,28 @@
<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">
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">
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>
@@ -78,7 +89,7 @@ const errorMsg = computed(() => props.errorMessage)
const loading = computed(() => props.loading)
function handleRefresh () {
function handleRefresh() {
if (props.verified) return
emit('refresh')
}
+3 -3
View File
@@ -35,9 +35,9 @@ const props = withDefaults(defineProps<Props>(), {
favoriteHeaderLabel: '我的最愛',
favoriteItems: () => [],
menuItems: () => [
{ title: '首頁', path: '/' },
{ title: '設定', path: '/settings' },
],
{ title: '首頁', path: '/' },
{ title: '設定', path: '/settings' },
],
})
defineEmits<{
+10 -7
View File
@@ -13,13 +13,16 @@
import type { AdminLayoutMenuItem } from './sk-admin-layout/types'
import SKAdminLayout from './SKAdminLayout.vue'
withDefaults(defineProps<{
systemTitle?: string
menuItems?: AdminLayoutMenuItem[]
}>(), {
systemTitle: '簡潔模式',
menuItems: () => [],
})
withDefaults(
defineProps<{
systemTitle?: string
menuItems?: AdminLayoutMenuItem[]
}>(),
{
systemTitle: '簡潔模式',
menuItems: () => [],
}
)
defineEmits<{
select: [item: AdminLayoutMenuItem]
@@ -1,18 +1,29 @@
<template>
<v-col
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface">
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface"
>
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
<template #prepend>
<v-btn
v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary" size="small"
variant="outlined" @click="emit('toggle-favorites-bar', true)">
v-if="features.showFavorites && !showFavoritesBar"
class="mr-2"
color="primary"
size="small"
variant="outlined"
@click="emit('toggle-favorites-bar', true)"
>
常用
</v-btn>
</template>
<template #item="{ item }">
<div class="d-flex align-center ga-1">
<v-icon v-if="getBreadcrumbIcon(item)" class="mr-1" size="14" :icon="getBreadcrumbIcon(item)" />
<v-icon
v-if="getBreadcrumbIcon(item)"
class="mr-1"
size="14"
:icon="getBreadcrumbIcon(item)"
/>
<span class="text-caption text-no-wrap">{{ getBreadcrumbTitle(item) }}</span>
</div>
</template>
@@ -57,7 +68,7 @@ const emit = defineEmits<{
'toggle-favorites-bar': [value: boolean]
}>()
function getBreadcrumbItem (item: unknown): AdminLayoutBreadcrumbItem | null {
function getBreadcrumbItem(item: unknown): AdminLayoutBreadcrumbItem | null {
if (typeof item !== 'object' || item === null) return null
if ('title' in item) {
@@ -70,11 +81,11 @@ function getBreadcrumbItem (item: unknown): AdminLayoutBreadcrumbItem | null {
return raw as AdminLayoutBreadcrumbItem
}
function getBreadcrumbIcon (item: unknown) {
function getBreadcrumbIcon(item: unknown) {
return getBreadcrumbItem(item)?.icon
}
function getBreadcrumbTitle (item: unknown) {
function getBreadcrumbTitle(item: unknown) {
return getBreadcrumbItem(item)?.title ?? ''
}
</script>
@@ -1,23 +1,36 @@
<template>
<v-col
v-if="features.showFavorites && showFavoritesBar && !isMobile"
class="d-flex align-center pr-2 pl-3 py-1 bg-surface">
v-if="features.showFavorites && showFavoritesBar && !isMobile"
class="d-flex align-center pr-2 pl-3 py-1 bg-surface"
>
<div class="favorites-label text-body-2 text-no-wrap pe-2">
{{ favoritesConfig.label }}
</div>
<div class="favorites-list flex-grow-1 d-flex flex-wrap ga-2">
<transition-group class="d-flex flex-wrap ga-2" name="favorite-list" tag="div">
<v-chip
v-for="item in favoriteItems" :key="item.path ?? item.title" class="favorite-item" closable
color="secondary" size="small" variant="outlined" @click="emit('select', item)"
@click:close="emit('remove-favorite', item)">
v-for="item in favoriteItems"
:key="item.path ?? item.title"
class="favorite-item"
closable
color="secondary"
size="small"
variant="outlined"
@click="emit('select', item)"
@click:close="emit('remove-favorite', item)"
>
<v-icon v-if="item.icon" class="me-1" size="16" :icon="item.icon" />
<span class="text-caption">{{ item.title }}</span>
</v-chip>
</transition-group>
<v-btn
v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" variant="outlined"
@click="emit('add-favorite')">
v-if="favoritesConfig.showAdd"
class="favorite-add"
color="primary"
size="small"
variant="outlined"
@click="emit('add-favorite')"
>
<v-icon class="mr-1" size="16" :icon="mdiPlus" />
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
</v-btn>
@@ -1,19 +1,38 @@
<template>
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
<v-btn v-if="isMobile" :icon="mdiMenu" size="small" variant="text" @click="emit('toggle-drawer')"></v-btn>
<v-btn
v-if="isMobile"
:icon="mdiMenu"
size="small"
variant="text"
@click="emit('toggle-drawer')"
></v-btn>
<div v-if="features.showSearch" class="search-input-wrapper">
<span id="admin-layout-search-label" class="sr-only">{{ searchConfig.label }}</span>
<v-text-field
v-model="searchValueModel" aria-labelledby="admin-layout-search-label" :aria-label="searchConfig.label"
class="search-input" density="compact" hide-details name="layout-search"
:placeholder="searchConfig.placeholder" variant="outlined"
@keyup.enter="triggerSearch">
v-model="searchValueModel"
aria-labelledby="admin-layout-search-label"
:aria-label="searchConfig.label"
class="search-input"
density="compact"
hide-details
name="layout-search"
:placeholder="searchConfig.placeholder"
variant="outlined"
@keyup.enter="triggerSearch"
>
<template v-if="!isMobile" #prepend-inner>
<v-icon size="small" :icon="mdiMagnify" />
</template>
<template #append-inner>
<v-btn :aria-label="searchConfig.label" color="primary" size="small" variant="text" @click="triggerSearch">
<v-btn
:aria-label="searchConfig.label"
color="primary"
size="small"
variant="text"
@click="triggerSearch"
>
開始搜尋
</v-btn>
</template>
@@ -22,16 +41,24 @@ v-model="searchValueModel" aria-labelledby="admin-layout-search-label" :aria-lab
<div v-if="features.showToolbarActions" class="top-actions">
<slot name="actions">
<!-- 通知 -->
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps" :aria-label="toolbarActions.notificationsLabel" icon size="small" variant="text"
@click="emit('action', 'notifications')">
v-bind="activatorProps"
:aria-label="toolbarActions.notificationsLabel"
icon
size="small"
variant="text"
@click="emit('action', 'notifications')"
>
<v-badge
v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notifications"
offset-x="4" offset-y="-2">
v-if="toolbarCounts.notifications"
color="error"
:content="toolbarCounts.notifications"
offset-x="4"
offset-y="-2"
>
<v-icon :icon="mdiBellOutline" />
</v-badge>
<v-icon v-else :icon="mdiBellOutline" />
@@ -43,11 +70,20 @@ v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notific
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps" :aria-label="toolbarActions.messagesLabel" icon size="small" variant="text"
@click="emit('action', 'messages')">
v-bind="activatorProps"
:aria-label="toolbarActions.messagesLabel"
icon
size="small"
variant="text"
@click="emit('action', 'messages')"
>
<v-badge
v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages" offset-x="4"
offset-y="-2">
v-if="toolbarCounts.messages"
color="warning"
:content="toolbarCounts.messages"
offset-x="4"
offset-y="-2"
>
<v-icon :icon="mdiMessageTextOutline" />
</v-badge>
<v-icon v-else :icon="mdiMessageTextOutline" />
@@ -59,8 +95,13 @@ v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages"
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
@click="emit('action', 'help')">
v-bind="activatorProps"
:aria-label="toolbarActions.helpLabel"
icon
size="small"
variant="text"
@click="emit('action', 'help')"
>
<v-icon :icon="mdiHelp" />
</v-btn>
</template>
@@ -72,8 +113,12 @@ v-bind="activatorProps" :aria-label="toolbarActions.helpLabel" icon size="small"
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsLabel" icon size="small"
variant="text">
v-bind="{ ...menuProps, ...tooltipProps }"
:aria-label="toolbarActions.settingsLabel"
icon
size="small"
variant="text"
>
<v-icon :icon="mdiCogOutline" />
</v-btn>
</template>
@@ -82,16 +127,26 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
<v-list density="compact" width="180">
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
<v-list-item>
<v-switch v-model="showFavoritesBarModel" color="primary" density="comfortable" hide-details>
<v-switch
v-model="showFavoritesBarModel"
color="primary"
density="comfortable"
hide-details
>
<template #label>
<span class="text-body-2" style="width: 8ch;">常用功能</span>
<span class="text-body-2" style="width: 8ch">常用功能</span>
</template>
</v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="breadcrumbBarVisibleModel" color="primary" density="comfortable" hide-details>
<v-switch
v-model="breadcrumbBarVisibleModel"
color="primary"
density="comfortable"
hide-details
>
<template #label>
<span class="text-body-2" style="width: 8ch;">路徑</span>
<span class="text-body-2" style="width: 8ch">路徑</span>
</template>
</v-switch>
</v-list-item>
@@ -101,7 +156,14 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
<!-- 登出 -->
<v-tooltip location="bottom" :text="logoutLabel">
<template #activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" :aria-label="logoutLabel" icon size="small" variant="text" @click="emit('logout')">
<v-btn
v-bind="activatorProps"
:aria-label="logoutLabel"
icon
size="small"
variant="text"
@click="emit('logout')"
>
<v-icon :icon="mdiLogout" />
</v-btn>
</template>
@@ -109,7 +171,13 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
<template #activator="{ props: activatorProps }">
<v-btn v-bind="activatorProps" :aria-label="themeToggleLabel" icon variant="text" @click="emit('toggle-theme')">
<v-btn
v-bind="activatorProps"
:aria-label="themeToggleLabel"
icon
variant="text"
@click="emit('toggle-theme')"
>
<v-icon :icon="mdiPaletteOutline" />
</v-btn>
</template>
@@ -120,8 +188,23 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
</template>
<script setup lang="ts">
import type { AdminLayoutActionType, AdminLayoutFeatures, AdminLayoutSearchConfig, AdminLayoutToolbarActions, AdminLayoutToolbarCounts } from './types'
import { mdiBellOutline, mdiCogOutline, mdiHelp, mdiLogout, mdiMagnify, mdiMenu, mdiMessageTextOutline, mdiPaletteOutline } from '@mdi/js'
import type {
AdminLayoutActionType,
AdminLayoutFeatures,
AdminLayoutSearchConfig,
AdminLayoutToolbarActions,
AdminLayoutToolbarCounts,
} from './types'
import {
mdiBellOutline,
mdiCogOutline,
mdiHelp,
mdiLogout,
mdiMagnify,
mdiMenu,
mdiMessageTextOutline,
mdiPaletteOutline,
} from '@mdi/js'
import { computed } from 'vue'
interface Props {
@@ -194,7 +277,7 @@ const breadcrumbBarVisibleModel = computed({
set: (value) => emit('update:breadcrumb-bar-visible', value),
})
function triggerSearch () {
function triggerSearch() {
emit('search')
}
</script>
@@ -1,23 +1,42 @@
<template>
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
<template v-for="item in menuItems" :key="item.path ?? item.title">
<v-list-group v-if="item.subItems?.length" :id="getGroupId(item)" :value="getGroupValue(item)">
<v-list-group
v-if="item.subItems?.length"
:id="getGroupId(item)"
:value="getGroupValue(item)"
>
<template #activator="{ props: activatorProps }">
<v-list-item
v-bind="isShrink ? undefined : activatorProps" :class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path" :to="isNavigable(item) ? item.path : undefined" @click="emitSelect(item)">
v-bind="isShrink ? undefined : activatorProps"
:class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path"
:to="isNavigable(item) ? item.path : undefined"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
item.title?.charAt(0) }}</v-btn>
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
<template #append>
<v-chip
v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary" size="x-small"
variant="tonal">
v-if="!isShrink && getItemCount(item) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(item) }}
</v-chip>
</template>
@@ -26,21 +45,29 @@ v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary"
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<v-list-group
v-if="subItem.subItems?.length"
v-if="subItem.subItems?.length"
:id="getGroupId(subItem, getGroupId(item))"
:value="getGroupValue(subItem, getGroupValue(item))">
:value="getGroupValue(subItem, getGroupValue(item))"
>
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps" :link="isNavigable(subItem)"
:prepend-icon="subItem.icon || mdiMenuRight" :to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)">
v-bind="subProps"
:link="isNavigable(subItem)"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)"
>
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
<template #append>
<v-chip
v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-small"
variant="tonal">
v-if="getItemCount(subItem) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(subItem) }}
</v-chip>
</template>
@@ -48,13 +75,19 @@ v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-sm
</template>
<v-list-item
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path" :prepend-icon="mdiCircleSmall" :to="subSubItem.path"
@click="emitSelect(subSubItem)">
v-for="subSubItem in subItem.subItems"
:key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path"
:prepend-icon="mdiCircleSmall"
:to="subSubItem.path"
@click="emitSelect(subSubItem)"
>
<template #title>
<v-tooltip location="end" :text="subSubItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subSubItem.title }}</span>
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subSubItem.title
}}</span>
</template>
</v-tooltip>
</template>
@@ -62,12 +95,18 @@ v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title
</v-list-group>
<v-list-item
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="subItem.path"
@click="emitSelect(subItem)">
v-else
:link="!!subItem.path"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="subItem.path"
@click="emitSelect(subItem)"
>
<template #title>
<v-tooltip location="end" :text="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subItem.title
}}</span>
</template>
</v-tooltip>
</template>
@@ -75,16 +114,31 @@ v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="
</template>
</v-list-group>
<v-list-item v-else :class="{ 'px-0': isShrink }" :link="!!item.path" :to="item.path" @click="emitSelect(item)">
<v-list-item
v-else
:class="{ 'px-0': isShrink }"
:link="!!item.path"
:to="item.path"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
item.title?.charAt(0) }}</v-btn>
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
item.title
}}</span>
</template>
</v-tooltip>
</template>
@@ -126,15 +180,18 @@ const openedModel = computed({
})
// 當側邊欄收縮時,自動收起所有展開的子選單
watch(() => props.isShrink, (newVal) => {
if (newVal) {
openedModel.value = []
watch(
() => props.isShrink,
(newVal) => {
if (newVal) {
openedModel.value = []
}
}
})
)
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
function emitSelect (item: AdminLayoutMenuItem) {
function emitSelect(item: AdminLayoutMenuItem) {
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
// 這樣可以讓使用者看到完整的選單結構和導航結果
if (props.isShrink) {
@@ -143,7 +200,7 @@ function emitSelect (item: AdminLayoutMenuItem) {
emit('select', item)
}
function getItemCount (item: AdminLayoutMenuItem) {
function getItemCount(item: AdminLayoutMenuItem) {
if (!item?.subItems?.length) return 0
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
@@ -2,8 +2,12 @@
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
@click="emit('select', item)">
v-for="item in favoriteItems"
:key="item.path ?? item.title"
class="mb-1"
rounded="lg"
@click="emit('select', item)"
>
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
@@ -13,11 +17,14 @@ v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounde
<script setup lang="ts">
import type { AdminLayoutMenuItem } from './types'
withDefaults(defineProps<{
favoriteItems?: AdminLayoutMenuItem[]
}>(), {
favoriteItems: () => [],
})
withDefaults(
defineProps<{
favoriteItems?: AdminLayoutMenuItem[]
}>(),
{
favoriteItems: () => [],
}
)
const emit = defineEmits<{
select: [item: AdminLayoutMenuItem]
@@ -2,8 +2,12 @@
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
@click="emit('item-click', item)">
v-for="item in mobileCurrentItems"
:key="item.path ?? item.title"
class="mb-1"
rounded="lg"
@click="emit('item-click', item)"
>
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
<template #append>
<v-icon size="18" :icon="item.subItems?.length ? mdiChevronRight : mdiArrowTopRight" />
@@ -17,11 +21,14 @@ v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" r
import type { AdminLayoutMenuItem } from './types'
import { mdiArrowTopRight, mdiChevronRight } from '@mdi/js'
withDefaults(defineProps<{
mobileCurrentItems?: AdminLayoutMenuItem[]
}>(), {
mobileCurrentItems: () => [],
})
withDefaults(
defineProps<{
mobileCurrentItems?: AdminLayoutMenuItem[]
}>(),
{
mobileCurrentItems: () => [],
}
)
const emit = defineEmits<{
'item-click': [item: AdminLayoutMenuItem]
@@ -65,7 +65,4 @@ export interface AdminLayoutDrawerConfig {
railWidth: number
}
export type AdminLayoutActionType =
| 'notifications'
| 'messages'
| 'help'
export type AdminLayoutActionType = 'notifications' | 'messages' | 'help'
@@ -1,7 +1,10 @@
<template>
<v-dialog
:max-width="maxWidth" :model-value="modelValue" :persistent="persistent"
@update:model-value="$emit('update:modelValue', $event)">
:max-width="maxWidth"
:model-value="modelValue"
:persistent="persistent"
@update:model-value="$emit('update:modelValue', $event)"
>
<v-card>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-text>
@@ -9,7 +12,12 @@
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
<v-btn :color="confirmColor" :loading="confirmLoading" :variant="confirmVariant" @click="$emit('confirm')">
<v-btn
:color="confirmColor"
:loading="confirmLoading"
:variant="confirmVariant"
@click="$emit('confirm')"
>
{{ confirmText }}
</v-btn>
</v-card-actions>
+3 -1
View File
@@ -3,7 +3,9 @@
<v-card-title class="dialog-title d-flex align-center ga-2">
<div>
<div class="text-h6">{{ dialogTitle }}</div>
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">{{ dialogSubtitle }}</div>
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">
{{ dialogSubtitle }}
</div>
</div>
<v-spacer />
<v-chip v-if="isViewMode" color="info" size="small" variant="tonal">檢視中</v-chip>
@@ -2,62 +2,119 @@
<div v-if="mobile" class="d-flex align-center flex-wrap ga-2 w-100">
<div class="d-flex align-center ga-1">
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :icon="mdiChevronLeft" size="small" variant="text"
@click="$emit('prev')" />
v-if="isViewMode || isEditMode"
:disabled="!hasPrevRecord"
:icon="mdiChevronLeft"
size="small"
variant="text"
@click="$emit('prev')"
/>
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" :icon="mdiChevronRight" size="small" variant="text"
@click="$emit('next')" />
v-if="isViewMode || isEditMode"
:disabled="!hasNextRecord"
:icon="mdiChevronRight"
size="small"
variant="text"
@click="$emit('next')"
/>
</div>
<v-spacer />
<v-btn
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
@click="$emit('switch-to-edit')">
v-if="isViewMode"
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="tonal"
@click="$emit('switch-to-edit')"
>
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
@click="$emit('switch-to-view')">
v-if="isEditMode"
color="primary"
:prepend-icon="mdiEye"
size="small"
variant="tonal"
@click="$emit('switch-to-view')"
>
{{ viewLabel }}
</v-btn>
</div>
<template v-else>
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiSkipPrevious" size="small"
variant="text" @click="$emit('first')">
v-if="isViewMode || isEditMode"
:disabled="!hasPrevRecord"
:prepend-icon="mdiSkipPrevious"
size="small"
variant="text"
@click="$emit('first')"
>
{{ firstLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiChevronLeft" size="small"
variant="text" @click="$emit('prev')">
v-if="isViewMode || isEditMode"
:disabled="!hasPrevRecord"
:prepend-icon="mdiChevronLeft"
size="small"
variant="text"
@click="$emit('prev')"
>
{{ prevLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" :append-icon="mdiChevronRight" :disabled="!hasNextRecord" size="small"
variant="text" @click="$emit('next')">
v-if="isViewMode || isEditMode"
:append-icon="mdiChevronRight"
:disabled="!hasNextRecord"
size="small"
variant="text"
@click="$emit('next')"
>
{{ nextLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" :append-icon="mdiSkipNext" :disabled="!hasNextRecord" size="small"
variant="text" @click="$emit('last')">
v-if="isViewMode || isEditMode"
:append-icon="mdiSkipNext"
:disabled="!hasNextRecord"
size="small"
variant="text"
@click="$emit('last')"
>
{{ lastLabel }}
</v-btn>
<v-spacer />
<v-btn
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
@click="$emit('switch-to-edit')">
v-if="isViewMode"
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="tonal"
@click="$emit('switch-to-edit')"
>
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
@click="$emit('switch-to-view')">
v-if="isEditMode"
color="primary"
:prepend-icon="mdiEye"
size="small"
variant="tonal"
@click="$emit('switch-to-view')"
>
{{ viewLabel }}
</v-btn>
</template>
</template>
<script setup lang="ts">
import { mdiChevronLeft, mdiChevronRight, mdiEye, mdiPencil, mdiSkipNext, mdiSkipPrevious } from '@mdi/js'
import {
mdiChevronLeft,
mdiChevronRight,
mdiEye,
mdiPencil,
mdiSkipNext,
mdiSkipPrevious,
} from '@mdi/js'
defineProps({
isViewMode: {
type: Boolean,
@@ -202,12 +202,12 @@ const totalCredits = computed(
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
)
function updateSemester (payload: Partial<SemesterRecord>) {
function updateSemester(payload: Partial<SemesterRecord>) {
if (!props.semester) return
emit('update-semester', props.semester.id, payload)
}
function updateCourse (courseIndex: number, payload: Partial<CourseRecord>) {
function updateCourse(courseIndex: number, payload: Partial<CourseRecord>) {
if (!props.semester) return
emit('update-course', props.semester.id, courseIndex, payload)
}
@@ -170,7 +170,16 @@
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { mdiArrowDown, mdiArrowUp, mdiBookOpenOutline, mdiChevronRight, mdiDeleteOutline, mdiPlus, mdiSchool, mdiSwapVertical } from '@mdi/js'
import {
mdiArrowDown,
mdiArrowUp,
mdiBookOpenOutline,
mdiChevronRight,
mdiDeleteOutline,
mdiPlus,
mdiSchool,
mdiSwapVertical,
} from '@mdi/js'
import { ref } from 'vue'
type CourseSortKey = 'name' | 'credits' | 'score'
@@ -209,7 +218,7 @@ const semesterSortStates = ref<Record<number, CourseSortState>>({})
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
function toggleSort (semesterId: number, key: CourseSortKey) {
function toggleSort(semesterId: number, key: CourseSortKey) {
const current = getSortState(semesterId)
semesterSortStates.value[semesterId] =
@@ -224,19 +233,19 @@ function toggleSort (semesterId: number, key: CourseSortKey) {
}
}
function getSortIcon (semesterId: number, key: CourseSortKey) {
function getSortIcon(semesterId: number, key: CourseSortKey) {
const current = getSortState(semesterId)
if (current?.key !== key) return mdiSwapVertical
return current.order === 'asc' ? mdiArrowUp : mdiArrowDown
}
function compareCourseValue (left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
function compareCourseValue(left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
if (key === 'name') return left.name.localeCompare(right.name, 'zh-Hant')
return left[key] - right[key]
}
function getSortedCourses (semester: SemesterRecord): SortedCourseRow[] {
function getSortedCourses(semester: SemesterRecord): SortedCourseRow[] {
const rows = semester.courses.map((course, originalIndex) => ({
course,
originalIndex,
@@ -114,7 +114,9 @@
:model-value="course.credits"
type="number"
variant="outlined"
@update:model-value="(value) => updateCourse(idx, { credits: Number(value) || 0 })"
@update:model-value="
(value) => updateCourse(idx, { credits: Number(value) || 0 })
"
/>
<v-text-field
density="comfortable"
@@ -166,7 +168,7 @@ const totalCredits = computed(
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
)
function updateCourse (courseIndex: number, payload: Partial<CourseRecord>) {
function updateCourse(courseIndex: number, payload: Partial<CourseRecord>) {
if (!props.semester) return
emit('update-course', props.semester.id, courseIndex, payload)
}
@@ -4,18 +4,27 @@
子檔資料示範
<v-spacer />
<v-btn
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" :prepend-icon="mdiPlus" size="small"
variant="tonal" @click="$emit('add-course')">
v-if="!isMobile && !isFormReadonly && !isFormLocked"
color="primary"
:prepend-icon="mdiPlus"
size="small"
variant="tonal"
@click="$emit('add-course')"
>
新增成績
</v-btn>
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card
v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
v-for="semester in semesters"
:key="semester.id"
class="cursor-pointer"
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select-semester', semester.id)">
@click="$emit('select-semester', semester.id)"
>
<v-card-text class="pa-4">
<div class="d-flex align-start justify-space-between ga-3">
<div>
@@ -34,13 +43,18 @@ v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="
</v-card>
</div>
<div v-else class="flex-grow-1" style="min-height: 0;">
<div v-else class="flex-grow-1" style="min-height: 0">
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
<v-card class="flex-shrink-0" variant="flat">
<v-card-text class="pa-2">
<v-data-table
class="border rounded" density="compact" :headers="headers" hide-default-footer
:items="flattenedCourses" :items-per-page="-1">
class="border rounded"
density="compact"
:headers="headers"
hide-default-footer
:items="flattenedCourses"
:items-per-page="-1"
>
<template #[`item.semesterName`]="slotProps">
{{ slotProps.item.semesterName }}
</template>
@@ -50,23 +64,56 @@ class="border rounded" density="compact" :headers="headers" hide-default-footer
<template #[`item.credits`]="slotProps">
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
<v-text-field
v-else :aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`" density="compact" :disabled="isFormLocked" hide-details
hide-spin-buttons :model-value="slotProps.item.credits" :name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-credits`" type="number" variant="outlined" @update:model-value="
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { credits: Number(value) || 0 })
" />
v-else
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`"
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="slotProps.item.credits"
:name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-credits`"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, {
credits: Number(value) || 0,
})
"
/>
</template>
<template #[`item.score`]="slotProps">
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
<v-text-field
v-else :aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`" density="compact" :disabled="isFormLocked" hide-details
hide-spin-buttons :model-value="slotProps.item.score" :name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-score`" type="number" variant="outlined" @update:model-value="
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { score: Number(value) || 0 })
" />
v-else
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`"
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="slotProps.item.score"
:name="`semester-${slotProps.item.semesterId}-course-${slotProps.item.courseIndex}-score`"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, {
score: Number(value) || 0,
})
"
/>
</template>
<template #[`item.actions`]="slotProps">
<v-btn
color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="text"
@click="$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)" />
color="error"
:disabled="isFormLocked"
:icon="mdiDelete"
size="small"
variant="text"
@click="
$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)
"
/>
</template>
</v-data-table>
</v-card-text>
@@ -74,7 +121,10 @@ color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="t
</div>
</div>
<div v-if="semesters.length === 0" class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded">
<div
v-if="semesters.length === 0"
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
>
尚無成績資料
</div>
</template>
@@ -95,7 +145,12 @@ const props = defineProps<{
defineEmits<{
(event: 'select-semester', semesterId: number): void
(event: 'add-course'): void
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
(
event: 'update-course',
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
): void
(event: 'delete-course', semesterId: number, courseIndex: number): void
}>()