refactor: update icon usage to use mdi imports for consistency

This commit is contained in:
skytek_xinliang
2026-03-26 11:48:05 +08:00
parent 069141794e
commit ec3fbace1a
50 changed files with 454 additions and 198 deletions
-3
View File
@@ -8,13 +8,10 @@ export default vuetify({
rules: {
'vue/no-required-prop-with-default': 'off',
'vue/attributes-order': 'off',
'vue/no-template-shadow': 'off',
'@typescript-eslint/unified-signatures': 'off',
'@typescript-eslint/member-ordering': 'off',
'unicorn/prefer-query-selector': 'off',
'unicorn/no-array-sort':'off',
"vue/no-mutating-props" : "off",
'unicorn/prefer-logical-operator-over-ternary': 'off',
'unicorn/prefer-structured-clone': 'off',
}
})
+24 -23
View File
@@ -9,11 +9,11 @@
<v-btn
color="secondary" :disabled="isFavoriteActionDisabled" size="small" variant="outlined"
@click="toggleFavorite">
<v-icon class="mr-1" size="14">{{ favoriteActionIcon }}</v-icon>
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
{{ favoriteActionLabel }}
</v-btn>
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
<v-icon class="mr-1" size="14">mdi-home</v-icon>
<v-icon class="mr-1" size="14" :icon="mdiHome" />
返回首頁
</v-btn>
</template>
@@ -28,7 +28,7 @@ v-model="activeTab" bg-color="background" color="primary" density="compact" show
<v-btn
class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon size="x-small"
variant="text" @click.prevent.stop="closeTab(tab.path)">
<v-icon>mdi-close</v-icon>
<v-icon :icon="mdiClose" />
</v-btn>
</v-tab>
</v-tabs>
@@ -58,7 +58,7 @@ class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon si
<v-list v-else density="compact">
<v-list-item v-for="item in searchResults" :key="item.path" class="mb-2" @click="handleSearchSelect(item)">
<template #prepend>
<v-icon v-if="item.icon" size="18">{{ item.icon }}</v-icon>
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.parents?.length">
@@ -94,7 +94,7 @@ class="pl-2" color="grey" density="compact" :disabled="tabs.length <= 1" icon si
<v-list-item v-for="wrapped in items" :key="resolveMessageItem(wrapped).id" border="sm" class="pa-2 mb-1">
<template #prepend>
<v-avatar color="primary" size="28" variant="tonal">
<v-icon size="16">{{ resolveMessageItem(wrapped).icon }}</v-icon>
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
</v-avatar>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
@@ -122,6 +122,7 @@ v-model="snackbar.visible" :color="snackbar.color" :location="snackbar.location"
</template>
<script setup>
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 { useRoute, useRouter } from 'vue-router'
import { SKAdminLayout, SKEmptyLayout } from '../src'
@@ -150,11 +151,11 @@ const _fixedMenuItems = [
title: '資料維護',
navigable: false,
subItems: [
{ title: '單筆資料維護', icon: 'mdi-file-document-outline', path: '/single-record-maintenance' },
{ title: '主從資料維護A', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance' },
{ title: '主從資料維護B', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance-b' },
{ title: '主從資料維護C', icon: 'mdi-file-tree-outline', path: '/master-detail-maintenance-c' },
{ title: '可編輯表格維護', icon: 'mdi-table-edit', path: '/editable-grid-maintenance' },
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
],
},
{ title: '登入頁', path: '/login' },
@@ -162,18 +163,18 @@ const _fixedMenuItems = [
// 範例選單(用於 tab 顯示名稱的保底資料)
const _menuItemsExample = [
{ title: '首頁', icon: 'mdi-home', path: '/' },
{ title: '工作台', icon: 'mdi-view-dashboard-variant', path: '/dashboard' },
{ title: '分析頁', icon: 'mdi-chart-box-outline', path: '/analysis' },
{ title: '首頁', icon: mdiHome, path: '/' },
{ title: '工作台', icon: mdiViewDashboardVariant, path: '/dashboard' },
{ title: '分析頁', icon: mdiChartBoxOutline, path: '/analysis' },
{
title: '設定',
icon: 'mdi-cog',
icon: mdiCog,
path: '/settings',
navigable: false,
subItems: [
{ title: '角色管理', icon: 'mdi-account-group', path: '/role-management' },
{ title: '選單管理', icon: 'mdi-menu', path: '/menu-management' },
{ title: '部門管理', icon: 'mdi-domain', path: '/dept-management' },
{ title: '角色管理', icon: mdiAccountGroup, path: '/role-management' },
{ title: '選單管理', icon: mdiMenu, path: '/menu-management' },
{ title: '部門管理', icon: mdiDomain, path: '/dept-management' },
],
},
..._fixedMenuItems,
@@ -305,10 +306,10 @@ function handleSearchSelect (item) {
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
const messageItems = [
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: 'mdi-bell-outline' },
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: 'mdi-school-outline' },
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: 'mdi-home-city-outline' },
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: 'mdi-calendar-outline' },
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: mdiBellOutline },
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: mdiSchoolOutline },
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: mdiHomeCityOutline },
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: mdiCalendarOutline },
]
// v-data-iterator 會包裝 items,這裡取回原始資料物件
@@ -436,7 +437,7 @@ const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
const isFavoriteActionDisabled = computed(() => !currentFavoriteInfo.value?.path || route.path === '/')
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
const favoriteActionIcon = computed(() => (isCurrentFavorite.value ? 'mdi-close-circle' : 'mdi-plus-circle'))
const favoriteActionIcon = computed(() => (isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle))
function toggleFavoriteItem (item) {
if (!item?.path || item.path === '/') return
@@ -474,7 +475,7 @@ function updateBreadcrumbs () {
favoriteItems: mergedFavoriteItems.value,
fallbackTitle,
homeLabel: '首頁',
homeIcon: 'mdi-home',
homeIcon: mdiHome,
})
}
+2 -1
View File
@@ -48,6 +48,7 @@
</template>
<script setup lang="ts">
import { mdiSchool } from '@mdi/js'
import { ref } from 'vue'
import AnalysisBarChart from './base/analysis/AnalysisBarChart.vue'
import AnalysisDonutChart from './base/analysis/AnalysisDonutChart.vue'
@@ -102,7 +103,7 @@ const props = defineProps({
},
pie2Data: {
type: Object as () => DonutData,
default: () => ({ value: 65, label: '及格率', color: 'success', icon: 'mdi-school' }),
default: () => ({ value: 65, label: '及格率', color: 'success', icon: mdiSchool }),
},
})
+2 -1
View File
@@ -31,7 +31,7 @@ class="board-wrapper pa-2 pa-lg-0" color="rgba(var(--v-theme-surface), 0.8)" ele
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon class="mobile-banner-icon" color="primary" size="small">mdi-bullhorn-variant-outline</v-icon>
<v-icon class="mobile-banner-icon" color="primary" size="small" :icon="mdiBullhornVariantOutline" />
</div>
</v-slide-x-transition>
</template>
@@ -144,6 +144,7 @@ class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
</template>
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, ref } from 'vue'
import LoginAnnouncementBoard from './base/login/LoginAnnouncementBoard.vue'
import LoginBrand from './base/login/LoginBrand.vue'
+1 -1
View File
@@ -38,7 +38,7 @@
<!-- Icon Column -->
<template #[`item.icon`]="{ item }">
<v-icon v-if="item.icon" size="small">{{ item.icon }}</v-icon>
<v-icon v-if="item.icon" size="small" :icon="item.icon" />
</template>
<!-- Permission Column -->
+2 -1
View File
@@ -57,7 +57,7 @@
@click="expand = !expand"
>
{{ expand ? collapseBtnText : expandBtnText }}
<v-icon end :icon="expand ? 'mdi-chevron-up' : 'mdi-chevron-down'"></v-icon>
<v-icon end :icon="expand ? mdiChevronUp : mdiChevronDown"></v-icon>
</v-btn>
</v-col>
</v-row>
@@ -66,6 +66,7 @@
</template>
<script setup lang="ts">
import { mdiChevronDown, mdiChevronUp } from '@mdi/js'
import { computed, reactive, ref } from 'vue'
import SKDatePicker from './input_field/SKDatePicker.vue'
import SKSelectField from './input_field/SKSelectField.vue'
+5 -4
View File
@@ -7,7 +7,7 @@
v-if="showCreate"
class="mr-4"
color="primary"
prepend-icon="mdi-plus"
:prepend-icon="mdiPlus"
@click="$emit('create')"
>
{{ createBtnText }}
@@ -23,7 +23,7 @@
variant="text"
@click="$emit('toggle-search')"
>
<v-icon :color="searchVisible ? 'primary-variant' : undefined"> mdi-magnify </v-icon>
<v-icon :color="searchVisible ? 'primary-variant' : undefined" :icon="mdiMagnify" />
</v-btn>
</template>
</v-tooltip>
@@ -31,7 +31,7 @@
<v-tooltip :disabled="!refreshTooltipText" location="top" :text="refreshTooltipText">
<template #activator="{ props }">
<v-btn v-bind="props" density="comfortable" icon variant="text" @click="$emit('refresh')">
<v-icon>mdi-refresh</v-icon>
<v-icon :icon="mdiRefresh" />
</v-btn>
</template>
</v-tooltip>
@@ -47,7 +47,7 @@
variant="text"
@click="$emit('settings')"
>
<v-icon>mdi-cog</v-icon>
<v-icon :icon="mdiCog" />
</v-btn>
</template>
</v-tooltip>
@@ -84,6 +84,7 @@
</template>
<script setup lang="ts">
import { mdiCog, mdiMagnify, mdiPlus, mdiRefresh } from '@mdi/js'
import { computed, toRefs } from 'vue'
interface SettingsItem {
+2 -1
View File
@@ -22,7 +22,7 @@
variant="text"
@click="toggleExpand(item.id)"
>
<v-icon>{{ isExpanded(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
<v-icon :icon="isExpanded(item.id) ? mdiChevronDown : mdiChevronRight" />
</v-btn>
<div v-else style="width: 20px"></div>
@@ -39,6 +39,7 @@
</template>
<script setup lang="ts">
import { mdiChevronDown, mdiChevronRight } from '@mdi/js'
import { computed, ref, watch } from 'vue'
interface Props {
@@ -16,7 +16,7 @@
:size="180"
:width="25"
>
<v-icon :color="data.color" size="40">{{ data.icon }}</v-icon>
<v-icon :color="data.color" size="40" :icon="data.icon" />
</v-progress-circular>
</div>
<div class="mt-6 text-center">
@@ -8,9 +8,7 @@
</div>
<div class="text-h4 font-weight-bold">{{ value }}</div>
</div>
<v-icon class="opacity-80" :color="color" size="x-large">
{{ icon }}
</v-icon>
<v-icon class="opacity-80" :color="color" size="x-large" :icon="icon" />
</div>
<div class="d-flex justify-space-between align-center border-t pt-3">
@@ -2,7 +2,7 @@
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
<v-card-title class="d-flex align-center py-4 px-4">
<div class="d-flex align-center">
<v-icon class="mr-2" color="primary" icon="mdi-chart-timeline-variant"></v-icon>
<v-icon class="mr-2" color="primary" :icon="mdiChartTimelineVariant"></v-icon>
<span>{{ title }}</span>
</div>
<v-spacer></v-spacer>
@@ -49,6 +49,7 @@
</template>
<script setup lang="ts">
import { mdiChartTimelineVariant } from '@mdi/js'
interface Props {
title: string
data: number[]
@@ -18,7 +18,7 @@
>
<div class="pa-4 h-100 hover-bg" @click="$emit('app-click', app)">
<div class="d-flex align-center mb-3">
<v-icon class="mr-3" :color="app.color" size="large">{{ app.icon }}</v-icon>
<v-icon class="mr-3" :color="app.color" size="large" :icon="app.icon" />
<span class="text-subtitle-1 font-weight-medium">{{ app.name }}</span>
</div>
<div
@@ -23,11 +23,11 @@
</div>
<div class="mt-4 d-flex justify-center gap-4 w-100">
<div class="d-flex align-center mr-4">
<v-icon class="mr-1" color="primary" size="small">mdi-circle</v-icon>
<v-icon class="mr-1" color="primary" size="small" :icon="mdiCircle" />
<span class="text-caption">{{ primaryLabel }}</span>
</div>
<div class="d-flex align-center">
<v-icon class="mr-1" color="grey-lighten-3" size="small">mdi-circle</v-icon>
<v-icon class="mr-1" color="grey-lighten-3" size="small" :icon="mdiCircle" />
<span class="text-caption">{{ secondaryLabel }}</span>
</div>
</div>
@@ -36,6 +36,7 @@
</template>
<script setup lang="ts">
import { mdiCircle } from '@mdi/js'
interface Props {
title: string
value: number
@@ -13,7 +13,7 @@
variant="text"
@click="$emit('nav-click', nav)"
>
<v-icon size="24">{{ nav.icon }}</v-icon>
<v-icon size="24" :icon="nav.icon" />
</v-btn>
<div class="text-caption text-grey-darken-1">{{ nav.title }}</div>
</v-col>
+2 -1
View File
@@ -5,7 +5,7 @@ v-model="username" bg-color="surface" class="mb-6 mb-md-4" color="primary"
density="comfortable" hide-details :placeholder="props.accPlaceholder" variant="outlined"></v-text-field>
<v-text-field
v-model="password" :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" bg-color="surface"
v-model="password" :append-inner-icon="showPassword ? mdiEyeOff : mdiEye" bg-color="surface"
class="mb-6 mb-md-4" color="primary" density="comfortable" hide-details :placeholder="props.passwPlaceholder" :type="showPassword ? 'text' : 'password'"
variant="outlined"
@click:append-inner="showPassword = !showPassword"></v-text-field>
@@ -30,6 +30,7 @@ class="text-body-2 text-primary text-decoration-none" :href="props.forgotPasswor
</template>
<script setup lang="ts">
import { mdiEye, mdiEyeOff } from '@mdi/js'
import { onMounted, ref, watch } from 'vue'
const username = ref('')
const password = ref('')
+5 -4
View File
@@ -1,13 +1,13 @@
<template>
<div class="d-flex justify-end py-0 py-sm-2">
<v-btn
class="d-none d-md-block" color="grey-darken-1" icon="mdi-palette-outline" size="small" variant="text"
class="d-none d-md-block" color="grey-darken-1" :icon="mdiPaletteOutline" size="small" variant="text"
@click="toggleTheme"></v-btn>
<!-- <v-btn icon="mdi-dock-window" variant="text" size="small" color="grey-darken-1" @click="handleToggleLayout"></v-btn> -->
<!-- <v-btn :icon="mdiDockWindow" variant="text" size="small" color="grey-darken-1" @click="handleToggleLayout"></v-btn> -->
<v-menu location="bottom end">
<template #activator="{ props: menuActivatorProps }">
<v-btn
v-bind="menuActivatorProps" color="grey-darken-1" icon="mdi-translate" size="small"
v-bind="menuActivatorProps" color="grey-darken-1" :icon="mdiTranslate" size="small"
variant="text"></v-btn>
</template>
<v-list density="compact">
@@ -18,11 +18,12 @@ v-for="locale in localeOptions" :key="locale" :active="locale === props.locale"
</v-list-item>
</v-list>
</v-menu>
<!-- <v-btn icon="mdi-weather-night" variant="text" size="small" color="grey-darken-1"></v-btn> -->
<!-- <v-btn :icon="mdiWeatherNight" variant="text" size="small" color="grey-darken-1"></v-btn> -->
</div>
</template>
<script setup lang="ts">
import { mdiDockWindow, mdiPaletteOutline, mdiTranslate, mdiWeatherNight } from '@mdi/js'
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
+4 -3
View File
@@ -10,16 +10,16 @@
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2" :title="props.refreshTitle"
@click="handleRefresh">
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
<v-icon class="ms-2" color="grey" icon="mdi-refresh"></v-icon>
<v-icon class="ms-2" color="grey" :icon="mdiRefresh"></v-icon>
</div>
<!-- Input and Verify -->
<v-text-field
v-model="inputCode" :append-inner-icon="props.verified ? 'mdi-check-circle' : ''" bg-color="surface" class="flex-grow-1"
v-model="inputCode" :append-inner-icon="props.verified ? mdiCheckCircle : undefined" bg-color="surface" class="flex-grow-1"
color="primary" density="compact" :disabled="props.verified" :error="!!errorMsg" hide-details
:placeholder="props.captchaPlaceholder" variant="outlined">
<template v-if="props.verified" #append-inner>
<v-icon color="success">mdi-check-circle</v-icon>
<v-icon color="success" :icon="mdiCheckCircle" />
</template>
</v-text-field>
</div>
@@ -31,6 +31,7 @@ v-model="inputCode" :append-inner-icon="props.verified ? 'mdi-check-circle' : ''
</template>
<script setup lang="ts">
import { mdiCheckCircle, mdiRefresh } from '@mdi/js'
import { computed } from 'vue'
interface CaptchaPayload {
+7 -6
View File
@@ -8,7 +8,7 @@ v-model="drawer" class="sk-admin-drawer" color="surface" :rail="isRail"
<!-- Sidebar Title -->
<v-card class="sidebar-header d-flex align-center pa-0 pl-3 py-2" flat>
<v-btn
:aria-label="sidebarToggleLabel" color="grey" :icon="isRail ? 'mdi-menu-open' : 'mdi-menu'" size="32"
:aria-label="sidebarToggleLabel" color="grey" :icon="isRail ? mdiMenuOpen : mdiMenu" size="32"
variant="text" @click="toggleSidebar" />
<v-card-text v-if="!isRail" class="sidebar-title flex-grow-1 py-0">
<slot name="title">
@@ -120,12 +120,12 @@ v-else :mobile-current-items="mobileCurrentItems"
<v-card v-if="helpWidgetVisible" class="help-widget" rounded="lg">
<v-card-item class="py-2">
<template #prepend>
<v-icon color="primary">mdi-help-circle-outline</v-icon>
<v-icon color="primary" :icon="mdiHelpCircleOutline" />
</template>
<v-card-title class="text-subtitle-2">操作說明</v-card-title>
<template #append>
<v-btn
aria-label="關閉說明" icon="mdi-close" size="small" variant="text"
aria-label="關閉說明" :icon="mdiClose" size="small" variant="text"
@click="helpWidgetVisible = false" />
</template>
</v-card-item>
@@ -141,11 +141,12 @@ aria-label="關閉說明" icon="mdi-close" size="small" variant="text"
</v-card>
</v-slide-y-reverse-transition>
<!-- <v-btn v-if="isMobile" class="mobile-menu-btn" color="primary" icon="mdi-menu" @click="drawer = true" /> -->
<!-- <v-btn v-if="isMobile" class="mobile-menu-btn" color="primary" :icon="mdiMenu" @click="drawer = true" /> -->
</v-app>
</template>
<script setup>
import { mdiClose, mdiHelpCircleOutline, mdiHome, mdiMenu, mdiMenuOpen } from '@mdi/js'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDisplay, useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
@@ -182,7 +183,7 @@ const defaultFeatures = {
const defaultBreadcrumbConfig = {
homeLabel: '首頁',
homeDisabled: true,
homeIcon: 'mdi-home',
homeIcon: mdiHome,
}
const props = defineProps({
@@ -246,7 +247,7 @@ const props = defineProps({
default: () => ({
homeLabel: '首頁',
homeDisabled: true,
homeIcon: 'mdi-home',
homeIcon: mdiHome,
}),
},
breadcrumbItems: {
+5 -4
View File
@@ -8,7 +8,7 @@
<div class="d-flex align-center w-100">
<span class="flex-grow-1">{{ favoriteHeaderLabel }}</span>
<v-btn
density="compact" icon="mdi-unfold-less-horizontal" :ripple="false" variant="text"
density="compact" :icon="mdiUnfoldLessHorizontal" :ripple="false" variant="text"
@click.stop="collapseFavoriteGroups" />
</div>
</v-list-subheader>
@@ -55,7 +55,7 @@ density="compact" icon="mdi-unfold-less-horizontal" :ripple="false" variant="tex
<div class="d-flex align-center w-100">
<span class="flex-grow-1">{{ menuHeaderLabel }}</span>
<v-btn
density="compact" icon="mdi-unfold-less-horizontal" :ripple="false" variant="text"
density="compact" :icon="mdiUnfoldLessHorizontal" :ripple="false" variant="text"
@click.stop="collapseMenuGroups" />
</div>
</v-list-subheader>
@@ -138,13 +138,13 @@ v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title
<v-spacer />
<v-tooltip location="bottom" :text="themeToggleLabel">
<template #activator="{ props }">
<v-btn v-bind="props" :aria-label="themeToggleLabel" icon="mdi-palette" variant="text" @click="toggleTheme" />
<v-btn v-bind="props" :aria-label="themeToggleLabel" :icon="mdiPalette" variant="text" @click="toggleTheme" />
</template>
</v-tooltip>
<v-tooltip location="bottom" :text="logoutLabel">
<template #activator="{ props }">
<v-btn v-bind="props" :aria-label="logoutLabel" icon="mdi-logout" variant="text" @click="$emit('logout')" />
<v-btn v-bind="props" :aria-label="logoutLabel" :icon="mdiLogout" variant="text" @click="$emit('logout')" />
</template>
</v-tooltip>
</v-app-bar>
@@ -158,6 +158,7 @@ v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title
</template>
<script setup>
import { mdiLogout, mdiPalette, mdiUnfoldLessHorizontal } from '@mdi/js'
import { computed, ref } from 'vue'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
+3 -2
View File
@@ -13,7 +13,7 @@
:width="menuWidth"
>
<template #activator="{ props }">
<v-btn v-bind="props" icon="mdi-dots-vertical" />
<v-btn v-bind="props" :icon="mdiDotsVertical" />
</template>
<v-list :density="menuDensity">
@@ -51,7 +51,7 @@
<template #activator="{ props }">
<v-list-item
v-bind="props"
append-icon="mdi-chevron-right"
:append-icon="mdiChevronRight"
:prepend-icon="item.icon"
:title="item.title"
/>
@@ -88,6 +88,7 @@
</template>
<script setup>
import { mdiChevronRight, mdiDotsVertical } from '@mdi/js'
import { computed, ref } from 'vue'
import { useDisplay } from 'vuetify'
@@ -12,12 +12,12 @@ v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary"
</template>
<template #item="{ item }">
<div class="d-flex align-center ga-1">
<v-icon v-if="item.icon" class="mr-1" size="14">{{ item.icon }}</v-icon>
<v-icon v-if="item.icon" class="mr-1" size="14" :icon="item.icon" />
<span class="text-caption text-no-wrap">{{ item.title }}</span>
</div>
</template>
<template #divider>
<v-icon color="primary-variant" size="12">mdi-chevron-right</v-icon>
<v-icon color="primary-variant" size="12" :icon="mdiChevronRight" />
</template>
</v-breadcrumbs>
<div class="page-actions">
@@ -27,6 +27,7 @@ v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary"
</template>
<script setup>
import { mdiChevronRight } from '@mdi/js'
defineProps({
features: { type: Object, default: () => ({}) },
showBreadcrumbBar: { type: Boolean, default: true },
@@ -11,24 +11,25 @@ v-if="features.showFavorites && showFavoritesBar && !isMobile"
v-for="item in favoriteItems" :key="item.path ?? item.title" class="favorite-item" closable
color="secondary" size="small" variant="outlined" @click="emit('select', item)"
@click:close="emit('remove-favorite', item)">
<v-icon v-if="item.icon" class="me-1" size="16">{{ item.icon }}</v-icon>
<v-icon v-if="item.icon" class="me-1" size="16" :icon="item.icon" />
<span class="text-caption">{{ item.title }}</span>
</v-chip>
</transition-group>
<v-btn
v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" variant="outlined"
@click="emit('add-favorite')">
<v-icon class="mr-1" size="16">mdi-plus</v-icon>
<v-icon class="mr-1" size="16" :icon="mdiPlus" />
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
</v-btn>
</div>
<v-btn color="grey" size="small" variant="text" @click="emit('toggle-favorites-bar', false)">
<v-icon>mdi-eye-off</v-icon>
<v-icon :icon="mdiEyeOff" />
</v-btn>
</v-col>
</template>
<script setup>
import { mdiEyeOff, mdiPlus } from '@mdi/js'
defineProps({
features: { type: Object, default: () => ({}) },
showFavoritesBar: { type: Boolean, default: true },
@@ -1,6 +1,6 @@
<template>
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
<v-btn v-if="isMobile" icon="mdi-menu" size="small" variant="text" @click="emit('toggle-drawer')"></v-btn>
<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">
<v-text-field
@@ -8,7 +8,7 @@ v-model="searchValueModel" :aria-label="searchConfig.label" class="search-input"
hide-details :placeholder="searchConfig.placeholder" variant="outlined"
@keyup.enter="triggerSearch">
<template v-if="!isMobile" #prepend-inner>
<v-icon size="small">mdi-magnify</v-icon>
<v-icon size="small" :icon="mdiMagnify" />
</template>
<template #append-inner>
<v-btn :aria-label="searchConfig.label" color="primary" size="small" variant="text" @click="triggerSearch">
@@ -30,9 +30,9 @@ v-bind="props" :aria-label="toolbarActions.notificationsLabel" icon size="small"
<v-badge
v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notifications"
offset-x="4" offset-y="-2">
<v-icon>mdi-bell-outline</v-icon>
<v-icon :icon="mdiBellOutline" />
</v-badge>
<v-icon v-else>mdi-bell-outline</v-icon>
<v-icon v-else :icon="mdiBellOutline" />
</v-btn>
</template>
</v-tooltip>
@@ -46,9 +46,9 @@ v-bind="props" :aria-label="toolbarActions.messagesLabel" icon size="small" vari
<v-badge
v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages" offset-x="4"
offset-y="-2">
<v-icon>mdi-message-text-outline</v-icon>
<v-icon :icon="mdiMessageTextOutline" />
</v-badge>
<v-icon v-else>mdi-message-text-outline</v-icon>
<v-icon v-else :icon="mdiMessageTextOutline" />
</v-btn>
</template>
</v-tooltip>
@@ -59,7 +59,7 @@ v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages"
<v-btn
v-bind="props" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
@click="emit('action', 'help')">
<v-icon>mdi-help</v-icon>
<v-icon :icon="mdiHelp" />
</v-btn>
</template>
</v-tooltip>
@@ -72,7 +72,7 @@ v-bind="props" :aria-label="toolbarActions.helpLabel" icon size="small" variant=
<v-btn
v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsLabel" icon size="small"
variant="text">
<v-icon>mdi-cog-outline</v-icon>
<v-icon :icon="mdiCogOutline" />
</v-btn>
</template>
</v-tooltip>
@@ -100,7 +100,7 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
<v-tooltip location="bottom" :text="logoutLabel">
<template #activator="{ props }">
<v-btn v-bind="props" :aria-label="logoutLabel" icon size="small" variant="text" @click="emit('logout')">
<v-icon>mdi-logout</v-icon>
<v-icon :icon="mdiLogout" />
</v-btn>
</template>
</v-tooltip>
@@ -108,7 +108,7 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
<template #activator="{ props }">
<v-btn v-bind="props" :aria-label="themeToggleLabel" icon variant="text" @click="emit('toggle-theme')">
<v-icon>mdi-palette-outline</v-icon>
<v-icon :icon="mdiPaletteOutline" />
</v-btn>
</template>
</v-tooltip>
@@ -118,6 +118,7 @@ v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsL
</template>
<script setup>
import { mdiBellOutline, mdiCogOutline, mdiHelp, mdiLogout, mdiMagnify, mdiMenu, mdiMessageTextOutline, mdiPaletteOutline } from '@mdi/js'
import { computed } from 'vue'
const props = defineProps({
@@ -7,7 +7,7 @@
v-bind="isShrink ? undefined : props" :class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path" :to="isNavigable(item) ? item.path : undefined" @click="emitSelect(item)">
<template #prepend>
<v-icon v-if="item.icon" size="20">{{ item.icon }}</v-icon>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
item.title?.charAt(0) }}</v-btn>
</template>
@@ -31,7 +31,7 @@ v-if="subItem.subItems?.length"
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps" :link="isNavigable(subItem)"
:prepend-icon="subItem.icon || 'mdi-menu-right'" :to="isNavigable(subItem) ? subItem.path : undefined"
:prepend-icon="subItem.icon || mdiMenuRight" :to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)">
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
@@ -48,7 +48,7 @@ v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-sm
<v-list-item
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path" prepend-icon="mdi-circle-small" :to="subSubItem.path"
:link="!!subSubItem.path" :prepend-icon="mdiCircleSmall" :to="subSubItem.path"
@click="emitSelect(subSubItem)">
<template #title>
<v-tooltip location="end" :text="subSubItem.title">
@@ -61,7 +61,7 @@ v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title
</v-list-group>
<v-list-item
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || 'mdi-menu-right'" :to="subItem.path"
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || mdiMenuRight" :to="subItem.path"
@click="emitSelect(subItem)">
<template #title>
<v-tooltip location="end" :text="subItem.title">
@@ -76,7 +76,7 @@ v-else :link="!!subItem.path" :prepend-icon="subItem.icon || 'mdi-menu-right'" :
<v-list-item v-else :class="{ 'px-0': isShrink }" :link="!!item.path" :to="item.path" @click="emitSelect(item)">
<template #prepend>
<v-icon v-if="item.icon" size="20">{{ item.icon }}</v-icon>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
item.title?.charAt(0) }}</v-btn>
</template>
@@ -93,6 +93,7 @@ v-else :link="!!subItem.path" :prepend-icon="subItem.icon || 'mdi-menu-right'" :
</template>
<script setup lang="ts">
import { mdiCircleSmall, mdiMenuRight } from '@mdi/js'
import { computed, watch } from 'vue'
interface MenuItem {
@@ -6,7 +6,7 @@ v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" r
@click="emit('item-click', item)">
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
<template #append>
<v-icon size="18">{{ item.subItems?.length ? 'mdi-chevron-right' : 'mdi-arrow-top-right' }}</v-icon>
<v-icon size="18" :icon="item.subItems?.length ? mdiChevronRight : mdiArrowTopRight" />
</template>
</v-list-item>
</v-list>
@@ -14,6 +14,7 @@ v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" r
</template>
<script setup>
import { mdiArrowTopRight, mdiChevronRight } from '@mdi/js'
defineProps({
mobileCurrentItems: {
type: Array,
+4 -3
View File
@@ -5,11 +5,11 @@
<span class="text-h6">{{ title }}</span>
<v-spacer />
<v-btn
:icon="mdAndUp ? false : 'mdi-magnify'" :prepend-icon="mdAndUp ? 'mdi-magnify' : undefined" size="small"
:icon="mdAndUp ? false : mdiMagnify" :prepend-icon="mdAndUp ? mdiMagnify : undefined" size="small"
:text="mdAndUp ? '搜尋條件' : false" variant="text" @click="$emit('toggle-search')">
</v-btn>
<v-btn
color="primary" :icon="mdAndUp ? false : 'mdi-plus'" :prepend-icon="mdAndUp ? 'mdi-plus' : undefined"
color="primary" :icon="mdAndUp ? false : mdiPlus" :prepend-icon="mdAndUp ? mdiPlus : undefined"
size="small" :text="mdAndUp ? createLabel : false" @click="$emit('create')">
</v-btn>
</v-card-title>
@@ -34,7 +34,7 @@ color="primary" :icon="mdAndUp ? false : 'mdi-plus'" :prepend-icon="mdAndUp ? 'm
<v-card-title class="d-flex align-center py-3 px-4">
<span class="text-subtitle-1 font-weight-medium">搜尋條件</span>
<v-spacer />
<v-btn icon="mdi-close" size="small" variant="text" @click="$emit('toggle-search')" />
<v-btn :icon="mdiClose" size="small" variant="text" @click="$emit('toggle-search')" />
</v-card-title>
<v-divider />
<v-card-text class="px-2 py-1">
@@ -47,6 +47,7 @@ color="primary" :icon="mdAndUp ? false : 'mdi-plus'" :prepend-icon="mdAndUp ? 'm
</template>
<script setup lang="ts">
import { mdiClose, mdiMagnify, mdiPlus } from '@mdi/js'
import { useDisplay } from 'vuetify'
const { mdAndUp } = useDisplay()
@@ -2,20 +2,20 @@
<div v-if="mobile" class="d-flex align-center flex-wrap ga-2 w-100">
<div class="d-flex align-center ga-1">
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" icon="mdi-chevron-left" size="small" variant="text"
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :icon="mdiChevronLeft" size="small" variant="text"
@click="$emit('prev')" />
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" icon="mdi-chevron-right" size="small" variant="text"
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" :icon="mdiChevronRight" size="small" variant="text"
@click="$emit('next')" />
</div>
<v-spacer />
<v-btn
v-if="isViewMode" color="primary" prepend-icon="mdi-pencil" size="small" variant="tonal"
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
@click="$emit('switch-to-edit')">
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="tonal"
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
@click="$emit('switch-to-view')">
{{ viewLabel }}
</v-btn>
@@ -23,33 +23,33 @@ v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="t
<template v-else>
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" prepend-icon="mdi-skip-previous" size="small"
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiSkipPrevious" size="small"
variant="text" @click="$emit('first')">
{{ firstLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" prepend-icon="mdi-chevron-left" size="small"
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" :prepend-icon="mdiChevronLeft" size="small"
variant="text" @click="$emit('prev')">
{{ prevLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" append-icon="mdi-chevron-right" :disabled="!hasNextRecord" size="small"
v-if="isViewMode || isEditMode" :append-icon="mdiChevronRight" :disabled="!hasNextRecord" size="small"
variant="text" @click="$emit('next')">
{{ nextLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" append-icon="mdi-skip-next" :disabled="!hasNextRecord" size="small"
v-if="isViewMode || isEditMode" :append-icon="mdiSkipNext" :disabled="!hasNextRecord" size="small"
variant="text" @click="$emit('last')">
{{ lastLabel }}
</v-btn>
<v-spacer />
<v-btn
v-if="isViewMode" color="primary" prepend-icon="mdi-pencil" size="small" variant="tonal"
v-if="isViewMode" color="primary" :prepend-icon="mdiPencil" size="small" variant="tonal"
@click="$emit('switch-to-edit')">
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="tonal"
v-if="isEditMode" color="primary" :prepend-icon="mdiEye" size="small" variant="tonal"
@click="$emit('switch-to-view')">
{{ viewLabel }}
</v-btn>
@@ -57,6 +57,7 @@ v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="t
</template>
<script setup lang="ts">
import { mdiChevronLeft, mdiChevronRight, mdiEye, mdiPencil, mdiSkipNext, mdiSkipPrevious } from '@mdi/js'
defineProps({
isViewMode: {
type: Boolean,
@@ -1,7 +1,7 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
<v-toolbar color="transparent" density="compact" flat>
<v-btn icon="mdi-arrow-left" size="small" variant="text" @click="$emit('close')" />
<v-btn :icon="mdiArrowLeft" size="small" variant="text" @click="$emit('close')" />
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ semester?.semesterName || '學期明細' }}
</v-toolbar-title>
@@ -84,7 +84,7 @@
<v-btn
color="primary"
:disabled="isFormLocked"
prepend-icon="mdi-plus"
:prepend-icon="mdiPlus"
size="small"
variant="text"
@click="$emit('add-course', semester.id)"
@@ -100,7 +100,7 @@
<v-btn
color="error"
:disabled="isFormLocked"
icon="mdi-delete"
:icon="mdiDelete"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, idx, course.name)"
@@ -159,6 +159,7 @@
</template>
<script setup lang="ts">
import { mdiArrowLeft, mdiDelete, mdiPlus } from '@mdi/js'
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
@@ -1,6 +1,6 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
<v-icon start :icon="mdiSchool" />
子檔資料示範
</div>
@@ -20,7 +20,7 @@
<div class="text-body-1 font-weight-bold">{{ semester.semesterName }}</div>
<div class="text-caption text-medium-emphasis">點擊查看課程與成績</div>
</div>
<v-icon size="small">mdi-chevron-right</v-icon>
<v-icon size="small" :icon="mdiChevronRight" />
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
@@ -49,7 +49,7 @@
v-if="!isFormReadonly"
color="primary"
:disabled="isFormLocked"
prepend-icon="mdi-plus"
:prepend-icon="mdiPlus"
size="small"
variant="tonal"
@click="$emit('add-course', semester.id)"
@@ -64,19 +64,19 @@
<th class="cursor-pointer" width="50%" @click="toggleSort(semester.id, 'name')">
<div class="d-flex align-center ga-1">
<span>課程名稱</span>
<v-icon size="small">{{ getSortIcon(semester.id, 'name') }}</v-icon>
<v-icon size="small" :icon="getSortIcon(semester.id, 'name')" />
</div>
</th>
<th class="cursor-pointer" @click="toggleSort(semester.id, 'credits')">
<div class="d-flex align-center ga-1">
<span>學分</span>
<v-icon size="small">{{ getSortIcon(semester.id, 'credits') }}</v-icon>
<v-icon size="small" :icon="getSortIcon(semester.id, 'credits')" />
</div>
</th>
<th class="cursor-pointer" @click="toggleSort(semester.id, 'score')">
<div class="d-flex align-center ga-1">
<span>分數</span>
<v-icon size="small">{{ getSortIcon(semester.id, 'score') }}</v-icon>
<v-icon size="small" :icon="getSortIcon(semester.id, 'score')" />
</div>
</th>
<th v-if="!isFormReadonly" width="52"></th>
@@ -130,7 +130,7 @@
<v-btn
color="error"
:disabled="isFormLocked"
icon="mdi-delete-outline"
:icon="mdiDeleteOutline"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, originalIndex, course.name)"
@@ -143,7 +143,7 @@
:colspan="isFormReadonly ? 3 : 4"
>
<div class="d-flex flex-column align-center ga-2">
<v-icon color="medium-emphasis" size="24">mdi-book-open-outline</v-icon>
<v-icon color="medium-emphasis" size="24" :icon="mdiBookOpenOutline" />
<span class="text-caption">尚無課程,點擊「加入課程」新增</span>
</div>
</td>
@@ -165,6 +165,7 @@
</template>
<script setup lang="ts">
import { mdiArrowDown, mdiArrowUp, mdiBookOpenOutline, mdiChevronRight, mdiDeleteOutline, mdiPlus, mdiSchool, mdiSwapVertical } from '@mdi/js'
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { ref } from 'vue'
@@ -222,8 +223,8 @@ function toggleSort (semesterId: number, key: CourseSortKey) {
function getSortIcon (semesterId: number, key: CourseSortKey) {
const current = getSortState(semesterId)
if (current?.key !== key) return 'mdi-swap-vertical'
return current.order === 'asc' ? 'mdi-arrow-up' : 'mdi-arrow-down'
if (current?.key !== key) return mdiSwapVertical
return current.order === 'asc' ? mdiArrowUp : mdiArrowDown
}
function compareCourseValue (left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
@@ -1,7 +1,7 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
<v-toolbar color="transparent" density="compact" flat>
<v-btn icon="mdi-arrow-left" size="small" variant="text" @click="$emit('close')" />
<v-btn :icon="mdiArrowLeft" size="small" variant="text" @click="$emit('close')" />
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ semester?.semesterName || '課程明細' }}
</v-toolbar-title>
@@ -36,7 +36,7 @@
v-if="!isViewMode"
color="primary"
:disabled="isFormLocked"
prepend-icon="mdi-plus"
:prepend-icon="mdiPlus"
size="small"
variant="text"
@click="$emit('add-course', semester.id)"
@@ -69,7 +69,7 @@
<v-btn
color="error"
:disabled="isFormLocked"
icon="mdi-delete"
:icon="mdiDelete"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, idx)"
@@ -129,6 +129,7 @@
</template>
<script setup lang="ts">
import { mdiArrowLeft, mdiDelete, mdiPlus } from '@mdi/js'
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
@@ -1,10 +1,10 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
<v-icon start :icon="mdiSchool" />
子檔資料示範
<v-spacer />
<v-btn
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" prepend-icon="mdi-plus" size="small"
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" :prepend-icon="mdiPlus" size="small"
variant="tonal" @click="$emit('add-course')">
新增成績
</v-btn>
@@ -22,7 +22,7 @@ v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="
<div class="text-body-1 font-weight-bold">{{ semester.semesterName }}</div>
<div class="text-caption text-medium-emphasis">點擊查看課程與成績</div>
</div>
<v-icon size="small">mdi-chevron-right</v-icon>
<v-icon size="small" :icon="mdiChevronRight" />
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
@@ -65,7 +65,7 @@ v-else density="compact" :disabled="isFormLocked" hide-details
</template>
<template #[`item.actions`]="slotProps">
<v-btn
color="error" :disabled="isFormLocked" icon="mdi-delete" size="small" variant="text"
color="error" :disabled="isFormLocked" :icon="mdiDelete" size="small" variant="text"
@click="$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)" />
</template>
</v-data-table>
@@ -80,6 +80,7 @@ color="error" :disabled="isFormLocked" icon="mdi-delete" size="small" variant="t
</template>
<script setup lang="ts">
import { mdiChevronRight, mdiDelete, mdiPlus, mdiSchool } from '@mdi/js'
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
@@ -1,9 +1,9 @@
<template>
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
<v-icon start :icon="mdiSchool" />
子檔資料示範 ({{ semesters.length }})
<v-spacer />
<v-btn v-if="!isViewMode" color="primary" prepend-icon="mdi-plus" size="small" variant="text" @click="$emit('add')">
<v-btn v-if="!isViewMode" color="primary" :prepend-icon="mdiPlus" size="small" variant="text" @click="$emit('add')">
新增學期
</v-btn>
</div>
@@ -23,7 +23,7 @@
平均: {{ semester.average }} 排名: {{ semester.rank }}
</v-list-item-subtitle>
<template #append>
<v-icon size="small">mdi-chevron-right</v-icon>
<v-icon size="small" :icon="mdiChevronRight" />
</template>
</v-list-item>
</v-card>
@@ -36,6 +36,7 @@
</template>
<script setup lang="ts">
import { mdiChevronRight, mdiPlus, mdiSchool } from '@mdi/js'
import type { SemesterRecord } from '@/stores/semesters'
defineProps<{
@@ -1,7 +1,7 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" flat width="100%">
<v-toolbar v-if="!isDetailEditing" color="transparent" density="compact" flat>
<v-btn :icon="isMobile ? 'mdi-arrow-left' : 'mdi-close'" size="small" variant="text" @click="$emit('close')" />
<v-btn :icon="isMobile ? mdiArrowLeft : mdiClose" size="small" variant="text" @click="$emit('close')" />
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ selectedSemester ? selectedSemester.semesterName : '學期明細' }}
</v-toolbar-title>
@@ -9,7 +9,7 @@
<v-btn
v-if="selectedSemester && !isViewMode"
color="error"
prepend-icon="mdi-delete"
:prepend-icon="mdiDelete"
size="small"
variant="text"
@click="$emit('delete', selectedSemester.id)"
@@ -19,7 +19,7 @@
<v-btn
v-if="selectedSemester && !isViewMode"
color="primary"
prepend-icon="mdi-pencil"
:prepend-icon="mdiPencil"
size="small"
variant="text"
@click="$emit('start-edit')"
@@ -29,7 +29,7 @@
</v-toolbar>
<v-toolbar v-else density="compact" flat>
<v-btn :icon="isMobile ? 'mdi-arrow-left' : 'mdi-close'" size="small" variant="text" @click="$emit('cancel-edit')" />
<v-btn :icon="isMobile ? mdiArrowLeft : mdiClose" size="small" variant="text" @click="$emit('cancel-edit')" />
<v-toolbar-title class="text-subtitle-1 font-weight-bold">
編輯學期
</v-toolbar-title>
@@ -107,21 +107,23 @@
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="detailForm.semesterName"
:model-value="detailForm.semesterName"
density="compact"
hide-details="auto"
label="學期名稱"
variant="outlined"
@update:model-value="updateDetailFormField('semesterName', $event)"
/>
</v-col>
<v-col :cols="isMobile ? 12 : 6">
<v-text-field
v-model.number="detailForm.rank"
:model-value="detailForm.rank"
density="compact"
hide-details="auto"
label="班級排名"
type="number"
variant="outlined"
@update:model-value="updateDetailFormField('rank', toNumber($event))"
/>
</v-col>
<v-col :cols="isMobile ? 12 : 6">
@@ -139,7 +141,7 @@
<div class="d-flex align-center mt-2 mb-1">
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-plus" size="small" variant="text" @click="$emit('add-course')">
<v-btn color="primary" :prepend-icon="mdiPlus" size="small" variant="text" @click="addCourse">
加入課程
</v-btn>
</div>
@@ -149,26 +151,42 @@
<div class="d-flex align-center mb-3">
<div class="text-subtitle-2 font-weight-bold">課程 {{ idx + 1 }}</div>
<v-spacer />
<v-btn color="error" icon="mdi-delete" size="small" variant="text" @click="$emit('remove-course', idx)" />
<v-btn color="error" :icon="mdiDelete" size="small" variant="text" @click="removeCourse(idx)" />
</div>
<div class="d-flex flex-column ga-3">
<v-text-field v-model="course.name" density="compact" hide-details label="課程名稱" variant="outlined" />
<v-text-field v-model="course.code" density="compact" hide-details label="代碼" variant="outlined" />
<v-text-field
v-model.number="course.credits"
:model-value="course.name"
density="compact"
hide-details
label="課程名稱"
variant="outlined"
@update:model-value="updateCourseField(idx, 'name', $event)"
/>
<v-text-field
:model-value="course.code"
density="compact"
hide-details
label="代碼"
variant="outlined"
@update:model-value="updateCourseField(idx, 'code', $event)"
/>
<v-text-field
:model-value="course.credits"
density="compact"
hide-details
label="學分"
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'credits', toNumber($event))"
/>
<v-text-field
v-model.number="course.score"
:model-value="course.score"
density="compact"
hide-details
label="分數"
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'score', toNumber($event))"
/>
</div>
</v-card>
@@ -189,23 +207,47 @@
<tbody>
<tr v-for="(course, idx) in detailForm.courses" :key="idx">
<td class="px-0 text-center">
<v-btn color="error" icon="mdi-delete" size="small" variant="text" @click="$emit('remove-course', idx)" />
<v-btn color="error" :icon="mdiDelete" size="small" variant="text" @click="removeCourse(idx)" />
</td>
<td class="py-2">
<v-text-field v-model="course.name" class="mb-1" density="compact" hide-details label="課程名稱" variant="underlined" />
<v-text-field v-model="course.code" density="compact" hide-details label="代碼" style="font-size: 0.85em" variant="underlined" />
<v-text-field
:model-value="course.name"
class="mb-1"
density="compact"
hide-details
label="課程名稱"
variant="underlined"
@update:model-value="updateCourseField(idx, 'name', $event)"
/>
<v-text-field
:model-value="course.code"
density="compact"
hide-details
label="代碼"
style="font-size: 0.85em"
variant="underlined"
@update:model-value="updateCourseField(idx, 'code', $event)"
/>
</td>
<td class="align-top py-2">
<v-text-field
v-model.number="course.credits"
:model-value="course.credits"
density="compact"
hide-details
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'credits', toNumber($event))"
/>
</td>
<td class="align-top py-2">
<v-text-field v-model.number="course.score" density="compact" hide-details type="number" variant="outlined" />
<v-text-field
:model-value="course.score"
density="compact"
hide-details
type="number"
variant="outlined"
@update:model-value="updateCourseField(idx, 'score', toNumber($event))"
/>
</td>
</tr>
<tr v-if="detailForm.courses.length === 0">
@@ -219,6 +261,7 @@
</template>
<script setup lang="ts">
import { mdiArrowLeft, mdiClose, mdiDelete, mdiPencil, mdiPlus } from '@mdi/js'
import type { SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
@@ -231,7 +274,7 @@ const props = defineProps<{
isMobile: boolean
}>()
defineEmits<{
const emit = defineEmits<{
(event: 'close'): void
(event: 'start-edit'): void
(event: 'delete', id: number): void
@@ -239,8 +282,64 @@ defineEmits<{
(event: 'save-edit'): void
(event: 'add-course'): void
(event: 'remove-course', index: number): void
(event: 'update:detail-form', value: SemesterRecord | null): void
}>()
function cloneDetailForm (form: SemesterRecord): SemesterRecord {
return {
...form,
courses: form.courses.map((course) => ({ ...course })),
}
}
function emitDetailFormUpdate (updater: (draft: SemesterRecord) => void) {
if (!props.detailForm) return
const nextForm = cloneDetailForm(props.detailForm)
updater(nextForm)
emit('update:detail-form', nextForm)
}
function updateDetailFormField<K extends keyof SemesterRecord> (key: K, value: SemesterRecord[K]) {
emitDetailFormUpdate((draft) => {
draft[key] = value
})
}
function updateCourseField<K extends keyof SemesterRecord['courses'][number]>(
index: number,
key: K,
value: SemesterRecord['courses'][number][K],
) {
emitDetailFormUpdate((draft) => {
const course = draft.courses[index]
if (!course) return
course[key] = value
})
}
function toNumber (value: unknown): number {
if (typeof value === 'number') return value
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
function addCourse () {
emitDetailFormUpdate((draft) => {
draft.courses.push({
code: '',
name: '',
credits: 3,
score: 0,
})
})
}
function removeCourse (index: number) {
emitDetailFormUpdate((draft) => {
draft.courses.splice(index, 1)
})
}
const totalCredits = computed(() =>
props.selectedSemester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
)
+114
View File
@@ -0,0 +1,114 @@
元件 (Component)
↓ 呼叫
Store (Pinia) ← 管理狀態、快取
↓ 呼叫
API Service ← 封裝業務邏輯
↓ 呼叫
HTTP Client ← Axios 實例、攔截器
## 目前的資料流(以登入為例)
1. `views/Login.vue`Playground 頁面)只負責表單/驗證碼/導頁等 UI 行為
2. `stores/auth.ts` 統一負責登入狀態(`user`/`token`/`loading`/`error`
3. `services/modules/user.ts` 封裝 `login/getProfile/...` 端點
4. `services/client.ts` 建立 `axios` instance
5. `services/interceptors.ts` 統一注入 token 與處理 HTTP 錯誤
## Menu API 與資料結構
選單系統採用 API 驅動設計:
### API 端點
- `GET /service/api/menu`:取得完整選單樹
- `GET /service/api/menu/favorite`:取得使用者收藏選單
### 資料結構
```ts
interface MenuNode {
mdl_id: string // 模組 ID
mdl_name: string // 模組名稱
unt_id?: string // 單位 ID
unt_name?: string // 單位名稱
fnc_id?: string // 功能 ID
fnc_name?: string // 功能名稱
children?: MenuNode[]
}
```
### 階層關係
- **第一層**:模組(mdl
- **第二層**:單位(unt
- **第三層**:功能(fnc),作為葉節點使用 `fnc_id` 作為路由路徑
### Store 持久化
`stores/menu.ts` 提供:
- 自動 localStorage 持久化選單與收藏
- 初始化時自動還原資料
- 登出時清除快取
## API 前綴:`/api`
目前 Playground 已將 `api` 資料夾更名為 `services`,避免與 API 前綴 `/api` 衝突。
在開發模式下:
- 前端呼叫一律使用 `/service/api/*`
- Vite dev server 透過 proxy 將 `/service/*` 轉送到後端(目前指向 `http://192.168.89.54:9002`
## HTTP Client 設定
- `baseURL`:優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`(搭配 Vite proxy
- `Content-Type`:預設 `application/json`
## Token Service(單一來源)
為避免「Pinia token」與「localStorage token」不同步的問題,這裡採用單一來源:
- `services/token.ts` 使用 `ref` 保存 token,並同步 localStorage
- Store 與 Interceptor 都只透過 `tokenService` 讀寫 token
- 401 時清除 token,可立即同步到 UI
## Token 注入策略(Interceptor
Interceptor 會從 `tokenService` 讀取 `token` 並注入 `Authorization: Bearer <token>`
這樣做的原因是避免循環依賴:
`store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)`
Store 仍然是「唯一負責更新 token 的地方」,Interceptor 只負責「讀取 token 並附加到 request」。
## 錯誤正規化(normalizeError
為了讓 UI 不需要理解 AxiosError,這裡將錯誤統一成 `ApiRequestError`
- `services/error.ts` 提供 `normalizeError()``ApiRequestError`
- Interceptor 在 response error 時呼叫 `normalizeError()`
- Store 只需要處理 `error.message / error.code / error.status`
最低限度的映射規則:
-`response.data.message` 優先使用
- 其次使用 `AxiosError.message`
- 都沒有則顯示 `請求失敗`
## 請求取消(AbortController
取消策略採「同類型請求互斥」,目前示範在 `login`
- Store 建立 `AbortController`,每次登入前先取消前一次
- Service 只接收 `signal`,不管理 controller 狀態
- `normalizeError` 會將取消行為轉為 `CanceledRequestError`
- UI 不顯示取消造成的錯誤訊息
| DECISION | WHY | WHY NOT |
|---|---|---|
| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
| API 模組化(userApi、orderApi)| 關注點分離、好維護 | 全塞一個檔案會變超大 |
| Interceptor 獨立檔案| 單一職責、好測試 | 寫在 client.ts 會雜亂 |
| 泛型 ApiResponse<T>| 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
+2 -1
View File
@@ -1,3 +1,4 @@
import { mdiHome } from '@mdi/js'
import type { LayoutMenuItem } from './menu'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -64,7 +65,7 @@ function toBreadcrumbItems (trail: LayoutMenuItem[],
export const useBreadcrumbStore = defineStore('breadcrumbs', () => {
const items = ref<BreadcrumbItem[]>([])
const homeLabel = ref('首頁')
const homeIcon = ref('mdi-home')
const homeIcon = ref(mdiHome)
const setBreadcrumbs = (payload: BreadcrumbPayload) => {
if (!payload?.path) return
+6 -5
View File
@@ -13,6 +13,7 @@
</template>
<script setup lang="ts">
import { mdiAccountSchool, mdiBookOpenPageVariant, mdiChartPie, mdiCheckDecagram, mdiCloudDownload } from '@mdi/js'
import { ref } from 'vue'
import SKAnalysis from '@/components/SKAnalysis.vue'
@@ -21,7 +22,7 @@ const stats = ref([
{
title: '學生總數',
value: '2,580',
icon: 'mdi-account-school',
icon: mdiAccountSchool,
color: 'primary',
label: '總學籍人數',
total: '120,000',
@@ -29,7 +30,7 @@ const stats = ref([
{
title: '平台訪問',
value: '20,000',
icon: 'mdi-chart-pie',
icon: mdiChartPie,
color: 'error',
label: '今日訪問量',
total: '500,000',
@@ -37,7 +38,7 @@ const stats = ref([
{
title: '教材下載',
value: '8,000',
icon: 'mdi-cloud-download',
icon: mdiCloudDownload,
color: 'warning',
label: '本月下載次數',
total: '120,000',
@@ -45,7 +46,7 @@ const stats = ref([
{
title: '圖書館借閱',
value: '5,000',
icon: 'mdi-book-open-page-variant',
icon: mdiBookOpenPageVariant,
color: 'success',
label: '總借閱量',
total: '50,000',
@@ -76,6 +77,6 @@ const pie2Data = ref({
value: 92,
label: '全校平均',
color: 'teal-lighten-1',
icon: 'mdi-check-decagram',
icon: mdiCheckDecagram,
})
</script>
+13 -12
View File
@@ -10,6 +10,7 @@
</template>
<script setup lang="ts">
import { mdiAccountGroup, mdiBookOpenVariant, mdiCalendarCheck, mdiChartBar, mdiCog, mdiHammerWrench, mdiHome, mdiLayers, mdiLock, mdiMonitorShimmer, mdiSchool, mdiViewDashboard } from '@mdi/js'
import { ref } from 'vue'
import SKDashboard from '@/components/SKDashboard.vue'
@@ -21,7 +22,7 @@ const greetingTitle = ref('早安,王校長,開始您一天的工作吧!')
const applications = ref([
{
name: '校務行政系統',
icon: 'mdi-school',
icon: mdiSchool,
desc: '全校教職員工生學籍資料、人事資料、財產管理等核心系統入口。',
group: '行政組',
date: '2025-01-05',
@@ -29,7 +30,7 @@ const applications = ref([
},
{
name: '數位學習平台',
icon: 'mdi-monitor-shimmer',
icon: mdiMonitorShimmer,
desc: '提供線上課程、作業繳交、測驗評量與師生互動討論功能。',
group: '教學組',
date: '2025-01-02',
@@ -37,7 +38,7 @@ const applications = ref([
},
{
name: '圖書館系統',
icon: 'mdi-book-open-variant',
icon: mdiBookOpenVariant,
desc: '館藏查詢、圖書借閱、還書預約與電子書資源整合平台。',
group: '圖書館',
date: '2024-12-28',
@@ -45,7 +46,7 @@ const applications = ref([
},
{
name: '學生請假系統',
icon: 'mdi-calendar-check',
icon: mdiCalendarCheck,
desc: '學生線上請假申請、導師審核、生輔組備查流程電子化。',
group: '學務處',
date: '2024-12-25',
@@ -53,7 +54,7 @@ const applications = ref([
},
{
name: '報修系統',
icon: 'mdi-hammer-wrench',
icon: mdiHammerWrench,
desc: '校園設施設備故障通報、維修進度查詢與滿意度調查。',
group: '總務處',
date: '2024-12-20',
@@ -61,7 +62,7 @@ const applications = ref([
},
{
name: '會議室預約',
icon: 'mdi-account-group',
icon: mdiAccountGroup,
desc: '校內各大型會議室、視聽教室場地查詢與線上預約登記。',
group: '總務處',
date: '2024-12-15',
@@ -101,12 +102,12 @@ const announcements = ref([
])
const quickNavs = ref([
{ title: '首頁', icon: 'mdi-home', color: 'primary' },
{ title: '控制台', icon: 'mdi-view-dashboard', color: 'error' },
{ title: '組件', icon: 'mdi-layers', color: 'warning' },
{ title: '系統管理', icon: 'mdi-cog', color: 'success' },
{ title: '權限', icon: 'mdi-lock', color: 'purple' },
{ title: '圖表', icon: 'mdi-chart-bar', color: 'info' },
{ title: '首頁', icon: mdiHome, color: 'primary' },
{ title: '控制台', icon: mdiViewDashboard, color: 'error' },
{ title: '組件', icon: mdiLayers, color: 'warning' },
{ title: '系統管理', icon: mdiCog, color: 'success' },
{ title: '權限', icon: mdiLock, color: 'purple' },
{ title: '圖表', icon: mdiChartBar, color: 'info' },
])
const todos = ref([
+3 -2
View File
@@ -48,11 +48,11 @@ v-if="resolveNewsItem(wrapped).isNew" class="ml-2" color="primary" size="x-small
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14">mdi-folder-outline</v-icon>
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14">mdi-eye-outline</v-icon>
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
@@ -119,6 +119,7 @@ class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item" va
</template>
<script setup lang="ts">
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
import { ref } from 'vue'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
+3 -2
View File
@@ -4,7 +4,7 @@
<v-col cols="12" md="8" sm="10">
<v-card border class="pa-6" variant="flat">
<v-card-title class="d-flex align-center ga-3">
<v-icon :color="color" size="36">{{ icon }}</v-icon>
<v-icon :color="color" size="36" :icon="icon" />
<div class="text-h5">{{ title }}</div>
<div class="text-caption text-medium-emphasis">{{ codeLabel }}</div>
<v-spacer />
@@ -35,6 +35,7 @@
</template>
<script setup lang="ts">
import { mdiAlertCircleOutline } from '@mdi/js'
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -51,7 +52,7 @@ type Props = {
const props = withDefaults(defineProps<Props>(), {
description: undefined,
icon: 'mdi-alert-circle-outline',
icon: mdiAlertCircleOutline,
color: 'warning',
showHome: true,
showLogin: true,
+2 -1
View File
@@ -3,11 +3,12 @@
:code="403"
color="warning"
description="你沒有權限存取此頁面或操作。"
icon="mdi-shield-lock-outline"
:icon="mdiShieldLockOutline"
title="沒有權限"
/>
</template>
<script setup lang="ts">
import { mdiShieldLockOutline } from '@mdi/js'
import ErrorShell from './ErrorShell.vue'
</script>
+2 -1
View File
@@ -3,13 +3,14 @@
code="MAINTENANCE"
color="info"
description="目前系統正在維護,請稍後再試。"
icon="mdi-tools"
:icon="mdiTools"
:show-login="false"
title="系統維護中"
/>
</template>
<script setup lang="ts">
import { mdiTools } from '@mdi/js'
import ErrorShell from './ErrorShell.vue'
</script>
+2 -1
View File
@@ -3,12 +3,13 @@
code="NETWORK"
color="warning"
description="無法連線到伺服器,請檢查網路或稍後再試。"
icon="mdi-wifi-off"
:icon="mdiWifiOff"
title="網路連線異常"
/>
</template>
<script setup lang="ts">
import { mdiWifiOff } from '@mdi/js'
import ErrorShell from './ErrorShell.vue'
</script>
+2 -1
View File
@@ -3,12 +3,13 @@
:code="404"
color="info"
description="你輸入的網址不存在,或頁面已被移除。"
icon="mdi-map-marker-question-outline"
:icon="mdiMapMarkerQuestionOutline"
:show-login="false"
title="找不到頁面"
/>
</template>
<script setup lang="ts">
import { mdiMapMarkerQuestionOutline } from '@mdi/js'
import ErrorShell from './ErrorShell.vue'
</script>
+2 -1
View File
@@ -3,12 +3,13 @@
:code="500"
color="error"
description="伺服器發生非預期錯誤,請稍後再試。"
icon="mdi-server"
:icon="mdiServer"
title="系統發生錯誤"
/>
</template>
<script setup lang="ts">
import { mdiServer } from '@mdi/js'
import ErrorShell from './ErrorShell.vue'
</script>
+2 -1
View File
@@ -3,12 +3,13 @@
:code="503"
color="warning"
description="服務目前無法使用,請稍後再試。"
icon="mdi-server-off"
:icon="mdiServerOff"
title="服務暫時無法使用"
/>
</template>
<script setup lang="ts">
import { mdiServerOff } from '@mdi/js'
import ErrorShell from './ErrorShell.vue'
</script>
+5 -4
View File
@@ -8,7 +8,7 @@
</v-chip>
<v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
<v-spacer />
<v-btn flat prepend-icon="mdi-magnify" @click="isSearchVisible = !isSearchVisible">條件搜尋</v-btn>
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible">條件搜尋</v-btn>
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
</v-card-title>
@@ -40,16 +40,16 @@ v-model="search.department" :class="{ 'select-hide-arrow': !isBulkEditEnabled }"
<!-- 操作 -->
<v-row v-if="isBulkEditEnabled" align="center" class="mb-2 ga-1" dense>
<v-btn :disabled="!hasSelectedRows" prepend-icon="mdi-delete" variant="outlined" @click="deleteSelectedRows">
<v-btn :disabled="!hasSelectedRows" :prepend-icon="mdiDelete" variant="outlined" @click="deleteSelectedRows">
批次刪除
</v-btn>
<v-spacer></v-spacer>
<v-btn
color="primary" :disabled="!hasAnyChange" prepend-icon="mdi-content-save" variant="outlined"
color="primary" :disabled="!hasAnyChange" :prepend-icon="mdiContentSave" variant="outlined"
@click="saveAllRows">
儲存變更
</v-btn>
<v-btn :disabled="!hasAnyChange" prepend-icon="mdi-restore" variant="text" @click="resetAllRows">
<v-btn :disabled="!hasAnyChange" :prepend-icon="mdiRestore" variant="text" @click="resetAllRows">
取消變更
</v-btn>
</v-row>
@@ -142,6 +142,7 @@ color="error" :disabled="!isBulkEditEnabled" size="small" variant="text"
</template>
<script setup lang="ts">
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { type StudentRecord, useStudentStore } from '@/stores/students'
+17 -10
View File
@@ -28,8 +28,8 @@ v-model="search.grade" density="compact" hide-details item-title="title" item-va
<v-select v-model="search.status" density="compact" hide-details :items="statuses" variant="outlined" />
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn prepend-icon="mdi-broom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled prepend-icon="mdi-magnify" variant="tonal">查詢</v-btn>
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<template #table>
@@ -47,14 +47,14 @@ class="student-table" density="compact" fixed-header :headers="tableHeaders" hei
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-1">
<v-btn color="info" prepend-icon="mdi-eye" 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 color="primary" prepend-icon="mdi-pencil" 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
color="error" prepend-icon="mdi-delete" size="small" variant="text"
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
@click="requestDeleteConfirmation(item)">
刪除
</v-btn>
@@ -79,11 +79,17 @@ class="dialog-overlay" :close-on-content-click="false" :model-value="dialogVisib
v-if="!isMobile || activeMobilePanel === 'detail'" class="detail-panel-wrapper"
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }">
<master-detail-semester-panel
:detail-form="detailForm" :is-detail-editing="isDetailEditing"
:is-mobile="isMobile" :is-view-mode="isViewMode" :selected-semester="selectedSemester"
@add-course="addCourseToDetail" @cancel-edit="cancelDetailEdit" @close="closeDetailPanel"
@delete="handleDeleteSemester" @remove-course="removeCourseFromDetail" @save-edit="saveDetailEdit"
@start-edit="startDetailEdit" />
v-model:detail-form="detailForm"
:is-detail-editing="isDetailEditing"
:is-mobile="isMobile"
:is-view-mode="isViewMode"
:selected-semester="selectedSemester"
@cancel-edit="cancelDetailEdit"
@close="closeDetailPanel"
@delete="handleDeleteSemester"
@save-edit="saveDetailEdit"
@start-edit="startDetailEdit"
/>
</div>
<!-- 主檔區塊 (Master Card)學生基本資料與學期列表 -->
@@ -257,6 +263,7 @@ v-model="confirmNavigateVisible" confirm-text="確定切換" max-width="480"
</template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
import { computed, nextTick, ref } from 'vue'
import { useDisplay } from 'vuetify'
+7 -6
View File
@@ -28,8 +28,8 @@ v-model="search.grade" density="compact" hide-details item-title="title" item-va
<v-select v-model="search.status" density="compact" hide-details :items="statuses" variant="outlined" />
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn prepend-icon="mdi-broom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled prepend-icon="mdi-magnify" variant="tonal">查詢</v-btn>
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<template #table>
@@ -47,14 +47,14 @@ class="student-table" density="compact" fixed-header :headers="tableHeaders" hei
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-2">
<v-btn color="info" prepend-icon="mdi-eye" 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 color="primary" prepend-icon="mdi-pencil" 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
color="error" prepend-icon="mdi-delete" size="small" variant="text"
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
@click="requestDeleteConfirmation(item)">
刪除
</v-btn>
@@ -255,7 +255,7 @@ v-model="confirmDeleteCourseVisible" confirm-color="error"
<v-dialog v-model="addCourseDialogVisible" max-width="420" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon color="primary" start>mdi-book-plus</v-icon>
<v-icon color="primary" start :icon="mdiBookPlus" />
加入課程
</v-card-title>
<v-card-text>
@@ -290,6 +290,7 @@ v-model.number="addCourseForm.score" density="comfortable" hide-spin-buttons lab
</template>
<script setup lang="ts">
import { mdiBookPlus, mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
import { computed, nextTick, ref } from 'vue'
import { useDisplay } from 'vuetify'
+7 -6
View File
@@ -28,8 +28,8 @@ v-model="search.grade" density="compact" hide-details item-title="title" item-va
<v-select v-model="search.status" density="compact" hide-details :items="statuses" variant="outlined" />
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn prepend-icon="mdi-broom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled prepend-icon="mdi-magnify" variant="tonal">查詢</v-btn>
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<template #table>
@@ -47,14 +47,14 @@ class="student-table" density="compact" fixed-header :headers="tableHeaders" hei
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-2">
<v-btn color="info" prepend-icon="mdi-eye" 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 color="primary" prepend-icon="mdi-pencil" 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
color="error" prepend-icon="mdi-delete" size="small" variant="text"
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
@click="requestDeleteConfirmation(item)">
刪除
</v-btn>
@@ -253,7 +253,7 @@ v-model="confirmNavigateVisible" confirm-text="確定切換" max-width="480"
<v-dialog v-model="addCourseDialogVisible" max-width="480" persistent>
<v-card>
<v-card-title class="d-flex align-center">
<v-icon color="primary" start>mdi-school</v-icon>
<v-icon color="primary" start :icon="mdiSchool" />
新增成績
</v-card-title>
<v-card-text>
@@ -280,6 +280,7 @@ v-model.number="addCourseForm.score" density="comfortable" label="分數" type="
</template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil, mdiSchool } from '@mdi/js'
import { computed, nextTick, ref } from 'vue'
import { useDisplay } from 'vuetify'
+6 -5
View File
@@ -28,8 +28,8 @@ v-model="search.grade" density="compact" hide-details item-title="title" item-va
<v-select v-model="search.status" density="compact" hide-details :items="statuses" variant="outlined" />
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn prepend-icon="mdi-broom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled prepend-icon="mdi-magnify" variant="tonal">查詢</v-btn>
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<template #table>
@@ -47,14 +47,14 @@ class="student-table" density="compact" fixed-header :headers="tableHeaders"
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-2">
<v-btn color="info" prepend-icon="mdi-eye" 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 color="primary" prepend-icon="mdi-pencil" 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
color="error" prepend-icon="mdi-delete" size="small" variant="text"
color="error" :prepend-icon="mdiDelete" size="small" variant="text"
@click="requestDeleteConfirmation(item)">
刪除
</v-btn>
@@ -238,6 +238,7 @@ v-model="confirmNavigateVisible" confirm-text="確定切換" max-width="480"
</template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
import { computed, nextTick, ref } from 'vue'
import { useDisplay } from 'vuetify'
import CommonConfirmDialog from '@/components/maintenance/CommonConfirmDialog.vue'