Refactor MasterDetailMntC.vue for improved readability and consistency

This commit is contained in:
skytek_xinliang
2026-03-30 09:18:55 +08:00
parent 7591ecd062
commit 16b58fbf7a
66 changed files with 2071 additions and 777 deletions
+98 -22
View File
@@ -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'
@@ -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,10 +502,14 @@ 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
@@ -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 }
) )
+3 -1
View File
@@ -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 }
+133 -54
View File
@@ -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">
@@ -390,7 +469,6 @@ function handleForgotPassword (e: MouseEvent) {
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);
} }
+3 -1
View File
@@ -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 }
+7 -1
View File
@@ -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>
@@ -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]
}) })
+40 -10
View File
@@ -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>
+2 -2
View File
@@ -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
@@ -78,5 +79,4 @@ function handleSelectLocale (locale: string) {
if (locale === props.locale) return if (locale === props.locale) return
emit('change-locale', locale) emit('change-locale', locale)
} }
</script> </script>
+16 -5
View File
@@ -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>
+6 -3
View File
@@ -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>
@@ -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 {
@@ -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>
@@ -28,19 +47,27 @@ v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary"
<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,11 +180,14 @@ 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
@@ -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 -1
View File
@@ -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,
@@ -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'
@@ -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"
@@ -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
}>() }>()
@@ -16,17 +16,94 @@ 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
@@ -112,31 +189,39 @@ 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) {
@@ -189,7 +274,8 @@ export function useEditableStudentGrid () {
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
@@ -50,7 +50,7 @@ interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
} }
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,16 +64,15 @@ 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 '這筆資料'
@@ -53,7 +53,7 @@ function createDefaultForm (
departments: string[], departments: string[],
gradeOptions: GradeOption[], gradeOptions: GradeOption[],
enrollYears: number[], enrollYears: number[],
statuses: string[], statuses: string[]
): StudentFormState { ): StudentFormState {
return { return {
studentId: '', studentId: '',
@@ -86,14 +86,17 @@ function createEmptyFieldErrors () {
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}`
@@ -124,7 +127,7 @@ 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)
}) })
@@ -142,7 +145,7 @@ export function useStudentMaintenanceForm (options: UseStudentMaintenanceFormOpt
options.departments, options.departments,
options.gradeOptions, options.gradeOptions,
options.enrollYears, options.enrollYears,
options.statuses, options.statuses
) )
syncInitialForm() syncInitialForm()
clearAllErrors() clearAllErrors()
@@ -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: '學號已存在,請確認是否重複' })
+4 -2
View File
@@ -29,8 +29,10 @@ function levelToColor (level: ToastLevel): string {
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)
-1
View File
@@ -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)
+2 -1
View File
@@ -46,6 +46,7 @@ interface MenuNode {
### Store 持久化 ### Store 持久化
`stores/menu.ts` 提供: `stores/menu.ts` 提供:
- 自動 localStorage 持久化選單與收藏 - 自動 localStorage 持久化選單與收藏
- 初始化時自動還原資料 - 初始化時自動還原資料
- 登出時清除快取 - 登出時清除快取
@@ -106,7 +107,7 @@ 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 會雜亂 |
-1
View File
@@ -19,4 +19,3 @@ function createClient (): AxiosInstance {
} }
export const httpClient = createClient() export const httpClient = createClient()
+1 -1
View File
@@ -113,7 +113,7 @@ export function normalizeError (error: unknown): ApiRequestError {
code, code,
status, status,
errors: apiError?.errors, errors: apiError?.errors,
raw: error raw: error,
}) })
} }
-1
View File
@@ -25,4 +25,3 @@ export function emitHttpError (detail: HttpErrorDetail) {
httpErrorEmitted = false httpErrorEmitted = false
}, 0) }, 0)
} }
-1
View File
@@ -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 }))
} }
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -21,5 +21,5 @@ export const tokenService = {
clearToken() { clearToken() {
tokenRef.value = null tokenRef.value = null
localStorage.removeItem(storageKey) localStorage.removeItem(storageKey)
} },
} }
+4 -2
View File
@@ -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[] = []
+1 -1
View File
@@ -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', () => {
+2 -5
View File
@@ -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 = []
+10 -10
View File
@@ -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,
} }
}) })
+7 -1
View File
@@ -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
View File
@@ -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'
-1
View File
@@ -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">
+16 -8
View File
@@ -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(() => ({
// 功能開關:是否顯示語系切換工具列 // 功能開關:是否顯示語系切換工具列
+32 -8
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
-1
View File
@@ -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>
+228 -57
View File
@@ -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 (子檔編輯狀態管理)
@@ -633,7 +806,7 @@ function handleSemesterSelect (id: number) {
return return
} }
selectedSemesterId.value = selectedSemesterId.value === id ? null : id; selectedSemesterId.value = selectedSemesterId.value === id ? null : id
} }
function closeDetailPanel() { function closeDetailPanel() {
@@ -737,8 +910,6 @@ function closeDetailPanel () {
} }
} }
.dialog-actions { .dialog-actions {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
+321 -77
View File
@@ -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,13 +580,29 @@ 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,
} }
// 輔助函式:重新載入當前學生的學期資料 // 輔助函式:重新載入當前學生的學期資料
@@ -390,8 +631,6 @@ function handleUpdateSemester (semesterId: number, payload: Partial<SemesterReco
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 }
@@ -432,7 +671,9 @@ 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,
+296 -73
View File
@@ -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,7 +591,7 @@ 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
) )
// 開啟新增成績對話框 // 開啟新增成績對話框
@@ -407,9 +629,7 @@ 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, {
@@ -450,11 +670,15 @@ 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 })
} }
@@ -467,7 +691,6 @@ function removeCourseFromSemester (semesterId: number, courseIndex: number) {
}) })
} }
const { const {
errorSummary, errorSummary,
fieldErrors, fieldErrors,