feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
<template>
|
||||
<v-col
|
||||
v-if="features.showBreadcrumb && showBreadcrumbBar && !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-btn>
|
||||
</template>
|
||||
<template #item="{ item }">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon v-if="item.icon" class="mr-1" size="14">{{ item.icon }}</v-icon>
|
||||
<span class="text-caption text-no-wrap">{{ item.title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #divider>
|
||||
<v-icon color="primary-variant" size="12">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-breadcrumbs>
|
||||
<div class="page-actions">
|
||||
<slot name="breadcrumb-actions"></slot>
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
features: { type: Object, default: () => ({}) },
|
||||
showBreadcrumbBar: { type: Boolean, default: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
breadcrumbItems: { type: Array, default: () => [] },
|
||||
showFavoritesBar: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggle-favorites-bar'])
|
||||
</script>
|
||||
@@ -0,0 +1,59 @@
|
||||
<template>
|
||||
<v-col
|
||||
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-icon v-if="item.icon" class="me-1" size="16">{{ item.icon }}</v-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-icon class="mr-1" size="16">mdi-plus</v-icon>
|
||||
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
<v-btn color="grey" size="small" variant="text" @click="emit('toggle-favorites-bar', false)">
|
||||
<v-icon>mdi-eye-off</v-icon>
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
features: { type: Object, default: () => ({}) },
|
||||
showFavoritesBar: { type: Boolean, default: true },
|
||||
isMobile: { type: Boolean, default: false },
|
||||
favoritesConfig: { type: Object, default: () => ({}) },
|
||||
favoriteItems: { type: Array, default: () => [] },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select', 'add-favorite', 'remove-favorite', 'toggle-favorites-bar'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-item {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.favorite-list-enter-active,
|
||||
.favorite-list-leave-active,
|
||||
.favorite-list-move {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.favorite-list-enter-from,
|
||||
.favorite-list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.92);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,184 @@
|
||||
<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="mdi-menu" size="small" variant="text" @click="emit('toggle-drawer')"></v-btn>
|
||||
|
||||
<div v-if="features.showSearch" class="search-input-wrapper">
|
||||
<v-text-field
|
||||
v-model="searchValueModel" :aria-label="searchConfig.label" class="search-input" density="compact"
|
||||
hide-details :placeholder="searchConfig.placeholder" variant="outlined"
|
||||
@keyup.enter="triggerSearch">
|
||||
<template v-if="!isMobile" #prepend-inner>
|
||||
<v-icon size="small">mdi-magnify</v-icon>
|
||||
</template>
|
||||
<template #append-inner>
|
||||
<v-btn :aria-label="searchConfig.label" color="primary" size="small" variant="text" @click="triggerSearch">
|
||||
開始搜尋
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-text-field>
|
||||
</div>
|
||||
|
||||
<div v-if="features.showToolbarActions" class="top-actions">
|
||||
<slot name="actions">
|
||||
|
||||
<!-- 通知 -->
|
||||
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props" :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-icon>mdi-bell-outline</v-icon>
|
||||
</v-badge>
|
||||
<v-icon v-else>mdi-bell-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<!-- 訊息 -->
|
||||
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props" :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-icon>mdi-message-text-outline</v-icon>
|
||||
</v-badge>
|
||||
<v-icon v-else>mdi-message-text-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<!-- 說明 -->
|
||||
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
|
||||
<template #activator="{ props }">
|
||||
<v-btn
|
||||
v-bind="props" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
|
||||
@click="emit('action', 'help')">
|
||||
<v-icon>mdi-help</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<!-- 設定 -->
|
||||
<v-menu :close-on-content-click="false" location="bottom end">
|
||||
<template #activator="{ props: menuProps }">
|
||||
<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-icon>mdi-cog-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
<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>
|
||||
<template #label>
|
||||
<span class="text-body-2" style="width: 8ch;">常用功能</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-list-item>
|
||||
<v-list-item>
|
||||
<v-switch v-model="showBreadcrumbBarModel" color="primary" density="comfortable" hide-details>
|
||||
<template #label>
|
||||
<span class="text-body-2" style="width: 8ch;">路徑</span>
|
||||
</template>
|
||||
</v-switch>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
|
||||
<!-- 登出 -->
|
||||
<v-tooltip location="bottom" :text="logoutLabel">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" :aria-label="logoutLabel" icon size="small" variant="text" @click="emit('logout')">
|
||||
<v-icon>mdi-logout</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
|
||||
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
|
||||
<template #activator="{ props }">
|
||||
<v-btn v-bind="props" :aria-label="themeToggleLabel" icon variant="text" @click="emit('toggle-theme')">
|
||||
<v-icon>mdi-palette-outline</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</slot>
|
||||
</div>
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
isMobile: { type: Boolean, default: false },
|
||||
features: { type: Object, default: () => ({}) },
|
||||
searchValue: { type: String, default: '' },
|
||||
searchConfig: { type: Object, default: () => ({}) },
|
||||
toolbarActions: { type: Object, default: () => ({}) },
|
||||
toolbarCounts: { type: Object, default: () => ({}) },
|
||||
logoutLabel: { type: String, default: '' },
|
||||
themeToggleLabel: { type: String, default: '' },
|
||||
showFavoritesBar: { type: Boolean, default: true },
|
||||
showBreadcrumbBar: { type: Boolean, default: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'toggle-drawer',
|
||||
'update:searchValue',
|
||||
'search',
|
||||
'action',
|
||||
'logout',
|
||||
'toggle-theme',
|
||||
'update:showFavoritesBar',
|
||||
'update:showBreadcrumbBar',
|
||||
])
|
||||
|
||||
const searchValueModel = computed({
|
||||
get: () => props.searchValue,
|
||||
set: (value) => emit('update:searchValue', value),
|
||||
})
|
||||
|
||||
const showFavoritesBarModel = computed({
|
||||
get: () => props.showFavoritesBar,
|
||||
set: (value) => emit('update:showFavoritesBar', value),
|
||||
})
|
||||
|
||||
const showBreadcrumbBarModel = computed({
|
||||
get: () => props.showBreadcrumbBar,
|
||||
set: (value) => emit('update:showBreadcrumbBar', value),
|
||||
})
|
||||
|
||||
function triggerSearch () {
|
||||
emit('search')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-input-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
:deep(.search-input-wrapper .v-field--appended) {
|
||||
padding-inline-end: 4px;
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,176 @@
|
||||
<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" class="menu-group" :value="`menu:${item.path ?? item.title}`">
|
||||
<template #activator="{ props }">
|
||||
<v-list-item
|
||||
v-bind="isShrink ? undefined : props" :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">{{ item.icon }}</v-icon>
|
||||
<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">
|
||||
{{ getItemCount(item) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
||||
<v-list-group
|
||||
v-if="subItem.subItems?.length"
|
||||
class="menu-group" :value="`menu:${item.path ?? item.title}::${subItem.path ?? subItem.title}`">
|
||||
<template #activator="{ props: subProps }">
|
||||
<v-list-item
|
||||
v-bind="subProps" :link="isNavigable(subItem)"
|
||||
:prepend-icon="subItem.icon || 'mdi-menu-right'" :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">
|
||||
{{ getItemCount(subItem) }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-list-item
|
||||
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
|
||||
:link="!!subSubItem.path" prepend-icon="mdi-circle-small" :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>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list-group>
|
||||
|
||||
<v-list-item
|
||||
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || 'mdi-menu-right'" :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>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list-group>
|
||||
|
||||
<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">{{ item.icon }}</v-icon>
|
||||
<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>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-list>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
interface MenuItem {
|
||||
title?: string
|
||||
path?: string
|
||||
icon?: string
|
||||
navigable?: boolean
|
||||
subItems?: MenuItem[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface Props {
|
||||
opened?: string[]
|
||||
menuItems?: MenuItem[]
|
||||
isShrink?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
opened: () => [],
|
||||
menuItems: () => [],
|
||||
isShrink: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:opened': [value: string[]]
|
||||
select: [item: MenuItem]
|
||||
unshrink: []
|
||||
}>()
|
||||
|
||||
const openedModel = computed({
|
||||
get: () => (props.isShrink ? [] : props.opened),
|
||||
set: (value) => {
|
||||
if (!props.isShrink) {
|
||||
emit('update:opened', value)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// 當側邊欄收縮時,自動收起所有展開的子選單
|
||||
watch(() => props.isShrink, (newVal) => {
|
||||
if (newVal) {
|
||||
openedModel.value = []
|
||||
}
|
||||
})
|
||||
|
||||
const isNavigable = (item: MenuItem) => item?.navigable !== false
|
||||
|
||||
function emitSelect (item: MenuItem) {
|
||||
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
|
||||
// 這樣可以讓使用者看到完整的選單結構和導航結果
|
||||
if (props.isShrink) {
|
||||
emit('unshrink')
|
||||
}
|
||||
emit('select', item)
|
||||
}
|
||||
|
||||
function getItemCount (item: MenuItem) {
|
||||
if (!item?.subItems?.length) return 0
|
||||
const countLeaf = (list: MenuItem[]): number =>
|
||||
(list || []).reduce((total: number, current: MenuItem) => {
|
||||
if (current?.subItems?.length) return total + countLeaf(current.subItems)
|
||||
return total + 1
|
||||
}, 0)
|
||||
return countLeaf(item.subItems)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.menu-count {
|
||||
min-width: 28px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-text-overflow {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<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-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
favoriteItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-favorites-panel {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<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-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||
<template #append>
|
||||
<v-icon size="18">{{ item.subItems?.length ? 'mdi-chevron-right' : 'mdi-arrow-top-right' }}</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
mobileCurrentItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['item-click'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mobile-menu-panel {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user