Refactor MasterDetailMntC.vue for improved readability and consistency
This commit is contained in:
+117
-41
@@ -1,14 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- 根據路由設定 meta.layout 動態切換佈局 -->
|
<!-- 根據路由設定 meta.layout 動態切換佈局 -->
|
||||||
<component
|
<component
|
||||||
:is="activeLayout" v-bind="layoutProps" v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
|
:is="activeLayout"
|
||||||
|
v-bind="layoutProps"
|
||||||
|
v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
|
||||||
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
|
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
|
||||||
v-model:is-rail="menuStore.isRail" @action="handleLayoutAction" @logout="handleLogout"
|
v-model:is-rail="menuStore.isRail"
|
||||||
@remove-favorite="handleRemoveFavorite" @search="handleSearch" @select="handleSelect">
|
@action="handleLayoutAction"
|
||||||
|
@logout="handleLogout"
|
||||||
|
@remove-favorite="handleRemoveFavorite"
|
||||||
|
@search="handleSearch"
|
||||||
|
@select="handleSelect"
|
||||||
|
>
|
||||||
<template #breadcrumb-actions>
|
<template #breadcrumb-actions>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="secondary" :disabled="isFavoriteActionDisabled" size="small" variant="outlined"
|
color="secondary"
|
||||||
@click="toggleFavorite">
|
:disabled="isFavoriteActionDisabled"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
@click="toggleFavorite"
|
||||||
|
>
|
||||||
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
|
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
|
||||||
{{ favoriteActionLabel }}
|
{{ favoriteActionLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
@@ -21,19 +32,31 @@ color="secondary" :disabled="isFavoriteActionDisabled" size="small" variant="out
|
|||||||
<template v-if="showTabs">
|
<template v-if="showTabs">
|
||||||
<div class="d-flex flex-column h-100">
|
<div class="d-flex flex-column h-100">
|
||||||
<v-tabs
|
<v-tabs
|
||||||
v-model="activeTab" bg-color="background" color="primary" density="compact" show-arrows
|
v-model="activeTab"
|
||||||
style="flex-shrink: 0;">
|
bg-color="background"
|
||||||
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
show-arrows
|
||||||
|
style="flex-shrink: 0"
|
||||||
|
>
|
||||||
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
|
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
|
||||||
{{ tab.title }}
|
{{ tab.title }}
|
||||||
<v-btn
|
<v-btn
|
||||||
class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon size="x-small"
|
class="pl-2"
|
||||||
variant="text" @click.prevent.stop="closeTab(tab.path)">
|
color="grey"
|
||||||
|
density="compact"
|
||||||
|
:disabled="tabs.length <= 1"
|
||||||
|
icon
|
||||||
|
size="x-small"
|
||||||
|
variant="text"
|
||||||
|
@click.prevent.stop="closeTab(tab.path)"
|
||||||
|
>
|
||||||
<v-icon :icon="mdiClose" />
|
<v-icon :icon="mdiClose" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-tab>
|
</v-tab>
|
||||||
</v-tabs>
|
</v-tabs>
|
||||||
|
|
||||||
<div class="flex-grow-1 overflow-auto" style="min-height: 0;">
|
<div class="flex-grow-1 overflow-auto" style="min-height: 0">
|
||||||
<router-view v-slot="{ Component }">
|
<router-view v-slot="{ Component }">
|
||||||
<keep-alive>
|
<keep-alive>
|
||||||
<component :is="Component" :key="route.fullPath" />
|
<component :is="Component" :key="route.fullPath" />
|
||||||
@@ -50,13 +73,26 @@ class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon si
|
|||||||
<v-dialog v-model="searchDialog" max-width="640">
|
<v-dialog v-model="searchDialog" max-width="640">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
|
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
|
||||||
<v-card-subtitle v-if="searchKeyword" class="pt-4">關鍵字:{{ searchKeyword }}</v-card-subtitle>
|
<v-card-subtitle v-if="searchKeyword" class="pt-4"
|
||||||
|
>關鍵字:{{ searchKeyword }}</v-card-subtitle
|
||||||
|
>
|
||||||
<v-card-text class="pt-2">
|
<v-card-text class="pt-2">
|
||||||
<v-alert v-if="searchResults.length === 0" class="mt-2" density="compact" type="info" variant="tonal">
|
<v-alert
|
||||||
|
v-if="searchResults.length === 0"
|
||||||
|
class="mt-2"
|
||||||
|
density="compact"
|
||||||
|
type="info"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
查無結果
|
查無結果
|
||||||
</v-alert>
|
</v-alert>
|
||||||
<v-list v-else density="compact">
|
<v-list v-else density="compact">
|
||||||
<v-list-item v-for="item in searchResults" :key="item.path" class="mb-2" @click="handleSearchSelect(item)">
|
<v-list-item
|
||||||
|
v-for="item in searchResults"
|
||||||
|
:key="item.path"
|
||||||
|
class="mb-2"
|
||||||
|
@click="handleSearchSelect(item)"
|
||||||
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
|
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
|
||||||
</template>
|
</template>
|
||||||
@@ -82,7 +118,9 @@ class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon si
|
|||||||
<v-dialog v-model="messageStore.isOpen" max-width="720">
|
<v-dialog v-model="messageStore.isOpen" max-width="720">
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
|
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
|
||||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">僅示意資料,不含延伸功能</v-card-subtitle>
|
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis"
|
||||||
|
>僅示意資料,不含延伸功能</v-card-subtitle
|
||||||
|
>
|
||||||
<v-card-text class="pa-4">
|
<v-card-text class="pa-4">
|
||||||
<!--
|
<!--
|
||||||
使用 v-data-iterator 進行資料展示,
|
使用 v-data-iterator 進行資料展示,
|
||||||
@@ -91,7 +129,12 @@ class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon si
|
|||||||
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
|
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
|
||||||
<template #default="{ items }">
|
<template #default="{ items }">
|
||||||
<v-list density="compact">
|
<v-list density="compact">
|
||||||
<v-list-item v-for="wrapped in items" :key="resolveMessageItem(wrapped).id" border="sm" class="pa-2 mb-1">
|
<v-list-item
|
||||||
|
v-for="wrapped in items"
|
||||||
|
:key="resolveMessageItem(wrapped).id"
|
||||||
|
border="sm"
|
||||||
|
class="pa-2 mb-1"
|
||||||
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-avatar color="primary" size="28" variant="tonal">
|
<v-avatar color="primary" size="28" variant="tonal">
|
||||||
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
|
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
|
||||||
@@ -115,14 +158,36 @@ class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon si
|
|||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
<v-snackbar
|
<v-snackbar
|
||||||
v-model="snackbar.visible" :color="snackbar.color" :location="snackbar.location"
|
v-model="snackbar.visible"
|
||||||
:timeout="snackbar.timeout" :variant="snackbar.variant">
|
:color="snackbar.color"
|
||||||
|
:location="snackbar.location"
|
||||||
|
:timeout="snackbar.timeout"
|
||||||
|
:variant="snackbar.variant"
|
||||||
|
>
|
||||||
{{ snackbar.message }}
|
{{ snackbar.message }}
|
||||||
</v-snackbar>
|
</v-snackbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { mdiAccountGroup, mdiBellOutline, mdiCalendarOutline, mdiChartBoxOutline, mdiClose, mdiCloseCircle, mdiCog, mdiDomain, mdiFileDocumentOutline, mdiFileTreeOutline, mdiHome, mdiHomeCityOutline, mdiMenu, mdiPlusCircle, mdiSchoolOutline, mdiTableEdit, mdiViewDashboardVariant } from '@mdi/js'
|
import {
|
||||||
|
mdiAccountGroup,
|
||||||
|
mdiBellOutline,
|
||||||
|
mdiCalendarOutline,
|
||||||
|
mdiChartBoxOutline,
|
||||||
|
mdiClose,
|
||||||
|
mdiCloseCircle,
|
||||||
|
mdiCog,
|
||||||
|
mdiDomain,
|
||||||
|
mdiFileDocumentOutline,
|
||||||
|
mdiFileTreeOutline,
|
||||||
|
mdiHome,
|
||||||
|
mdiHomeCityOutline,
|
||||||
|
mdiMenu,
|
||||||
|
mdiPlusCircle,
|
||||||
|
mdiSchoolOutline,
|
||||||
|
mdiTableEdit,
|
||||||
|
mdiViewDashboardVariant,
|
||||||
|
} from '@mdi/js'
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import SKAdminLayout from '@/components/layouts/SKAdminLayout.vue'
|
import SKAdminLayout from '@/components/layouts/SKAdminLayout.vue'
|
||||||
@@ -194,7 +259,7 @@ const activeLayout = computed(() => {
|
|||||||
return layoutMap[route.meta.layout] || SKAdminLayout
|
return layoutMap[route.meta.layout] || SKAdminLayout
|
||||||
})
|
})
|
||||||
|
|
||||||
function buildMergedMenuItems (items) {
|
function buildMergedMenuItems(items) {
|
||||||
const flatPaths = new Set()
|
const flatPaths = new Set()
|
||||||
const collectPaths = (list) => {
|
const collectPaths = (list) => {
|
||||||
for (const item of list || []) {
|
for (const item of list || []) {
|
||||||
@@ -249,7 +314,7 @@ const layoutProps = computed(() => {
|
|||||||
return {}
|
return {}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSelect (item) {
|
function handleSelect(item) {
|
||||||
console.log('Selected:', item)
|
console.log('Selected:', item)
|
||||||
if (item.path) {
|
if (item.path) {
|
||||||
router.push(item.path)
|
router.push(item.path)
|
||||||
@@ -260,7 +325,7 @@ const searchDialog = ref(false)
|
|||||||
const searchKeyword = ref('')
|
const searchKeyword = ref('')
|
||||||
const searchResults = ref([])
|
const searchResults = ref([])
|
||||||
|
|
||||||
function buildSearchResults (items, keyword, parents = []) {
|
function buildSearchResults(items, keyword, parents = []) {
|
||||||
const results = []
|
const results = []
|
||||||
for (const item of items || []) {
|
for (const item of items || []) {
|
||||||
const currentParents = item?.title ? [...parents, item.title] : parents
|
const currentParents = item?.title ? [...parents, item.title] : parents
|
||||||
@@ -283,7 +348,7 @@ function buildSearchResults (items, keyword, parents = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
|
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
|
||||||
function handleSearch (value) {
|
function handleSearch(value) {
|
||||||
const keyword = String(value ?? '').trim()
|
const keyword = String(value ?? '').trim()
|
||||||
searchKeyword.value = keyword
|
searchKeyword.value = keyword
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
@@ -300,7 +365,7 @@ function handleSearch (value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 點擊搜尋結果後導頁(行為等同選單點擊)
|
// 點擊搜尋結果後導頁(行為等同選單點擊)
|
||||||
function handleSearchSelect (item) {
|
function handleSearchSelect(item) {
|
||||||
searchDialog.value = false
|
searchDialog.value = false
|
||||||
handleSelect(item)
|
handleSelect(item)
|
||||||
}
|
}
|
||||||
@@ -314,7 +379,7 @@ const messageItems = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// v-data-iterator 會包裝 items,這裡取回原始資料物件
|
// v-data-iterator 會包裝 items,這裡取回原始資料物件
|
||||||
function resolveMessageItem (wrapped) {
|
function resolveMessageItem(wrapped) {
|
||||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||||
return wrapped.raw
|
return wrapped.raw
|
||||||
}
|
}
|
||||||
@@ -323,14 +388,14 @@ function resolveMessageItem (wrapped) {
|
|||||||
|
|
||||||
// 由 layout 的 action 事件統一進入此處處理
|
// 由 layout 的 action 事件統一進入此處處理
|
||||||
// 目前只處理訊息中心,其他 action 可在此擴充
|
// 目前只處理訊息中心,其他 action 可在此擴充
|
||||||
function handleLayoutAction (type) {
|
function handleLayoutAction(type) {
|
||||||
if (type === 'messages') {
|
if (type === 'messages') {
|
||||||
messageStore.open()
|
messageStore.open()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function performLogout ({ message, color }) {
|
function performLogout({ message, color }) {
|
||||||
authStore.logout()
|
authStore.logout()
|
||||||
tabs.value = []
|
tabs.value = []
|
||||||
activeTab.value = null
|
activeTab.value = null
|
||||||
@@ -338,16 +403,16 @@ function performLogout ({ message, color }) {
|
|||||||
router.replace({ name: 'login' })
|
router.replace({ name: 'login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout () {
|
function handleLogout() {
|
||||||
performLogout({ message: '登出成功', color: 'success' })
|
performLogout({ message: '登出成功', color: 'success' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleForceLogout (event) {
|
function handleForceLogout(event) {
|
||||||
const message = event?.detail?.message || '請重新登入'
|
const message = event?.detail?.message || '請重新登入'
|
||||||
performLogout({ message, color: 'warning' })
|
performLogout({ message, color: 'warning' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleHttpToast (event) {
|
function handleHttpToast(event) {
|
||||||
const detail = event?.detail
|
const detail = event?.detail
|
||||||
const message = detail?.message
|
const message = detail?.message
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -377,7 +442,7 @@ const showTabs = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 遞迴尋找標題
|
// 遞迴尋找標題
|
||||||
function findTitle (path) {
|
function findTitle(path) {
|
||||||
const recursiveFind = (items) => {
|
const recursiveFind = (items) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.path === path) return item.title
|
if (item.path === path) return item.title
|
||||||
@@ -407,7 +472,7 @@ function findTitle (path) {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
function findMenuItem (path) {
|
function findMenuItem(path) {
|
||||||
const recursiveFind = (items) => {
|
const recursiveFind = (items) => {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.path === path) return item
|
if (item.path === path) return item
|
||||||
@@ -426,7 +491,9 @@ const currentFavoriteInfo = computed(() => {
|
|||||||
const path = route.path
|
const path = route.path
|
||||||
const menuItem = findMenuItem(path)
|
const menuItem = findMenuItem(path)
|
||||||
const title =
|
const title =
|
||||||
menuItem?.title || (typeof route.meta?.title === 'string' ? route.meta.title : null) || findTitle(path)
|
menuItem?.title ||
|
||||||
|
(typeof route.meta?.title === 'string' ? route.meta.title : null) ||
|
||||||
|
findTitle(path)
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
path,
|
path,
|
||||||
@@ -435,12 +502,16 @@ const currentFavoriteInfo = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
|
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
|
||||||
const isFavoriteActionDisabled = computed(() => !currentFavoriteInfo.value?.path || route.path === '/')
|
const isFavoriteActionDisabled = computed(
|
||||||
|
() => !currentFavoriteInfo.value?.path || route.path === '/'
|
||||||
|
)
|
||||||
|
|
||||||
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
|
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
|
||||||
const favoriteActionIcon = computed(() => (isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle))
|
const favoriteActionIcon = computed(() =>
|
||||||
|
isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle
|
||||||
|
)
|
||||||
|
|
||||||
function toggleFavoriteItem (item) {
|
function toggleFavoriteItem(item) {
|
||||||
if (!item?.path || item.path === '/') return
|
if (!item?.path || item.path === '/') return
|
||||||
favoritesStore.toggle({
|
favoritesStore.toggle({
|
||||||
title: item.title || findTitle(item.path),
|
title: item.title || findTitle(item.path),
|
||||||
@@ -449,19 +520,19 @@ function toggleFavoriteItem (item) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavorite () {
|
function toggleFavorite() {
|
||||||
toggleFavoriteItem(currentFavoriteInfo.value)
|
toggleFavoriteItem(currentFavoriteInfo.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRemoveFavorite (item) {
|
function handleRemoveFavorite(item) {
|
||||||
toggleFavoriteItem(item)
|
toggleFavoriteItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function goHome () {
|
function goHome() {
|
||||||
router.push('/')
|
router.push('/')
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateBreadcrumbs () {
|
function updateBreadcrumbs() {
|
||||||
const resolvedTitle = findTitle(route.path)
|
const resolvedTitle = findTitle(route.path)
|
||||||
const fallbackTitle =
|
const fallbackTitle =
|
||||||
resolvedTitle && resolvedTitle !== route.path
|
resolvedTitle && resolvedTitle !== route.path
|
||||||
@@ -481,7 +552,12 @@ function updateBreadcrumbs () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => route.path, () => menuStore.menuItems, () => menuStore.favoriteItems, () => favoritesStore.items],
|
[
|
||||||
|
() => route.path,
|
||||||
|
() => menuStore.menuItems,
|
||||||
|
() => menuStore.favoriteItems,
|
||||||
|
() => favoritesStore.items,
|
||||||
|
],
|
||||||
() => updateBreadcrumbs(),
|
() => updateBreadcrumbs(),
|
||||||
{ immediate: true, deep: true }
|
{ immediate: true, deep: true }
|
||||||
)
|
)
|
||||||
@@ -502,7 +578,7 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function closeTab (path) {
|
function closeTab(path) {
|
||||||
if (tabs.value.length <= 1) return
|
if (tabs.value.length <= 1) return
|
||||||
|
|
||||||
const index = tabs.value.findIndex((t) => t.path === path)
|
const index = tabs.value.findIndex((t) => t.path === path)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ The following example assumes a component located at `src/components/MyComponent
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
//
|
//
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -30,6 +30,6 @@ When your template is rendered, the component's import will automatically be inl
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import MyComponent from '@/components/MyComponent.vue'
|
import MyComponent from '@/components/MyComponent.vue'
|
||||||
</script>
|
</script>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -179,7 +179,9 @@ const emit = defineEmits([
|
|||||||
'toggle-expand',
|
'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) => {
|
return options.map((o) => {
|
||||||
if (typeof o === 'string' || typeof o === 'number') {
|
if (typeof o === 'string' || typeof o === 'number') {
|
||||||
return { title: String(o), value: o }
|
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 addSubDraftItem = ref<Record<string, unknown> | null>(null)
|
||||||
const selectedItem = ref<DeptItem | null>(null)
|
const selectedItem = ref<DeptItem | null>(null)
|
||||||
|
|
||||||
function openAddSub (item: DeptItem) {
|
function openAddSub(item: DeptItem) {
|
||||||
addSubParentItem.value = item
|
addSubParentItem.value = item
|
||||||
addSubDraftItem.value = {
|
addSubDraftItem.value = {
|
||||||
name: '',
|
name: '',
|
||||||
@@ -213,12 +215,12 @@ function openAddSub (item: DeptItem) {
|
|||||||
addSubDialogOpen.value = true
|
addSubDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit (item: DeptItem) {
|
function openEdit(item: DeptItem) {
|
||||||
selectedItem.value = item
|
selectedItem.value = item
|
||||||
editDialogOpen.value = true
|
editDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onAddSubSubmit (payload: Record<string, unknown>) {
|
function onAddSubSubmit(payload: Record<string, unknown>) {
|
||||||
if (!addSubParentItem.value) return
|
if (!addSubParentItem.value) return
|
||||||
const newItem: DeptItem = {
|
const newItem: DeptItem = {
|
||||||
id: Date.now(),
|
id: Date.now(),
|
||||||
@@ -231,7 +233,7 @@ function onAddSubSubmit (payload: Record<string, unknown>) {
|
|||||||
emit('add-sub', addSubParentItem.value, newItem)
|
emit('add-sub', addSubParentItem.value, newItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditSubmit (updated: Record<string, unknown>) {
|
function onEditSubmit(updated: Record<string, unknown>) {
|
||||||
emit('edit', updated)
|
emit('edit', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+140
-61
@@ -2,76 +2,129 @@
|
|||||||
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||||
<!-- Side Layouts -->
|
<!-- Side Layouts -->
|
||||||
<v-row
|
<v-row
|
||||||
v-if="props.layout !== 'card'" class="fill-height" :class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
v-if="props.layout !== 'card'"
|
||||||
no-gutters>
|
class="fill-height"
|
||||||
|
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||||
|
no-gutters
|
||||||
|
>
|
||||||
<!-- Illustration Column -->
|
<!-- Illustration Column -->
|
||||||
<v-col
|
<v-col
|
||||||
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1" cols="12" lg="8"
|
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
|
||||||
sm="6">
|
cols="12"
|
||||||
|
lg="8"
|
||||||
|
sm="6"
|
||||||
|
>
|
||||||
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
||||||
<LoginBrand :title="props.branding.title" />
|
<LoginBrand :title="props.branding.title" />
|
||||||
</div>
|
</div>
|
||||||
<v-sheet
|
<v-sheet
|
||||||
class="board-wrapper pa-2 pa-lg-0" color="rgba(var(--v-theme-surface), 0.8)" elevation="0" max-width="680"
|
class="board-wrapper pa-2 pa-lg-0"
|
||||||
rounded="lg" width="100%">
|
color="rgba(var(--v-theme-surface), 0.8)"
|
||||||
|
elevation="0"
|
||||||
|
max-width="680"
|
||||||
|
rounded="lg"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
<LoginAnnouncementBoard
|
<LoginAnnouncementBoard
|
||||||
:all-tab-label="props.announcementBoard.allTabLabel" :date-header="props.announcementBoard.dateHeader"
|
:all-tab-label="props.announcementBoard.allTabLabel"
|
||||||
:empty-text="props.announcementBoard.emptyText" :items="props.announcementBoard.items"
|
:date-header="props.announcementBoard.dateHeader"
|
||||||
|
:empty-text="props.announcementBoard.emptyText"
|
||||||
|
:items="props.announcementBoard.items"
|
||||||
:items-per-page="props.announcementBoard.itemsPerPage"
|
:items-per-page="props.announcementBoard.itemsPerPage"
|
||||||
:pagination-label="props.announcementBoard.paginationLabel" :school-header="props.announcementBoard.schoolHeader"
|
:pagination-label="props.announcementBoard.paginationLabel"
|
||||||
:system-announcements="props.announcementBoard.systemAnnouncements" :tabs="props.announcementBoard.tabs"
|
:school-header="props.announcementBoard.schoolHeader"
|
||||||
:title="props.announcementBoard.title" :title-header="props.announcementBoard.titleHeader"
|
:system-announcements="props.announcementBoard.systemAnnouncements"
|
||||||
@select-announcement="handleSelectAnnouncement" />
|
:tabs="props.announcementBoard.tabs"
|
||||||
|
:title="props.announcementBoard.title"
|
||||||
|
:title-header="props.announcementBoard.titleHeader"
|
||||||
|
@select-announcement="handleSelectAnnouncement"
|
||||||
|
/>
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
</v-col>
|
</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">
|
<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>
|
<template #prepend>
|
||||||
<v-slide-x-transition appear>
|
<v-slide-x-transition appear>
|
||||||
<div class="mobile-banner-icon-wrap d-flex align-center">
|
<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>
|
</div>
|
||||||
</v-slide-x-transition>
|
</v-slide-x-transition>
|
||||||
</template>
|
</template>
|
||||||
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
|
||||||
<v-btn
|
<v-btn
|
||||||
class="text-none" color="primary" size="small" variant="text"
|
class="text-none"
|
||||||
@click="mobileAnnouncementSheetVisible = true">
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="mobileAnnouncementSheetVisible = true"
|
||||||
|
>
|
||||||
{{ props.mobileAnnouncement.viewAllText }}
|
{{ props.mobileAnnouncement.viewAllText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</v-banner>
|
</v-banner>
|
||||||
</div>
|
</div>
|
||||||
<LoginToolBar
|
<LoginToolBar
|
||||||
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
|
v-if="props.toolbar.show"
|
||||||
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
|
:locale="props.toolbar.locale"
|
||||||
|
:locales="props.toolbar.locales"
|
||||||
|
@change-locale="handleChangeLocale"
|
||||||
|
@toggle-layout="handleToggleLayout"
|
||||||
|
/>
|
||||||
<div
|
<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">
|
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
||||||
<LoginBrand :title="props.branding.title" />
|
<LoginBrand :title="props.branding.title" />
|
||||||
</div>
|
</div>
|
||||||
<LoginHeader
|
<LoginHeader
|
||||||
class="d-none d-sm-block" :welcome-description="props.header.welcomeDescription"
|
class="d-none d-sm-block"
|
||||||
:welcome-text="props.header.welcomeText" />
|
:welcome-description="props.header.welcomeDescription"
|
||||||
|
:welcome-text="props.header.welcomeText"
|
||||||
|
/>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
|
:acc-placeholder="props.form.accPlaceholder"
|
||||||
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
|
: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"
|
:passw-placeholder="props.form.passwPlaceholder"
|
||||||
:remember-me-label="props.form.rememberMeLabel"
|
:remember-me-label="props.form.rememberMeLabel"
|
||||||
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
|
:remember-storage-key="props.form.rememberStorageKey"
|
||||||
@forgot-password="handleForgotPassword" @submit="handleLogin">
|
:submit-text="props.form.submitText"
|
||||||
|
@forgot-password="handleForgotPassword"
|
||||||
|
@submit="handleLogin"
|
||||||
|
>
|
||||||
<template v-if="props.form.withCaptcha" #verify>
|
<template v-if="props.form.withCaptcha" #verify>
|
||||||
<LoginVerify
|
<LoginVerify
|
||||||
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
|
:captcha="props.form.captcha"
|
||||||
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
|
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||||
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
|
:error-message="props.form.captchaErrorMessage"
|
||||||
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
|
:loading="props.form.captchaLoading"
|
||||||
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
|
: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>
|
</template>
|
||||||
</LoginForm>
|
</LoginForm>
|
||||||
<div class="mt-auto py-8 text-center text-caption text-grey-darken-1">
|
<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>
|
</v-row>
|
||||||
|
|
||||||
<!-- Card Layout (Centered) -->
|
<!-- 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
|
<v-card
|
||||||
class="rounded-lg" :class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'" elevation="10" max-width="450"
|
class="rounded-lg"
|
||||||
width="100%">
|
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
||||||
|
elevation="10"
|
||||||
|
max-width="450"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
<LoginToolBar
|
<LoginToolBar
|
||||||
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
|
v-if="props.toolbar.show"
|
||||||
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
|
: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">
|
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||||
<LoginBrand :title="props.branding.title" />
|
<LoginBrand :title="props.branding.title" />
|
||||||
</div>
|
</div>
|
||||||
<LoginHeader
|
<LoginHeader
|
||||||
class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
|
class="d-none d-md-block"
|
||||||
:welcome-text="props.header.welcomeText" />
|
:welcome-description="props.header.welcomeDescription"
|
||||||
|
:welcome-text="props.header.welcomeText"
|
||||||
|
/>
|
||||||
<LoginForm
|
<LoginForm
|
||||||
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
|
:acc-placeholder="props.form.accPlaceholder"
|
||||||
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
|
: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"
|
:passw-placeholder="props.form.passwPlaceholder"
|
||||||
:remember-me-label="props.form.rememberMeLabel"
|
:remember-me-label="props.form.rememberMeLabel"
|
||||||
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
|
:remember-storage-key="props.form.rememberStorageKey"
|
||||||
@forgot-password="handleForgotPassword" @submit="handleLogin">
|
:submit-text="props.form.submitText"
|
||||||
|
@forgot-password="handleForgotPassword"
|
||||||
|
@submit="handleLogin"
|
||||||
|
>
|
||||||
<template v-if="props.form.withCaptcha" #verify>
|
<template v-if="props.form.withCaptcha" #verify>
|
||||||
<LoginVerify
|
<LoginVerify
|
||||||
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
|
:captcha="props.form.captcha"
|
||||||
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
|
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||||
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
|
:error-message="props.form.captchaErrorMessage"
|
||||||
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
|
:loading="props.form.captchaLoading"
|
||||||
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
|
: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>
|
</template>
|
||||||
</LoginForm>
|
</LoginForm>
|
||||||
<div class="mt-8 text-center text-caption text-grey-darken-1">
|
<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 v-for="item in mobileAnnouncementItems" :key="item.id">
|
||||||
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
||||||
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
<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-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
||||||
@@ -363,34 +442,33 @@ const layoutClass = computed(() => {
|
|||||||
return `layout-${props.layout}`
|
return `layout-${props.layout}`
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLogin (formData: Record<string, unknown>) {
|
function handleLogin(formData: Record<string, unknown>) {
|
||||||
emit('submit', formData)
|
emit('submit', formData)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCaptchaRefresh () {
|
function handleCaptchaRefresh() {
|
||||||
emit('captcha-refresh')
|
emit('captcha-refresh')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCaptchaChange (value: string) {
|
function handleCaptchaChange(value: string) {
|
||||||
emit('captcha-change', value)
|
emit('captcha-change', value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChangeLocale (nextLocale: string) {
|
function handleChangeLocale(nextLocale: string) {
|
||||||
emit('change-locale', nextLocale)
|
emit('change-locale', nextLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleLayout () {
|
function handleToggleLayout() {
|
||||||
emit('toggle-layout')
|
emit('toggle-layout')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleForgotPassword (e: MouseEvent) {
|
function handleForgotPassword(e: MouseEvent) {
|
||||||
emit('forgot-password', e)
|
emit('forgot-password', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
|
||||||
emit('select-announcement', item)
|
emit('select-announcement', item)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -409,7 +487,6 @@ function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes mobile-banner-breathe {
|
@keyframes mobile-banner-breathe {
|
||||||
|
|
||||||
0%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
@@ -429,9 +506,11 @@ function handleSelectAnnouncement (item: AnnouncementItemConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.illustration-panel {
|
.illustration-panel {
|
||||||
background: linear-gradient(135deg,
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
rgb(var(--v-theme-background)) 0%,
|
rgb(var(--v-theme-background)) 0%,
|
||||||
rgb(var(--v-theme-surface)) 100%);
|
rgb(var(--v-theme-surface)) 100%
|
||||||
|
);
|
||||||
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ const emit = defineEmits(['edit', 'refresh', 'settings', 'toggle-expand'])
|
|||||||
const editDialogOpen = ref(false)
|
const editDialogOpen = ref(false)
|
||||||
const selectedItem = ref<MenuItem | null>(null)
|
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) => {
|
return options.map((o) => {
|
||||||
if (typeof o === 'string' || typeof o === 'number') {
|
if (typeof o === 'string' || typeof o === 'number') {
|
||||||
return { title: String(o), value: o }
|
return { title: String(o), value: o }
|
||||||
@@ -161,12 +163,12 @@ const resolvedStatusEnabledValue = computed(() => {
|
|||||||
|
|
||||||
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
|
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
|
||||||
|
|
||||||
function openEdit (item: MenuItem) {
|
function openEdit(item: MenuItem) {
|
||||||
selectedItem.value = item
|
selectedItem.value = item
|
||||||
editDialogOpen.value = true
|
editDialogOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEditSubmit (updated: Record<string, unknown>) {
|
function onEditSubmit(updated: Record<string, unknown>) {
|
||||||
emit('edit', updated)
|
emit('edit', updated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +189,7 @@ const formattedHeaders = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function getPermissionColor (permission: string) {
|
function getPermissionColor(permission: string) {
|
||||||
switch (permission) {
|
switch (permission) {
|
||||||
case '管理員': {
|
case '管理員': {
|
||||||
return 'primary'
|
return 'primary'
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ const dialogModel = computed({
|
|||||||
set: (v: boolean) => emit('update:modelValue', v),
|
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) => {
|
return options.map((o) => {
|
||||||
if (typeof o === 'string' || typeof o === 'number') {
|
if (typeof o === 'string' || typeof o === 'number') {
|
||||||
return { title: String(o), value: o }
|
return { title: String(o), value: o }
|
||||||
@@ -121,7 +121,7 @@ const normalizedPermissionOptions = computed(() => normalizeOptions(props.permis
|
|||||||
|
|
||||||
const form = reactive<GenericRecord>({})
|
const form = reactive<GenericRecord>({})
|
||||||
|
|
||||||
function resetForm (next: GenericRecord) {
|
function resetForm(next: GenericRecord) {
|
||||||
for (const key of Object.keys(form)) {
|
for (const key of Object.keys(form)) {
|
||||||
delete form[key]
|
delete form[key]
|
||||||
}
|
}
|
||||||
@@ -129,7 +129,7 @@ function resetForm (next: GenericRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
|
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
|
||||||
function getDefaultPermission (): OptionValue | '' {
|
function getDefaultPermission(): OptionValue | '' {
|
||||||
return normalizedPermissionOptions.value[0]?.value ?? ''
|
return normalizedPermissionOptions.value[0]?.value ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ const formPermission = computed<OptionValue | ''>({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncFromItem () {
|
function syncFromItem() {
|
||||||
const item = props.item ?? {}
|
const item = props.item ?? {}
|
||||||
resetForm({ ...item })
|
resetForm({ ...item })
|
||||||
|
|
||||||
@@ -182,12 +182,12 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleCancel () {
|
function handleCancel() {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
dialogModel.value = false
|
dialogModel.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit () {
|
function handleSubmit() {
|
||||||
emit('submit', { ...form })
|
emit('submit', { ...form })
|
||||||
|
|
||||||
if (props.closeOnSubmit) {
|
if (props.closeOnSubmit) {
|
||||||
|
|||||||
@@ -30,7 +30,13 @@
|
|||||||
|
|
||||||
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
|
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
|
||||||
<template #activator="{ props: activatorProps }">
|
<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-icon :icon="mdiRefresh" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -146,7 +152,7 @@ const selectAllIndeterminate = computed(() => {
|
|||||||
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
|
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
|
||||||
})
|
})
|
||||||
|
|
||||||
function toggleSelectAll (checked: unknown) {
|
function toggleSelectAll(checked: unknown) {
|
||||||
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
|
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
|
||||||
const nonSettingsKeys = current.filter((k) => !allSettingsKeys.value.includes(k))
|
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 : [])
|
emit('update:settingsSelectedKeys', Array.isArray(value) ? value : [])
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggleExpand (id: string | number) {
|
function toggleExpand(id: string | number) {
|
||||||
if (expandedIds.value.has(id)) {
|
if (expandedIds.value.has(id)) {
|
||||||
expandedIds.value.delete(id)
|
expandedIds.value.delete(id)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['click'])
|
const emit = defineEmits(['click'])
|
||||||
|
|
||||||
function handleClick (e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
emit('click', e)
|
emit('click', e)
|
||||||
if (!props.href) {
|
if (!props.href) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-card class="w-100 h-100 d-flex flex-column bg-transparent pa-2 pa-lg-4" elevation="3">
|
<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-tabs v-model="activeTab" class="mb-3" color="primary" density="comfortable">
|
||||||
<v-tab v-for="tab in normalizedTabs" :key="tab.value" :value="tab.value">
|
<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 class="text-no-wrap">{{ item.school }}</td>
|
||||||
<td>
|
<td>
|
||||||
<v-btn
|
<v-btn
|
||||||
class="px-0 text-none justify-start" color="primary" variant="text"
|
class="px-0 text-none justify-start"
|
||||||
@click="emit('select-announcement', item)">
|
color="primary"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('select-announcement', item)"
|
||||||
|
>
|
||||||
{{ item.title }}
|
{{ item.title }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</td>
|
</td>
|
||||||
@@ -41,17 +46,18 @@ class="px-0 text-none justify-start" color="primary" variant="text"
|
|||||||
{{ item.content }}
|
{{ item.content }}
|
||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
<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-subtitle>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<v-list-item v-if="systemPageItems.length === 0" class="h-100">
|
<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-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="d-flex justify-space-between align-center mt-auto pt-3">
|
<div class="d-flex justify-space-between align-center mt-auto pt-3">
|
||||||
<span class="text-caption text-medium-emphasis">
|
<span class="text-caption text-medium-emphasis">
|
||||||
{{ paginationLabel }} {{ totalItems }}
|
{{ paginationLabel }} {{ totalItems }}
|
||||||
@@ -125,7 +131,8 @@ const systemTab = computed<AnnouncementTab>(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const normalizedTabs = 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
|
if (baseTabs.some((tab) => tab.value === systemTabValue)) return baseTabs
|
||||||
return [...baseTabs, systemTab.value]
|
return [...baseTabs, systemTab.value]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,29 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
|
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="username" bg-color="surface" class="mb-6 mb-md-4" color="primary"
|
v-model="username"
|
||||||
density="comfortable" hide-details :placeholder="props.accPlaceholder" variant="outlined"></v-text-field>
|
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-text-field
|
||||||
v-model="password" :append-inner-icon="showPassword ? mdiEyeOff : mdiEye" bg-color="surface"
|
v-model="password"
|
||||||
class="mb-6 mb-md-4" color="primary" density="comfortable" hide-details :placeholder="props.passwPlaceholder" :type="showPassword ? 'text' : '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"
|
variant="outlined"
|
||||||
@click:append-inner="showPassword = !showPassword"></v-text-field>
|
@click:append-inner="showPassword = !showPassword"
|
||||||
|
></v-text-field>
|
||||||
|
|
||||||
<slot name="verify"></slot>
|
<slot name="verify"></slot>
|
||||||
|
|
||||||
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
|
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-model="rememberMe" color="primary" density="compact" hide-details
|
v-model="rememberMe"
|
||||||
:label="props.rememberMeLabel"></v-checkbox>
|
color="primary"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:label="props.rememberMeLabel"
|
||||||
|
></v-checkbox>
|
||||||
<a
|
<a
|
||||||
class="text-body-2 text-primary text-decoration-none" :href="props.forgotPasswordHref || '#'"
|
class="text-body-2 text-primary text-decoration-none"
|
||||||
:target="props.forgotPasswordTarget" @click="handleForgotPasswordClick">
|
:href="props.forgotPasswordHref || '#'"
|
||||||
|
:target="props.forgotPasswordTarget"
|
||||||
|
@click="handleForgotPasswordClick"
|
||||||
|
>
|
||||||
{{ props.forgotPasswordText }}
|
{{ props.forgotPasswordText }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</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 }}
|
{{ props.submitText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-form>
|
</v-form>
|
||||||
@@ -96,7 +126,7 @@ watch([rememberMe, username], ([nextRemember, nextUsername]) => {
|
|||||||
localStorage.setItem(props.rememberStorageKey, nextUsername)
|
localStorage.setItem(props.rememberStorageKey, nextUsername)
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleForgotPasswordClick (e: MouseEvent) {
|
function handleForgotPasswordClick(e: MouseEvent) {
|
||||||
emit('forgot-password', e)
|
emit('forgot-password', e)
|
||||||
if (!props.forgotPasswordHref) {
|
if (!props.forgotPasswordHref) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -6,7 +6,8 @@
|
|||||||
:icon="mdiPaletteOutline"
|
:icon="mdiPaletteOutline"
|
||||||
size="small"
|
size="small"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="toggleTheme"></v-btn>
|
@click="toggleTheme"
|
||||||
|
></v-btn>
|
||||||
<v-menu location="bottom end">
|
<v-menu location="bottom end">
|
||||||
<template #activator="{ props: menuActivatorProps }">
|
<template #activator="{ props: menuActivatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -60,7 +61,7 @@ const availableThemeNames = computed(() =>
|
|||||||
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggleTheme () {
|
function toggleTheme() {
|
||||||
const names = availableThemeNames.value
|
const names = availableThemeNames.value
|
||||||
if (names.length === 0) return
|
if (names.length === 0) return
|
||||||
|
|
||||||
@@ -74,9 +75,8 @@ const localeOptions = computed(() =>
|
|||||||
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
|
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
|
||||||
)
|
)
|
||||||
|
|
||||||
function handleSelectLocale (locale: string) {
|
function handleSelectLocale(locale: string) {
|
||||||
if (locale === props.locale) return
|
if (locale === props.locale) return
|
||||||
emit('change-locale', locale)
|
emit('change-locale', locale)
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -7,17 +7,28 @@
|
|||||||
<div v-else class="d-flex align-center gap-2">
|
<div v-else class="d-flex align-center gap-2">
|
||||||
<!-- Captcha Image and Refresh -->
|
<!-- Captcha Image and Refresh -->
|
||||||
<div
|
<div
|
||||||
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2" :title="props.refreshTitle"
|
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2"
|
||||||
@click="handleRefresh">
|
:title="props.refreshTitle"
|
||||||
|
@click="handleRefresh"
|
||||||
|
>
|
||||||
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
|
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
|
||||||
<v-icon class="ms-2" color="grey" :icon="mdiRefresh"></v-icon>
|
<v-icon class="ms-2" color="grey" :icon="mdiRefresh"></v-icon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input and Verify -->
|
<!-- Input and Verify -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="inputCode" :append-inner-icon="props.verified ? mdiCheckCircle : undefined" bg-color="surface" class="flex-grow-1"
|
v-model="inputCode"
|
||||||
color="primary" density="compact" :disabled="props.verified" :error="!!errorMsg" hide-details
|
:append-inner-icon="props.verified ? mdiCheckCircle : undefined"
|
||||||
:placeholder="props.captchaPlaceholder" variant="outlined">
|
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>
|
<template v-if="props.verified" #append-inner>
|
||||||
<v-icon color="success" :icon="mdiCheckCircle" />
|
<v-icon color="success" :icon="mdiCheckCircle" />
|
||||||
</template>
|
</template>
|
||||||
@@ -78,7 +89,7 @@ const errorMsg = computed(() => props.errorMessage)
|
|||||||
|
|
||||||
const loading = computed(() => props.loading)
|
const loading = computed(() => props.loading)
|
||||||
|
|
||||||
function handleRefresh () {
|
function handleRefresh() {
|
||||||
if (props.verified) return
|
if (props.verified) return
|
||||||
emit('refresh')
|
emit('refresh')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,16 @@
|
|||||||
import type { AdminLayoutMenuItem } from './sk-admin-layout/types'
|
import type { AdminLayoutMenuItem } from './sk-admin-layout/types'
|
||||||
import SKAdminLayout from './SKAdminLayout.vue'
|
import SKAdminLayout from './SKAdminLayout.vue'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
systemTitle?: string
|
systemTitle?: string
|
||||||
menuItems?: AdminLayoutMenuItem[]
|
menuItems?: AdminLayoutMenuItem[]
|
||||||
}>(), {
|
}>(),
|
||||||
|
{
|
||||||
systemTitle: '簡潔模式',
|
systemTitle: '簡潔模式',
|
||||||
menuItems: () => [],
|
menuItems: () => [],
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
select: [item: AdminLayoutMenuItem]
|
select: [item: AdminLayoutMenuItem]
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-col
|
<v-col
|
||||||
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
|
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
|
||||||
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface">
|
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">
|
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary" size="small"
|
v-if="features.showFavorites && !showFavoritesBar"
|
||||||
variant="outlined" @click="emit('toggle-favorites-bar', true)">
|
class="mr-2"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
@click="emit('toggle-favorites-bar', true)"
|
||||||
|
>
|
||||||
常用
|
常用
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<template #item="{ item }">
|
<template #item="{ item }">
|
||||||
<div class="d-flex align-center ga-1">
|
<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>
|
<span class="text-caption text-no-wrap">{{ getBreadcrumbTitle(item) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -57,7 +68,7 @@ const emit = defineEmits<{
|
|||||||
'toggle-favorites-bar': [value: boolean]
|
'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 (typeof item !== 'object' || item === null) return null
|
||||||
|
|
||||||
if ('title' in item) {
|
if ('title' in item) {
|
||||||
@@ -70,11 +81,11 @@ function getBreadcrumbItem (item: unknown): AdminLayoutBreadcrumbItem | null {
|
|||||||
return raw as AdminLayoutBreadcrumbItem
|
return raw as AdminLayoutBreadcrumbItem
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBreadcrumbIcon (item: unknown) {
|
function getBreadcrumbIcon(item: unknown) {
|
||||||
return getBreadcrumbItem(item)?.icon
|
return getBreadcrumbItem(item)?.icon
|
||||||
}
|
}
|
||||||
|
|
||||||
function getBreadcrumbTitle (item: unknown) {
|
function getBreadcrumbTitle(item: unknown) {
|
||||||
return getBreadcrumbItem(item)?.title ?? ''
|
return getBreadcrumbItem(item)?.title ?? ''
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,23 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-col
|
<v-col
|
||||||
v-if="features.showFavorites && showFavoritesBar && !isMobile"
|
v-if="features.showFavorites && showFavoritesBar && !isMobile"
|
||||||
class="d-flex align-center pr-2 pl-3 py-1 bg-surface">
|
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">
|
<div class="favorites-label text-body-2 text-no-wrap pe-2">
|
||||||
{{ favoritesConfig.label }}
|
{{ favoritesConfig.label }}
|
||||||
</div>
|
</div>
|
||||||
<div class="favorites-list flex-grow-1 d-flex flex-wrap ga-2">
|
<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">
|
<transition-group class="d-flex flex-wrap ga-2" name="favorite-list" tag="div">
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="item in favoriteItems" :key="item.path ?? item.title" class="favorite-item" closable
|
v-for="item in favoriteItems"
|
||||||
color="secondary" size="small" variant="outlined" @click="emit('select', item)"
|
:key="item.path ?? item.title"
|
||||||
@click:close="emit('remove-favorite', item)">
|
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" />
|
<v-icon v-if="item.icon" class="me-1" size="16" :icon="item.icon" />
|
||||||
<span class="text-caption">{{ item.title }}</span>
|
<span class="text-caption">{{ item.title }}</span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</transition-group>
|
</transition-group>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" variant="outlined"
|
v-if="favoritesConfig.showAdd"
|
||||||
@click="emit('add-favorite')">
|
class="favorite-add"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
@click="emit('add-favorite')"
|
||||||
|
>
|
||||||
<v-icon class="mr-1" size="16" :icon="mdiPlus" />
|
<v-icon class="mr-1" size="16" :icon="mdiPlus" />
|
||||||
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
|
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|||||||
@@ -1,19 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
|
<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">
|
<div v-if="features.showSearch" class="search-input-wrapper">
|
||||||
<span id="admin-layout-search-label" class="sr-only">{{ searchConfig.label }}</span>
|
<span id="admin-layout-search-label" class="sr-only">{{ searchConfig.label }}</span>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="searchValueModel" aria-labelledby="admin-layout-search-label" :aria-label="searchConfig.label"
|
v-model="searchValueModel"
|
||||||
class="search-input" density="compact" hide-details name="layout-search"
|
aria-labelledby="admin-layout-search-label"
|
||||||
:placeholder="searchConfig.placeholder" variant="outlined"
|
:aria-label="searchConfig.label"
|
||||||
@keyup.enter="triggerSearch">
|
class="search-input"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="layout-search"
|
||||||
|
:placeholder="searchConfig.placeholder"
|
||||||
|
variant="outlined"
|
||||||
|
@keyup.enter="triggerSearch"
|
||||||
|
>
|
||||||
<template v-if="!isMobile" #prepend-inner>
|
<template v-if="!isMobile" #prepend-inner>
|
||||||
<v-icon size="small" :icon="mdiMagnify" />
|
<v-icon size="small" :icon="mdiMagnify" />
|
||||||
</template>
|
</template>
|
||||||
<template #append-inner>
|
<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>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -22,16 +41,24 @@ v-model="searchValueModel" aria-labelledby="admin-layout-search-label" :aria-lab
|
|||||||
|
|
||||||
<div v-if="features.showToolbarActions" class="top-actions">
|
<div v-if="features.showToolbarActions" class="top-actions">
|
||||||
<slot name="actions">
|
<slot name="actions">
|
||||||
|
|
||||||
<!-- 通知 -->
|
<!-- 通知 -->
|
||||||
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
|
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="activatorProps" :aria-label="toolbarActions.notificationsLabel" icon size="small" variant="text"
|
v-bind="activatorProps"
|
||||||
@click="emit('action', 'notifications')">
|
:aria-label="toolbarActions.notificationsLabel"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('action', 'notifications')"
|
||||||
|
>
|
||||||
<v-badge
|
<v-badge
|
||||||
v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notifications"
|
v-if="toolbarCounts.notifications"
|
||||||
offset-x="4" offset-y="-2">
|
color="error"
|
||||||
|
:content="toolbarCounts.notifications"
|
||||||
|
offset-x="4"
|
||||||
|
offset-y="-2"
|
||||||
|
>
|
||||||
<v-icon :icon="mdiBellOutline" />
|
<v-icon :icon="mdiBellOutline" />
|
||||||
</v-badge>
|
</v-badge>
|
||||||
<v-icon v-else :icon="mdiBellOutline" />
|
<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">
|
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="activatorProps" :aria-label="toolbarActions.messagesLabel" icon size="small" variant="text"
|
v-bind="activatorProps"
|
||||||
@click="emit('action', 'messages')">
|
:aria-label="toolbarActions.messagesLabel"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('action', 'messages')"
|
||||||
|
>
|
||||||
<v-badge
|
<v-badge
|
||||||
v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages" offset-x="4"
|
v-if="toolbarCounts.messages"
|
||||||
offset-y="-2">
|
color="warning"
|
||||||
|
:content="toolbarCounts.messages"
|
||||||
|
offset-x="4"
|
||||||
|
offset-y="-2"
|
||||||
|
>
|
||||||
<v-icon :icon="mdiMessageTextOutline" />
|
<v-icon :icon="mdiMessageTextOutline" />
|
||||||
</v-badge>
|
</v-badge>
|
||||||
<v-icon v-else :icon="mdiMessageTextOutline" />
|
<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">
|
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
|
||||||
<template #activator="{ props: activatorProps }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="activatorProps" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
|
v-bind="activatorProps"
|
||||||
@click="emit('action', 'help')">
|
:aria-label="toolbarActions.helpLabel"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('action', 'help')"
|
||||||
|
>
|
||||||
<v-icon :icon="mdiHelp" />
|
<v-icon :icon="mdiHelp" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -72,8 +113,12 @@ v-bind="activatorProps" :aria-label="toolbarActions.helpLabel" icon size="small"
|
|||||||
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
|
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
|
||||||
<template #activator="{ props: tooltipProps }">
|
<template #activator="{ props: tooltipProps }">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsLabel" icon size="small"
|
v-bind="{ ...menuProps, ...tooltipProps }"
|
||||||
variant="text">
|
:aria-label="toolbarActions.settingsLabel"
|
||||||
|
icon
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
<v-icon :icon="mdiCogOutline" />
|
<v-icon :icon="mdiCogOutline" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -82,16 +127,26 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
|||||||
<v-list density="compact" width="180">
|
<v-list density="compact" width="180">
|
||||||
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
|
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
|
||||||
<v-list-item>
|
<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>
|
<template #label>
|
||||||
<span class="text-body-2" style="width: 8ch;">常用功能</span>
|
<span class="text-body-2" style="width: 8ch">常用功能</span>
|
||||||
</template>
|
</template>
|
||||||
</v-switch>
|
</v-switch>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
<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>
|
<template #label>
|
||||||
<span class="text-body-2" style="width: 8ch;">路徑</span>
|
<span class="text-body-2" style="width: 8ch">路徑</span>
|
||||||
</template>
|
</template>
|
||||||
</v-switch>
|
</v-switch>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
@@ -101,7 +156,14 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
|||||||
<!-- 登出 -->
|
<!-- 登出 -->
|
||||||
<v-tooltip location="bottom" :text="logoutLabel">
|
<v-tooltip location="bottom" :text="logoutLabel">
|
||||||
<template #activator="{ props: activatorProps }">
|
<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-icon :icon="mdiLogout" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -109,7 +171,13 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
|||||||
|
|
||||||
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
|
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
|
||||||
<template #activator="{ props: activatorProps }">
|
<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-icon :icon="mdiPaletteOutline" />
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -120,8 +188,23 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminLayoutActionType, AdminLayoutFeatures, AdminLayoutSearchConfig, AdminLayoutToolbarActions, AdminLayoutToolbarCounts } from './types'
|
import type {
|
||||||
import { mdiBellOutline, mdiCogOutline, mdiHelp, mdiLogout, mdiMagnify, mdiMenu, mdiMessageTextOutline, mdiPaletteOutline } from '@mdi/js'
|
AdminLayoutActionType,
|
||||||
|
AdminLayoutFeatures,
|
||||||
|
AdminLayoutSearchConfig,
|
||||||
|
AdminLayoutToolbarActions,
|
||||||
|
AdminLayoutToolbarCounts,
|
||||||
|
} from './types'
|
||||||
|
import {
|
||||||
|
mdiBellOutline,
|
||||||
|
mdiCogOutline,
|
||||||
|
mdiHelp,
|
||||||
|
mdiLogout,
|
||||||
|
mdiMagnify,
|
||||||
|
mdiMenu,
|
||||||
|
mdiMessageTextOutline,
|
||||||
|
mdiPaletteOutline,
|
||||||
|
} from '@mdi/js'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -194,7 +277,7 @@ const breadcrumbBarVisibleModel = computed({
|
|||||||
set: (value) => emit('update:breadcrumb-bar-visible', value),
|
set: (value) => emit('update:breadcrumb-bar-visible', value),
|
||||||
})
|
})
|
||||||
|
|
||||||
function triggerSearch () {
|
function triggerSearch() {
|
||||||
emit('search')
|
emit('search')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
|
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
|
||||||
<template v-for="item in menuItems" :key="item.path ?? item.title">
|
<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 }">
|
<template #activator="{ props: activatorProps }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-bind="isShrink ? undefined : activatorProps" :class="{ 'px-0': isShrink }"
|
v-bind="isShrink ? undefined : activatorProps"
|
||||||
:link="isNavigable(item) && !!item.path" :to="isNavigable(item) ? item.path : undefined" @click="emitSelect(item)">
|
:class="{ 'px-0': isShrink }"
|
||||||
|
:link="isNavigable(item) && !!item.path"
|
||||||
|
:to="isNavigable(item) ? item.path : undefined"
|
||||||
|
@click="emitSelect(item)"
|
||||||
|
>
|
||||||
<template #prepend>
|
<template #prepend>
|
||||||
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
|
<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">{{
|
<v-btn
|
||||||
item.title?.charAt(0) }}</v-btn>
|
v-if="isShrink && !item.icon"
|
||||||
|
class=""
|
||||||
|
rounded
|
||||||
|
size="36"
|
||||||
|
spaced="start"
|
||||||
|
variant="text"
|
||||||
|
>{{ item.title?.charAt(0) }}</v-btn
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary" size="x-small"
|
v-if="!isShrink && getItemCount(item) > 0"
|
||||||
variant="tonal">
|
class="menu-count"
|
||||||
|
color="secondary"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
{{ getItemCount(item) }}
|
{{ getItemCount(item) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</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">
|
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
|
||||||
<v-list-group
|
<v-list-group
|
||||||
v-if="subItem.subItems?.length"
|
v-if="subItem.subItems?.length"
|
||||||
:id="getGroupId(subItem, getGroupId(item))"
|
:id="getGroupId(subItem, getGroupId(item))"
|
||||||
:value="getGroupValue(subItem, getGroupValue(item))">
|
:value="getGroupValue(subItem, getGroupValue(item))"
|
||||||
|
>
|
||||||
<template #activator="{ props: subProps }">
|
<template #activator="{ props: subProps }">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-bind="subProps" :link="isNavigable(subItem)"
|
v-bind="subProps"
|
||||||
:prepend-icon="subItem.icon || mdiMenuRight" :to="isNavigable(subItem) ? subItem.path : undefined"
|
:link="isNavigable(subItem)"
|
||||||
@click="emitSelect(subItem)">
|
:prepend-icon="subItem.icon || mdiMenuRight"
|
||||||
|
:to="isNavigable(subItem) ? subItem.path : undefined"
|
||||||
|
@click="emitSelect(subItem)"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-chip
|
<v-chip
|
||||||
v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-small"
|
v-if="getItemCount(subItem) > 0"
|
||||||
variant="tonal">
|
class="menu-count"
|
||||||
|
color="secondary"
|
||||||
|
size="x-small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
{{ getItemCount(subItem) }}
|
{{ getItemCount(subItem) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
</template>
|
</template>
|
||||||
@@ -48,13 +75,19 @@ v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-sm
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
|
v-for="subSubItem in subItem.subItems"
|
||||||
:link="!!subSubItem.path" :prepend-icon="mdiCircleSmall" :to="subSubItem.path"
|
:key="subSubItem.path ?? subSubItem.title"
|
||||||
@click="emitSelect(subSubItem)">
|
:link="!!subSubItem.path"
|
||||||
|
:prepend-icon="mdiCircleSmall"
|
||||||
|
:to="subSubItem.path"
|
||||||
|
@click="emitSelect(subSubItem)"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<v-tooltip location="end" :text="subSubItem.title">
|
<v-tooltip location="end" :text="subSubItem.title">
|
||||||
<template #activator="{ props: tooltipProps }">
|
<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>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</template>
|
</template>
|
||||||
@@ -62,12 +95,18 @@ v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title
|
|||||||
</v-list-group>
|
</v-list-group>
|
||||||
|
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="subItem.path"
|
v-else
|
||||||
@click="emitSelect(subItem)">
|
:link="!!subItem.path"
|
||||||
|
:prepend-icon="subItem.icon || mdiMenuRight"
|
||||||
|
:to="subItem.path"
|
||||||
|
@click="emitSelect(subItem)"
|
||||||
|
>
|
||||||
<template #title>
|
<template #title>
|
||||||
<v-tooltip location="end" :text="subItem.title">
|
<v-tooltip location="end" :text="subItem.title">
|
||||||
<template #activator="{ props: tooltipProps }">
|
<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>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</template>
|
</template>
|
||||||
@@ -75,16 +114,31 @@ v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="
|
|||||||
</template>
|
</template>
|
||||||
</v-list-group>
|
</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>
|
<template #prepend>
|
||||||
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
|
<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">{{
|
<v-btn
|
||||||
item.title?.charAt(0) }}</v-btn>
|
v-if="isShrink && !item.icon"
|
||||||
|
class=""
|
||||||
|
rounded
|
||||||
|
size="36"
|
||||||
|
spaced="start"
|
||||||
|
variant="text"
|
||||||
|
>{{ item.title?.charAt(0) }}</v-btn
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
|
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
|
||||||
<template #activator="{ props: tooltipProps }">
|
<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>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</template>
|
</template>
|
||||||
@@ -126,15 +180,18 @@ const openedModel = computed({
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 當側邊欄收縮時,自動收起所有展開的子選單
|
// 當側邊欄收縮時,自動收起所有展開的子選單
|
||||||
watch(() => props.isShrink, (newVal) => {
|
watch(
|
||||||
|
() => props.isShrink,
|
||||||
|
(newVal) => {
|
||||||
if (newVal) {
|
if (newVal) {
|
||||||
openedModel.value = []
|
openedModel.value = []
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
|
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
|
||||||
|
|
||||||
function emitSelect (item: AdminLayoutMenuItem) {
|
function emitSelect(item: AdminLayoutMenuItem) {
|
||||||
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
|
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
|
||||||
// 這樣可以讓使用者看到完整的選單結構和導航結果
|
// 這樣可以讓使用者看到完整的選單結構和導航結果
|
||||||
if (props.isShrink) {
|
if (props.isShrink) {
|
||||||
@@ -143,7 +200,7 @@ function emitSelect (item: AdminLayoutMenuItem) {
|
|||||||
emit('select', item)
|
emit('select', item)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getItemCount (item: AdminLayoutMenuItem) {
|
function getItemCount(item: AdminLayoutMenuItem) {
|
||||||
if (!item?.subItems?.length) return 0
|
if (!item?.subItems?.length) return 0
|
||||||
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
|
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
|
||||||
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
|
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
|
<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 class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
|
v-for="item in favoriteItems"
|
||||||
@click="emit('select', item)">
|
: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-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||||
</v-list-item>
|
</v-list-item>
|
||||||
</v-list>
|
</v-list>
|
||||||
@@ -13,11 +17,14 @@ v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounde
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { AdminLayoutMenuItem } from './types'
|
import type { AdminLayoutMenuItem } from './types'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
favoriteItems?: AdminLayoutMenuItem[]
|
favoriteItems?: AdminLayoutMenuItem[]
|
||||||
}>(), {
|
}>(),
|
||||||
|
{
|
||||||
favoriteItems: () => [],
|
favoriteItems: () => [],
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
select: [item: AdminLayoutMenuItem]
|
select: [item: AdminLayoutMenuItem]
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
|
<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 class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
|
||||||
<v-list-item
|
<v-list-item
|
||||||
v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
|
v-for="item in mobileCurrentItems"
|
||||||
@click="emit('item-click', item)">
|
: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>
|
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
|
||||||
<template #append>
|
<template #append>
|
||||||
<v-icon size="18" :icon="item.subItems?.length ? mdiChevronRight : mdiArrowTopRight" />
|
<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 type { AdminLayoutMenuItem } from './types'
|
||||||
import { mdiArrowTopRight, mdiChevronRight } from '@mdi/js'
|
import { mdiArrowTopRight, mdiChevronRight } from '@mdi/js'
|
||||||
|
|
||||||
withDefaults(defineProps<{
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
mobileCurrentItems?: AdminLayoutMenuItem[]
|
mobileCurrentItems?: AdminLayoutMenuItem[]
|
||||||
}>(), {
|
}>(),
|
||||||
|
{
|
||||||
mobileCurrentItems: () => [],
|
mobileCurrentItems: () => [],
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'item-click': [item: AdminLayoutMenuItem]
|
'item-click': [item: AdminLayoutMenuItem]
|
||||||
|
|||||||
@@ -65,7 +65,4 @@ export interface AdminLayoutDrawerConfig {
|
|||||||
railWidth: number
|
railWidth: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AdminLayoutActionType =
|
export type AdminLayoutActionType = 'notifications' | 'messages' | 'help'
|
||||||
| 'notifications'
|
|
||||||
| 'messages'
|
|
||||||
| 'help'
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<v-dialog
|
<v-dialog
|
||||||
:max-width="maxWidth" :model-value="modelValue" :persistent="persistent"
|
:max-width="maxWidth"
|
||||||
@update:model-value="$emit('update:modelValue', $event)">
|
:model-value="modelValue"
|
||||||
|
:persistent="persistent"
|
||||||
|
@update:model-value="$emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="text-h6">{{ title }}</v-card-title>
|
<v-card-title class="text-h6">{{ title }}</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -9,7 +12,12 @@
|
|||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions class="justify-end">
|
<v-card-actions class="justify-end">
|
||||||
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
|
<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 }}
|
{{ confirmText }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
<v-card-title class="dialog-title d-flex align-center ga-2">
|
<v-card-title class="dialog-title d-flex align-center ga-2">
|
||||||
<div>
|
<div>
|
||||||
<div class="text-h6">{{ dialogTitle }}</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>
|
</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-chip v-if="isViewMode" color="info" size="small" variant="tonal">檢視中</v-chip>
|
<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 v-if="mobile" class="d-flex align-center flex-wrap ga-2 w-100">
|
||||||
<div class="d-flex align-center ga-1">
|
<div class="d-flex align-center ga-1">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :icon="mdiChevronLeft" size="small" variant="text"
|
v-if="isViewMode || isEditMode"
|
||||||
@click="$emit('prev')" />
|
:disabled="!hasPrevRecord"
|
||||||
|
:icon="mdiChevronLeft"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('prev')"
|
||||||
|
/>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" :icon="mdiChevronRight" size="small" variant="text"
|
v-if="isViewMode || isEditMode"
|
||||||
@click="$emit('next')" />
|
:disabled="!hasNextRecord"
|
||||||
|
:icon="mdiChevronRight"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('next')"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
|
v-if="isViewMode"
|
||||||
@click="$emit('switch-to-edit')">
|
color="primary"
|
||||||
|
:prepend-icon="mdiPencil"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="$emit('switch-to-edit')"
|
||||||
|
>
|
||||||
{{ editLabel }}
|
{{ editLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
|
v-if="isEditMode"
|
||||||
@click="$emit('switch-to-view')">
|
color="primary"
|
||||||
|
:prepend-icon="mdiEye"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="$emit('switch-to-view')"
|
||||||
|
>
|
||||||
{{ viewLabel }}
|
{{ viewLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiSkipPrevious" size="small"
|
v-if="isViewMode || isEditMode"
|
||||||
variant="text" @click="$emit('first')">
|
:disabled="!hasPrevRecord"
|
||||||
|
:prepend-icon="mdiSkipPrevious"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('first')"
|
||||||
|
>
|
||||||
{{ firstLabel }}
|
{{ firstLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiChevronLeft" size="small"
|
v-if="isViewMode || isEditMode"
|
||||||
variant="text" @click="$emit('prev')">
|
:disabled="!hasPrevRecord"
|
||||||
|
:prepend-icon="mdiChevronLeft"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('prev')"
|
||||||
|
>
|
||||||
{{ prevLabel }}
|
{{ prevLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode || isEditMode" :append-icon="mdiChevronRight" :disabled="!hasNextRecord" size="small"
|
v-if="isViewMode || isEditMode"
|
||||||
variant="text" @click="$emit('next')">
|
:append-icon="mdiChevronRight"
|
||||||
|
:disabled="!hasNextRecord"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('next')"
|
||||||
|
>
|
||||||
{{ nextLabel }}
|
{{ nextLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode || isEditMode" :append-icon="mdiSkipNext" :disabled="!hasNextRecord" size="small"
|
v-if="isViewMode || isEditMode"
|
||||||
variant="text" @click="$emit('last')">
|
:append-icon="mdiSkipNext"
|
||||||
|
:disabled="!hasNextRecord"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="$emit('last')"
|
||||||
|
>
|
||||||
{{ lastLabel }}
|
{{ lastLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
|
v-if="isViewMode"
|
||||||
@click="$emit('switch-to-edit')">
|
color="primary"
|
||||||
|
:prepend-icon="mdiPencil"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="$emit('switch-to-edit')"
|
||||||
|
>
|
||||||
{{ editLabel }}
|
{{ editLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
|
v-if="isEditMode"
|
||||||
@click="$emit('switch-to-view')">
|
color="primary"
|
||||||
|
:prepend-icon="mdiEye"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="$emit('switch-to-view')"
|
||||||
|
>
|
||||||
{{ viewLabel }}
|
{{ viewLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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({
|
defineProps({
|
||||||
isViewMode: {
|
isViewMode: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
@@ -202,12 +202,12 @@ const totalCredits = computed(
|
|||||||
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
|
() => 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
|
if (!props.semester) return
|
||||||
emit('update-semester', props.semester.id, payload)
|
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
|
if (!props.semester) return
|
||||||
emit('update-course', props.semester.id, courseIndex, payload)
|
emit('update-course', props.semester.id, courseIndex, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,16 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
|
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'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
type CourseSortKey = 'name' | 'credits' | 'score'
|
type CourseSortKey = 'name' | 'credits' | 'score'
|
||||||
@@ -209,7 +218,7 @@ const semesterSortStates = ref<Record<number, CourseSortState>>({})
|
|||||||
|
|
||||||
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
|
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
|
||||||
|
|
||||||
function toggleSort (semesterId: number, key: CourseSortKey) {
|
function toggleSort(semesterId: number, key: CourseSortKey) {
|
||||||
const current = getSortState(semesterId)
|
const current = getSortState(semesterId)
|
||||||
|
|
||||||
semesterSortStates.value[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)
|
const current = getSortState(semesterId)
|
||||||
|
|
||||||
if (current?.key !== key) return mdiSwapVertical
|
if (current?.key !== key) return mdiSwapVertical
|
||||||
return current.order === 'asc' ? mdiArrowUp : mdiArrowDown
|
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')
|
if (key === 'name') return left.name.localeCompare(right.name, 'zh-Hant')
|
||||||
return left[key] - right[key]
|
return left[key] - right[key]
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSortedCourses (semester: SemesterRecord): SortedCourseRow[] {
|
function getSortedCourses(semester: SemesterRecord): SortedCourseRow[] {
|
||||||
const rows = semester.courses.map((course, originalIndex) => ({
|
const rows = semester.courses.map((course, originalIndex) => ({
|
||||||
course,
|
course,
|
||||||
originalIndex,
|
originalIndex,
|
||||||
|
|||||||
@@ -114,7 +114,9 @@
|
|||||||
:model-value="course.credits"
|
:model-value="course.credits"
|
||||||
type="number"
|
type="number"
|
||||||
variant="outlined"
|
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
|
<v-text-field
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
@@ -166,7 +168,7 @@ const totalCredits = computed(
|
|||||||
() => props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0
|
() => 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
|
if (!props.semester) return
|
||||||
emit('update-course', props.semester.id, courseIndex, payload)
|
emit('update-course', props.semester.id, courseIndex, payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,18 +4,27 @@
|
|||||||
子檔資料示範
|
子檔資料示範
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" :prepend-icon="mdiPlus" size="small"
|
v-if="!isMobile && !isFormReadonly && !isFormLocked"
|
||||||
variant="tonal" @click="$emit('add-course')">
|
color="primary"
|
||||||
|
:prepend-icon="mdiPlus"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
@click="$emit('add-course')"
|
||||||
|
>
|
||||||
新增成績
|
新增成績
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isMobile" class="d-flex flex-column ga-3">
|
<div v-if="isMobile" class="d-flex flex-column ga-3">
|
||||||
<v-card
|
<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"
|
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
@click="$emit('select-semester', semester.id)">
|
@click="$emit('select-semester', semester.id)"
|
||||||
|
>
|
||||||
<v-card-text class="pa-4">
|
<v-card-text class="pa-4">
|
||||||
<div class="d-flex align-start justify-space-between ga-3">
|
<div class="d-flex align-start justify-space-between ga-3">
|
||||||
<div>
|
<div>
|
||||||
@@ -34,13 +43,18 @@ v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="
|
|||||||
</v-card>
|
</v-card>
|
||||||
</div>
|
</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">
|
<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 class="flex-shrink-0" variant="flat">
|
||||||
<v-card-text class="pa-2">
|
<v-card-text class="pa-2">
|
||||||
<v-data-table
|
<v-data-table
|
||||||
class="border rounded" density="compact" :headers="headers" hide-default-footer
|
class="border rounded"
|
||||||
:items="flattenedCourses" :items-per-page="-1">
|
density="compact"
|
||||||
|
:headers="headers"
|
||||||
|
hide-default-footer
|
||||||
|
:items="flattenedCourses"
|
||||||
|
:items-per-page="-1"
|
||||||
|
>
|
||||||
<template #[`item.semesterName`]="slotProps">
|
<template #[`item.semesterName`]="slotProps">
|
||||||
{{ slotProps.item.semesterName }}
|
{{ slotProps.item.semesterName }}
|
||||||
</template>
|
</template>
|
||||||
@@ -50,23 +64,56 @@ class="border rounded" density="compact" :headers="headers" hide-default-footer
|
|||||||
<template #[`item.credits`]="slotProps">
|
<template #[`item.credits`]="slotProps">
|
||||||
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
|
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else :aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`" density="compact" :disabled="isFormLocked" hide-details
|
v-else
|
||||||
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="
|
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 學分`"
|
||||||
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { credits: Number(value) || 0 })
|
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>
|
||||||
<template #[`item.score`]="slotProps">
|
<template #[`item.score`]="slotProps">
|
||||||
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
|
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else :aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`" density="compact" :disabled="isFormLocked" hide-details
|
v-else
|
||||||
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="
|
:aria-label="`${slotProps.item.semesterName} ${slotProps.item.name} 分數`"
|
||||||
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { score: Number(value) || 0 })
|
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>
|
||||||
<template #[`item.actions`]="slotProps">
|
<template #[`item.actions`]="slotProps">
|
||||||
<v-btn
|
<v-btn
|
||||||
color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="text"
|
color="error"
|
||||||
@click="$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)" />
|
:disabled="isFormLocked"
|
||||||
|
:icon="mdiDelete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="
|
||||||
|
$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)
|
||||||
|
"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -74,7 +121,10 @@ color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="t
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -95,7 +145,12 @@ const props = defineProps<{
|
|||||||
defineEmits<{
|
defineEmits<{
|
||||||
(event: 'select-semester', semesterId: number): void
|
(event: 'select-semester', semesterId: number): void
|
||||||
(event: 'add-course'): 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
|
(event: 'delete-course', semesterId: number, courseIndex: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,14 @@ import { computed } from 'vue'
|
|||||||
import { useTheme } from 'vuetify'
|
import { useTheme } from 'vuetify'
|
||||||
import { getNextThemeName } from '@/utils/theme'
|
import { getNextThemeName } from '@/utils/theme'
|
||||||
|
|
||||||
export function useThemeToggle () {
|
export function useThemeToggle() {
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
|
|
||||||
const availableThemeNames = computed(() =>
|
const availableThemeNames = computed(() =>
|
||||||
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
|
||||||
)
|
)
|
||||||
|
|
||||||
function toggleTheme () {
|
function toggleTheme() {
|
||||||
const names = availableThemeNames.value
|
const names = availableThemeNames.value
|
||||||
if (names.length === 0) return null
|
if (names.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -16,23 +16,100 @@ const statuses = ['在學', '休學', '畢業']
|
|||||||
|
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
{ title: '', key: 'select', sortable: false, nowrap: true },
|
{ title: '', key: 'select', sortable: false, nowrap: true },
|
||||||
{ title: '學號', key: 'studentId', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
|
{
|
||||||
{ title: '姓名', key: 'name', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
|
title: '學號',
|
||||||
{ title: '系所', key: 'department', sortable: true, minWidth: 140, nowrap: true, cellProps: { class: 'px-1' } },
|
key: 'studentId',
|
||||||
{ title: '年級', key: 'grade', sortable: true, minWidth: 140, nowrap: true, cellProps: { class: 'px-1' } },
|
sortable: true,
|
||||||
{ title: '入學年度', key: 'enrollYear', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
|
minWidth: 120,
|
||||||
{ title: '已修學分', key: 'credits', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
|
nowrap: true,
|
||||||
{ title: '指導老師', key: 'advisor', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
|
cellProps: { class: 'px-1' },
|
||||||
{ title: 'Email', key: 'email', sortable: true, minWidth: 200, nowrap: true, cellProps: { class: 'px-1' } },
|
},
|
||||||
{ title: '電話', key: 'phone', sortable: true, minWidth: 150, nowrap: true, cellProps: { class: 'px-1' } },
|
{
|
||||||
{ title: '狀態', key: 'status', sortable: true, minWidth: 120, nowrap: true, cellProps: { class: 'px-1' } },
|
title: '姓名',
|
||||||
{ title: '操作', key: 'actions', sortable: false, width: 'auto', nowrap: true, cellProps: { class: 'bg-background' } },
|
key: 'name',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 120,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '系所',
|
||||||
|
key: 'department',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 140,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '年級',
|
||||||
|
key: 'grade',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 140,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '入學年度',
|
||||||
|
key: 'enrollYear',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 120,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '已修學分',
|
||||||
|
key: 'credits',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 120,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '指導老師',
|
||||||
|
key: 'advisor',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 120,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Email',
|
||||||
|
key: 'email',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 200,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '電話',
|
||||||
|
key: 'phone',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 150,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '狀態',
|
||||||
|
key: 'status',
|
||||||
|
sortable: true,
|
||||||
|
minWidth: 120,
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'px-1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
sortable: false,
|
||||||
|
width: 'auto',
|
||||||
|
nowrap: true,
|
||||||
|
cellProps: { class: 'bg-background' },
|
||||||
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
const TABLE_BOTTOM_GAP = 64
|
const TABLE_BOTTOM_GAP = 64
|
||||||
const TABLE_MIN_HEIGHT = 240
|
const TABLE_MIN_HEIGHT = 240
|
||||||
|
|
||||||
export function useEditableStudentGrid () {
|
export function useEditableStudentGrid() {
|
||||||
const studentStore = useStudentStore()
|
const studentStore = useStudentStore()
|
||||||
const students = computed(() => studentStore.students)
|
const students = computed(() => studentStore.students)
|
||||||
const search = ref({
|
const search = ref({
|
||||||
@@ -49,7 +126,7 @@ export function useEditableStudentGrid () {
|
|||||||
const draftRows = ref<Record<number, StudentPayload>>({})
|
const draftRows = ref<Record<number, StudentPayload>>({})
|
||||||
const selectedRowIds = ref<number[]>([])
|
const selectedRowIds = ref<number[]>([])
|
||||||
|
|
||||||
function toPayload (student: StudentRecord): StudentPayload {
|
function toPayload(student: StudentRecord): StudentPayload {
|
||||||
return {
|
return {
|
||||||
studentId: student.studentId,
|
studentId: student.studentId,
|
||||||
name: student.name,
|
name: student.name,
|
||||||
@@ -64,17 +141,17 @@ export function useEditableStudentGrid () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function rebuildDraftRows () {
|
function rebuildDraftRows() {
|
||||||
draftRows.value = Object.fromEntries(students.value.map((item) => [item.id, toPayload(item)]))
|
draftRows.value = Object.fromEntries(students.value.map((item) => [item.id, toPayload(item)]))
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDraftRow (id: number) {
|
function getDraftRow(id: number) {
|
||||||
return draftRows.value[id] ?? null
|
return draftRows.value[id] ?? null
|
||||||
}
|
}
|
||||||
|
|
||||||
const getSourceRow = (id: number) => students.value.find((item) => item.id === id) || null
|
const getSourceRow = (id: number) => students.value.find((item) => item.id === id) || null
|
||||||
|
|
||||||
function isRowDirty (id: number) {
|
function isRowDirty(id: number) {
|
||||||
const source = getSourceRow(id)
|
const source = getSourceRow(id)
|
||||||
const draft = getDraftRow(id)
|
const draft = getDraftRow(id)
|
||||||
if (!source || !draft) return false
|
if (!source || !draft) return false
|
||||||
@@ -112,34 +189,42 @@ export function useEditableStudentGrid () {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const visibleStudentIds = computed(() => filteredStudents.value.map((item) => item.id))
|
const visibleStudentIds = computed(() => filteredStudents.value.map((item) => item.id))
|
||||||
const isAllVisibleSelected = computed(() =>
|
const isAllVisibleSelected = computed(
|
||||||
|
() =>
|
||||||
visibleStudentIds.value.length > 0 &&
|
visibleStudentIds.value.length > 0 &&
|
||||||
visibleStudentIds.value.every((id) => selectedRowIds.value.includes(id))
|
visibleStudentIds.value.every((id) => selectedRowIds.value.includes(id))
|
||||||
)
|
)
|
||||||
const isPartiallyVisibleSelected = computed(() =>
|
const isPartiallyVisibleSelected = computed(
|
||||||
|
() =>
|
||||||
visibleStudentIds.value.some((id) => selectedRowIds.value.includes(id)) &&
|
visibleStudentIds.value.some((id) => selectedRowIds.value.includes(id)) &&
|
||||||
!isAllVisibleSelected.value
|
!isAllVisibleSelected.value
|
||||||
)
|
)
|
||||||
const hasAnyChange = computed(() =>
|
const hasAnyChange = computed(
|
||||||
students.value.some((item) => isRowDirty(item.id)) || studentStore.deletedIds.size > 0
|
() => students.value.some((item) => isRowDirty(item.id)) || studentStore.deletedIds.size > 0
|
||||||
)
|
)
|
||||||
const hasSelectedRows = computed(() => selectedRowIds.value.length > 0)
|
const hasSelectedRows = computed(() => selectedRowIds.value.length > 0)
|
||||||
|
|
||||||
function toggleSelectAll (checked: boolean | null) {
|
function toggleSelectAll(checked: boolean | null) {
|
||||||
if (isPartiallyVisibleSelected.value) {
|
if (isPartiallyVisibleSelected.value) {
|
||||||
selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id))
|
selectedRowIds.value = selectedRowIds.value.filter(
|
||||||
|
(id) => !visibleStudentIds.value.includes(id)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checked) {
|
if (!checked) {
|
||||||
selectedRowIds.value = selectedRowIds.value.filter((id) => !visibleStudentIds.value.includes(id))
|
selectedRowIds.value = selectedRowIds.value.filter(
|
||||||
|
(id) => !visibleStudentIds.value.includes(id)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedRowIds.value = Array.from(new Set([...selectedRowIds.value, ...visibleStudentIds.value]))
|
selectedRowIds.value = Array.from(
|
||||||
|
new Set([...selectedRowIds.value, ...visibleStudentIds.value])
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSingleRowSelection (id: number, checked: boolean | null) {
|
function toggleSingleRowSelection(id: number, checked: boolean | null) {
|
||||||
if (checked) {
|
if (checked) {
|
||||||
if (!selectedRowIds.value.includes(id)) {
|
if (!selectedRowIds.value.includes(id)) {
|
||||||
selectedRowIds.value = [...selectedRowIds.value, id]
|
selectedRowIds.value = [...selectedRowIds.value, id]
|
||||||
@@ -150,13 +235,13 @@ export function useEditableStudentGrid () {
|
|||||||
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
|
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSingleRow (id: number) {
|
function deleteSingleRow(id: number) {
|
||||||
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
|
selectedRowIds.value = selectedRowIds.value.filter((selectedId) => selectedId !== id)
|
||||||
studentStore.markAsDeleted(id)
|
studentStore.markAsDeleted(id)
|
||||||
rebuildDraftRows()
|
rebuildDraftRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteSelectedRows () {
|
function deleteSelectedRows() {
|
||||||
if (selectedRowIds.value.length === 0) return
|
if (selectedRowIds.value.length === 0) return
|
||||||
|
|
||||||
for (const id of selectedRowIds.value) {
|
for (const id of selectedRowIds.value) {
|
||||||
@@ -167,7 +252,7 @@ export function useEditableStudentGrid () {
|
|||||||
rebuildDraftRows()
|
rebuildDraftRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveAllRows () {
|
function saveAllRows() {
|
||||||
for (const item of students.value) {
|
for (const item of students.value) {
|
||||||
const draft = getDraftRow(item.id)
|
const draft = getDraftRow(item.id)
|
||||||
if (!draft) continue
|
if (!draft) continue
|
||||||
@@ -179,17 +264,18 @@ export function useEditableStudentGrid () {
|
|||||||
rebuildDraftRows()
|
rebuildDraftRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAllRows () {
|
function resetAllRows() {
|
||||||
studentStore.restoreDeleted()
|
studentStore.restoreDeleted()
|
||||||
selectedRowIds.value = []
|
selectedRowIds.value = []
|
||||||
rebuildDraftRows()
|
rebuildDraftRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
function recalculateTableHeight () {
|
function recalculateTableHeight() {
|
||||||
const container = tableContainerRef.value
|
const container = tableContainerRef.value
|
||||||
if (!container) return
|
if (!container) return
|
||||||
|
|
||||||
const scrollParent = tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null)
|
const scrollParent =
|
||||||
|
tableScrollParentRef.value || (container.closest('.overflow-auto') as HTMLElement | null)
|
||||||
tableScrollParentRef.value = scrollParent
|
tableScrollParentRef.value = scrollParent
|
||||||
|
|
||||||
const parentBottom = scrollParent
|
const parentBottom = scrollParent
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
|
|||||||
handleDialogVisibility: (nextValue: boolean) => void
|
handleDialogVisibility: (nextValue: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMaintenanceCrudFlow<T extends { id: number }> (
|
export function useMaintenanceCrudFlow<T extends { id: number }>(
|
||||||
options: UseMaintenanceCrudFlowOptions<T>,
|
options: UseMaintenanceCrudFlowOptions<T>
|
||||||
): UseMaintenanceCrudFlowResult<T> {
|
): UseMaintenanceCrudFlowResult<T> {
|
||||||
const confirmCloseVisible = ref(false)
|
const confirmCloseVisible = ref(false)
|
||||||
const confirmSaveVisible = ref(false)
|
const confirmSaveVisible = ref(false)
|
||||||
@@ -64,23 +64,22 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
const isEditMode = computed(() => options.dialogMode.value === 'edit')
|
const isEditMode = computed(() => options.dialogMode.value === 'edit')
|
||||||
const isViewMode = computed(() => options.dialogMode.value === 'view')
|
const isViewMode = computed(() => options.dialogMode.value === 'view')
|
||||||
const currentRecordIndex = computed(() =>
|
const currentRecordIndex = computed(() =>
|
||||||
options.records.value.findIndex((item) => item.id === options.editingId.value),
|
options.records.value.findIndex((item) => item.id === options.editingId.value)
|
||||||
)
|
)
|
||||||
const currentEditingRecord = computed(
|
const currentEditingRecord = computed(
|
||||||
() => options.records.value.find((item) => item.id === options.editingId.value) || null,
|
() => options.records.value.find((item) => item.id === options.editingId.value) || null
|
||||||
)
|
)
|
||||||
const hasPrevRecord = computed(() => currentRecordIndex.value > 0)
|
const hasPrevRecord = computed(() => currentRecordIndex.value > 0)
|
||||||
const hasNextRecord = computed(
|
const hasNextRecord = computed(
|
||||||
() =>
|
() =>
|
||||||
currentRecordIndex.value >= 0
|
currentRecordIndex.value >= 0 && currentRecordIndex.value < options.records.value.length - 1
|
||||||
&& currentRecordIndex.value < options.records.value.length - 1,
|
|
||||||
)
|
)
|
||||||
const pendingDeleteLabel = computed(() => {
|
const pendingDeleteLabel = computed(() => {
|
||||||
if (!pendingDelete.value) return '這筆資料'
|
if (!pendingDelete.value) return '這筆資料'
|
||||||
return options.describeRecord(pendingDelete.value)
|
return options.describeRecord(pendingDelete.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
function openAdjacentRecord (direction: 'prev' | 'next') {
|
function openAdjacentRecord(direction: 'prev' | 'next') {
|
||||||
if (!isViewMode.value && !isEditMode.value) return
|
if (!isViewMode.value && !isEditMode.value) return
|
||||||
const index = currentRecordIndex.value
|
const index = currentRecordIndex.value
|
||||||
if (index < 0) return
|
if (index < 0) return
|
||||||
@@ -99,7 +98,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
options.openViewDialog(target)
|
options.openViewDialog(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdgeRecord (position: 'first' | 'last') {
|
function openEdgeRecord(position: 'first' | 'last') {
|
||||||
if (!isViewMode.value && !isEditMode.value) return
|
if (!isViewMode.value && !isEditMode.value) return
|
||||||
if (options.records.value.length === 0) return
|
if (options.records.value.length === 0) return
|
||||||
const target = position === 'first' ? options.records.value[0] : options.records.value.at(-1)
|
const target = position === 'first' ? options.records.value[0] : options.records.value.at(-1)
|
||||||
@@ -116,14 +115,14 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
options.openViewDialog(target)
|
options.openViewDialog(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToEditMode () {
|
function switchToEditMode() {
|
||||||
if (!isViewMode.value) return
|
if (!isViewMode.value) return
|
||||||
const current = currentEditingRecord.value
|
const current = currentEditingRecord.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
options.openEditDialog(current)
|
options.openEditDialog(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchToViewMode () {
|
function switchToViewMode() {
|
||||||
if (!isEditMode.value) return
|
if (!isEditMode.value) return
|
||||||
const current = currentEditingRecord.value
|
const current = currentEditingRecord.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
@@ -135,7 +134,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
options.openViewDialog(current)
|
options.openViewDialog(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmSwitch () {
|
function confirmSwitch() {
|
||||||
const target = pendingSwitchTarget.value
|
const target = pendingSwitchTarget.value
|
||||||
pendingSwitchTarget.value = null
|
pendingSwitchTarget.value = null
|
||||||
confirmSwitchVisible.value = false
|
confirmSwitchVisible.value = false
|
||||||
@@ -143,7 +142,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
options.openViewDialog(target)
|
options.openViewDialog(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmNavigate () {
|
function confirmNavigate() {
|
||||||
const target = pendingNavigateTarget.value
|
const target = pendingNavigateTarget.value
|
||||||
pendingNavigateTarget.value = null
|
pendingNavigateTarget.value = null
|
||||||
confirmNavigateVisible.value = false
|
confirmNavigateVisible.value = false
|
||||||
@@ -151,18 +150,18 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
options.openEditDialog(target)
|
options.openEditDialog(target)
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDeleteConfirmation (record: T) {
|
function requestDeleteConfirmation(record: T) {
|
||||||
pendingDelete.value = record
|
pendingDelete.value = record
|
||||||
confirmDeleteVisible.value = true
|
confirmDeleteVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestDeleteCurrent () {
|
function requestDeleteCurrent() {
|
||||||
const current = currentEditingRecord.value
|
const current = currentEditingRecord.value
|
||||||
if (!current) return
|
if (!current) return
|
||||||
requestDeleteConfirmation(current)
|
requestDeleteConfirmation(current)
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDialog () {
|
function closeDialog() {
|
||||||
options.dialogVisible.value = false
|
options.dialogVisible.value = false
|
||||||
options.isLoading.value = false
|
options.isLoading.value = false
|
||||||
options.isSaving.value = false
|
options.isSaving.value = false
|
||||||
@@ -181,7 +180,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
options.onCloseReset?.()
|
options.onCloseReset?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmDelete () {
|
function confirmDelete() {
|
||||||
if (!pendingDelete.value) return
|
if (!pendingDelete.value) return
|
||||||
const deletedId = pendingDelete.value.id
|
const deletedId = pendingDelete.value.id
|
||||||
options.removeRecord(deletedId)
|
options.removeRecord(deletedId)
|
||||||
@@ -193,7 +192,7 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requestCloseDialog () {
|
function requestCloseDialog() {
|
||||||
if (options.isDirty.value && !options.isSaving.value) {
|
if (options.isDirty.value && !options.isSaving.value) {
|
||||||
confirmCloseVisible.value = true
|
confirmCloseVisible.value = true
|
||||||
return
|
return
|
||||||
@@ -201,12 +200,12 @@ export function useMaintenanceCrudFlow<T extends { id: number }> (
|
|||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmClose () {
|
function confirmClose() {
|
||||||
confirmCloseVisible.value = false
|
confirmCloseVisible.value = false
|
||||||
closeDialog()
|
closeDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDialogVisibility (nextValue: boolean) {
|
function handleDialogVisibility(nextValue: boolean) {
|
||||||
if (nextValue) {
|
if (nextValue) {
|
||||||
options.dialogVisible.value = true
|
options.dialogVisible.value = true
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -49,11 +49,11 @@ const fieldLabels: Record<keyof StudentFormState, string> = {
|
|||||||
status: '狀態',
|
status: '狀態',
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultForm (
|
function createDefaultForm(
|
||||||
departments: string[],
|
departments: string[],
|
||||||
gradeOptions: GradeOption[],
|
gradeOptions: GradeOption[],
|
||||||
enrollYears: number[],
|
enrollYears: number[],
|
||||||
statuses: string[],
|
statuses: string[]
|
||||||
): StudentFormState {
|
): StudentFormState {
|
||||||
return {
|
return {
|
||||||
studentId: '',
|
studentId: '',
|
||||||
@@ -69,7 +69,7 @@ function createDefaultForm (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEmptyFieldErrors () {
|
function createEmptyFieldErrors() {
|
||||||
return {
|
return {
|
||||||
studentId: [],
|
studentId: [],
|
||||||
name: [],
|
name: [],
|
||||||
@@ -84,22 +84,25 @@ function createEmptyFieldErrors () {
|
|||||||
} as Record<keyof StudentFormState, string[]>
|
} as Record<keyof StudentFormState, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOptions) {
|
export function useStudentMaintenanceForm(options: UseStudentMaintenanceFormOptions) {
|
||||||
const form = ref<StudentFormState>(
|
const form = ref<StudentFormState>(
|
||||||
createDefaultForm(options.departments, options.gradeOptions, options.enrollYears, options.statuses),
|
createDefaultForm(
|
||||||
|
options.departments,
|
||||||
|
options.gradeOptions,
|
||||||
|
options.enrollYears,
|
||||||
|
options.statuses
|
||||||
|
)
|
||||||
)
|
)
|
||||||
const initialForm = ref<StudentFormState>({ ...form.value })
|
const initialForm = ref<StudentFormState>({ ...form.value })
|
||||||
const fieldErrors = ref(createEmptyFieldErrors())
|
const fieldErrors = ref(createEmptyFieldErrors())
|
||||||
|
|
||||||
const isDirty = computed(
|
const isDirty = computed(() => JSON.stringify(form.value) !== JSON.stringify(initialForm.value))
|
||||||
() => JSON.stringify(form.value) !== JSON.stringify(initialForm.value),
|
|
||||||
)
|
|
||||||
|
|
||||||
function gradeLabel (grade: number) {
|
function gradeLabel(grade: number) {
|
||||||
return options.gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
|
return options.gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatSummaryValue (key: string, value: string | number | null | undefined) {
|
function formatSummaryValue(key: string, value: string | number | null | undefined) {
|
||||||
if (value === null || value === undefined || value === '') return '—'
|
if (value === null || value === undefined || value === '') return '—'
|
||||||
if (key === 'grade') return gradeLabel(Number(value))
|
if (key === 'grade') return gradeLabel(Number(value))
|
||||||
return String(value)
|
return String(value)
|
||||||
@@ -124,40 +127,40 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
|
|||||||
|
|
||||||
const errorSummary = computed(() => {
|
const errorSummary = computed(() => {
|
||||||
const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) =>
|
const entries = Object.entries(fieldErrors.value).flatMap(([field, messages]) =>
|
||||||
messages.map((message) => ({ field, message })),
|
messages.map((message) => ({ field, message }))
|
||||||
)
|
)
|
||||||
return entries.slice(0, 3)
|
return entries.slice(0, 3)
|
||||||
})
|
})
|
||||||
|
|
||||||
function setForm (nextForm: StudentFormState) {
|
function setForm(nextForm: StudentFormState) {
|
||||||
form.value = { ...nextForm }
|
form.value = { ...nextForm }
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncInitialForm () {
|
function syncInitialForm() {
|
||||||
initialForm.value = { ...form.value }
|
initialForm.value = { ...form.value }
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetForm () {
|
function resetForm() {
|
||||||
form.value = createDefaultForm(
|
form.value = createDefaultForm(
|
||||||
options.departments,
|
options.departments,
|
||||||
options.gradeOptions,
|
options.gradeOptions,
|
||||||
options.enrollYears,
|
options.enrollYears,
|
||||||
options.statuses,
|
options.statuses
|
||||||
)
|
)
|
||||||
syncInitialForm()
|
syncInitialForm()
|
||||||
clearAllErrors()
|
clearAllErrors()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAllErrors () {
|
function clearAllErrors() {
|
||||||
fieldErrors.value = createEmptyFieldErrors()
|
fieldErrors.value = createEmptyFieldErrors()
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearFieldError (field: keyof StudentFormState | string) {
|
function clearFieldError(field: keyof StudentFormState | string) {
|
||||||
if (!fieldErrors.value[field as keyof StudentFormState]?.length) return
|
if (!fieldErrors.value[field as keyof StudentFormState]?.length) return
|
||||||
fieldErrors.value[field as keyof StudentFormState] = []
|
fieldErrors.value[field as keyof StudentFormState] = []
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateForm () {
|
function validateForm() {
|
||||||
const errors: Array<{ field: keyof StudentFormState; message: string }> = []
|
const errors: Array<{ field: keyof StudentFormState; message: string }> = []
|
||||||
const studentId = form.value.studentId.trim()
|
const studentId = form.value.studentId.trim()
|
||||||
const name = form.value.name.trim()
|
const name = form.value.name.trim()
|
||||||
@@ -180,7 +183,7 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
|
|||||||
}
|
}
|
||||||
|
|
||||||
const duplicate = options.students.value.find(
|
const duplicate = options.students.value.find(
|
||||||
(item) => item.studentId === studentId && item.id !== options.editingId.value,
|
(item) => item.studentId === studentId && item.id !== options.editingId.value
|
||||||
)
|
)
|
||||||
if (studentId && duplicate) {
|
if (studentId && duplicate) {
|
||||||
errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' })
|
errors.push({ field: 'studentId', message: '學號已存在,請確認是否重複' })
|
||||||
@@ -189,14 +192,14 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
|
|||||||
return errors
|
return errors
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor (status: string) {
|
function statusColor(status: string) {
|
||||||
if (status === '在學') return 'success'
|
if (status === '在學') return 'success'
|
||||||
if (status === '休學') return 'warning'
|
if (status === '休學') return 'warning'
|
||||||
if (status === '畢業') return 'secondary'
|
if (status === '畢業') return 'secondary'
|
||||||
return 'default'
|
return 'default'
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowProps (data: { item: StudentRecord }) {
|
function rowProps(data: { item: StudentRecord }) {
|
||||||
return {
|
return {
|
||||||
class: data.item.id === options.highlightedId.value ? 'is-highlighted' : '',
|
class: data.item.id === options.highlightedId.value ? 'is-highlighted' : '',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,19 +18,21 @@ interface UseApiCallResult<TResult, TArgs extends unknown[]> {
|
|||||||
reset: () => void
|
reset: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultToastLevel (error: ApiRequestError): ToastLevel {
|
function getDefaultToastLevel(error: ApiRequestError): ToastLevel {
|
||||||
if (typeof error.status === 'number' && error.status >= 500) return 'error'
|
if (typeof error.status === 'number' && error.status >= 500) return 'error'
|
||||||
return 'warning'
|
return 'warning'
|
||||||
}
|
}
|
||||||
|
|
||||||
function levelToColor (level: ToastLevel): string {
|
function levelToColor(level: ToastLevel): string {
|
||||||
if (level === 'error') return 'error'
|
if (level === 'error') return 'error'
|
||||||
if (level === 'warning') return 'warning'
|
if (level === 'warning') return 'warning'
|
||||||
return 'info'
|
return 'info'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useApiCall <TResult, TArgs extends unknown[]>(action: (...args: TArgs) => Promise<TResult>,
|
export function useApiCall<TResult, TArgs extends unknown[]>(
|
||||||
options?: Options): UseApiCallResult<TResult, TArgs> {
|
action: (...args: TArgs) => Promise<TResult>,
|
||||||
|
options?: Options
|
||||||
|
): UseApiCallResult<TResult, TArgs> {
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const data = ref<TResult | null>(null) as Ref<TResult | null>
|
const data = ref<TResult | null>(null) as Ref<TResult | null>
|
||||||
const error = ref<ApiRequestError | null>(null)
|
const error = ref<ApiRequestError | null>(null)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { registerPlugins } from '@/plugins'
|
|||||||
// Components
|
// Components
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
registerPlugins(app)
|
registerPlugins(app)
|
||||||
|
|||||||
+1
-1
@@ -24,7 +24,7 @@ const router = createRouter({
|
|||||||
|
|
||||||
registerGuards(router)
|
registerGuards(router)
|
||||||
|
|
||||||
function getErrorRouteName (status?: number) {
|
function getErrorRouteName(status?: number) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 403: {
|
case 403: {
|
||||||
return 'forbidden'
|
return 'forbidden'
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
元件 (Component)
|
元件 (Component)
|
||||||
↓ 呼叫
|
↓ 呼叫
|
||||||
Store (Pinia) ← 管理狀態、快取
|
Store (Pinia) ← 管理狀態、快取
|
||||||
↓ 呼叫
|
↓ 呼叫
|
||||||
API Service ← 封裝業務邏輯
|
API Service ← 封裝業務邏輯
|
||||||
↓ 呼叫
|
↓ 呼叫
|
||||||
HTTP Client ← Axios 實例、攔截器
|
HTTP Client ← Axios 實例、攔截器
|
||||||
|
|
||||||
## 目前的資料流(以登入為例)
|
## 目前的資料流(以登入為例)
|
||||||
@@ -46,6 +46,7 @@ interface MenuNode {
|
|||||||
### Store 持久化
|
### Store 持久化
|
||||||
|
|
||||||
`stores/menu.ts` 提供:
|
`stores/menu.ts` 提供:
|
||||||
|
|
||||||
- 自動 localStorage 持久化選單與收藏
|
- 自動 localStorage 持久化選單與收藏
|
||||||
- 初始化時自動還原資料
|
- 初始化時自動還原資料
|
||||||
- 登出時清除快取
|
- 登出時清除快取
|
||||||
@@ -106,9 +107,9 @@ Store 仍然是「唯一負責更新 token 的地方」,Interceptor 只負責
|
|||||||
- UI 不顯示取消造成的錯誤訊息
|
- UI 不顯示取消造成的錯誤訊息
|
||||||
|
|
||||||
| DECISION | WHY | WHY NOT |
|
| DECISION | WHY | WHY NOT |
|
||||||
|---|---|---|
|
| -------------------------------- | -------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
|
| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
|
||||||
| API 模組化(userApi、orderApi)| 關注點分離、好維護 | 全塞一個檔案會變超大 |
|
| API 模組化(userApi、orderApi) | 關注點分離、好維護 | 全塞一個檔案會變超大 |
|
||||||
| Interceptor 獨立檔案| 單一職責、好測試 | 寫在 client.ts 會雜亂 |
|
| Interceptor 獨立檔案 | 單一職責、好測試 | 寫在 client.ts 會雜亂 |
|
||||||
| 泛型 ApiResponse<T>| 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
|
| 泛型 ApiResponse<T> | 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
|
||||||
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
|
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { setupInterceptors } from './interceptors'
|
|||||||
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
|
||||||
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
|
||||||
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
|
||||||
function createClient (): AxiosInstance {
|
function createClient(): AxiosInstance {
|
||||||
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
|
||||||
const client = axios.create({
|
const client = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
@@ -19,4 +19,3 @@ function createClient (): AxiosInstance {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const httpClient = createClient()
|
export const httpClient = createClient()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import type { ApiError } from '@/types/api'
|
import type { ApiError } from '@/types/api'
|
||||||
import { isAxiosError } from 'axios'
|
import { isAxiosError } from 'axios'
|
||||||
|
|
||||||
function isRecord (value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
function firstString (value: unknown): string | undefined {
|
function firstString(value: unknown): string | undefined {
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
const trimmed = value.trim()
|
const trimmed = value.trim()
|
||||||
return trimmed ? trimmed : undefined
|
return trimmed ? trimmed : undefined
|
||||||
@@ -47,7 +47,7 @@ function firstString (value: unknown): string | undefined {
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractErrorMessage (data: unknown): string | undefined {
|
export function extractErrorMessage(data: unknown): string | undefined {
|
||||||
return firstString(data)
|
return firstString(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,14 +86,14 @@ export class CanceledRequestError extends ApiRequestError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isRequestCanceled (error: unknown): boolean {
|
export function isRequestCanceled(error: unknown): boolean {
|
||||||
if (isAxiosError(error)) {
|
if (isAxiosError(error)) {
|
||||||
return error.code === 'ERR_CANCELED'
|
return error.code === 'ERR_CANCELED'
|
||||||
}
|
}
|
||||||
return error instanceof DOMException && error.name === 'AbortError'
|
return error instanceof DOMException && error.name === 'AbortError'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeError (error: unknown): ApiRequestError {
|
export function normalizeError(error: unknown): ApiRequestError {
|
||||||
if (error instanceof ApiRequestError) {
|
if (error instanceof ApiRequestError) {
|
||||||
return error
|
return error
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ export function normalizeError (error: unknown): ApiRequestError {
|
|||||||
code,
|
code,
|
||||||
status,
|
status,
|
||||||
errors: apiError?.errors,
|
errors: apiError?.errors,
|
||||||
raw: error
|
raw: error,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export type HttpErrorDetail = {
|
|||||||
|
|
||||||
let httpErrorEmitted = false
|
let httpErrorEmitted = false
|
||||||
|
|
||||||
export function emitHttpError (detail: HttpErrorDetail) {
|
export function emitHttpError(detail: HttpErrorDetail) {
|
||||||
// 避免同一波大量錯誤觸發多次導頁
|
// 避免同一波大量錯誤觸發多次導頁
|
||||||
if (httpErrorEmitted) return
|
if (httpErrorEmitted) return
|
||||||
httpErrorEmitted = true
|
httpErrorEmitted = true
|
||||||
@@ -25,4 +25,3 @@ export function emitHttpError (detail: HttpErrorDetail) {
|
|||||||
httpErrorEmitted = false
|
httpErrorEmitted = false
|
||||||
}, 0)
|
}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type HttpToastDetail = {
|
|||||||
let lastKey = ''
|
let lastKey = ''
|
||||||
let lastAt = 0
|
let lastAt = 0
|
||||||
|
|
||||||
export function emitHttpToast (detail: HttpToastDetail) {
|
export function emitHttpToast(detail: HttpToastDetail) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const key = detail.dedupeKey ?? `${detail.level}:${detail.message}`
|
const key = detail.dedupeKey ?? `${detail.level}:${detail.message}`
|
||||||
|
|
||||||
@@ -28,4 +28,3 @@ export function emitHttpToast (detail: HttpToastDetail) {
|
|||||||
|
|
||||||
window.dispatchEvent(new CustomEvent<HttpToastDetail>(HTTP_TOAST_EVENT, { detail }))
|
window.dispatchEvent(new CustomEvent<HttpToastDetail>(HTTP_TOAST_EVENT, { detail }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { tokenService } from './token'
|
|||||||
// 注意:
|
// 注意:
|
||||||
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout)
|
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout)
|
||||||
// - Interceptor 只負責「讀取 token 並附加到 request」
|
// - Interceptor 只負責「讀取 token 並附加到 request」
|
||||||
export function setupInterceptors (client: AxiosInstance) {
|
export function setupInterceptors(client: AxiosInstance) {
|
||||||
// Request: 自動注入 token
|
// Request: 自動注入 token
|
||||||
client.interceptors.request.use(
|
client.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
@@ -113,7 +113,7 @@ export function setupInterceptors (client: AxiosInstance) {
|
|||||||
if (shouldToast) {
|
if (shouldToast) {
|
||||||
emitHttpToast({
|
emitHttpToast({
|
||||||
level: status ? 'error' : 'warning',
|
level: status ? 'error' : 'warning',
|
||||||
message: normalized.message
|
message: normalized.message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return Promise.reject(normalized)
|
return Promise.reject(normalized)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type ForceLogoutDetail = {
|
|||||||
|
|
||||||
let forceLogoutEmitted = false
|
let forceLogoutEmitted = false
|
||||||
|
|
||||||
export function emitForceLogout (detail: ForceLogoutDetail) {
|
export function emitForceLogout(detail: ForceLogoutDetail) {
|
||||||
// 避免同一波大量 401 觸發多次登出流程
|
// 避免同一波大量 401 觸發多次登出流程
|
||||||
if (forceLogoutEmitted) return
|
if (forceLogoutEmitted) return
|
||||||
forceLogoutEmitted = true
|
forceLogoutEmitted = true
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ export const tokenService = {
|
|||||||
clearToken() {
|
clearToken() {
|
||||||
tokenRef.value = null
|
tokenRef.value = null
|
||||||
localStorage.removeItem(storageKey)
|
localStorage.removeItem(storageKey)
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ interface BreadcrumbPayload {
|
|||||||
homeIcon?: string
|
homeIcon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTrail (items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
|
function buildTrail(items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
|
||||||
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
|
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
const nextTrail = [...trail, node]
|
const nextTrail = [...trail, node]
|
||||||
@@ -35,9 +35,11 @@ function buildTrail (items: LayoutMenuItem[], targetPath: string): LayoutMenuIte
|
|||||||
return walk(items || [], [])
|
return walk(items || [], [])
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBreadcrumbItems (trail: LayoutMenuItem[],
|
function toBreadcrumbItems(
|
||||||
|
trail: LayoutMenuItem[],
|
||||||
homeLabel: string,
|
homeLabel: string,
|
||||||
homeIcon: string): BreadcrumbItem[] {
|
homeIcon: string
|
||||||
|
): BreadcrumbItem[] {
|
||||||
const isHomePath = (path?: string) => path === '/' || path === ''
|
const isHomePath = (path?: string) => path === '/' || path === ''
|
||||||
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
|
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
|
||||||
const crumbs: BreadcrumbItem[] = []
|
const crumbs: BreadcrumbItem[] = []
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const storageKey = 'sk_playground_user_favorites'
|
|||||||
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
|
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
|
||||||
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
|
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
|
||||||
|
|
||||||
function readFavorites (): FavoriteItem[] {
|
function readFavorites(): FavoriteItem[] {
|
||||||
if (typeof window === 'undefined') return []
|
if (typeof window === 'undefined') return []
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(storageKey)
|
const raw = window.localStorage.getItem(storageKey)
|
||||||
@@ -24,7 +24,7 @@ function readFavorites (): FavoriteItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeFavorites (items: FavoriteItem[]) {
|
function writeFavorites(items: FavoriteItem[]) {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const defaultItems: LoginAnnouncementItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function readItems (): LoginAnnouncementItem[] {
|
function readItems(): LoginAnnouncementItem[] {
|
||||||
if (typeof window === 'undefined') return defaultItems
|
if (typeof window === 'undefined') return defaultItems
|
||||||
try {
|
try {
|
||||||
const raw = window.localStorage.getItem(storageKey)
|
const raw = window.localStorage.getItem(storageKey)
|
||||||
@@ -90,7 +90,7 @@ function readItems (): LoginAnnouncementItem[] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeItems (items: LoginAnnouncementItem[]) {
|
function writeItems(items: LoginAnnouncementItem[]) {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
try {
|
try {
|
||||||
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
window.localStorage.setItem(storageKey, JSON.stringify(items))
|
||||||
@@ -99,7 +99,7 @@ function writeItems (items: LoginAnnouncementItem[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mockFetchMobileAnnouncementsApi (): Promise<LoginMobileAnnouncementItem[]> {
|
async function mockFetchMobileAnnouncementsApi(): Promise<LoginMobileAnnouncementItem[]> {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'mobile-announcement-1',
|
id: 'mobile-announcement-1',
|
||||||
@@ -107,7 +107,7 @@ async function mockFetchMobileAnnouncementsApi (): Promise<LoginMobileAnnounceme
|
|||||||
title: '系統公告',
|
title: '系統公告',
|
||||||
createdAt: '2026-02-11',
|
createdAt: '2026-02-11',
|
||||||
},
|
},
|
||||||
];
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
|
||||||
|
|||||||
@@ -174,12 +174,9 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(isRail, (val) => {
|
||||||
isRail,
|
|
||||||
(val) => {
|
|
||||||
writeValue(isRailStorageKey, val)
|
writeValue(isRailStorageKey, val)
|
||||||
}
|
})
|
||||||
)
|
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
menu.value = []
|
menu.value = []
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ const seedSemesters: SemesterRecord[] = []
|
|||||||
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
|
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
|
|
||||||
// Helper to generate mock semesters for a student
|
// Helper to generate mock semesters for a student
|
||||||
export function generateMockSemesters (studentId: number) {
|
export function generateMockSemesters(studentId: number) {
|
||||||
const semesters = [
|
const semesters = [
|
||||||
{ name: '111 學年度第 1 學期', baseId: 1000 },
|
{ name: '111 學年度第 1 學期', baseId: 1000 },
|
||||||
{ name: '111 學年度第 2 學期', baseId: 2000 },
|
{ name: '111 學年度第 2 學期', baseId: 2000 },
|
||||||
@@ -74,7 +74,7 @@ export function generateMockSemesters (studentId: number) {
|
|||||||
code: `CS${1000 + idx}`,
|
code: `CS${1000 + idx}`,
|
||||||
name: subject.name,
|
name: subject.name,
|
||||||
credits: subject.credits,
|
credits: subject.credits,
|
||||||
score
|
score,
|
||||||
})
|
})
|
||||||
|
|
||||||
totalScore += score * subject.credits
|
totalScore += score * subject.credits
|
||||||
@@ -87,7 +87,7 @@ export function generateMockSemesters (studentId: number) {
|
|||||||
semesterName: sem.name,
|
semesterName: sem.name,
|
||||||
courses,
|
courses,
|
||||||
rank: Math.floor(Math.random() * 20) + 1,
|
rank: Math.floor(Math.random() * 20) + 1,
|
||||||
average: Number((totalScore / totalCredits).toFixed(2))
|
average: Number((totalScore / totalCredits).toFixed(2)),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -104,7 +104,7 @@ export const useSemesterStore = defineStore('semesters', () => {
|
|||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
const getStudentSemesters = (studentId: number) => {
|
const getStudentSemesters = (studentId: number) => {
|
||||||
return semesters.value.filter(s => s.studentId === studentId)
|
return semesters.value.filter((s) => s.studentId === studentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const generateForStudent = (studentId: number) => {
|
const generateForStudent = (studentId: number) => {
|
||||||
@@ -113,28 +113,28 @@ export const useSemesterStore = defineStore('semesters', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addSemester = (studentId: number) => {
|
const addSemester = (studentId: number) => {
|
||||||
const newId = Math.max(...semesters.value.map(s => s.id), 0) + 1
|
const newId = Math.max(...semesters.value.map((s) => s.id), 0) + 1
|
||||||
const newSemester: SemesterRecord = {
|
const newSemester: SemesterRecord = {
|
||||||
id: newId,
|
id: newId,
|
||||||
studentId,
|
studentId,
|
||||||
semesterName: '新學期',
|
semesterName: '新學期',
|
||||||
courses: [],
|
courses: [],
|
||||||
rank: 0,
|
rank: 0,
|
||||||
average: 0
|
average: 0,
|
||||||
}
|
}
|
||||||
semesters.value.push(newSemester)
|
semesters.value.push(newSemester)
|
||||||
return newSemester
|
return newSemester
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
|
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
|
||||||
const index = semesters.value.findIndex(s => s.id === id)
|
const index = semesters.value.findIndex((s) => s.id === id)
|
||||||
if (index === -1) return
|
if (index === -1) return
|
||||||
const current = semesters.value[index]
|
const current = semesters.value[index]
|
||||||
if (!current) return
|
if (!current) return
|
||||||
|
|
||||||
// Recalculate average if courses are updated
|
// Recalculate average if courses are updated
|
||||||
if (payload.courses) {
|
if (payload.courses) {
|
||||||
const totalScore = payload.courses.reduce((sum, c) => sum + (c.score * c.credits), 0)
|
const totalScore = payload.courses.reduce((sum, c) => sum + c.score * c.credits, 0)
|
||||||
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
|
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
|
||||||
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
|
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
|
||||||
}
|
}
|
||||||
@@ -143,14 +143,14 @@ export const useSemesterStore = defineStore('semesters', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const removeSemester = (id: number) => {
|
const removeSemester = (id: number) => {
|
||||||
const index = semesters.value.findIndex(s => s.id === id)
|
const index = semesters.value.findIndex((s) => s.id === id)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
semesters.value.splice(index, 1)
|
semesters.value.splice(index, 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const removeByStudentId = (studentId: number) => {
|
const removeByStudentId = (studentId: number) => {
|
||||||
semesters.value = semesters.value.filter(s => s.studentId !== studentId)
|
semesters.value = semesters.value.filter((s) => s.studentId !== studentId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -160,6 +160,6 @@ export const useSemesterStore = defineStore('semesters', () => {
|
|||||||
addSemester,
|
addSemester,
|
||||||
updateSemester,
|
updateSemester,
|
||||||
removeSemester,
|
removeSemester,
|
||||||
removeByStudentId
|
removeByStudentId,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
export function getNextThemeName (themeNames: string[], currentName: string): string | undefined {
|
export function getNextThemeName(themeNames: string[], currentName: string): string | undefined {
|
||||||
if (themeNames.length === 0) return undefined
|
if (themeNames.length === 0) return undefined
|
||||||
|
|
||||||
const currentIndex = themeNames.indexOf(currentName)
|
const currentIndex = themeNames.indexOf(currentName)
|
||||||
|
|||||||
@@ -13,7 +13,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { mdiAccountSchool, mdiBookOpenPageVariant, mdiChartPie, mdiCheckDecagram, mdiCloudDownload } from '@mdi/js'
|
import {
|
||||||
|
mdiAccountSchool,
|
||||||
|
mdiBookOpenPageVariant,
|
||||||
|
mdiChartPie,
|
||||||
|
mdiCheckDecagram,
|
||||||
|
mdiCloudDownload,
|
||||||
|
} from '@mdi/js'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import SKAnalysis from '@/components/SKAnalysis.vue'
|
import SKAnalysis from '@/components/SKAnalysis.vue'
|
||||||
|
|
||||||
|
|||||||
+14
-1
@@ -10,7 +10,20 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { mdiAccountGroup, mdiBookOpenVariant, mdiCalendarCheck, mdiChartBar, mdiCog, mdiHammerWrench, mdiHome, mdiLayers, mdiLock, mdiMonitorShimmer, mdiSchool, mdiViewDashboard } from '@mdi/js'
|
import {
|
||||||
|
mdiAccountGroup,
|
||||||
|
mdiBookOpenVariant,
|
||||||
|
mdiCalendarCheck,
|
||||||
|
mdiChartBar,
|
||||||
|
mdiCog,
|
||||||
|
mdiHammerWrench,
|
||||||
|
mdiHome,
|
||||||
|
mdiLayers,
|
||||||
|
mdiLock,
|
||||||
|
mdiMonitorShimmer,
|
||||||
|
mdiSchool,
|
||||||
|
mdiViewDashboard,
|
||||||
|
} from '@mdi/js'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import SKDashboard from '@/components/SKDashboard.vue'
|
import SKDashboard from '@/components/SKDashboard.vue'
|
||||||
|
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ const deptItems = ref([
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function addChildById (items: DeptItem[], parentId: string | number, child: DeptItem): boolean {
|
function addChildById(items: DeptItem[], parentId: string | number, child: DeptItem): boolean {
|
||||||
for (const current of items) {
|
for (const current of items) {
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ function addChildById (items: DeptItem[], parentId: string | number, child: Dept
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTreeItemById (items: DeptItem[], updated: DeptItem): boolean {
|
function updateTreeItemById(items: DeptItem[], updated: DeptItem): boolean {
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
const current = items[i]
|
const current = items[i]
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
@@ -174,13 +174,13 @@ function updateTreeItemById (items: DeptItem[], updated: DeptItem): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onCreate = () => alert('Create Dept')
|
const onCreate = () => alert('Create Dept')
|
||||||
function onAddSub (parent: DeptItem, newItem: DeptItem) {
|
function onAddSub(parent: DeptItem, newItem: DeptItem) {
|
||||||
addChildById(deptItems.value, parent.id, newItem)
|
addChildById(deptItems.value, parent.id, newItem)
|
||||||
}
|
}
|
||||||
function onEdit (item: DeptItem) {
|
function onEdit(item: DeptItem) {
|
||||||
updateTreeItemById(deptItems.value, item)
|
updateTreeItemById(deptItems.value, item)
|
||||||
}
|
}
|
||||||
function onDelete (item: DeptItem) {
|
function onDelete(item: DeptItem) {
|
||||||
if (confirm(`Delete ${item.name}?`)) {
|
if (confirm(`Delete ${item.name}?`)) {
|
||||||
alert('Deleted')
|
alert('Deleted')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<v-sheet height="100%" width="100%">
|
<v-sheet height="100%" width="100%">
|
||||||
{{ fncId }}
|
{{ fncId }}
|
||||||
</v-sheet>
|
</v-sheet>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
|||||||
+23
-15
@@ -5,10 +5,17 @@
|
|||||||
:form="form"
|
:form="form"
|
||||||
:header="header"
|
:header="header"
|
||||||
:illustration="illustration"
|
:illustration="illustration"
|
||||||
:layout="formPositionLayout" :mobile-announcement="mobileAnnouncement" :toolbar="toolbar"
|
:layout="formPositionLayout"
|
||||||
@captcha-change="handleCaptchaChange" @captcha-refresh="handleCaptchaRefresh" @change-locale="handleChangeLocale"
|
:mobile-announcement="mobileAnnouncement"
|
||||||
@forgot-password="handleForgotPassword" @select-announcement="handleSelectAnnouncement" @submit="onLogin"
|
:toolbar="toolbar"
|
||||||
@toggle-layout="handleToggleLayout" />
|
@captcha-change="handleCaptchaChange"
|
||||||
|
@captcha-refresh="handleCaptchaRefresh"
|
||||||
|
@change-locale="handleChangeLocale"
|
||||||
|
@forgot-password="handleForgotPassword"
|
||||||
|
@select-announcement="handleSelectAnnouncement"
|
||||||
|
@submit="onLogin"
|
||||||
|
@toggle-layout="handleToggleLayout"
|
||||||
|
/>
|
||||||
|
|
||||||
<v-dialog v-model="dialogVisible" width="360">
|
<v-dialog v-model="dialogVisible" width="360">
|
||||||
<v-card>
|
<v-card>
|
||||||
@@ -52,7 +59,10 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
import HyakkaouAcademyImage from '@/assets/logo.png'
|
import HyakkaouAcademyImage from '@/assets/logo.png'
|
||||||
import SKLogin from '@/components/SKLogin.vue'
|
import SKLogin from '@/components/SKLogin.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { type LoginAnnouncementListItem, useLoginAnnouncementsStore } from '@/stores/loginAnnouncements'
|
import {
|
||||||
|
type LoginAnnouncementListItem,
|
||||||
|
useLoginAnnouncementsStore,
|
||||||
|
} from '@/stores/loginAnnouncements'
|
||||||
import { useMenuStore } from '@/stores/menu'
|
import { useMenuStore } from '@/stores/menu'
|
||||||
import { useSnackbarStore } from '@/stores/snackbar'
|
import { useSnackbarStore } from '@/stores/snackbar'
|
||||||
|
|
||||||
@@ -71,8 +81,7 @@ const {
|
|||||||
mobileAnnouncementConfig: mobileAnnouncement,
|
mobileAnnouncementConfig: mobileAnnouncement,
|
||||||
selectedAnnouncement,
|
selectedAnnouncement,
|
||||||
selectedAnnouncementDetail,
|
selectedAnnouncementDetail,
|
||||||
} =
|
} = storeToRefs(loginAnnouncementsStore)
|
||||||
storeToRefs(loginAnnouncementsStore)
|
|
||||||
|
|
||||||
// 語系選項
|
// 語系選項
|
||||||
const locales = ['zh-TW', 'en-US']
|
const locales = ['zh-TW', 'en-US']
|
||||||
@@ -164,7 +173,6 @@ const form = computed(() => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
// 右上工具列設定(含顯示開關)
|
// 右上工具列設定(含顯示開關)
|
||||||
const toolbar = computed(() => ({
|
const toolbar = computed(() => ({
|
||||||
// 功能開關:是否顯示語系切換工具列
|
// 功能開關:是否顯示語系切換工具列
|
||||||
@@ -174,37 +182,37 @@ const toolbar = computed(() => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// 事件處理
|
// 事件處理
|
||||||
function handleForgotPassword (e: MouseEvent) {
|
function handleForgotPassword(e: MouseEvent) {
|
||||||
console.log('Playground Forgot Password Click:', e)
|
console.log('Playground Forgot Password Click:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleChangeLocale (nextLocale: string) {
|
function handleChangeLocale(nextLocale: string) {
|
||||||
locale.value = nextLocale
|
locale.value = nextLocale
|
||||||
localStorage.setItem('playground.locale', nextLocale)
|
localStorage.setItem('playground.locale', nextLocale)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCaptchaRefresh () {
|
async function handleCaptchaRefresh() {
|
||||||
captchaValue.value = ''
|
captchaValue.value = ''
|
||||||
await authStore.getCaptcha()
|
await authStore.getCaptcha()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCaptchaChange (value: string) {
|
function handleCaptchaChange(value: string) {
|
||||||
captchaValue.value = value
|
captchaValue.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleLayout () {
|
function handleToggleLayout() {
|
||||||
const layoutOrder: LayoutType[] = ['side-left', 'side-right', 'card']
|
const layoutOrder: LayoutType[] = ['side-left', 'side-right', 'card']
|
||||||
const currentIndex = layoutOrder.indexOf(formPositionLayout.value)
|
const currentIndex = layoutOrder.indexOf(formPositionLayout.value)
|
||||||
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % layoutOrder.length
|
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % layoutOrder.length
|
||||||
formPositionLayout.value = layoutOrder[nextIndex] ?? 'side-left'
|
formPositionLayout.value = layoutOrder[nextIndex] ?? 'side-left'
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSelectAnnouncement (item: LoginAnnouncementListItem) {
|
function handleSelectAnnouncement(item: LoginAnnouncementListItem) {
|
||||||
loginAnnouncementsStore.selectById(item.id)
|
loginAnnouncementsStore.selectById(item.id)
|
||||||
announcementDialogVisible.value = true
|
announcementDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onLogin (data: Record<string, unknown>) {
|
async function onLogin(data: Record<string, unknown>) {
|
||||||
if (withCaptcha.value && !captchaValue.value) {
|
if (withCaptcha.value && !captchaValue.value) {
|
||||||
dialogTitle.value = t('common.notice')
|
dialogTitle.value = t('common.notice')
|
||||||
dialogMessage.value = t('pages.login.alert.verifyRequired')
|
dialogMessage.value = t('pages.login.alert.verifyRequired')
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ const menuItems = ref([
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function updateTreeItemById (items: MenuItem[], updated: MenuItem): boolean {
|
function updateTreeItemById(items: MenuItem[], updated: MenuItem): boolean {
|
||||||
for (let i = 0; i < items.length; i += 1) {
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
const current = items[i]
|
const current = items[i]
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
@@ -138,7 +138,7 @@ function updateTreeItemById (items: MenuItem[], updated: MenuItem): boolean {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEdit (item: MenuItem) {
|
function onEdit(item: MenuItem) {
|
||||||
updateTreeItemById(menuItems.value, item)
|
updateTreeItemById(menuItems.value, item)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ const roles = ref([
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
function onSearch (params: Record<string, unknown>) {
|
function onSearch(params: Record<string, unknown>) {
|
||||||
console.log('Search:', params)
|
console.log('Search:', params)
|
||||||
loading.value = true
|
loading.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -93,28 +93,28 @@ function onSearch (params: Record<string, unknown>) {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onReset () {
|
function onReset() {
|
||||||
console.log('Reset')
|
console.log('Reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreate () {
|
function onCreate() {
|
||||||
console.log('Create Role')
|
console.log('Create Role')
|
||||||
alert('Create Role Clicked')
|
alert('Create Role Clicked')
|
||||||
}
|
}
|
||||||
|
|
||||||
function onEdit (item: RoleItem) {
|
function onEdit(item: RoleItem) {
|
||||||
console.log('Edit:', item)
|
console.log('Edit:', item)
|
||||||
alert(`Edit ${item.name}`)
|
alert(`Edit ${item.name}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDelete (item: RoleItem) {
|
function onDelete(item: RoleItem) {
|
||||||
console.log('Delete:', item)
|
console.log('Delete:', item)
|
||||||
if (confirm(`Are you sure you want to delete ${item.name}?`)) {
|
if (confirm(`Are you sure you want to delete ${item.name}?`)) {
|
||||||
roles.value = roles.value.filter((r) => r.id !== item.id)
|
roles.value = roles.value.filter((r) => r.id !== item.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatusUpdate (item: RoleItem, newVal: boolean) {
|
function onStatusUpdate(item: RoleItem, newVal: boolean) {
|
||||||
// In a real app, API call here
|
// In a real app, API call here
|
||||||
item.status = newVal
|
item.status = newVal
|
||||||
console.log('Status Update:', item.name, newVal)
|
console.log('Status Update:', item.name, newVal)
|
||||||
|
|||||||
@@ -8,14 +8,27 @@
|
|||||||
<div class="text-h5">{{ title }}</div>
|
<div class="text-h5">{{ title }}</div>
|
||||||
<div class="text-caption text-medium-emphasis">{{ codeLabel }}</div>
|
<div class="text-caption text-medium-emphasis">{{ codeLabel }}</div>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<img alt="robot" class="robot-icon" height="32" src="@/assets/robot-svgrepo-com.svg" width="32" />
|
<img
|
||||||
|
alt="robot"
|
||||||
|
class="robot-icon"
|
||||||
|
height="32"
|
||||||
|
src="@/assets/robot-svgrepo-com.svg"
|
||||||
|
width="32"
|
||||||
|
/>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text v-if="description" class="text-body-1 mt-4">
|
<v-card-text v-if="description" class="text-body-1 mt-4">
|
||||||
<p class="py-3">
|
<p class="py-3">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</p>
|
</p>
|
||||||
<v-alert v-if="backendMessage" class="mt-6" :color="color" density="compact" type="warning" variant="tonal">
|
<v-alert
|
||||||
|
v-if="backendMessage"
|
||||||
|
class="mt-6"
|
||||||
|
:color="color"
|
||||||
|
density="compact"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
{{ backendMessage }}
|
{{ backendMessage }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
@@ -23,8 +36,16 @@
|
|||||||
<v-card-actions class="mt-6">
|
<v-card-actions class="mt-6">
|
||||||
<v-btn v-if="showBack" variant="text" @click="router.back()">返回上一頁</v-btn>
|
<v-btn v-if="showBack" variant="text" @click="router.back()">返回上一頁</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn v-if="showHome" color="primary" :to="{ name: 'home' }" variant="flat">回到首頁</v-btn>
|
<v-btn v-if="showHome" color="primary" :to="{ name: 'home' }" variant="flat"
|
||||||
<v-btn v-if="showLogin" class="ml-2" color="primary" :to="{ name: 'login' }" variant="outlined">
|
>回到首頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
v-if="showLogin"
|
||||||
|
class="ml-2"
|
||||||
|
color="primary"
|
||||||
|
:to="{ name: 'login' }"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
前往登入
|
前往登入
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
@@ -56,7 +77,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
color: 'warning',
|
color: 'warning',
|
||||||
showHome: true,
|
showHome: true,
|
||||||
showLogin: true,
|
showLogin: true,
|
||||||
showBack: true
|
showBack: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -76,7 +97,8 @@ const backendMessage = computed(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@keyframes breathe {
|
@keyframes breathe {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -87,7 +109,10 @@ const backendMessage = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 45%, 55%, 100% {
|
0%,
|
||||||
|
45%,
|
||||||
|
55%,
|
||||||
|
100% {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
50% {
|
50% {
|
||||||
@@ -108,4 +133,3 @@ const backendMessage = computed(() => {
|
|||||||
animation-delay: 0.1s;
|
animation-delay: 0.1s;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,3 @@
|
|||||||
import { mdiTools } from '@mdi/js'
|
import { mdiTools } from '@mdi/js'
|
||||||
import ErrorShell from './ErrorShell.vue'
|
import ErrorShell from './ErrorShell.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,3 @@
|
|||||||
import { mdiWifiOff } from '@mdi/js'
|
import { mdiWifiOff } from '@mdi/js'
|
||||||
import ErrorShell from './ErrorShell.vue'
|
import ErrorShell from './ErrorShell.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,3 @@
|
|||||||
import { mdiServer } from '@mdi/js'
|
import { mdiServer } from '@mdi/js'
|
||||||
import ErrorShell from './ErrorShell.vue'
|
import ErrorShell from './ErrorShell.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -12,4 +12,3 @@
|
|||||||
import { mdiServerOff } from '@mdi/js'
|
import { mdiServerOff } from '@mdi/js'
|
||||||
import ErrorShell from './ErrorShell.vue'
|
import ErrorShell from './ErrorShell.vue'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<mnt-page-cards
|
<mnt-page-cards
|
||||||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範A`"
|
:search-panel-open="searchPanelOpen"
|
||||||
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
|
:title="`主從資料維護示範A`"
|
||||||
|
@create="openAddDialog"
|
||||||
|
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||||
|
>
|
||||||
<template #search-fields>
|
<template #search-fields>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
id="search-student-id" v-model="search.studentId" aria-labelledby="search-student-id-label" density="compact" hide-details name="searchStudentId" placeholder="例如:S2024001"
|
id="search-student-id"
|
||||||
variant="outlined" />
|
v-model="search.studentId"
|
||||||
|
aria-labelledby="search-student-id-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchStudentId"
|
||||||
|
placeholder="例如:S2024001"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||||
<v-text-field id="search-name" v-model="search.name" aria-labelledby="search-name-label" density="compact" hide-details name="searchName" placeholder="例如:王小明" variant="outlined" />
|
<v-text-field
|
||||||
|
id="search-name"
|
||||||
|
v-model="search.name"
|
||||||
|
aria-labelledby="search-name-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchName"
|
||||||
|
placeholder="例如:王小明"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||||
<v-select id="search-department" v-model="search.department" aria-labelledby="search-department-label" density="compact" hide-details :items="departments" name="searchDepartment" variant="outlined" />
|
<v-select
|
||||||
|
id="search-department"
|
||||||
|
v-model="search.department"
|
||||||
|
aria-labelledby="search-department-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="departments"
|
||||||
|
name="searchDepartment"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||||
<v-select
|
<v-select
|
||||||
id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" density="compact" hide-details item-title="title" item-value="value"
|
id="search-grade"
|
||||||
:items="gradeOptions" name="searchGrade" variant="outlined" />
|
v-model="search.grade"
|
||||||
|
aria-labelledby="search-grade-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="gradeOptions"
|
||||||
|
name="searchGrade"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||||
<v-select id="search-status" v-model="search.status" aria-labelledby="search-status-label" density="compact" hide-details :items="statuses" name="searchStatus" variant="outlined" />
|
<v-select
|
||||||
|
id="search-status"
|
||||||
|
v-model="search.status"
|
||||||
|
aria-labelledby="search-status-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="statuses"
|
||||||
|
name="searchStatus"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
||||||
@@ -34,9 +80,17 @@ id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" de
|
|||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<v-data-table
|
<v-data-table
|
||||||
v-model:page="currentPage" class="student-table" density="compact" fixed-header :headers="tableHeaders" height="100%"
|
v-model:page="currentPage"
|
||||||
hide-default-footer :items="students" :items-per-page="itemsPerPage"
|
class="student-table"
|
||||||
:row-props="rowProps">
|
density="compact"
|
||||||
|
fixed-header
|
||||||
|
:headers="tableHeaders"
|
||||||
|
height="100%"
|
||||||
|
hide-default-footer
|
||||||
|
:items="students"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:row-props="rowProps"
|
||||||
|
>
|
||||||
<template #[`item.grade`]="{ item }">
|
<template #[`item.grade`]="{ item }">
|
||||||
{{ gradeLabel(item.grade) }}
|
{{ gradeLabel(item.grade) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -47,15 +101,31 @@ v-model:page="currentPage" class="student-table" density="compact" fixed-header
|
|||||||
</template>
|
</template>
|
||||||
<template #[`item.actions`]="{ item }">
|
<template #[`item.actions`]="{ item }">
|
||||||
<div class="d-flex ga-1">
|
<div class="d-flex ga-1">
|
||||||
<v-btn color="info" :prepend-icon="mdiEye" size="small" variant="text" @click="openViewDialog(item)">
|
<v-btn
|
||||||
|
color="info"
|
||||||
|
:prepend-icon="mdiEye"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openViewDialog(item)"
|
||||||
|
>
|
||||||
檢視
|
檢視
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn color="primary" :prepend-icon="mdiPencil" size="small" variant="text" @click="openEditDialog(item)">
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:prepend-icon="mdiPencil"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openEditDialog(item)"
|
||||||
|
>
|
||||||
修改
|
修改
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
color="error"
|
||||||
@click="requestDeleteConfirmation(item)">
|
:prepend-icon="mdiDelete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="requestDeleteConfirmation(item)"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,11 +136,35 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
|||||||
{{ pageSummary }}
|
{{ pageSummary }}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<v-btn :disabled="currentPage <= 1" size="small" variant="text" @click="currentPage = 1">第一頁</v-btn>
|
<v-btn
|
||||||
<v-btn :disabled="currentPage <= 1" size="small" variant="text" @click="currentPage -= 1">上一頁</v-btn>
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage = 1"
|
||||||
|
>第一頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage -= 1"
|
||||||
|
>上一頁</v-btn
|
||||||
|
>
|
||||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||||
<v-btn :disabled="currentPage >= pageCount" size="small" variant="text" @click="currentPage += 1">下一頁</v-btn>
|
<v-btn
|
||||||
<v-btn :disabled="currentPage >= pageCount" size="small" variant="text" @click="currentPage = pageCount">最後頁</v-btn>
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage += 1"
|
||||||
|
>下一頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage = pageCount"
|
||||||
|
>最後頁</v-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,15 +177,21 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
|||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
||||||
<v-overlay
|
<v-overlay
|
||||||
class="dialog-overlay" :close-on-content-click="false" :model-value="dialogVisible" scrim="rgba(0, 0, 0, 0.45)"
|
class="dialog-overlay"
|
||||||
scroll-strategy="block" @update:model-value="handleDialogVisibility">
|
:close-on-content-click="false"
|
||||||
|
:model-value="dialogVisible"
|
||||||
|
scrim="rgba(0, 0, 0, 0.45)"
|
||||||
|
scroll-strategy="block"
|
||||||
|
@update:model-value="handleDialogVisibility"
|
||||||
|
>
|
||||||
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
||||||
|
|
||||||
<!-- 子檔區塊 (Detail Card):學期成績明細 -->
|
<!-- 子檔區塊 (Detail Card):學期成績明細 -->
|
||||||
<!-- 說明:點選主檔的學期後,會從左側滑出顯示該學期的詳細課程成績 -->
|
<!-- 說明:點選主檔的學期後,會從左側滑出顯示該學期的詳細課程成績 -->
|
||||||
<div
|
<div
|
||||||
v-if="!isMobile || activeMobilePanel === 'detail'" class="detail-panel-wrapper"
|
v-if="!isMobile || activeMobilePanel === 'detail'"
|
||||||
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }">
|
class="detail-panel-wrapper"
|
||||||
|
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
|
||||||
|
>
|
||||||
<master-detail-semester-panel
|
<master-detail-semester-panel
|
||||||
v-model:detail-form="detailForm"
|
v-model:detail-form="detailForm"
|
||||||
:is-detail-editing="isDetailEditing"
|
:is-detail-editing="isDetailEditing"
|
||||||
@@ -109,34 +209,65 @@ v-if="!isMobile || activeMobilePanel === 'detail'" class="detail-panel-wrapper"
|
|||||||
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
||||||
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
||||||
<mnt-dialog-card
|
<mnt-dialog-card
|
||||||
v-if="!isMobile || activeMobilePanel === 'master'" :dialog-subtitle="dialogSubtitle"
|
v-if="!isMobile || activeMobilePanel === 'master'"
|
||||||
:dialog-title="dialogTitle" :is-edit-mode="isEditMode" :is-view-mode="isViewMode"
|
:dialog-subtitle="dialogSubtitle"
|
||||||
:width="isMobile ? '100%' : 760">
|
:dialog-title="dialogTitle"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
:width="isMobile ? '100%' : 760"
|
||||||
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<mnt-record-nav-toolbar
|
<mnt-record-nav-toolbar
|
||||||
:has-next-record="hasNextRecord" :has-prev-record="hasPrevRecord"
|
:has-next-record="hasNextRecord"
|
||||||
:is-edit-mode="isEditMode" :is-view-mode="isViewMode" :mobile="isMobile"
|
:has-prev-record="hasPrevRecord"
|
||||||
@first="openEdgeRecord('first')" @last="openEdgeRecord('last')" @next="openAdjacentRecord('next')"
|
:is-edit-mode="isEditMode"
|
||||||
@prev="openAdjacentRecord('prev')" @switch-to-edit="switchToEditMode" @switch-to-view="switchToViewMode" />
|
:is-view-mode="isViewMode"
|
||||||
|
:mobile="isMobile"
|
||||||
|
@first="openEdgeRecord('first')"
|
||||||
|
@last="openEdgeRecord('last')"
|
||||||
|
@next="openAdjacentRecord('next')"
|
||||||
|
@prev="openAdjacentRecord('prev')"
|
||||||
|
@switch-to-edit="switchToEditMode"
|
||||||
|
@switch-to-view="switchToViewMode"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
||||||
<v-alert v-if="errorSummary.length > 0 && !isLoading" class="mb-4" type="error" variant="tonal">
|
<v-alert
|
||||||
|
v-if="errorSummary.length > 0 && !isLoading"
|
||||||
|
class="mb-4"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
||||||
<div class="d-flex flex-column ga-1">
|
<div class="d-flex flex-column ga-1">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-for="error in errorSummary" :key="error.field" color="error" size="small" variant="text"
|
v-for="error in errorSummary"
|
||||||
@click="scrollToField(error.field)">
|
:key="error.field"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="scrollToField(error.field)"
|
||||||
|
>
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- 載入中骨架畫面 -->
|
<!-- 載入中骨架畫面 -->
|
||||||
<v-skeleton-loader v-if="isLoading" class="mt-4" type="subtitle,paragraph" width="100%" />
|
<v-skeleton-loader
|
||||||
|
v-if="isLoading"
|
||||||
|
class="mt-4"
|
||||||
|
type="subtitle,paragraph"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
||||||
<v-form v-else :class="{ 'form-readonly': isFormReadonly }" @submit.prevent="requestSaveConfirmation">
|
<v-form
|
||||||
|
v-else
|
||||||
|
:class="{ 'form-readonly': isFormReadonly }"
|
||||||
|
@submit.prevent="requestSaveConfirmation"
|
||||||
|
>
|
||||||
<maintenance-student-form-fields
|
<maintenance-student-form-fields
|
||||||
:departments="departments"
|
:departments="departments"
|
||||||
:enroll-years="enrollYears"
|
:enroll-years="enrollYears"
|
||||||
@@ -154,21 +285,35 @@ v-for="error in errorSummary" :key="error.field" color="error" size="small" vari
|
|||||||
<!-- 學期成績紀錄區塊 -->
|
<!-- 學期成績紀錄區塊 -->
|
||||||
<!-- 說明:顯示該學生的所有學期紀錄,並提供新增與選取功能 -->
|
<!-- 說明:顯示該學生的所有學期紀錄,並提供新增與選取功能 -->
|
||||||
<master-detail-semester-list
|
<master-detail-semester-list
|
||||||
:is-mobile="isMobile" :is-view-mode="isViewMode"
|
:is-mobile="isMobile"
|
||||||
:selected-semester-id="selectedSemesterId" :semesters="studentSemesters" @add="handleAddSemester"
|
:is-view-mode="isViewMode"
|
||||||
@select="handleSemesterSelect" />
|
:selected-semester-id="selectedSemesterId"
|
||||||
|
:semesters="studentSemesters"
|
||||||
|
@add="handleAddSemester"
|
||||||
|
@select="handleSemesterSelect"
|
||||||
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<template v-if="isMobile">
|
<template v-if="isMobile">
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||||
<v-btn v-if="isEditMode" color="error" :disabled="isSaving" variant="tonal" @click="requestDeleteCurrent">
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="requestDeleteCurrent"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="isSaving"
|
v-if="!isViewMode"
|
||||||
variant="flat" @click="requestSaveConfirmation">
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestSaveConfirmation"
|
||||||
|
>
|
||||||
儲存
|
儲存
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||||
@@ -176,20 +321,29 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||||
<v-btn v-if="isEditMode" color="error" :disabled="isSaving" variant="tonal" @click="requestDeleteCurrent">
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="requestDeleteCurrent"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="isSaving"
|
v-if="!isViewMode"
|
||||||
variant="flat" @click="requestSaveConfirmation">
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestSaveConfirmation"
|
||||||
|
>
|
||||||
儲存
|
儲存
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</mnt-dialog-card>
|
</mnt-dialog-card>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</v-overlay>
|
</v-overlay>
|
||||||
</teleport>
|
</teleport>
|
||||||
@@ -255,8 +409,20 @@ const isMobile = computed(() => !smAndUp.value)
|
|||||||
|
|
||||||
// 表格欄位設定(含固定欄與排序)
|
// 表格欄位設定(含固定欄與排序)
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && 'start' as const, width: 120 },
|
{
|
||||||
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && 'start' as const, width: 100 },
|
title: '學號',
|
||||||
|
key: 'studentId',
|
||||||
|
sortable: true,
|
||||||
|
fixed: smAndUp.value && ('start' as const),
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
key: 'name',
|
||||||
|
sortable: true,
|
||||||
|
fixed: smAndUp.value && ('start' as const),
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
||||||
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
||||||
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
||||||
@@ -265,7 +431,14 @@ const tableHeaders = [
|
|||||||
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
||||||
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
||||||
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
||||||
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && 'end' as const, width: 'auto', cellProps: { class: 'px-0 bg-background' } },
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
sortable: false,
|
||||||
|
fixed: smAndUp.value && ('end' as const),
|
||||||
|
width: 'auto',
|
||||||
|
cellProps: { class: 'px-0 bg-background' },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 查詢條件(示意用,未接 API)
|
// 查詢條件(示意用,未接 API)
|
||||||
@@ -312,8 +485,8 @@ const loadSequence = ref(0)
|
|||||||
const studentSemesters = ref<SemesterRecord[]>([])
|
const studentSemesters = ref<SemesterRecord[]>([])
|
||||||
const selectedSemesterId = ref<number | null>(null)
|
const selectedSemesterId = ref<number | null>(null)
|
||||||
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
||||||
const selectedSemester = computed(() =>
|
const selectedSemester = computed(
|
||||||
studentSemesters.value.find((s) => s.id === selectedSemesterId.value) || null
|
() => studentSemesters.value.find((s) => s.id === selectedSemesterId.value) || null
|
||||||
)
|
)
|
||||||
|
|
||||||
// Detail Editing State (子檔編輯狀態管理)
|
// Detail Editing State (子檔編輯狀態管理)
|
||||||
@@ -321,14 +494,14 @@ const isDetailEditing = ref(false)
|
|||||||
const detailForm = ref<SemesterRecord | null>(null)
|
const detailForm = ref<SemesterRecord | null>(null)
|
||||||
|
|
||||||
// 輔助函式:重新載入當前學生的學期資料
|
// 輔助函式:重新載入當前學生的學期資料
|
||||||
function refreshSemesters () {
|
function refreshSemesters() {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 處理新增學期
|
// 處理新增學期
|
||||||
function handleAddSemester () {
|
function handleAddSemester() {
|
||||||
if (!editingId.value) return
|
if (!editingId.value) return
|
||||||
const newSem = semesterStore.addSemester(editingId.value)
|
const newSem = semesterStore.addSemester(editingId.value)
|
||||||
refreshSemesters()
|
refreshSemesters()
|
||||||
@@ -338,7 +511,7 @@ function handleAddSemester () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 處理刪除學期
|
// 處理刪除學期
|
||||||
function handleDeleteSemester (id: number) {
|
function handleDeleteSemester(id: number) {
|
||||||
// 簡單確認對話框 (實作時可改用更美觀的 Modal)
|
// 簡單確認對話框 (實作時可改用更美觀的 Modal)
|
||||||
if (!confirm('確定要刪除此學期紀錄嗎?')) return
|
if (!confirm('確定要刪除此學期紀錄嗎?')) return
|
||||||
|
|
||||||
@@ -353,7 +526,7 @@ function handleDeleteSemester (id: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 開始編輯子檔 (複製資料到暫存表單)
|
// 開始編輯子檔 (複製資料到暫存表單)
|
||||||
function startDetailEdit () {
|
function startDetailEdit() {
|
||||||
if (!selectedSemester.value) return
|
if (!selectedSemester.value) return
|
||||||
// 需要深拷貝來避免直接改到原始資料,且保留巢狀結構的語意
|
// 需要深拷貝來避免直接改到原始資料,且保留巢狀結構的語意
|
||||||
detailForm.value = structuredClone(selectedSemester.value)
|
detailForm.value = structuredClone(selectedSemester.value)
|
||||||
@@ -361,7 +534,7 @@ function startDetailEdit () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 取消編輯子檔
|
// 取消編輯子檔
|
||||||
function cancelDetailEdit () {
|
function cancelDetailEdit() {
|
||||||
isDetailEditing.value = false
|
isDetailEditing.value = false
|
||||||
detailForm.value = null
|
detailForm.value = null
|
||||||
if (isMobile.value && selectedSemesterId.value === null) {
|
if (isMobile.value && selectedSemesterId.value === null) {
|
||||||
@@ -370,7 +543,7 @@ function cancelDetailEdit () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 儲存子檔變更
|
// 儲存子檔變更
|
||||||
function saveDetailEdit () {
|
function saveDetailEdit() {
|
||||||
if (!detailForm.value || !detailForm.value.id) return
|
if (!detailForm.value || !detailForm.value.id) return
|
||||||
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
|
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
|
||||||
refreshSemesters()
|
refreshSemesters()
|
||||||
@@ -467,7 +640,7 @@ const {
|
|||||||
})
|
})
|
||||||
const isFormReadonly = computed(() => isViewMode.value)
|
const isFormReadonly = computed(() => isViewMode.value)
|
||||||
// 重設查詢條件
|
// 重設查詢條件
|
||||||
function resetSearch () {
|
function resetSearch() {
|
||||||
search.value = {
|
search.value = {
|
||||||
studentId: '',
|
studentId: '',
|
||||||
name: '',
|
name: '',
|
||||||
@@ -484,7 +657,7 @@ watch(pageCount, (value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 新增:開啟彈窗,使用預設值
|
// 新增:開啟彈窗,使用預設值
|
||||||
function openAddDialog () {
|
function openAddDialog() {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
@@ -498,7 +671,7 @@ function openAddDialog () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 編輯:先開彈窗,資料載入後填入
|
// 編輯:先開彈窗,資料載入後填入
|
||||||
function openEditDialog (student: StudentRecord) {
|
function openEditDialog(student: StudentRecord) {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
const sequence = loadSequence.value
|
const sequence = loadSequence.value
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
@@ -529,7 +702,7 @@ function openEditDialog (student: StudentRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 檢視:只讀模式並預設展開所有分組
|
// 檢視:只讀模式並預設展開所有分組
|
||||||
function openViewDialog (student: StudentRecord) {
|
function openViewDialog(student: StudentRecord) {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
const sequence = loadSequence.value
|
const sequence = loadSequence.value
|
||||||
dialogMode.value = 'view'
|
dialogMode.value = 'view'
|
||||||
@@ -560,7 +733,7 @@ function openViewDialog (student: StudentRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先檢核再提示儲存確認
|
// 先檢核再提示儲存確認
|
||||||
async function requestSaveConfirmation () {
|
async function requestSaveConfirmation() {
|
||||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||||
clearAllErrors()
|
clearAllErrors()
|
||||||
|
|
||||||
@@ -579,13 +752,13 @@ async function requestSaveConfirmation () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 儲存確認後才真正送出
|
// 儲存確認後才真正送出
|
||||||
function confirmSave () {
|
function confirmSave() {
|
||||||
confirmSaveVisible.value = false
|
confirmSaveVisible.value = false
|
||||||
saveStudent()
|
saveStudent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 寫入資料(Demo:直接更新列表)
|
// 寫入資料(Demo:直接更新列表)
|
||||||
async function saveStudent () {
|
async function saveStudent() {
|
||||||
if (isSaving.value || isLoading.value) return
|
if (isSaving.value || isLoading.value) return
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||||
@@ -620,23 +793,23 @@ async function saveStudent () {
|
|||||||
}, 1600)
|
}, 1600)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToField (field: string) {
|
function scrollToField(field: string) {
|
||||||
const target = document.getElementById(`field-${field}`)
|
const target = document.getElementById(`field-${field}`)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSemesterSelect (id: number) {
|
function handleSemesterSelect(id: number) {
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
selectedSemesterId.value = id
|
selectedSemesterId.value = id
|
||||||
activeMobilePanel.value = 'detail'
|
activeMobilePanel.value = 'detail'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
selectedSemesterId.value = selectedSemesterId.value === id ? null : id;
|
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDetailPanel () {
|
function closeDetailPanel() {
|
||||||
isDetailEditing.value = false
|
isDetailEditing.value = false
|
||||||
detailForm.value = null
|
detailForm.value = null
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
@@ -668,7 +841,7 @@ function closeDetailPanel () {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-panel>.v-card {
|
.dialog-panel > .v-card {
|
||||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -727,7 +900,7 @@ function closeDetailPanel () {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-panel>.v-card {
|
.dialog-panel > .v-card {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
@@ -737,8 +910,6 @@ function closeDetailPanel () {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
|
|||||||
@@ -1,31 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<mnt-page-cards
|
<mnt-page-cards
|
||||||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範B`"
|
:search-panel-open="searchPanelOpen"
|
||||||
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
|
:title="`主從資料維護示範B`"
|
||||||
|
@create="openAddDialog"
|
||||||
|
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||||
|
>
|
||||||
<template #search-fields>
|
<template #search-fields>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
id="search-student-id" v-model="search.studentId" aria-labelledby="search-student-id-label" density="compact" hide-details name="searchStudentId" placeholder="例如:S2024001"
|
id="search-student-id"
|
||||||
variant="outlined" />
|
v-model="search.studentId"
|
||||||
|
aria-labelledby="search-student-id-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchStudentId"
|
||||||
|
placeholder="例如:S2024001"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||||
<v-text-field id="search-name" v-model="search.name" aria-labelledby="search-name-label" density="compact" hide-details name="searchName" placeholder="例如:王小明" variant="outlined" />
|
<v-text-field
|
||||||
|
id="search-name"
|
||||||
|
v-model="search.name"
|
||||||
|
aria-labelledby="search-name-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchName"
|
||||||
|
placeholder="例如:王小明"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||||
<v-select id="search-department" v-model="search.department" aria-labelledby="search-department-label" density="compact" hide-details :items="departments" name="searchDepartment" variant="outlined" />
|
<v-select
|
||||||
|
id="search-department"
|
||||||
|
v-model="search.department"
|
||||||
|
aria-labelledby="search-department-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="departments"
|
||||||
|
name="searchDepartment"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||||
<v-select
|
<v-select
|
||||||
id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" density="compact" hide-details item-title="title" item-value="value"
|
id="search-grade"
|
||||||
:items="gradeOptions" name="searchGrade" variant="outlined" />
|
v-model="search.grade"
|
||||||
|
aria-labelledby="search-grade-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="gradeOptions"
|
||||||
|
name="searchGrade"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||||
<v-select id="search-status" v-model="search.status" aria-labelledby="search-status-label" density="compact" hide-details :items="statuses" name="searchStatus" variant="outlined" />
|
<v-select
|
||||||
|
id="search-status"
|
||||||
|
v-model="search.status"
|
||||||
|
aria-labelledby="search-status-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="statuses"
|
||||||
|
name="searchStatus"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
||||||
@@ -34,9 +80,17 @@ id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" de
|
|||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<v-data-table
|
<v-data-table
|
||||||
v-model:page="currentPage" class="student-table" density="compact" fixed-header :headers="tableHeaders" height="100%"
|
v-model:page="currentPage"
|
||||||
hide-default-footer :items="students" :items-per-page="itemsPerPage"
|
class="student-table"
|
||||||
:row-props="rowProps">
|
density="compact"
|
||||||
|
fixed-header
|
||||||
|
:headers="tableHeaders"
|
||||||
|
height="100%"
|
||||||
|
hide-default-footer
|
||||||
|
:items="students"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:row-props="rowProps"
|
||||||
|
>
|
||||||
<template #[`item.grade`]="{ item }">
|
<template #[`item.grade`]="{ item }">
|
||||||
{{ gradeLabel(item.grade) }}
|
{{ gradeLabel(item.grade) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -47,15 +101,31 @@ v-model:page="currentPage" class="student-table" density="compact" fixed-header
|
|||||||
</template>
|
</template>
|
||||||
<template #[`item.actions`]="{ item }">
|
<template #[`item.actions`]="{ item }">
|
||||||
<div class="d-flex ga-2">
|
<div class="d-flex ga-2">
|
||||||
<v-btn color="info" :prepend-icon="mdiEye" size="small" variant="text" @click="openViewDialog(item)">
|
<v-btn
|
||||||
|
color="info"
|
||||||
|
:prepend-icon="mdiEye"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openViewDialog(item)"
|
||||||
|
>
|
||||||
檢視
|
檢視
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn color="primary" :prepend-icon="mdiPencil" size="small" variant="text" @click="openEditDialog(item)">
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:prepend-icon="mdiPencil"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openEditDialog(item)"
|
||||||
|
>
|
||||||
修改
|
修改
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
color="error"
|
||||||
@click="requestDeleteConfirmation(item)">
|
:prepend-icon="mdiDelete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="requestDeleteConfirmation(item)"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,11 +136,35 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
|||||||
{{ pageSummary }}
|
{{ pageSummary }}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<v-btn :disabled="currentPage <= 1" size="small" variant="text" @click="currentPage = 1">第一頁</v-btn>
|
<v-btn
|
||||||
<v-btn :disabled="currentPage <= 1" size="small" variant="text" @click="currentPage -= 1">上一頁</v-btn>
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage = 1"
|
||||||
|
>第一頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage -= 1"
|
||||||
|
>上一頁</v-btn
|
||||||
|
>
|
||||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||||
<v-btn :disabled="currentPage >= pageCount" size="small" variant="text" @click="currentPage += 1">下一頁</v-btn>
|
<v-btn
|
||||||
<v-btn :disabled="currentPage >= pageCount" size="small" variant="text" @click="currentPage = pageCount">最後頁</v-btn>
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage += 1"
|
||||||
|
>下一頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage = pageCount"
|
||||||
|
>最後頁</v-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,50 +177,96 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
|||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
||||||
<v-overlay
|
<v-overlay
|
||||||
class="dialog-overlay" :close-on-content-click="false" :model-value="dialogVisible" scrim="rgba(0, 0, 0, 0.45)"
|
class="dialog-overlay"
|
||||||
scroll-strategy="block" @update:model-value="handleDialogVisibility">
|
:close-on-content-click="false"
|
||||||
|
:model-value="dialogVisible"
|
||||||
|
scrim="rgba(0, 0, 0, 0.45)"
|
||||||
|
scroll-strategy="block"
|
||||||
|
@update:model-value="handleDialogVisibility"
|
||||||
|
>
|
||||||
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
||||||
<master-detail-b-semester-mobile-panel
|
<master-detail-b-semester-mobile-panel
|
||||||
v-if="isMobile && activeMobilePanel === 'detail'"
|
v-if="isMobile && activeMobilePanel === 'detail'"
|
||||||
:is-form-locked="isFormLocked" :is-view-mode="isViewMode" :semester="selectedSemester"
|
:is-form-locked="isFormLocked"
|
||||||
@add-course="openAddCourseDialog" @close="closeDetailPanel" @delete-course="requestDeleteCourse"
|
:is-view-mode="isViewMode"
|
||||||
@update-course="handleUpdateCourse" @update-semester="handleUpdateSemester" />
|
:semester="selectedSemester"
|
||||||
|
@add-course="openAddCourseDialog"
|
||||||
|
@close="closeDetailPanel"
|
||||||
|
@delete-course="requestDeleteCourse"
|
||||||
|
@update-course="handleUpdateCourse"
|
||||||
|
@update-semester="handleUpdateSemester"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
||||||
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
||||||
<mnt-dialog-card
|
<mnt-dialog-card
|
||||||
v-else :content-class="isMobile ? 'pa-3 flex-grow-1 overflow-y-auto pb-16' : 'pa-2 flex-grow-1 overflow-hidden d-flex flex-column'" :dialog-subtitle="dialogSubtitle" :dialog-title="dialogTitle"
|
v-else
|
||||||
:is-edit-mode="isEditMode" :is-view-mode="isViewMode"
|
:content-class="
|
||||||
width="100%">
|
isMobile
|
||||||
|
? 'pa-3 flex-grow-1 overflow-y-auto pb-16'
|
||||||
|
: 'pa-2 flex-grow-1 overflow-hidden d-flex flex-column'
|
||||||
|
"
|
||||||
|
:dialog-subtitle="dialogSubtitle"
|
||||||
|
:dialog-title="dialogTitle"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<mnt-record-nav-toolbar
|
<mnt-record-nav-toolbar
|
||||||
:has-next-record="hasNextRecord" :has-prev-record="hasPrevRecord"
|
:has-next-record="hasNextRecord"
|
||||||
:is-edit-mode="isEditMode" :is-view-mode="isViewMode" :mobile="isMobile"
|
:has-prev-record="hasPrevRecord"
|
||||||
@first="openEdgeRecord('first')" @last="openEdgeRecord('last')" @next="openAdjacentRecord('next')"
|
:is-edit-mode="isEditMode"
|
||||||
@prev="openAdjacentRecord('prev')" @switch-to-edit="switchToEditMode" @switch-to-view="switchToViewMode" />
|
:is-view-mode="isViewMode"
|
||||||
|
:mobile="isMobile"
|
||||||
|
@first="openEdgeRecord('first')"
|
||||||
|
@last="openEdgeRecord('last')"
|
||||||
|
@next="openAdjacentRecord('next')"
|
||||||
|
@prev="openAdjacentRecord('prev')"
|
||||||
|
@switch-to-edit="switchToEditMode"
|
||||||
|
@switch-to-view="switchToViewMode"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
||||||
<v-alert v-if="errorSummary.length > 0 && !isLoading" class="mb-4" type="error" variant="tonal">
|
<v-alert
|
||||||
|
v-if="errorSummary.length > 0 && !isLoading"
|
||||||
|
class="mb-4"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
||||||
<div class="d-flex flex-column ga-1">
|
<div class="d-flex flex-column ga-1">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-for="error in errorSummary" :key="error.field" color="error" size="small" variant="text"
|
v-for="error in errorSummary"
|
||||||
@click="scrollToField(error.field)">
|
:key="error.field"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="scrollToField(error.field)"
|
||||||
|
>
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- 載入中骨架畫面 -->
|
<!-- 載入中骨架畫面 -->
|
||||||
<v-skeleton-loader v-if="isLoading" class="mt-4" type="subtitle,paragraph" width="100%" />
|
<v-skeleton-loader
|
||||||
|
v-if="isLoading"
|
||||||
|
class="mt-4"
|
||||||
|
type="subtitle,paragraph"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
||||||
<v-form
|
<v-form
|
||||||
v-else :class="[
|
v-else
|
||||||
|
:class="[
|
||||||
{ 'form-readonly': isFormReadonly },
|
{ 'form-readonly': isFormReadonly },
|
||||||
isMobile ? '' : 'd-flex flex-column h-100',
|
isMobile ? '' : 'd-flex flex-column h-100',
|
||||||
]" @submit.prevent="requestSaveConfirmation">
|
]"
|
||||||
|
@submit.prevent="requestSaveConfirmation"
|
||||||
|
>
|
||||||
<maintenance-student-form-fields
|
<maintenance-student-form-fields
|
||||||
:departments="departments"
|
:departments="departments"
|
||||||
:enroll-years="enrollYears"
|
:enroll-years="enrollYears"
|
||||||
@@ -142,37 +282,77 @@ v-else :class="[
|
|||||||
<v-divider />
|
<v-divider />
|
||||||
|
|
||||||
<master-detail-b-semester-section
|
<master-detail-b-semester-section
|
||||||
:is-form-locked="isFormLocked" :is-form-readonly="isFormReadonly"
|
:is-form-locked="isFormLocked"
|
||||||
:is-mobile="isMobile" :selected-semester-id="selectedSemesterId"
|
:is-form-readonly="isFormReadonly"
|
||||||
:semesters="studentSemesters" @add-course="openAddCourseDialog"
|
:is-mobile="isMobile"
|
||||||
@delete-course="requestDeleteCourse" @select="handleSemesterSelect"
|
:selected-semester-id="selectedSemesterId"
|
||||||
@update-course="handleUpdateCourse" />
|
:semesters="studentSemesters"
|
||||||
|
@add-course="openAddCourseDialog"
|
||||||
|
@delete-course="requestDeleteCourse"
|
||||||
|
@select="handleSemesterSelect"
|
||||||
|
@update-course="handleUpdateCourse"
|
||||||
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<template v-if="isMobile">
|
<template v-if="isMobile">
|
||||||
<v-btn class="flex-grow-1" :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isEditMode" class="flex-grow-1" color="error" :disabled="isSaving" variant="tonal"
|
class="flex-grow-1"
|
||||||
@click="requestDeleteCurrent">
|
:disabled="isSaving"
|
||||||
|
variant="text"
|
||||||
|
@click="requestCloseDialog"
|
||||||
|
>取消</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
class="flex-grow-1"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="requestDeleteCurrent"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isViewMode" class="flex-grow-1" color="primary" :disabled="!isDirty || isLoading" :loading="isSaving"
|
v-if="!isViewMode"
|
||||||
variant="flat" @click="requestSaveConfirmation">
|
class="flex-grow-1"
|
||||||
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestSaveConfirmation"
|
||||||
|
>
|
||||||
儲存
|
儲存
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else class="flex-grow-1" color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
<v-btn
|
||||||
|
v-else
|
||||||
|
class="flex-grow-1"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestCloseDialog"
|
||||||
|
>關閉</v-btn
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||||
<v-btn v-if="isEditMode" color="error" :disabled="isSaving" variant="tonal" @click="requestDeleteCurrent">
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="requestDeleteCurrent"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="isSaving"
|
v-if="!isViewMode"
|
||||||
variant="flat" @click="requestSaveConfirmation">
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestSaveConfirmation"
|
||||||
|
>
|
||||||
儲存
|
儲存
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||||
@@ -206,9 +386,13 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i
|
|||||||
|
|
||||||
<!-- 刪除課程確認 -->
|
<!-- 刪除課程確認 -->
|
||||||
<common-confirm-dialog
|
<common-confirm-dialog
|
||||||
v-model="confirmDeleteCourseVisible" confirm-color="error"
|
v-model="confirmDeleteCourseVisible"
|
||||||
confirm-text="確定移除" :message="`確定要移除「${pendingDeleteCourseName}」嗎?`" :title="`刪除課程`"
|
confirm-color="error"
|
||||||
@confirm="confirmDeleteCourse" />
|
confirm-text="確定移除"
|
||||||
|
:message="`確定要移除「${pendingDeleteCourseName}」嗎?`"
|
||||||
|
:title="`刪除課程`"
|
||||||
|
@confirm="confirmDeleteCourse"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 加入課程對話框 -->
|
<!-- 加入課程對話框 -->
|
||||||
<v-dialog v-model="addCourseDialogVisible" max-width="420" persistent>
|
<v-dialog v-model="addCourseDialogVisible" max-width="420" persistent>
|
||||||
@@ -219,25 +403,47 @@ v-model="confirmDeleteCourseVisible" confirm-color="error"
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="addCourseForm.name" class="mb-3" density="comfortable" :items="availableCourses"
|
v-model="addCourseForm.name"
|
||||||
label="課程名稱" variant="outlined" @update:model-value="handleAddCourseNameSelect" />
|
class="mb-3"
|
||||||
|
density="comfortable"
|
||||||
|
:items="availableCourses"
|
||||||
|
label="課程名稱"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="handleAddCourseNameSelect"
|
||||||
|
/>
|
||||||
<v-row density="compact">
|
<v-row density="compact">
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="addCourseForm.credits" density="comfortable" hide-spin-buttons label="學分"
|
v-model.number="addCourseForm.credits"
|
||||||
type="number" variant="outlined" />
|
density="comfortable"
|
||||||
|
hide-spin-buttons
|
||||||
|
label="學分"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="6">
|
<v-col cols="6">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="addCourseForm.score" density="comfortable" hide-spin-buttons label="分數"
|
v-model.number="addCourseForm.score"
|
||||||
type="number" variant="outlined" />
|
density="comfortable"
|
||||||
|
hide-spin-buttons
|
||||||
|
label="分數"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" @click="addCourseDialogVisible = false">取消</v-btn>
|
<v-btn variant="text" @click="addCourseDialogVisible = false">取消</v-btn>
|
||||||
<v-btn color="primary" :disabled="!addCourseForm.name" variant="flat" @click="confirmAddCourse">加入</v-btn>
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:disabled="!addCourseForm.name"
|
||||||
|
variant="flat"
|
||||||
|
@click="confirmAddCourse"
|
||||||
|
>加入</v-btn
|
||||||
|
>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -283,8 +489,20 @@ const isMobile = computed(() => !smAndUp.value)
|
|||||||
|
|
||||||
// 表格欄位設定(含固定欄與排序)
|
// 表格欄位設定(含固定欄與排序)
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && 'start' as const, width: 120 },
|
{
|
||||||
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && 'start' as const, width: 100 },
|
title: '學號',
|
||||||
|
key: 'studentId',
|
||||||
|
sortable: true,
|
||||||
|
fixed: smAndUp.value && ('start' as const),
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
key: 'name',
|
||||||
|
sortable: true,
|
||||||
|
fixed: smAndUp.value && ('start' as const),
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
||||||
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
||||||
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
||||||
@@ -293,7 +511,14 @@ const tableHeaders = [
|
|||||||
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
||||||
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
||||||
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
||||||
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && 'end' as const, width: 'auto', cellProps: { class: 'px-0 bg-background' } },
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
sortable: false,
|
||||||
|
fixed: smAndUp.value && ('end' as const),
|
||||||
|
width: 'auto',
|
||||||
|
cellProps: { class: 'px-0 bg-background' },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 查詢條件(示意用,未接 API)
|
// 查詢條件(示意用,未接 API)
|
||||||
@@ -340,8 +565,8 @@ const loadSequence = ref(0)
|
|||||||
const studentSemesters = ref<SemesterRecord[]>([])
|
const studentSemesters = ref<SemesterRecord[]>([])
|
||||||
const selectedSemesterId = ref<number | null>(null)
|
const selectedSemesterId = ref<number | null>(null)
|
||||||
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
||||||
const selectedSemester = computed(() =>
|
const selectedSemester = computed(
|
||||||
studentSemesters.value.find((semester) => semester.id === selectedSemesterId.value) ?? null,
|
() => studentSemesters.value.find((semester) => semester.id === selectedSemesterId.value) ?? null
|
||||||
)
|
)
|
||||||
|
|
||||||
// 刪除課程確認狀態
|
// 刪除課程確認狀態
|
||||||
@@ -355,30 +580,46 @@ const addCourseTargetSemesterId = ref<number | null>(null)
|
|||||||
const addCourseForm = ref({ name: '', credits: 3, score: 0 })
|
const addCourseForm = ref({ name: '', credits: 3, score: 0 })
|
||||||
|
|
||||||
const availableCourses = [
|
const availableCourses = [
|
||||||
'資料結構', '演算法', '作業系統', '計算機組織', '線性代數',
|
'資料結構',
|
||||||
'機率與統計', '資料庫系統', '人工智慧導論', '網頁程式設計', '計算機網路',
|
'演算法',
|
||||||
|
'作業系統',
|
||||||
|
'計算機組織',
|
||||||
|
'線性代數',
|
||||||
|
'機率與統計',
|
||||||
|
'資料庫系統',
|
||||||
|
'人工智慧導論',
|
||||||
|
'網頁程式設計',
|
||||||
|
'計算機網路',
|
||||||
]
|
]
|
||||||
|
|
||||||
const creditsMap: Record<string, number> = {
|
const creditsMap: Record<string, number> = {
|
||||||
資料結構: 3, 演算法: 3, 作業系統: 3, 計算機組織: 3, 線性代數: 3,
|
資料結構: 3,
|
||||||
機率與統計: 3, 資料庫系統: 3, 人工智慧導論: 3, 網頁程式設計: 3, 計算機網路: 3,
|
演算法: 3,
|
||||||
|
作業系統: 3,
|
||||||
|
計算機組織: 3,
|
||||||
|
線性代數: 3,
|
||||||
|
機率與統計: 3,
|
||||||
|
資料庫系統: 3,
|
||||||
|
人工智慧導論: 3,
|
||||||
|
網頁程式設計: 3,
|
||||||
|
計算機網路: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 輔助函式:重新載入當前學生的學期資料
|
// 輔助函式:重新載入當前學生的學期資料
|
||||||
function refreshSemesters () {
|
function refreshSemesters() {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSemesterSelect (semesterId: number) {
|
function handleSemesterSelect(semesterId: number) {
|
||||||
selectedSemesterId.value = semesterId
|
selectedSemesterId.value = semesterId
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
activeMobilePanel.value = 'detail'
|
activeMobilePanel.value = 'detail'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDetailPanel () {
|
function closeDetailPanel() {
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
activeMobilePanel.value = 'master'
|
activeMobilePanel.value = 'master'
|
||||||
return
|
return
|
||||||
@@ -386,21 +627,19 @@ function closeDetailPanel () {
|
|||||||
selectedSemesterId.value = null
|
selectedSemesterId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUpdateSemester (semesterId: number, payload: Partial<SemesterRecord>) {
|
function handleUpdateSemester(semesterId: number, payload: Partial<SemesterRecord>) {
|
||||||
semesterStore.updateSemester(semesterId, payload)
|
semesterStore.updateSemester(semesterId, payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 請求刪除課程(開啟確認對話框)
|
// 請求刪除課程(開啟確認對話框)
|
||||||
function requestDeleteCourse (semesterId: number, courseIndex: number, courseName: string) {
|
function requestDeleteCourse(semesterId: number, courseIndex: number, courseName: string) {
|
||||||
pendingDeleteCourseKey.value = { semesterId, courseIndex }
|
pendingDeleteCourseKey.value = { semesterId, courseIndex }
|
||||||
pendingDeleteCourseName.value = courseName
|
pendingDeleteCourseName.value = courseName
|
||||||
confirmDeleteCourseVisible.value = true
|
confirmDeleteCourseVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 確認刪除課程
|
// 確認刪除課程
|
||||||
function confirmDeleteCourse () {
|
function confirmDeleteCourse() {
|
||||||
if (!pendingDeleteCourseKey.value) return
|
if (!pendingDeleteCourseKey.value) return
|
||||||
const { semesterId, courseIndex } = pendingDeleteCourseKey.value
|
const { semesterId, courseIndex } = pendingDeleteCourseKey.value
|
||||||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||||||
@@ -415,14 +654,14 @@ function confirmDeleteCourse () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 開啟加入課程對話框
|
// 開啟加入課程對話框
|
||||||
function openAddCourseDialog (semesterId: number) {
|
function openAddCourseDialog(semesterId: number) {
|
||||||
addCourseTargetSemesterId.value = semesterId
|
addCourseTargetSemesterId.value = semesterId
|
||||||
addCourseForm.value = { name: '', credits: 3, score: 0 }
|
addCourseForm.value = { name: '', credits: 3, score: 0 }
|
||||||
addCourseDialogVisible.value = true
|
addCourseDialogVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 選擇課程名稱時自動帶入預設學分
|
// 選擇課程名稱時自動帶入預設學分
|
||||||
function handleAddCourseNameSelect (name: string) {
|
function handleAddCourseNameSelect(name: string) {
|
||||||
const credits = creditsMap[name]
|
const credits = creditsMap[name]
|
||||||
if (credits !== undefined) {
|
if (credits !== undefined) {
|
||||||
addCourseForm.value.credits = credits
|
addCourseForm.value.credits = credits
|
||||||
@@ -430,9 +669,11 @@ function handleAddCourseNameSelect (name: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 確認加入課程
|
// 確認加入課程
|
||||||
function confirmAddCourse () {
|
function confirmAddCourse() {
|
||||||
if (!addCourseTargetSemesterId.value || !addCourseForm.value.name) return
|
if (!addCourseTargetSemesterId.value || !addCourseForm.value.name) return
|
||||||
const semester = studentSemesters.value.find((item) => item.id === addCourseTargetSemesterId.value)
|
const semester = studentSemesters.value.find(
|
||||||
|
(item) => item.id === addCourseTargetSemesterId.value
|
||||||
|
)
|
||||||
if (!semester) return
|
if (!semester) return
|
||||||
semesterStore.updateSemester(addCourseTargetSemesterId.value, {
|
semesterStore.updateSemester(addCourseTargetSemesterId.value, {
|
||||||
courses: [
|
courses: [
|
||||||
@@ -449,16 +690,19 @@ function confirmAddCourse () {
|
|||||||
addCourseDialogVisible.value = false
|
addCourseDialogVisible.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUpdateCourse (semesterId: number, courseIndex: number, payload: Partial<CourseRecord>) {
|
function handleUpdateCourse(
|
||||||
|
semesterId: number,
|
||||||
|
courseIndex: number,
|
||||||
|
payload: Partial<CourseRecord>
|
||||||
|
) {
|
||||||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||||||
if (!semester) return
|
if (!semester) return
|
||||||
const nextCourses = semester.courses.map((course, idx) =>
|
const nextCourses = semester.courses.map((course, idx) =>
|
||||||
idx === courseIndex ? { ...course, ...payload } : course,
|
idx === courseIndex ? { ...course, ...payload } : course
|
||||||
)
|
)
|
||||||
semesterStore.updateSemester(semesterId, { courses: nextCourses })
|
semesterStore.updateSemester(semesterId, { courses: nextCourses })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
errorSummary,
|
errorSummary,
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
@@ -547,7 +791,7 @@ const {
|
|||||||
})
|
})
|
||||||
const isFormReadonly = computed(() => isViewMode.value)
|
const isFormReadonly = computed(() => isViewMode.value)
|
||||||
// 重設查詢條件
|
// 重設查詢條件
|
||||||
function resetSearch () {
|
function resetSearch() {
|
||||||
search.value = {
|
search.value = {
|
||||||
studentId: '',
|
studentId: '',
|
||||||
name: '',
|
name: '',
|
||||||
@@ -564,7 +808,7 @@ watch(pageCount, (value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 新增:開啟彈窗,使用預設值
|
// 新增:開啟彈窗,使用預設值
|
||||||
function openAddDialog () {
|
function openAddDialog() {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
@@ -577,7 +821,7 @@ function openAddDialog () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 編輯:先開彈窗,資料載入後填入
|
// 編輯:先開彈窗,資料載入後填入
|
||||||
function openEditDialog (student: StudentRecord) {
|
function openEditDialog(student: StudentRecord) {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
const sequence = loadSequence.value
|
const sequence = loadSequence.value
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
@@ -608,7 +852,7 @@ function openEditDialog (student: StudentRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 檢視:只讀模式並預設展開所有分組
|
// 檢視:只讀模式並預設展開所有分組
|
||||||
function openViewDialog (student: StudentRecord) {
|
function openViewDialog(student: StudentRecord) {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
const sequence = loadSequence.value
|
const sequence = loadSequence.value
|
||||||
dialogMode.value = 'view'
|
dialogMode.value = 'view'
|
||||||
@@ -639,7 +883,7 @@ function openViewDialog (student: StudentRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先檢核再提示儲存確認
|
// 先檢核再提示儲存確認
|
||||||
async function requestSaveConfirmation () {
|
async function requestSaveConfirmation() {
|
||||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||||
clearAllErrors()
|
clearAllErrors()
|
||||||
|
|
||||||
@@ -658,13 +902,13 @@ async function requestSaveConfirmation () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 儲存確認後才真正送出
|
// 儲存確認後才真正送出
|
||||||
function confirmSave () {
|
function confirmSave() {
|
||||||
confirmSaveVisible.value = false
|
confirmSaveVisible.value = false
|
||||||
saveStudent()
|
saveStudent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 寫入資料(Demo:直接更新列表)
|
// 寫入資料(Demo:直接更新列表)
|
||||||
async function saveStudent () {
|
async function saveStudent() {
|
||||||
if (isSaving.value || isLoading.value) return
|
if (isSaving.value || isLoading.value) return
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||||
@@ -699,7 +943,7 @@ async function saveStudent () {
|
|||||||
}, 1600)
|
}, 1600)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToField (field: string) {
|
function scrollToField(field: string) {
|
||||||
const target = document.getElementById(`field-${field}`)
|
const target = document.getElementById(`field-${field}`)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
@@ -737,7 +981,7 @@ function scrollToField (field: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-panel>.v-card {
|
.dialog-panel > .v-card {
|
||||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +1,77 @@
|
|||||||
<template>
|
<template>
|
||||||
<mnt-page-cards
|
<mnt-page-cards
|
||||||
:search-panel-open="searchPanelOpen" :title="`主從資料維護示範C`"
|
:search-panel-open="searchPanelOpen"
|
||||||
@create="openAddDialog" @toggle-search="searchPanelOpen = !searchPanelOpen">
|
:title="`主從資料維護示範C`"
|
||||||
|
@create="openAddDialog"
|
||||||
|
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||||
|
>
|
||||||
<template #search-fields>
|
<template #search-fields>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
id="search-student-id" v-model="search.studentId" aria-labelledby="search-student-id-label" density="compact" hide-details name="searchStudentId" placeholder="例如:S2024001"
|
id="search-student-id"
|
||||||
variant="outlined" />
|
v-model="search.studentId"
|
||||||
|
aria-labelledby="search-student-id-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchStudentId"
|
||||||
|
placeholder="例如:S2024001"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||||
<v-text-field id="search-name" v-model="search.name" aria-labelledby="search-name-label" density="compact" hide-details name="searchName" placeholder="例如:王小明" variant="outlined" />
|
<v-text-field
|
||||||
|
id="search-name"
|
||||||
|
v-model="search.name"
|
||||||
|
aria-labelledby="search-name-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchName"
|
||||||
|
placeholder="例如:王小明"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||||
<v-select id="search-department" v-model="search.department" aria-labelledby="search-department-label" density="compact" hide-details :items="departments" name="searchDepartment" variant="outlined" />
|
<v-select
|
||||||
|
id="search-department"
|
||||||
|
v-model="search.department"
|
||||||
|
aria-labelledby="search-department-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="departments"
|
||||||
|
name="searchDepartment"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||||
<v-select
|
<v-select
|
||||||
id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" density="compact" hide-details item-title="title" item-value="value"
|
id="search-grade"
|
||||||
:items="gradeOptions" name="searchGrade" variant="outlined" />
|
v-model="search.grade"
|
||||||
|
aria-labelledby="search-grade-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="gradeOptions"
|
||||||
|
name="searchGrade"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="2">
|
<v-col cols="12" md="2">
|
||||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||||
<v-select id="search-status" v-model="search.status" aria-labelledby="search-status-label" density="compact" hide-details :items="statuses" name="searchStatus" variant="outlined" />
|
<v-select
|
||||||
|
id="search-status"
|
||||||
|
v-model="search.status"
|
||||||
|
aria-labelledby="search-status-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="statuses"
|
||||||
|
name="searchStatus"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
||||||
@@ -34,9 +80,17 @@ id="search-grade" v-model="search.grade" aria-labelledby="search-grade-label" de
|
|||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<v-data-table
|
<v-data-table
|
||||||
v-model:page="currentPage" class="student-table" density="compact" fixed-header :headers="tableHeaders" height="100%"
|
v-model:page="currentPage"
|
||||||
hide-default-footer :items="students" :items-per-page="itemsPerPage"
|
class="student-table"
|
||||||
:row-props="rowProps">
|
density="compact"
|
||||||
|
fixed-header
|
||||||
|
:headers="tableHeaders"
|
||||||
|
height="100%"
|
||||||
|
hide-default-footer
|
||||||
|
:items="students"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:row-props="rowProps"
|
||||||
|
>
|
||||||
<template #[`item.grade`]="{ item }">
|
<template #[`item.grade`]="{ item }">
|
||||||
{{ gradeLabel(item.grade) }}
|
{{ gradeLabel(item.grade) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -47,15 +101,31 @@ v-model:page="currentPage" class="student-table" density="compact" fixed-header
|
|||||||
</template>
|
</template>
|
||||||
<template #[`item.actions`]="{ item }">
|
<template #[`item.actions`]="{ item }">
|
||||||
<div class="d-flex ga-2">
|
<div class="d-flex ga-2">
|
||||||
<v-btn color="info" :prepend-icon="mdiEye" size="small" variant="text" @click="openViewDialog(item)">
|
<v-btn
|
||||||
|
color="info"
|
||||||
|
:prepend-icon="mdiEye"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openViewDialog(item)"
|
||||||
|
>
|
||||||
檢視
|
檢視
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn color="primary" :prepend-icon="mdiPencil" size="small" variant="text" @click="openEditDialog(item)">
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:prepend-icon="mdiPencil"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="openEditDialog(item)"
|
||||||
|
>
|
||||||
修改
|
修改
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
color="error"
|
||||||
@click="requestDeleteConfirmation(item)">
|
:prepend-icon="mdiDelete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="requestDeleteConfirmation(item)"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
@@ -66,11 +136,35 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
|||||||
{{ pageSummary }}
|
{{ pageSummary }}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-center ga-2">
|
<div class="d-flex align-center ga-2">
|
||||||
<v-btn :disabled="currentPage <= 1" size="small" variant="text" @click="currentPage = 1">第一頁</v-btn>
|
<v-btn
|
||||||
<v-btn :disabled="currentPage <= 1" size="small" variant="text" @click="currentPage -= 1">上一頁</v-btn>
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage = 1"
|
||||||
|
>第一頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage -= 1"
|
||||||
|
>上一頁</v-btn
|
||||||
|
>
|
||||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||||
<v-btn :disabled="currentPage >= pageCount" size="small" variant="text" @click="currentPage += 1">下一頁</v-btn>
|
<v-btn
|
||||||
<v-btn :disabled="currentPage >= pageCount" size="small" variant="text" @click="currentPage = pageCount">最後頁</v-btn>
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage += 1"
|
||||||
|
>下一頁</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="currentPage = pageCount"
|
||||||
|
>最後頁</v-btn
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -83,50 +177,95 @@ color="error" :prepend-icon="mdiDelete" size="small" variant="text"
|
|||||||
<teleport to="body">
|
<teleport to="body">
|
||||||
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
||||||
<v-overlay
|
<v-overlay
|
||||||
class="dialog-overlay" :close-on-content-click="false" :model-value="dialogVisible" scrim="rgba(0, 0, 0, 0.45)"
|
class="dialog-overlay"
|
||||||
scroll-strategy="block" @update:model-value="handleDialogVisibility">
|
:close-on-content-click="false"
|
||||||
|
:model-value="dialogVisible"
|
||||||
|
scrim="rgba(0, 0, 0, 0.45)"
|
||||||
|
scroll-strategy="block"
|
||||||
|
@update:model-value="handleDialogVisibility"
|
||||||
|
>
|
||||||
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
||||||
<master-detail-c-course-mobile-panel
|
<master-detail-c-course-mobile-panel
|
||||||
v-if="isMobile && activeMobilePanel === 'detail'"
|
v-if="isMobile && activeMobilePanel === 'detail'"
|
||||||
:is-form-locked="isFormLocked" :is-view-mode="isViewMode" :semester="selectedSemester"
|
:is-form-locked="isFormLocked"
|
||||||
@add-course="openAddCourseDialog" @close="closeDetailPanel" @delete-course="removeCourseFromSemester"
|
:is-view-mode="isViewMode"
|
||||||
@update-course="handleUpdateCourse" />
|
:semester="selectedSemester"
|
||||||
|
@add-course="openAddCourseDialog"
|
||||||
|
@close="closeDetailPanel"
|
||||||
|
@delete-course="removeCourseFromSemester"
|
||||||
|
@update-course="handleUpdateCourse"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
<!-- 主檔區塊 (Master Card):學生基本資料與學期列表 -->
|
||||||
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
<!-- 說明:固定在視窗右側,包含學生表單與學期清單 -->
|
||||||
<mnt-dialog-card
|
<mnt-dialog-card
|
||||||
v-else :content-class="isMobile ? 'pa-3 flex-grow-1 overflow-y-auto pb-16' : 'pa-2 flex-grow-1 overflow-hidden d-flex flex-column'" :dialog-subtitle="dialogSubtitle" :dialog-title="dialogTitle"
|
v-else
|
||||||
:is-edit-mode="isEditMode" :is-view-mode="isViewMode"
|
:content-class="
|
||||||
width="100%">
|
isMobile
|
||||||
|
? 'pa-3 flex-grow-1 overflow-y-auto pb-16'
|
||||||
|
: 'pa-2 flex-grow-1 overflow-hidden d-flex flex-column'
|
||||||
|
"
|
||||||
|
:dialog-subtitle="dialogSubtitle"
|
||||||
|
:dialog-title="dialogTitle"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<mnt-record-nav-toolbar
|
<mnt-record-nav-toolbar
|
||||||
:has-next-record="hasNextRecord" :has-prev-record="hasPrevRecord"
|
:has-next-record="hasNextRecord"
|
||||||
:is-edit-mode="isEditMode" :is-view-mode="isViewMode" :mobile="isMobile"
|
:has-prev-record="hasPrevRecord"
|
||||||
@first="openEdgeRecord('first')" @last="openEdgeRecord('last')" @next="openAdjacentRecord('next')"
|
:is-edit-mode="isEditMode"
|
||||||
@prev="openAdjacentRecord('prev')" @switch-to-edit="switchToEditMode" @switch-to-view="switchToViewMode" />
|
:is-view-mode="isViewMode"
|
||||||
|
:mobile="isMobile"
|
||||||
|
@first="openEdgeRecord('first')"
|
||||||
|
@last="openEdgeRecord('last')"
|
||||||
|
@next="openAdjacentRecord('next')"
|
||||||
|
@prev="openAdjacentRecord('prev')"
|
||||||
|
@switch-to-edit="switchToEditMode"
|
||||||
|
@switch-to-view="switchToViewMode"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
<template #content>
|
||||||
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
<!-- 錯誤提示:當表單驗證未通過時顯示 -->
|
||||||
<v-alert v-if="errorSummary.length > 0 && !isLoading" class="mb-4" type="error" variant="tonal">
|
<v-alert
|
||||||
|
v-if="errorSummary.length > 0 && !isLoading"
|
||||||
|
class="mb-4"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
||||||
<div class="d-flex flex-column ga-1">
|
<div class="d-flex flex-column ga-1">
|
||||||
<v-btn
|
<v-btn
|
||||||
v-for="error in errorSummary" :key="error.field" color="error" size="small" variant="text"
|
v-for="error in errorSummary"
|
||||||
@click="scrollToField(error.field)">
|
:key="error.field"
|
||||||
|
color="error"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="scrollToField(error.field)"
|
||||||
|
>
|
||||||
{{ error.message }}
|
{{ error.message }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<!-- 載入中骨架畫面 -->
|
<!-- 載入中骨架畫面 -->
|
||||||
<v-skeleton-loader v-if="isLoading" class="mt-4" type="subtitle,paragraph" width="100%" />
|
<v-skeleton-loader
|
||||||
|
v-if="isLoading"
|
||||||
|
class="mt-4"
|
||||||
|
type="subtitle,paragraph"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
<!-- 學生主檔表單:檢視模式時會自動套用 readonly 樣式 -->
|
||||||
<v-form
|
<v-form
|
||||||
v-else :class="[
|
v-else
|
||||||
|
:class="[
|
||||||
{ 'form-readonly': isFormReadonly },
|
{ 'form-readonly': isFormReadonly },
|
||||||
isMobile ? '' : 'd-flex flex-column h-100',
|
isMobile ? '' : 'd-flex flex-column h-100',
|
||||||
]" @submit.prevent="requestSaveConfirmation">
|
]"
|
||||||
|
@submit.prevent="requestSaveConfirmation"
|
||||||
|
>
|
||||||
<maintenance-student-form-fields
|
<maintenance-student-form-fields
|
||||||
:departments="departments"
|
:departments="departments"
|
||||||
:enroll-years="enrollYears"
|
:enroll-years="enrollYears"
|
||||||
@@ -141,37 +280,77 @@ v-else :class="[
|
|||||||
|
|
||||||
<v-divider />
|
<v-divider />
|
||||||
<master-detail-c-course-section
|
<master-detail-c-course-section
|
||||||
:is-form-locked="isFormLocked" :is-form-readonly="isFormReadonly"
|
:is-form-locked="isFormLocked"
|
||||||
:is-mobile="isMobile" :selected-semester-id="selectedSemesterId"
|
:is-form-readonly="isFormReadonly"
|
||||||
:semesters="studentSemesters" @add-course="openAddCourseDialog"
|
:is-mobile="isMobile"
|
||||||
@delete-course="removeCourseFromSemester" @select-semester="handleSemesterSelect"
|
:selected-semester-id="selectedSemesterId"
|
||||||
@update-course="handleUpdateCourse" />
|
:semesters="studentSemesters"
|
||||||
|
@add-course="openAddCourseDialog"
|
||||||
|
@delete-course="removeCourseFromSemester"
|
||||||
|
@select-semester="handleSemesterSelect"
|
||||||
|
@update-course="handleUpdateCourse"
|
||||||
|
/>
|
||||||
</v-form>
|
</v-form>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<template v-if="isMobile">
|
<template v-if="isMobile">
|
||||||
<v-btn class="flex-grow-1" :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="isEditMode" class="flex-grow-1" color="error" :disabled="isSaving" variant="tonal"
|
class="flex-grow-1"
|
||||||
@click="requestDeleteCurrent">
|
:disabled="isSaving"
|
||||||
|
variant="text"
|
||||||
|
@click="requestCloseDialog"
|
||||||
|
>取消</v-btn
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
class="flex-grow-1"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="requestDeleteCurrent"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isViewMode" class="flex-grow-1" color="primary" :disabled="!isDirty || isLoading" :loading="isSaving"
|
v-if="!isViewMode"
|
||||||
variant="flat" @click="requestSaveConfirmation">
|
class="flex-grow-1"
|
||||||
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestSaveConfirmation"
|
||||||
|
>
|
||||||
儲存
|
儲存
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else class="flex-grow-1" color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
<v-btn
|
||||||
|
v-else
|
||||||
|
class="flex-grow-1"
|
||||||
|
color="primary"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestCloseDialog"
|
||||||
|
>關閉</v-btn
|
||||||
|
>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
||||||
<v-btn v-if="isEditMode" color="error" :disabled="isSaving" variant="tonal" @click="requestDeleteCurrent">
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="requestDeleteCurrent"
|
||||||
|
>
|
||||||
刪除
|
刪除
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="isSaving"
|
v-if="!isViewMode"
|
||||||
variant="flat" @click="requestSaveConfirmation">
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="requestSaveConfirmation"
|
||||||
|
>
|
||||||
儲存
|
儲存
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
||||||
@@ -217,22 +396,46 @@ v-if="!isViewMode" color="primary" :disabled="!isDirty || isLoading" :loading="i
|
|||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="addCourseForm.semesterId" class="mb-3" density="comfortable" item-title="label"
|
v-model="addCourseForm.semesterId"
|
||||||
item-value="value" :items="semesterOptions" label="學期" variant="outlined" />
|
class="mb-3"
|
||||||
|
density="comfortable"
|
||||||
|
item-title="label"
|
||||||
|
item-value="value"
|
||||||
|
:items="semesterOptions"
|
||||||
|
label="學期"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
<v-select
|
<v-select
|
||||||
v-model="addCourseForm.courseName" class="mb-3" density="comfortable" :items="availableCourses"
|
v-model="addCourseForm.courseName"
|
||||||
label="課程名稱" variant="outlined" @update:model-value="handleCourseSelect" />
|
class="mb-3"
|
||||||
|
density="comfortable"
|
||||||
|
:items="availableCourses"
|
||||||
|
label="課程名稱"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="handleCourseSelect"
|
||||||
|
/>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="addCourseForm.credits" class="mb-3" density="comfortable" label="學分"
|
v-model.number="addCourseForm.credits"
|
||||||
type="number" variant="outlined" />
|
class="mb-3"
|
||||||
|
density="comfortable"
|
||||||
|
label="學分"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model.number="addCourseForm.score" density="comfortable" label="分數" type="number"
|
v-model.number="addCourseForm.score"
|
||||||
variant="outlined" />
|
density="comfortable"
|
||||||
|
label="分數"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" @click="addCourseDialogVisible = false">取消</v-btn>
|
<v-btn variant="text" @click="addCourseDialogVisible = false">取消</v-btn>
|
||||||
<v-btn color="primary" :disabled="!canAddCourse" variant="flat" @click="confirmAddCourse">新增</v-btn>
|
<v-btn color="primary" :disabled="!canAddCourse" variant="flat" @click="confirmAddCourse"
|
||||||
|
>新增</v-btn
|
||||||
|
>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
@@ -272,8 +475,20 @@ const isMobile = computed(() => !smAndUp.value)
|
|||||||
|
|
||||||
// 表格欄位設定(含固定欄與排序)
|
// 表格欄位設定(含固定欄與排序)
|
||||||
const tableHeaders = [
|
const tableHeaders = [
|
||||||
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && 'start' as const, width: 120 },
|
{
|
||||||
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && 'start' as const, width: 100 },
|
title: '學號',
|
||||||
|
key: 'studentId',
|
||||||
|
sortable: true,
|
||||||
|
fixed: smAndUp.value && ('start' as const),
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '姓名',
|
||||||
|
key: 'name',
|
||||||
|
sortable: true,
|
||||||
|
fixed: smAndUp.value && ('start' as const),
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
||||||
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
||||||
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
||||||
@@ -282,7 +497,14 @@ const tableHeaders = [
|
|||||||
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
||||||
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
||||||
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
||||||
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && 'end' as const, width: 'auto', cellProps: { class: 'px-0 bg-background' } },
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'actions',
|
||||||
|
sortable: false,
|
||||||
|
fixed: smAndUp.value && ('end' as const),
|
||||||
|
width: 'auto',
|
||||||
|
cellProps: { class: 'px-0 bg-background' },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 查詢條件(示意用,未接 API)
|
// 查詢條件(示意用,未接 API)
|
||||||
@@ -330,7 +552,7 @@ const studentSemesters = ref<SemesterRecord[]>([])
|
|||||||
const selectedSemesterId = ref<number | null>(null)
|
const selectedSemesterId = ref<number | null>(null)
|
||||||
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
||||||
const selectedSemester = computed(
|
const selectedSemester = computed(
|
||||||
() => studentSemesters.value.find((item) => item.id === selectedSemesterId.value) ?? null,
|
() => studentSemesters.value.find((item) => item.id === selectedSemesterId.value) ?? null
|
||||||
)
|
)
|
||||||
|
|
||||||
// 新增成績對話框狀態
|
// 新增成績對話框狀態
|
||||||
@@ -361,7 +583,7 @@ const semesterOptions = computed(() =>
|
|||||||
studentSemesters.value.map((sem) => ({
|
studentSemesters.value.map((sem) => ({
|
||||||
value: sem.id,
|
value: sem.id,
|
||||||
label: sem.semesterName,
|
label: sem.semesterName,
|
||||||
})),
|
}))
|
||||||
)
|
)
|
||||||
|
|
||||||
// 是否可以新增
|
// 是否可以新增
|
||||||
@@ -369,11 +591,11 @@ const canAddCourse = computed(
|
|||||||
() =>
|
() =>
|
||||||
addCourseForm.value.semesterId !== null &&
|
addCourseForm.value.semesterId !== null &&
|
||||||
addCourseForm.value.courseName !== '' &&
|
addCourseForm.value.courseName !== '' &&
|
||||||
addCourseForm.value.credits > 0,
|
addCourseForm.value.credits > 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// 開啟新增成績對話框
|
// 開啟新增成績對話框
|
||||||
function openAddCourseDialog (semesterId?: number) {
|
function openAddCourseDialog(semesterId?: number) {
|
||||||
addCourseForm.value = {
|
addCourseForm.value = {
|
||||||
semesterId: semesterId ?? selectedSemesterId.value ?? studentSemesters.value[0]?.id ?? null,
|
semesterId: semesterId ?? selectedSemesterId.value ?? studentSemesters.value[0]?.id ?? null,
|
||||||
courseName: '',
|
courseName: '',
|
||||||
@@ -384,7 +606,7 @@ function openAddCourseDialog (semesterId?: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 選擇課程時自動帶入學分
|
// 選擇課程時自動帶入學分
|
||||||
function handleCourseSelect (courseName: string) {
|
function handleCourseSelect(courseName: string) {
|
||||||
const creditsMap: Record<string, number> = {
|
const creditsMap: Record<string, number> = {
|
||||||
資料結構: 3,
|
資料結構: 3,
|
||||||
演算法: 3,
|
演算法: 3,
|
||||||
@@ -404,12 +626,10 @@ function handleCourseSelect (courseName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 確認新增成績
|
// 確認新增成績
|
||||||
function confirmAddCourse () {
|
function confirmAddCourse() {
|
||||||
if (!addCourseForm.value.semesterId || !addCourseForm.value.courseName) return
|
if (!addCourseForm.value.semesterId || !addCourseForm.value.courseName) return
|
||||||
|
|
||||||
const semester = studentSemesters.value.find(
|
const semester = studentSemesters.value.find((sem) => sem.id === addCourseForm.value.semesterId)
|
||||||
(sem) => sem.id === addCourseForm.value.semesterId,
|
|
||||||
)
|
|
||||||
if (!semester) return
|
if (!semester) return
|
||||||
|
|
||||||
semesterStore.updateSemester(addCourseForm.value.semesterId, {
|
semesterStore.updateSemester(addCourseForm.value.semesterId, {
|
||||||
@@ -429,20 +649,20 @@ function confirmAddCourse () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 輔助函式:重新載入當前學生的學期資料
|
// 輔助函式:重新載入當前學生的學期資料
|
||||||
function refreshSemesters () {
|
function refreshSemesters() {
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSemesterSelect (semesterId: number) {
|
function handleSemesterSelect(semesterId: number) {
|
||||||
selectedSemesterId.value = semesterId
|
selectedSemesterId.value = semesterId
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
activeMobilePanel.value = 'detail'
|
activeMobilePanel.value = 'detail'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDetailPanel () {
|
function closeDetailPanel() {
|
||||||
if (isMobile.value) {
|
if (isMobile.value) {
|
||||||
activeMobilePanel.value = 'master'
|
activeMobilePanel.value = 'master'
|
||||||
return
|
return
|
||||||
@@ -450,16 +670,20 @@ function closeDetailPanel () {
|
|||||||
selectedSemesterId.value = null
|
selectedSemesterId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleUpdateCourse (semesterId: number, courseIndex: number, payload: Partial<CourseRecord>) {
|
function handleUpdateCourse(
|
||||||
|
semesterId: number,
|
||||||
|
courseIndex: number,
|
||||||
|
payload: Partial<CourseRecord>
|
||||||
|
) {
|
||||||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||||||
if (!semester) return
|
if (!semester) return
|
||||||
const nextCourses = semester.courses.map((course, idx) =>
|
const nextCourses = semester.courses.map((course, idx) =>
|
||||||
idx === courseIndex ? { ...course, ...payload } : course,
|
idx === courseIndex ? { ...course, ...payload } : course
|
||||||
)
|
)
|
||||||
semesterStore.updateSemester(semesterId, { courses: nextCourses })
|
semesterStore.updateSemester(semesterId, { courses: nextCourses })
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCourseFromSemester (semesterId: number, courseIndex: number) {
|
function removeCourseFromSemester(semesterId: number, courseIndex: number) {
|
||||||
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
const semester = studentSemesters.value.find((item) => item.id === semesterId)
|
||||||
if (!semester) return
|
if (!semester) return
|
||||||
semesterStore.updateSemester(semesterId, {
|
semesterStore.updateSemester(semesterId, {
|
||||||
@@ -467,7 +691,6 @@ function removeCourseFromSemester (semesterId: number, courseIndex: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
errorSummary,
|
errorSummary,
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
@@ -556,7 +779,7 @@ const {
|
|||||||
})
|
})
|
||||||
const isFormReadonly = computed(() => isViewMode.value)
|
const isFormReadonly = computed(() => isViewMode.value)
|
||||||
// 重設查詢條件
|
// 重設查詢條件
|
||||||
function resetSearch () {
|
function resetSearch() {
|
||||||
search.value = {
|
search.value = {
|
||||||
studentId: '',
|
studentId: '',
|
||||||
name: '',
|
name: '',
|
||||||
@@ -573,7 +796,7 @@ watch(pageCount, (value) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// 新增:開啟彈窗,使用預設值
|
// 新增:開啟彈窗,使用預設值
|
||||||
function openAddDialog () {
|
function openAddDialog() {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
dialogMode.value = 'create'
|
dialogMode.value = 'create'
|
||||||
editingId.value = null
|
editingId.value = null
|
||||||
@@ -586,7 +809,7 @@ function openAddDialog () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 編輯:先開彈窗,資料載入後填入
|
// 編輯:先開彈窗,資料載入後填入
|
||||||
function openEditDialog (student: StudentRecord) {
|
function openEditDialog(student: StudentRecord) {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
const sequence = loadSequence.value
|
const sequence = loadSequence.value
|
||||||
dialogMode.value = 'edit'
|
dialogMode.value = 'edit'
|
||||||
@@ -617,7 +840,7 @@ function openEditDialog (student: StudentRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 檢視:只讀模式並預設展開所有分組
|
// 檢視:只讀模式並預設展開所有分組
|
||||||
function openViewDialog (student: StudentRecord) {
|
function openViewDialog(student: StudentRecord) {
|
||||||
loadSequence.value += 1
|
loadSequence.value += 1
|
||||||
const sequence = loadSequence.value
|
const sequence = loadSequence.value
|
||||||
dialogMode.value = 'view'
|
dialogMode.value = 'view'
|
||||||
@@ -648,7 +871,7 @@ function openViewDialog (student: StudentRecord) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 先檢核再提示儲存確認
|
// 先檢核再提示儲存確認
|
||||||
async function requestSaveConfirmation () {
|
async function requestSaveConfirmation() {
|
||||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
||||||
clearAllErrors()
|
clearAllErrors()
|
||||||
|
|
||||||
@@ -667,13 +890,13 @@ async function requestSaveConfirmation () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 儲存確認後才真正送出
|
// 儲存確認後才真正送出
|
||||||
function confirmSave () {
|
function confirmSave() {
|
||||||
confirmSaveVisible.value = false
|
confirmSaveVisible.value = false
|
||||||
saveStudent()
|
saveStudent()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 寫入資料(Demo:直接更新列表)
|
// 寫入資料(Demo:直接更新列表)
|
||||||
async function saveStudent () {
|
async function saveStudent() {
|
||||||
if (isSaving.value || isLoading.value) return
|
if (isSaving.value || isLoading.value) return
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
await new Promise((resolve) => setTimeout(resolve, 450))
|
||||||
@@ -708,7 +931,7 @@ async function saveStudent () {
|
|||||||
}, 1600)
|
}, 1600)
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToField (field: string) {
|
function scrollToField(field: string) {
|
||||||
const target = document.getElementById(`field-${field}`)
|
const target = document.getElementById(`field-${field}`)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
@@ -739,7 +962,7 @@ function scrollToField (field: string) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-panel>.v-card {
|
.dialog-panel > .v-card {
|
||||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user