feat: add SingleRecordMnt component for student record maintenance with search, add, edit, view, and delete functionalities

This commit is contained in:
skytek_xinliang
2026-03-26 11:24:37 +08:00
parent 507afcc99c
commit 069141794e
116 changed files with 15247 additions and 107 deletions
+3 -92
View File
@@ -1,94 +1,5 @@
<template>
<v-container class="fill-height d-flex flex-column justify-center" max-width="1100">
<div>
<v-img
class="mb-4 font-weight-bold"
height="150"
src="@/assets/logo.png"
/>
<div class="mb-8 text-center">
<div class="text-body-medium font-weight-light mb-n1">Welcome to</div>
<div class="text-display-medium font-weight-bold">Vuetify</div>
</div>
<v-row>
<v-col cols="12">
<v-card
class="py-4"
color="surface-variant"
image="https://cdn.vuetifyjs.com/docs/images/one/create/feature.png"
rounded="lg"
variant="tonal"
>
<template #prepend>
<v-avatar class="ml-2 mr-4" icon="mdi-rocket-launch-outline" size="60" variant="tonal" />
</template>
<template #image>
<v-img position="top right" />
</template>
<template #title>
<div class="my-title my-uppercase text-headline-medium font-weight-bold">Get started</div>
</template>
<template #subtitle>
<div class="text-body-large">
Change this page by updating <v-kbd>{{ `<HelloWorld />` }}</v-kbd> in <v-kbd>components/HelloWorld.vue</v-kbd>.
</div>
</template>
</v-card>
</v-col>
<v-col v-for="link in links" :key="link.href" cols="6">
<v-card
append-icon="mdi-open-in-new"
class="py-4"
color="surface-variant"
:href="link.href"
rel="noopener noreferrer"
rounded="lg"
:subtitle="link.subtitle"
target="_blank"
:title="link.title"
variant="tonal"
>
<template #prepend>
<v-avatar class="ml-2 mr-4" :icon="link.icon" size="60" variant="tonal" />
</template>
</v-card>
</v-col>
</v-row>
</div>
</v-container>
<div class="d-flex align-center justify-center pa-8 text-body-1">
Hello World
</div>
</template>
<script setup lang="ts">
const links = [
{
href: 'https://vuetifyjs.com/',
icon: 'mdi-text-box-outline',
subtitle: 'Learn about all things Vuetify in our documentation.',
title: 'Documentation',
},
{
href: 'https://vuetifyjs.com/introduction/why-vuetify/#feature-guides',
icon: 'mdi-star-circle-outline',
subtitle: 'Explore available framework Features.',
title: 'Features',
},
{
href: 'https://vuetifyjs.com/components/all',
icon: 'mdi-widgets-outline',
subtitle: 'Discover components in the API Explorer.',
title: 'Components',
},
{
href: 'https://discord.vuetifyjs.com',
icon: 'mdi-account-group-outline',
subtitle: 'Connect with Vuetify developers.',
title: 'Community',
},
]
</script>
+110
View File
@@ -0,0 +1,110 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<!-- Top Stats Cards -->
<v-row class="mb-4">
<v-col v-for="(stat, index) in props.stats" :key="index" cols="12" md="3" sm="6">
<AnalysisStatsCard
:color="stat.color"
:icon="stat.icon"
:label="stat.label"
:title="stat.title"
:total="stat.total"
:value="stat.value"
/>
</v-col>
</v-row>
<!-- Main Trend Chart (Sparkline Area) -->
<v-row class="mb-4">
<v-col cols="12">
<AnalysisTrendChart
:active-filter="activeFilter"
:data="props.trendData"
:filters="props.trendFilters"
:title="props.trendTitle"
@filter-change="activeFilter = $event"
/>
</v-col>
</v-row>
<!-- Bottom Charts Grid -->
<v-row>
<!-- Chart 1: Bar Chart (Proxy for Radar/Distribution) -->
<v-col cols="12" md="4">
<AnalysisBarChart :data="props.barData" :title="props.chart1Title" />
</v-col>
<!-- Chart 2: Donut Chart (Source) -->
<v-col cols="12" md="4">
<AnalysisPieChart :data="props.pie1Data" :title="props.chart2Title" />
</v-col>
<!-- Chart 3: Donut Chart (Distribution) -->
<v-col cols="12" md="4">
<AnalysisDonutChart :data="props.pie2Data" :title="props.chart3Title" />
</v-col>
</v-row>
</v-sheet>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import AnalysisBarChart from './base/analysis/AnalysisBarChart.vue'
import AnalysisDonutChart from './base/analysis/AnalysisDonutChart.vue'
import AnalysisPieChart from './base/analysis/AnalysisPieChart.vue'
import AnalysisStatsCard from './base/analysis/AnalysisStatsCard.vue'
import AnalysisTrendChart from './base/analysis/AnalysisTrendChart.vue'
interface StatsItem {
title: string
value: string | number
label: string
total: string | number
icon: string
color: string
}
interface BarDataItem {
label: string
value: number
color: string
}
interface PieData {
value: number
label: string
color: string
}
interface DonutData extends PieData {
icon: string
}
const props = defineProps({
// Stats Cards Data
stats: { type: Array as () => StatsItem[], default: () => [] },
// Trend Chart
trendTitle: { type: String, default: '流量趨勢' },
trendData: { type: Array as () => number[], default: () => [] },
trendFilters: { type: Array as () => string[], default: () => ['流量', '訪問量'] },
// Chart Titles
chart1Title: { type: String, default: '核心素養' },
chart2Title: { type: String, default: '訪問來源' },
chart3Title: { type: String, default: '成績分佈' },
// Data for Charts
barData: { type: Array as () => BarDataItem[], default: () => [] },
pie1Data: {
type: Object as () => PieData,
default: () => ({ value: 75, label: '直接訪問', color: 'primary' }),
},
pie2Data: {
type: Object as () => DonutData,
default: () => ({ value: 65, label: '及格率', color: 'success', icon: 'mdi-school' }),
},
})
const activeFilter = ref(props.trendFilters[0] || '')
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<!-- Header Section -->
<DashboardHeader
class="mb-4"
:greeting-title="props.greetingTitle"
:projects="props.statProjects"
:team="props.statTeam"
:todo="props.statTodo"
:user-avatar="props.userAvatar"
:weather-info="props.weatherInfo"
/>
<v-row>
<!-- Left Column (Main) -->
<v-col cols="12" md="8">
<!-- Applications Card -->
<DashboardApps
:apps="props.applications"
class="mb-4"
:title="props.appsTitle"
@app-click="$emit('app-click', $event)"
@view-all="$emit('view-all-apps')"
/>
<!-- School Announcements (Dynamic) -->
<DashboardAnnouncements
:announcements="props.announcements"
:title="props.announcementsTitle"
@item-click="$emit('announcement-click', $event)"
@view-more="$emit('view-more-announcements')"
/>
</v-col>
<!-- Right Column (Side) -->
<v-col cols="12" md="4">
<!-- Quick Nav -->
<DashboardQuickNav
class="mb-4"
:navs="props.quickNavs"
:title="props.quickNavTitle"
@nav-click="$emit('nav-click', $event)"
/>
<!-- To-Do List -->
<DashboardTodoList
class="mb-4"
:title="props.todoTitle"
:todos="props.todos"
@toggle-todo="$emit('toggle-todo', $event)"
/>
<!-- Visit Source Chart -->
<DashboardChart :title="props.chartTitle" :value="props.chartValue" />
</v-col>
</v-row>
</v-sheet>
</template>
<script setup lang="ts">
import DashboardAnnouncements from './base/dashboard/DashboardAnnouncements.vue'
import DashboardApps from './base/dashboard/DashboardApps.vue'
import DashboardChart from './base/dashboard/DashboardChart.vue'
import DashboardHeader from './base/dashboard/DashboardHeader.vue'
import DashboardQuickNav from './base/dashboard/DashboardQuickNav.vue'
import DashboardTodoList from './base/dashboard/DashboardTodoList.vue'
defineEmits([
'view-all-apps',
'app-click',
'view-more-announcements',
'announcement-click',
'nav-click',
'toggle-todo',
])
interface DashboardApp {
name: string
desc: string
icon: string
color: string
group: string
date: string
}
interface Announcement {
title: string
author: string
time: string
avatarSrc?: string | null
avatarColor?: string
}
interface QuickNav {
icon: string
title: string
color: string
}
interface Todo {
title: string
due: string
done: boolean
}
const props = defineProps({
// Header
userAvatar: {
type: String,
default:
'https://avataaars.io/?avatarStyle=Circle&topType=ShortHairShortFlat&accessoriesType=Sunglasses&hairColor=Blonde&facialHairType=Blank&clotheType=Hoodie&clotheColor=Red&eyeType=Happy&eyebrowType=Default&mouthType=Smile&skinColor=Light',
},
greetingTitle: { type: String, default: '早安,校長!開始您一天的工作吧!' },
weatherInfo: { type: String, default: '今日晴,20℃ - 32℃!' },
statTodo: { type: String, default: '2/10' },
statProjects: { type: String, default: '8' },
statTeam: { type: String, default: '300' },
// Apps
appsTitle: { type: String, default: '應用程式' },
applications: { type: Array as () => DashboardApp[], default: () => [] },
// Announcements
announcementsTitle: { type: String, default: '學校公告' },
announcements: { type: Array as () => Announcement[], default: () => [] },
// Right Side
quickNavTitle: { type: String, default: '快速導航' },
quickNavs: { type: Array as () => QuickNav[], default: () => [] },
todoTitle: { type: String, default: '待辦事項' },
todos: { type: Array as () => Todo[], default: () => [] },
chartTitle: { type: String, default: '訪問來源' },
chartValue: { type: Number, default: 75 },
})
</script>
+253
View File
@@ -0,0 +1,253 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<v-card border elevation="0">
<!-- Top Action Bar -->
<SKTableActionBar
:create-btn-text="props.createBtnText"
:title="props.listTitle"
@create="emit('create')"
@refresh="emit('refresh')"
@settings="emit('settings')"
/>
<SKFormEditDialog
v-model="addSubDialogOpen"
:cancel-text="props.dialogCancelText"
:confirm-text="props.dialogConfirmText"
:item="addSubDraftItem"
:loading="props.dialogLoading"
:show-permission="false"
:status-label-text="props.dialogStatusLabelText"
:status-options="props.statusOptions"
:title-text="props.addSubDialogTitleText"
@submit="onAddSubSubmit"
>
<template #fields="{ form }">
<v-text-field
v-model="form.name"
density="comfortable"
hide-details
:label="props.dialogNameLabelText"
variant="outlined"
/>
<v-textarea
v-model="form.note"
density="comfortable"
hide-details
:label="props.dialogNoteLabelText"
rows="3"
variant="outlined"
/>
</template>
</SKFormEditDialog>
<SKFormEditDialog
v-model="editDialogOpen"
:cancel-text="props.dialogCancelText"
:confirm-text="props.dialogConfirmText"
:item="selectedItem"
:loading="props.dialogLoading"
:show-permission="false"
:status-label-text="props.dialogStatusLabelText"
:status-options="props.statusOptions"
:title-text="props.editDialogTitleText"
@submit="onEditSubmit"
>
<template #fields="{ form }">
<v-text-field
v-model="form.name"
density="comfortable"
hide-details
:label="props.dialogNameLabelText"
variant="outlined"
/>
<v-textarea
v-model="form.note"
density="comfortable"
hide-details
:label="props.dialogNoteLabelText"
rows="3"
variant="outlined"
/>
</template>
</SKFormEditDialog>
<!-- Tree Table -->
<SKTreeTable
:headers="formattedHeaders"
:items="props.items"
:loading="props.loading"
table-class="dept-table"
:tree-column-keys="['name']"
@toggle-expand="emit('toggle-expand', $event)"
>
<!-- Status Column -->
<template #[`item.status`]="{ item }">
<v-chip
:color="isEnabledStatus(item.status) ? 'success' : 'error'"
label
size="small"
variant="tonal"
>
{{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }}
</v-chip>
</template>
<!-- Actions Column -->
<template #[`item.actions`]="{ item }">
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openAddSub(item)">
{{ props.addSubActionText }}
</v-btn>
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openEdit(item)">
{{ props.editActionText }}
</v-btn>
<v-btn
class="px-1"
color="error"
size="small"
variant="text"
@click="emit('delete', item)"
>
{{ props.deleteActionText }}
</v-btn>
</template>
</SKTreeTable>
</v-card>
</v-sheet>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import SKFormEditDialog from './base/SKFormEditDialog.vue'
import SKTableActionBar from './base/SKTableActionBar.vue'
import SKTreeTable from './base/SKTreeTable.vue'
export interface DeptItem {
id: string | number
name: string
status: string | number
createTime: string
note: string
children?: DeptItem[]
[key: string]: unknown
}
const props = defineProps({
items: { type: Array as () => DeptItem[], default: () => [] },
loading: { type: Boolean, default: false },
// Text Props
listTitle: { type: String, default: '部門列表' },
createBtnText: { type: String, default: '新增部門' },
addSubActionText: { type: String, default: '新增下級' },
editActionText: { type: String, default: '修改' },
deleteActionText: { type: String, default: '刪除' },
statusEnabledText: { type: String, default: '已啟用' },
statusDisabledText: { type: String, default: '已禁用' },
// Dialog Props
addSubDialogTitleText: { type: String, default: '新增下級' },
editDialogTitleText: { type: String, default: '編輯' },
dialogStatusLabelText: { type: String, default: '狀態' },
dialogNameLabelText: { type: String, default: '部門名稱' },
dialogNoteLabelText: { type: String, default: '備註' },
dialogCancelText: { type: String, default: '取消' },
dialogConfirmText: { type: String, default: '確認' },
dialogLoading: { type: Boolean, default: false },
statusEnabledValue: { type: [String, Number] as PropType<string | number>, default: undefined },
statusOptions: {
type: Array as () => Array<string | number | { title: string; value: string | number }>,
default: () => [],
},
// Header Texts
nameHeader: { type: String, default: '部門名稱' },
statusHeader: { type: String, default: '狀態' },
createTimeHeader: { type: String, default: '創建時間' },
noteHeader: { type: String, default: '備註' },
actionsHeader: { type: String, default: '操作' },
})
const emit = defineEmits([
'create',
'add-sub',
'edit',
'delete',
'refresh',
'settings',
'toggle-expand',
])
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
return options.map((o) => {
if (typeof o === 'string' || typeof o === 'number') {
return { title: String(o), value: o }
}
return o
})
}
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
const resolvedStatusEnabledValue = computed(() => {
if (props.statusEnabledValue !== undefined) return props.statusEnabledValue
return normalizedStatusOptions.value[0]?.value ?? 'enable'
})
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
const addSubDialogOpen = ref(false)
const editDialogOpen = ref(false)
const addSubParentItem = ref<DeptItem | null>(null)
const addSubDraftItem = ref<Record<string, unknown> | null>(null)
const selectedItem = ref<DeptItem | null>(null)
function openAddSub (item: DeptItem) {
addSubParentItem.value = item
addSubDraftItem.value = {
name: '',
note: '',
status: resolvedStatusEnabledValue.value,
}
addSubDialogOpen.value = true
}
function openEdit (item: DeptItem) {
selectedItem.value = item
editDialogOpen.value = true
}
function onAddSubSubmit (payload: Record<string, unknown>) {
if (!addSubParentItem.value) return
const newItem: DeptItem = {
id: Date.now(),
name: String(payload.name ?? ''),
note: String(payload.note ?? ''),
status: (payload.status as string | number | undefined) ?? resolvedStatusEnabledValue.value,
createTime: new Date().toISOString(),
children: [],
}
emit('add-sub', addSubParentItem.value, newItem)
}
function onEditSubmit (updated: Record<string, unknown>) {
emit('edit', updated)
}
// --- Table Config ---
const formattedHeaders = computed(() => [
{ title: props.nameHeader, key: 'name', align: 'start' as const, minWidth: '250px' },
{ title: props.statusHeader, key: 'status', align: 'center' as const, width: '100px' },
{ title: props.createTimeHeader, key: 'createTime', align: 'start' as const },
{ title: props.noteHeader, key: 'note', align: 'start' as const },
{
title: props.actionsHeader,
key: 'actions',
align: 'center' as const,
width: '250px',
sortable: false,
},
])
</script>
+452
View File
@@ -0,0 +1,452 @@
<template>
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
<!-- Side Layouts -->
<v-row
v-if="props.layout !== 'card'" class="fill-height" :class="{ 'flex-row-reverse': props.layout === 'side-right' }"
no-gutters>
<!-- Illustration Column -->
<v-col
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1" cols="12" lg="8"
sm="6">
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="props.branding.title" />
</div>
<v-sheet
class="board-wrapper pa-2 pa-lg-0" color="rgba(var(--v-theme-surface), 0.8)" elevation="0" max-width="680"
rounded="lg" width="100%">
<LoginAnnouncementBoard
:all-tab-label="props.announcementBoard.allTabLabel" :date-header="props.announcementBoard.dateHeader"
:empty-text="props.announcementBoard.emptyText" :items="props.announcementBoard.items"
:items-per-page="props.announcementBoard.itemsPerPage"
:pagination-label="props.announcementBoard.paginationLabel" :school-header="props.announcementBoard.schoolHeader"
:system-announcements="props.announcementBoard.systemAnnouncements" :tabs="props.announcementBoard.tabs"
:title="props.announcementBoard.title" :title-header="props.announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement" />
</v-sheet>
</v-col>
<v-col class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0" cols="12" lg="4" sm="6">
<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">
<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>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none" color="primary" size="small" variant="text"
@click="mobileAnnouncementSheetVisible = true">
{{ props.mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
<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">
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block" :welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText" />
<LoginForm
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
@forgot-password="handleForgotPassword" @submit="handleLogin">
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-1">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</div>
</v-col>
</v-row>
<!-- Card Layout (Centered) -->
<v-row v-else class="fill-height align-center justify-center bg-background pa-4 pa-md-0" no-gutters>
<v-card
class="rounded-lg" :class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'" elevation="10" max-width="450"
width="100%">
<LoginToolBar
v-if="props.toolbar.show" :locale="props.toolbar.locale" :locales="props.toolbar.locales"
@change-locale="handleChangeLocale" @toggle-layout="handleToggleLayout" />
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-md-block" :welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText" />
<LoginForm
:acc-placeholder="props.form.accPlaceholder" :forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target" :forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey" :submit-text="props.form.submitText"
@forgot-password="handleForgotPassword" @submit="handleLogin">
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha" :captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage" :loading="props.form.captchaLoading"
:model-value="props.form.captchaValue" :refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified" :verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh" @update:model-value="handleCaptchaChange" />
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-1">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet v-model="mobileAnnouncementSheetVisible" class="d-sm-none">
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ props.mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ props.mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import LoginAnnouncementBoard from './base/login/LoginAnnouncementBoard.vue'
import LoginBrand from './base/login/LoginBrand.vue'
import LoginForm from './base/login/LoginForm.vue'
import LoginHeader from './base/login/LoginHeader.vue'
import LoginToolBar from './base/login/LoginToolBar.vue'
import LoginVerify from './base/login/LoginVerify.vue'
interface BrandingConfig {
title?: string
organization?: string
}
interface IllustrationConfig {
image?: string | null
title?: string
description?: string
}
interface HeaderConfig {
welcomeText?: string
welcomeDescription?: string
}
interface AnnouncementTabConfig {
label: string
value: string
}
interface AnnouncementItemConfig {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface AnnouncementBoardConfig {
title?: string
tabs?: AnnouncementTabConfig[]
items?: AnnouncementItemConfig[]
systemAnnouncements?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
interface MobileAnnouncementConfig {
items?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
show?: boolean
viewAllText?: string
listTitle?: string
closeText?: string
emptyText?: string
}
interface ForgotPasswordConfig {
text?: string
href?: string
target?: string
}
interface FormConfig {
accPlaceholder?: string
passwPlaceholder?: string
rememberMeLabel?: string
submitText?: string
rememberStorageKey?: string
withCaptcha?: boolean
captcha?: {
imgUrl?: string
id?: string
tokenValue?: string
}
captchaValue?: string
captchaLoading?: boolean
captchaErrorMessage?: string
captchaVerified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
forgotPassword: ForgotPasswordConfig
}
interface ToolBarConfig {
show?: boolean
locale?: string
locales?: string[]
}
interface Props {
layout: 'side-left' | 'side-right' | 'card'
branding: BrandingConfig
illustration: IllustrationConfig
announcementBoard: AnnouncementBoardConfig
mobileAnnouncement: MobileAnnouncementConfig
header: HeaderConfig
form: FormConfig
toolbar: ToolBarConfig
}
const props = withDefaults(defineProps<Props>(), {
layout: 'side-left',
branding: () => ({
title: 'Skyteck Login',
organization: 'school',
}),
illustration: () => ({
image: null,
title: 'Login',
description: 'Login to your account',
}),
announcementBoard: () => ({
title: '學校公告區',
tabs: [
{ label: '全部', value: '__all__' },
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: [
{
id: 'announcement-1',
date: '2024-03-19',
school: '市立實踐國中',
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
tab: 'junior',
},
],
systemAnnouncements: [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
}),
mobileAnnouncement: () => ({
items: [],
show: false,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}),
header: () => ({
welcomeText: 'Welcome back 👋🏻',
welcomeDescription: 'Please enter your account password to login',
}),
form: () => ({
accPlaceholder: '請輸入帳號',
passwPlaceholder: '請輸入密碼',
rememberMeLabel: '記住帳號',
submitText: '登入',
rememberStorageKey: 'sklogin.remember.username',
withCaptcha: true,
captcha: undefined,
captchaValue: '',
captchaLoading: false,
captchaErrorMessage: '',
captchaVerified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
forgotPassword: {
text: '忘記密碼?',
href: '',
target: undefined,
},
}),
toolbar: () => ({
show: true,
locale: 'zh-TW',
locales: ['zh-TW', 'en-US'],
}),
})
const emit = defineEmits([
'submit',
'change-locale',
'forgot-password',
'captcha-refresh',
'captcha-change',
'toggle-layout',
'select-announcement',
])
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (props.mobileAnnouncement.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => {
return `layout-${props.layout}`
})
function handleLogin (formData: Record<string, unknown>) {
emit('submit', formData)
}
function handleCaptchaRefresh () {
emit('captcha-refresh')
}
function handleCaptchaChange (value: string) {
emit('captcha-change', value)
}
function handleChangeLocale (nextLocale: string) {
emit('change-locale', nextLocale)
}
function handleToggleLayout () {
emit('toggle-layout')
}
function handleForgotPassword (e: MouseEvent) {
emit('forgot-password', e)
}
function handleSelectAnnouncement (item: AnnouncementItemConfig) {
emit('select-announcement', item)
}
</script>
<style scoped>
:deep(.v-banner__prepend) {
align-self: center;
margin-inline-end: 16px;
}
:deep(.v-banner-actions) {
align-self: center;
}
.mobile-banner-icon {
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
transform-origin: center;
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-banner-icon {
animation: none;
}
}
.illustration-panel {
background: linear-gradient(135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
/* Specific styles for side-right to flip border */
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
+209
View File
@@ -0,0 +1,209 @@
<template>
<v-sheet class="bg-surface" v-bind="$attrs">
<v-card border elevation="0">
<!-- Top Action Bar -->
<SKTableActionBar
:show-create="false"
@refresh="emit('refresh')"
@settings="emit('settings')"
/>
<SKFormEditDialog
v-model="editDialogOpen"
:cancel-text="props.editDialogCancelText"
:confirm-text="props.editDialogConfirmText"
:item="selectedItem"
:loading="props.editDialogLoading"
:permission-label-text="props.editDialogPermissionLabelText"
:permission-options="props.permissionOptions"
:status-label-text="props.editDialogStatusLabelText"
:status-options="props.statusOptions"
:title-text="props.editDialogTitleText"
@submit="onEditSubmit"
/>
<!-- Tree Table -->
<SKTreeTable
:headers="formattedHeaders"
:items="props.items"
:loading="props.loading"
table-class="menu-table"
:tree-column-keys="['title']"
@toggle-expand="emit('toggle-expand', $event)"
>
<!-- Title Column (Tree Indentation) -->
<template #[`tree-title`]="{ item }">
<v-chip v-if="item.isNew" class="px-1" color="primary" label size="x-small">new</v-chip>
</template>
<!-- Icon Column -->
<template #[`item.icon`]="{ item }">
<v-icon v-if="item.icon" size="small">{{ item.icon }}</v-icon>
</template>
<!-- Permission Column -->
<template #[`item.permission`]="{ item }">
<v-chip
:color="getPermissionColor(item.permission)"
label
size="small"
variant="outlined"
>
{{ item.permission }}
</v-chip>
</template>
<!-- Status Column -->
<template #[`item.status`]="{ item }">
<v-chip
:color="isEnabledStatus(item.status) ? 'success' : 'error'"
label
size="small"
variant="tonal"
>
{{ isEnabledStatus(item.status) ? props.statusEnabledText : props.statusDisabledText }}
</v-chip>
</template>
<!-- Actions Column -->
<template #[`item.actions`]="{ item }">
<v-btn class="px-1" color="primary" size="small" variant="text" @click="openEdit(item)">
{{ props.editActionText }}
</v-btn>
</template>
</SKTreeTable>
</v-card>
</v-sheet>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
import { computed, ref } from 'vue'
import SKFormEditDialog from './base/SKFormEditDialog.vue'
import SKTableActionBar from './base/SKTableActionBar.vue'
import SKTreeTable from './base/SKTreeTable.vue'
export interface MenuItem {
id: string | number
title: string
icon?: string
permission: string
path?: string
component?: string
status: string | number
isNew?: boolean
children?: MenuItem[]
[key: string]: unknown
}
const props = defineProps({
items: { type: Array as () => MenuItem[], default: () => [] },
loading: { type: Boolean, default: false },
// Text Props
editActionText: { type: String, default: '修改' },
statusEnabledText: { type: String, default: '已啟用' },
statusDisabledText: { type: String, default: '已禁用' },
// Edit Dialog Props
editDialogTitleText: { type: String, default: '編輯' },
editDialogStatusLabelText: { type: String, default: '狀態' },
editDialogPermissionLabelText: { type: String, default: '權限' },
editDialogCancelText: { type: String, default: '取消' },
editDialogConfirmText: { type: String, default: '確認' },
editDialogLoading: { type: Boolean, default: false },
statusEnabledValue: {
type: [String, Number] as PropType<string | number>,
default: undefined,
},
statusDisabledValue: {
type: [String, Number] as PropType<string | number>,
default: undefined,
},
statusOptions: {
type: Array as () => Array<string | number | { title: string; value: string | number }>,
default: () => [],
},
permissionOptions: {
type: Array as () => Array<string | number | { title: string; value: string | number }>,
default: () => [],
},
// Header Texts
titleHeader: { type: String, default: '標題' },
permissionHeader: { type: String, default: '權限' },
pathHeader: { type: String, default: '路由路徑' },
componentHeader: { type: String, default: '組件路徑' },
statusHeader: { type: String, default: '狀態' },
actionsHeader: { type: String, default: '操作' },
})
const emit = defineEmits(['edit', 'refresh', 'settings', 'toggle-expand'])
const editDialogOpen = ref(false)
const selectedItem = ref<MenuItem | null>(null)
function normalizeOptions (options: Array<string | number | { title: string; value: string | number }>) {
return options.map((o) => {
if (typeof o === 'string' || typeof o === 'number') {
return { title: String(o), value: o }
}
return o
})
}
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
const resolvedStatusEnabledValue = computed(() => {
if (props.statusEnabledValue !== undefined) return props.statusEnabledValue
return normalizedStatusOptions.value[0]?.value ?? 'enable'
})
const isEnabledStatus = (status: unknown) => status === resolvedStatusEnabledValue.value
function openEdit (item: MenuItem) {
selectedItem.value = item
editDialogOpen.value = true
}
function onEditSubmit (updated: Record<string, unknown>) {
emit('edit', updated)
}
// --- Table Config ---
const formattedHeaders = computed(() => [
{ title: props.titleHeader, key: 'title', align: 'start' as const, minWidth: '250px' },
{ title: props.permissionHeader, key: 'permission', align: 'center' as const, width: '120px' },
{ title: props.pathHeader, key: 'path', align: 'start' as const },
{ title: props.componentHeader, key: 'component', align: 'start' as const },
{ title: props.statusHeader, key: 'status', align: 'center' as const, width: '100px' },
{
title: props.actionsHeader,
key: 'actions',
align: 'center' as const,
width: '250px',
sortable: false,
},
])
function getPermissionColor (permission: string) {
switch (permission) {
case '管理員': {
return 'primary'
}
case '一級主管': {
return 'success'
}
case '二級主管': {
return 'info'
}
case '使用者': {
return 'warning'
}
default: {
return 'grey'
}
}
}
</script>
+263
View File
@@ -0,0 +1,263 @@
<template>
<v-sheet class="bg-background" v-bind="$attrs">
<!-- Search Filter Section -->
<SKSearchFilter
v-if="showSearchFilter"
:collapse-btn-text="props.collapseBtnText"
:expand-btn-text="props.expandBtnText"
:fields="searchFields"
:reset-btn-text="props.resetBtnText"
:search-btn-text="props.searchBtnText"
:show-expand="true"
:visible-when-collapsed="['roleName', 'roleId']"
@reset="$emit('reset')"
@search="$emit('search', $event)"
/>
<!-- Data Table Section -->
<v-card class="bg-surface">
<SKTableActionBar
v-model:settings-selected-keys="visibleHeaderKeys"
:create-btn-text="props.createBtnText"
:search-visible="showSearchFilter"
:settings-items="headerSettingsItems"
:show-search-toggle="true"
:title="props.listTitle"
@create="emit('create')"
@refresh="emit('refresh')"
@settings="emit('settings')"
@toggle-search="showSearchFilter = !showSearchFilter"
/>
<v-divider></v-divider>
<v-data-table
class="role-table"
:headers="filteredHeaders"
hover
:items="props.roles"
:items-per-page="10"
:items-per-page-options="itemsPerPageOptions"
:items-per-page-text="props.itemsPerPageText"
:loading="props.loading"
>
<!-- Status Slot -->
<template #[`item.status`]="{ item }">
<v-switch
color="primary"
density="compact"
hide-details
:model-value="item.status"
@update:model-value="emit('update:status', item, $event)"
></v-switch>
</template>
<!-- Actions Slot -->
<template #[`item.actions`]="{ item }">
<v-btn
class="px-1"
color="primary"
size="small"
variant="text"
@click="emit('edit', item)"
>
{{ props.editActionText }}
</v-btn>
<v-divider
class="mx-1 d-inline-block"
style="height: 12px; vertical-align: middle"
vertical
></v-divider>
<v-btn
class="px-1"
color="error"
size="small"
variant="text"
@click="emit('delete', item)"
>
{{ props.deleteActionText }}
</v-btn>
</template>
</v-data-table>
</v-card>
</v-sheet>
</template>
<script setup lang="ts">
import { computed, ref, watchEffect } from 'vue'
import SKSearchFilter from './base/SKSearchFilter.vue'
import SKTableActionBar from './base/SKTableActionBar.vue'
export interface RoleItem {
name: string
id: string
status: boolean
note: string
createTime: string
}
const showSearchFilter = ref(true)
const props = defineProps({
// Filter Labels & Placeholders
roleNameLabel: { type: String, default: '角色名稱' },
roleNamePlaceholder: { type: String, default: '請輸入' },
roleIdLabel: { type: String, default: '角色ID' },
roleIdPlaceholder: { type: String, default: '請輸入' },
statusLabel: { type: String, default: '狀態' },
statusPlaceholder: { type: String, default: '請選擇' },
statusOptions: { type: Array, default: () => ['已啟用', '已禁用'] },
noteLabel: { type: String, default: '備註' },
notePlaceholder: { type: String, default: '請輸入' },
createTimeLabel: { type: String, default: '建立時間' },
startDateLabel: { type: String, default: '開始日期' },
endDateLabel: { type: String, default: '結束日期' },
datePlaceholder: { type: String, default: '請選擇' },
// Button Texts
searchBtnText: { type: String, default: '搜尋' },
resetBtnText: { type: String, default: '重置' },
expandBtnText: { type: String, default: '展開' },
collapseBtnText: { type: String, default: '收起' },
createBtnText: { type: String, default: '新增角色' },
// Table Texts
listTitle: { type: String, default: '權限列表' },
roleNameHeader: { type: String, default: '角色名稱' },
roleIdHeader: { type: String, default: '角色ID' },
statusHeader: { type: String, default: '狀態' },
noteHeader: { type: String, default: '備註' },
createTimeHeader: { type: String, default: '建立時間' },
actionsHeader: { type: String, default: '操作' },
editActionText: { type: String, default: '修改' },
deleteActionText: { type: String, default: '刪除' },
// Data Table Footer Texts
itemsPerPageText: { type: String, default: '每頁筆數:' },
itemsPerPageAllText: { type: String, default: '全部' },
// Data
roles: {
type: Array as () => RoleItem[],
default: () => [],
},
loading: { type: Boolean, default: false },
})
const emit = defineEmits([
'search',
'reset',
'create',
'edit',
'delete',
'update:status',
'refresh',
'settings',
])
// --- Search Fields Configuration ---
const searchFields = computed(() => [
{
key: 'roleName',
type: 'text' as const,
label: props.roleNameLabel,
placeholder: props.roleNamePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
key: 'roleId',
type: 'text' as const,
label: props.roleIdLabel,
placeholder: props.roleIdPlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
key: 'status',
type: 'select' as const,
label: props.statusLabel,
placeholder: props.statusPlaceholder,
items: props.statusOptions,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
key: 'note',
type: 'text' as const,
label: props.noteLabel,
placeholder: props.notePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
type: 'date' as const,
key: 'startDate',
label: props.startDateLabel,
placeholder: props.datePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
{
type: 'date' as const,
key: 'endDate',
label: props.endDateLabel,
placeholder: props.datePlaceholder,
meta: {
cols: 12,
md: 4,
lg: 3,
},
},
])
// --- Table Config ---
const formattedHeaders = computed(() => [
{ title: props.roleNameHeader, key: 'name', align: 'start' as const },
{ title: props.roleIdHeader, key: 'id', align: 'start' as const },
{ title: props.statusHeader, key: 'status', align: 'start' as const },
{ title: props.noteHeader, key: 'note', align: 'start' as const },
{ title: props.createTimeHeader, key: 'createTime', align: 'start' as const },
{ title: props.actionsHeader, key: 'actions', align: 'end' as const, sortable: false },
])
const visibleHeaderKeys = ref<string[]>([])
watchEffect(() => {
if (visibleHeaderKeys.value.length > 0) {
return
}
visibleHeaderKeys.value = formattedHeaders.value.map((h) => String(h.key))
})
const headerSettingsItems = computed(() =>
formattedHeaders.value
.filter((h) => h.key !== 'actions')
.map((h) => ({ key: String(h.key), label: String(h.title) }))
)
const filteredHeaders = computed(() =>
formattedHeaders.value.filter((h) => visibleHeaderKeys.value.includes(String(h.key)))
)
const itemsPerPageOptions = computed(() =>
[10, 25, 50, 100, -1].map((value) => ({
value,
title: value === -1 ? props.itemsPerPageAllText : String(value),
}))
)
</script>
+197
View File
@@ -0,0 +1,197 @@
<template>
<v-dialog v-model="dialogModel" max-width="480" v-bind="$attrs">
<v-card>
<v-card-title class="text-subtitle-1 font-weight-medium">
<slot name="title">
{{ props.titleText }}
</slot>
</v-card-title>
<v-card-text class="pt-2">
<slot :form="form" name="content" :permission="formPermission" :status="formStatus">
<div class="d-flex flex-column ga-4">
<v-select
v-if="props.showStatus"
v-model="formStatus"
density="comfortable"
hide-details
item-title="title"
item-value="value"
:items="normalizedStatusOptions"
:label="props.statusLabelText"
variant="outlined"
/>
<v-select
v-if="props.showPermission"
v-model="formPermission"
density="comfortable"
hide-details
item-title="title"
item-value="value"
:items="normalizedPermissionOptions"
:label="props.permissionLabelText"
variant="outlined"
/>
<slot :form="form" name="fields"></slot>
</div>
</slot>
</v-card-text>
<v-card-actions class="px-4 pb-4">
<slot :cancel="handleCancel" name="actions" :submit="handleSubmit">
<v-spacer />
<v-btn :disabled="props.loading" variant="text" @click="handleCancel">
{{ props.cancelText }}
</v-btn>
<v-btn color="primary" :loading="props.loading" @click="handleSubmit">
{{ props.confirmText }}
</v-btn>
</slot>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, reactive, watch } from 'vue'
type OptionValue = string | number
type Option = { title: string; value: OptionValue }
type GenericRecord = Record<string, unknown>
interface Props {
modelValue: boolean
item: GenericRecord | null
statusKey?: string
permissionKey?: string
showStatus?: boolean
showPermission?: boolean
statusOptions?: Array<Option | string | number>
permissionOptions?: Array<Option | string | number>
titleText?: string
statusLabelText?: string
permissionLabelText?: string
cancelText?: string
confirmText?: string
loading?: boolean
closeOnSubmit?: boolean
}
const props = withDefaults(defineProps<Props>(), {
statusKey: 'status',
permissionKey: 'permission',
showStatus: true,
showPermission: true,
statusOptions: () => [],
permissionOptions: () => [],
titleText: '編輯',
statusLabelText: '狀態',
permissionLabelText: '權限',
cancelText: '取消',
confirmText: '確認',
loading: false,
closeOnSubmit: true,
})
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void
(e: 'submit', value: GenericRecord): void
(e: 'cancel'): void
}>()
const dialogModel = computed({
get: () => props.modelValue,
set: (v: boolean) => emit('update:modelValue', v),
})
function normalizeOptions (options: Array<Option | string | number>) {
return options.map((o) => {
if (typeof o === 'string' || typeof o === 'number') {
return { title: String(o), value: o }
}
return o
})
}
const normalizedStatusOptions = computed(() => normalizeOptions(props.statusOptions))
const normalizedPermissionOptions = computed(() => normalizeOptions(props.permissionOptions))
const form = reactive<GenericRecord>({})
function resetForm (next: GenericRecord) {
for (const key of Object.keys(form)) {
delete form[key]
}
Object.assign(form, next)
}
const getDefaultStatus = (): OptionValue | '' => normalizedStatusOptions.value[0]?.value ?? ''
function getDefaultPermission (): OptionValue | '' {
return normalizedPermissionOptions.value[0]?.value ?? ''
}
const formStatus = computed<OptionValue | ''>({
get: () => {
const current = form[props.statusKey] as OptionValue | undefined
return current ?? getDefaultStatus()
},
set: (v) => {
form[props.statusKey] = v
},
})
const formPermission = computed<OptionValue | ''>({
get: () => {
const current = form[props.permissionKey] as OptionValue | undefined
return current ?? getDefaultPermission()
},
set: (v) => {
form[props.permissionKey] = v
},
})
function syncFromItem () {
const item = props.item ?? {}
resetForm({ ...item })
if (props.showStatus) {
const status = item[props.statusKey] as OptionValue | undefined
form[props.statusKey] = status ?? getDefaultStatus()
}
if (props.showPermission) {
const permission = item[props.permissionKey] as OptionValue | undefined
form[props.permissionKey] = permission ?? getDefaultPermission()
}
}
watch(
() => props.modelValue,
(open) => {
if (open) syncFromItem()
}
)
watch(
() => props.item,
() => {
if (props.modelValue) syncFromItem()
}
)
function handleCancel () {
emit('cancel')
dialogModel.value = false
}
function handleSubmit () {
emit('submit', { ...form })
if (props.closeOnSubmit) {
dialogModel.value = false
}
}
</script>
+145
View File
@@ -0,0 +1,145 @@
<template>
<v-card class="bg-surface mb-4" v-bind="$attrs">
<v-card-text>
<v-row dense>
<!-- Dynamic Search Fields -->
<v-col
v-for="field in visibleFields"
:key="field.key"
:cols="field.meta?.cols || field.cols || 12"
:lg="field.meta?.lg || field.lg"
:md="field.meta?.md || field.md"
>
<v-row class="ma-0" dense>
<v-col class="d-flex align-center justify-start justify-md-end" cols="12" md="4">
<span class="text-body-1">{{ field.label }}</span>
</v-col>
<v-col class="py-0" cols="12" md="8">
<SKTextField
v-if="field.type === 'text'"
:model-value="searchState[field.key]"
:placeholder="field.placeholder"
@update:model-value="searchState[field.key] = $event"
/>
<SKSelectField
v-else-if="field.type === 'select'"
:items="field.items"
:model-value="searchState[field.key]"
:placeholder="field.placeholder"
@update:model-value="searchState[field.key] = $event"
/>
<SKDatePicker
v-else-if="field.type === 'date'"
:model-value="searchState[field.key]"
:placeholder="field.placeholder"
@update:model-value="searchState[field.key] = $event"
/>
</v-col>
</v-row>
</v-col>
<!-- Actions -->
<v-col class="d-flex justify-end align-center flex-md-grow-1" cols="12" md="auto">
<v-btn class="mr-2" variant="outlined" @click="handleReset">
{{ resetBtnText }}
</v-btn>
<v-btn color="primary" @click="handleSearch">
{{ searchBtnText }}
</v-btn>
<v-btn
v-if="showExpand"
class="ml-2"
color="primary"
variant="text"
@click="expand = !expand"
>
{{ expand ? collapseBtnText : expandBtnText }}
<v-icon end :icon="expand ? 'mdi-chevron-up' : 'mdi-chevron-down'"></v-icon>
</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import SKDatePicker from './input_field/SKDatePicker.vue'
import SKSelectField from './input_field/SKSelectField.vue'
import SKTextField from './input_field/SKTextField.vue'
interface Field {
key: string
type: 'text' | 'select' | 'date'
label: string
placeholder?: string
meta?: {
cols?: number
md?: number
lg?: number
}
cols?: number
md?: number
lg?: number
items?: unknown[]
}
interface Props {
fields: Field[]
visibleWhenCollapsed?: string[]
searchBtnText?: string
resetBtnText?: string
expandBtnText?: string
collapseBtnText?: string
showExpand?: boolean
actionCols?: number
actionMd?: number
actionLg?: number
}
const props = withDefaults(defineProps<Props>(), {
searchBtnText: '搜尋',
resetBtnText: '重置',
expandBtnText: '展開',
collapseBtnText: '收起',
showExpand: false,
})
const emit = defineEmits(['search', 'reset'])
const expand = ref(false)
// Compute visible fields based on expand state
const visibleFields = computed(() => {
if (expand.value) {
return props.fields
}
if (props.visibleWhenCollapsed && props.visibleWhenCollapsed.length > 0) {
return props.fields.filter((field) => props.visibleWhenCollapsed?.includes(field.key))
}
return props.fields
})
// Initialize search state
const searchState = reactive<Record<string, unknown>>({})
// Initialize search state based on fields
for (const field of props.fields) {
searchState[field.key] = field.type === 'select' ? null : ''
}
function handleSearch () {
emit('search', { ...searchState })
}
function handleReset () {
// Reset all fields
for (const field of props.fields) {
searchState[field.key] = field.type === 'select' ? null : ''
}
emit('reset')
}
</script>
+161
View File
@@ -0,0 +1,161 @@
<template>
<v-row align="center" class="pa-4" no-gutters v-bind="$attrs">
<span v-if="title">{{ title }}</span>
<v-spacer></v-spacer>
<v-btn
v-if="showCreate"
class="mr-4"
color="primary"
prepend-icon="mdi-plus"
@click="$emit('create')"
>
{{ createBtnText }}
</v-btn>
<v-tooltip :disabled="!searchToggleTooltipText" location="top" :text="searchToggleTooltipText">
<template #activator="{ props }">
<v-btn
v-if="showSearchToggle"
v-bind="props"
density="comfortable"
icon
variant="text"
@click="$emit('toggle-search')"
>
<v-icon :color="searchVisible ? 'primary-variant' : undefined"> mdi-magnify </v-icon>
</v-btn>
</template>
</v-tooltip>
<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-btn>
</template>
</v-tooltip>
<v-menu v-if="settingsItems && settingsItems.length > 0">
<template #activator="{ props: menuProps }">
<v-tooltip :disabled="!settingsTooltipText" location="top" :text="settingsTooltipText">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="{ ...menuProps, ...tooltipProps }"
density="comfortable"
icon
variant="text"
@click="$emit('settings')"
>
<v-icon>mdi-cog</v-icon>
</v-btn>
</template>
</v-tooltip>
</template>
<v-list density="compact">
<v-list-item class="py-0">
<v-checkbox
color="primary"
density="compact"
:disabled="selectAllChecked"
hide-details
:indeterminate="selectAllIndeterminate"
label="全選"
:model-value="selectAllChecked"
@update:model-value="toggleSelectAll"
/>
</v-list-item>
<v-list-item v-for="item in settingsItems" :key="item.key" class="py-0">
<v-checkbox
color="primary"
density="compact"
hide-details
:label="item.label"
:model-value="settingsSelectedKeys"
:value="item.key"
@update:model-value="updateSettingsSelectedKeys"
/>
</v-list-item>
</v-list>
</v-menu>
</v-row>
</template>
<script setup lang="ts">
import { computed, toRefs } from 'vue'
interface SettingsItem {
key: string
label: string
}
interface Props {
title?: string
createBtnText?: string
showCreate?: boolean
showSearchToggle?: boolean
searchVisible?: boolean
searchToggleTooltipText?: string
refreshTooltipText?: string
settingsTooltipText?: string
settingsItems?: SettingsItem[]
settingsSelectedKeys?: string[]
}
const props = withDefaults(defineProps<Props>(), {
createBtnText: '新增',
showCreate: true,
showSearchToggle: false,
searchVisible: true,
searchToggleTooltipText: '顯示/隱藏搜尋條件',
refreshTooltipText: '更新',
settingsTooltipText: '欄位設定',
settingsItems: () => [],
settingsSelectedKeys: () => [],
})
const { settingsItems, settingsSelectedKeys } = toRefs(props)
const emit = defineEmits([
'create',
'refresh',
'settings',
'toggle-search',
'update:settingsSelectedKeys',
])
const allSettingsKeys = computed(() => settingsItems.value.map((i) => i.key))
const selectAllChecked = computed(() => {
if (allSettingsKeys.value.length === 0) {
return false
}
return allSettingsKeys.value.every((k) => settingsSelectedKeys.value.includes(k))
})
const selectAllIndeterminate = computed(() => {
if (allSettingsKeys.value.length === 0) {
return false
}
const selectedCount = allSettingsKeys.value.filter((k) =>
settingsSelectedKeys.value.includes(k)
).length
return selectedCount > 0 && selectedCount < allSettingsKeys.value.length
})
function toggleSelectAll (checked: unknown) {
const current = Array.isArray(settingsSelectedKeys.value) ? settingsSelectedKeys.value : []
const nonSettingsKeys = current.filter((k) => !allSettingsKeys.value.includes(k))
emit(
'update:settingsSelectedKeys',
checked ? [...nonSettingsKeys, ...allSettingsKeys.value] : nonSettingsKeys
)
}
function updateSettingsSelectedKeys (value: unknown) {
emit('update:settingsSelectedKeys', Array.isArray(value) ? value : [])
}
</script>
+137
View File
@@ -0,0 +1,137 @@
<template>
<v-data-table
:class="`${tableClass} tree-table`"
:headers="formattedHeaders"
hide-default-footer
hover
:items="flattenedItems"
:items-per-page="-1"
:loading="loading"
v-bind="$attrs"
>
<!-- Tree Column Slot -->
<template v-for="header in treeHeaders" :key="header.key" #[`item.${header.key}`]="{ item }">
<div class="d-flex align-center" :style="{ paddingLeft: `${(item.level as number) * 16}px` }">
<!-- Expand Toggle -->
<v-btn
v-if="item.hasChildren"
class="mr-1"
density="compact"
icon
size="small"
variant="text"
@click="toggleExpand(item.id)"
>
<v-icon>{{ isExpanded(item.id) ? 'mdi-chevron-down' : 'mdi-chevron-right' }}</v-icon>
</v-btn>
<div v-else style="width: 20px"></div>
<span class="mr-2 text-body-2">{{ item[header.key] }}</span>
<slot :item="item" :name="`tree-${header.key}`"></slot>
</div>
</template>
<!-- Custom Slots -->
<template v-for="(_, name) in $slots" #[name]="slotData">
<slot :name="name" v-bind="slotData"></slot>
</template>
</v-data-table>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
interface Props {
headers: TableHeader[]
items: TreeNode[]
loading?: boolean
treeColumnKeys?: string[]
tableClass?: string
}
interface TableHeader {
title: string
key: string
align?: 'start' | 'end' | 'center'
width?: string
minWidth?: string
sortable?: boolean
}
interface TreeNode {
id: string | number
children?: TreeNode[]
[key: string]: unknown
}
const props = withDefaults(defineProps<Props>(), {
loading: false,
treeColumnKeys: () => ['name', 'title'],
tableClass: '',
})
const emit = defineEmits(['toggle-expand'])
const expandedIds = ref<Set<string | number>>(new Set())
watch(
() => props.items,
(newVal) => {
if (newVal && newVal.length > 0) {
for (const item of newVal) expandedIds.value.add(item.id)
}
},
{ immediate: true }
)
function toggleExpand (id: string | number) {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id)
} else {
expandedIds.value.add(id)
}
emit('toggle-expand', id, expandedIds.value.has(id))
}
const isExpanded = (id: string | number) => expandedIds.value.has(id)
const treeHeaders = computed(() =>
props.headers.filter((h: TableHeader) => props.treeColumnKeys.includes(h.key))
)
const flattenedItems = computed(() => {
const result: TreeNode[] = []
const traverse = (nodes: TreeNode[], level = 0) => {
for (const node of nodes) {
const hasChildren = node.children && node.children.length > 0
result.push({
...node,
level,
hasChildren,
})
if (hasChildren && expandedIds.value.has(node.id)) {
traverse(node.children as TreeNode[], level + 1)
}
}
}
traverse(props.items)
return result
})
const formattedHeaders = computed(() => props.headers)
</script>
<style scoped>
.tree-table :deep(th) {
font-weight: 600 !important;
color: #666;
background-color: #f8f9fa;
}
.tree-table :deep(td) {
height: 54px !important;
}
</style>
@@ -0,0 +1,38 @@
<template>
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
{{ title }}
</v-card-title>
<v-divider></v-divider>
<v-card-text class="d-flex align-center justify-center pt-8">
<div class="w-100">
<div v-for="(item, i) in data" :key="i" class="mb-4">
<div class="d-flex justify-space-between text-caption mb-1">
<span>{{ item.label }}</span>
<span>{{ item.value }}%</span>
</div>
<v-progress-linear
:color="item.color"
height="8"
:model-value="item.value"
rounded
striped
></v-progress-linear>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
data: Array<{
label: string
value: number
color: string
}>
}
defineProps<Props>()
</script>
@@ -0,0 +1,42 @@
<template>
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
{{ title }}
</v-card-title>
<v-divider></v-divider>
<v-card-text class="d-flex flex-column align-center justify-center pt-8">
<div
class="d-flex align-center justify-center"
style="position: relative; width: 200px; height: 200px"
>
<v-progress-circular
bg-color="grey-lighten-4"
:color="data.color"
:model-value="data.value"
:size="180"
:width="25"
>
<v-icon :color="data.color" size="40">{{ data.icon }}</v-icon>
</v-progress-circular>
</div>
<div class="mt-6 text-center">
<div class="text-h6">{{ data.label }}</div>
<div class="text-body-2 text-grey">佔比 {{ data.value }}%</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
data: {
value: number
label: string
color: string
icon: string
}
}
defineProps<Props>()
</script>
@@ -0,0 +1,56 @@
<template>
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
<v-card-title class="text-subtitle-1 font-weight-bold py-4">
{{ title }}
</v-card-title>
<v-divider></v-divider>
<v-card-text class="d-flex flex-column align-center justify-center pt-8">
<div
class="d-flex align-center justify-center"
style="position: relative; width: 200px; height: 200px"
>
<v-progress-circular
class="position-absolute"
color="grey-lighten-3"
:model-value="100"
:size="180"
:width="25"
></v-progress-circular>
<v-progress-circular
class="position-absolute"
:color="data.color"
:model-value="data.value"
rotate="270"
:size="180"
:width="25"
>
<div class="text-center">
<div class="text-h5 font-weight-bold">{{ data.value }}%</div>
<div class="text-caption text-grey">{{ data.label }}</div>
</div>
</v-progress-circular>
</div>
<div class="mt-8 d-flex flex-wrap justify-center gap-2">
<v-chip class="mr-2" :color="data.color" label size="small" variant="flat">
{{ data.label }}
</v-chip>
<v-chip color="grey-lighten-3" label size="small" variant="flat"> 其他 </v-chip>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
data: {
value: number
label: string
color: string
}
}
defineProps<Props>()
</script>
@@ -0,0 +1,35 @@
<template>
<v-card class="rounded-lg h-100" elevation="2" v-bind="$attrs">
<v-card-text class="d-flex flex-column justify-space-between h-100">
<div class="d-flex justify-space-between align-start mb-4">
<div>
<div class="text-subtitle-1 font-weight-bold text-grey-darken-1 mb-1">
{{ title }}
</div>
<div class="text-h4 font-weight-bold">{{ value }}</div>
</div>
<v-icon class="opacity-80" :color="color" size="x-large">
{{ icon }}
</v-icon>
</div>
<div class="d-flex justify-space-between align-center border-t pt-3">
<span class="text-body-2 text-grey">{{ label }}</span>
<span class="text-body-2 font-weight-medium">{{ total }}</span>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
value: string | number
label: string
total: string | number
icon: string
color: string
}
defineProps<Props>()
</script>
@@ -0,0 +1,68 @@
<template>
<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>
<span>{{ title }}</span>
</div>
<v-spacer></v-spacer>
<div class="d-flex">
<v-btn
v-for="filter in filters"
:key="filter"
:color="activeFilter === filter ? 'primary' : 'grey'"
density="compact"
variant="text"
@click="$emit('filter-change', filter)"
>
{{ filter }}
</v-btn>
</div>
</v-card-title>
<v-card-text class="pt-6 pb-2">
<div class="chart-container" style="height: 300px; position: relative">
<v-sparkline
auto-draw
fill
:gradient="['#1890ff', '#e6f7ff']"
gradient-direction="top"
height="100"
:line-width="2"
:model-value="data"
:padding="8"
:smooth="10"
stroke-linecap="round"
>
<template #label="item">
{{ item.value }}
</template>
</v-sparkline>
<slot name="x-axis">
<div class="d-flex justify-space-between mt-2 px-2 text-caption text-grey">
<span v-for="i in 12" :key="i">{{ 6 + i }}:00</span>
</div>
</slot>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
data: number[]
filters: string[]
activeFilter: string
}
defineProps<Props>()
defineEmits(['filter-change'])
</script>
<style scoped>
.chart-container {
overflow: hidden;
}
</style>
@@ -0,0 +1,55 @@
<template>
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
<v-card-title class="d-flex align-center py-4 px-4 border-b">
<span class="font-weight-bold">{{ title }}</span>
<v-spacer></v-spacer>
<v-btn color="primary" density="compact" variant="text" @click="$emit('view-more')">
{{ viewMoreText }}
</v-btn>
</v-card-title>
<v-list class="pa-0" lines="two">
<v-list-item
v-for="(item, index) in announcements"
:key="index"
class="border-b"
@click="$emit('item-click', item)"
>
<template #prepend>
<v-avatar :color="item.avatarColor || 'primary'" size="40" variant="tonal">
<span v-if="!item.avatarSrc" class="text-h6">{{ item.author[0] }}</span>
<v-img v-else :src="item.avatarSrc"></v-img>
</v-avatar>
</template>
<v-list-item-title class="font-weight-medium mb-1">
{{ item.title }}
</v-list-item-title>
<v-list-item-subtitle>
<span class="text-caption text-grey mr-2">{{ item.author }}</span>
<span class="text-caption text-grey">{{ item.time }}</span>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
announcements: Array<{
title: string
author: string
time: string
avatarSrc?: string | null
avatarColor?: string
}>
viewMoreText?: string
}
withDefaults(defineProps<Props>(), {
viewMoreText: '更多',
})
defineEmits(['view-more', 'item-click'])
</script>
@@ -0,0 +1,80 @@
<template>
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
<v-card-title class="d-flex align-center py-4 px-4 border-b">
<span class="font-weight-bold">{{ title }}</span>
<v-spacer></v-spacer>
<v-btn color="primary" density="compact" variant="text" @click="$emit('view-all')">
{{ viewAllText }}
</v-btn>
</v-card-title>
<v-card-text class="pa-0">
<v-row no-gutters>
<v-col
v-for="(app, index) in apps"
:key="index"
class="border-e border-b app-item"
cols="12"
sm="4"
>
<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>
<span class="text-subtitle-1 font-weight-medium">{{ app.name }}</span>
</div>
<div
class="text-body-2 text-grey mb-4"
style="
height: 40px;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
"
>
{{ app.desc }}
</div>
<div class="d-flex justify-space-between text-caption text-grey-lighten-1">
<span>{{ app.group }}</span>
<span>{{ app.date }}</span>
</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
apps: Array<{
name: string
desc: string
icon: string
color: string
group: string
date: string
}>
viewAllText?: string
}
withDefaults(defineProps<Props>(), {
viewAllText: '全部',
})
defineEmits(['view-all', 'app-click'])
</script>
<style scoped>
.hover-bg {
transition: background-color 0.2s;
cursor: pointer;
}
.hover-bg:hover {
background-color: #f5f5f5;
}
.app-item:last-child {
border-right: none !important;
}
</style>
@@ -0,0 +1,52 @@
<template>
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
<v-card-title class="text-subtitle-1 font-weight-bold py-4 border-b">
{{ title }}
</v-card-title>
<v-card-text class="d-flex flex-column align-center justify-center pt-6 pb-6">
<div
class="d-flex align-center justify-center"
style="position: relative; width: 180px; height: 180px"
>
<v-progress-circular
bg-color="grey-lighten-3"
color="primary"
:model-value="value"
:size="160"
:width="20"
>
<div class="text-center">
<div class="text-h6 font-weight-bold">{{ value }}%</div>
<div class="text-caption text-grey">{{ subtitle }}</div>
</div>
</v-progress-circular>
</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>
<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>
<span class="text-caption">{{ secondaryLabel }}</span>
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
value: number
subtitle?: string
primaryLabel?: string
secondaryLabel?: string
}
withDefaults(defineProps<Props>(), {
subtitle: '來源佔比',
primaryLabel: '校內',
secondaryLabel: '校外',
})
</script>
@@ -0,0 +1,56 @@
<template>
<v-card class="rounded-lg pa-4 white-bg" elevation="2" v-bind="$attrs">
<div class="d-flex flex-column flex-md-row align-center">
<!-- Avatar -->
<v-avatar class="mr-md-6 mb-4 mb-md-0" size="72">
<v-img alt="Avatar" cover :src="userAvatar"></v-img>
</v-avatar>
<!-- Greeting -->
<div class="flex-grow-1 text-center text-md-left mb-4 mb-md-0">
<h2 class="text-h5 font-weight-bold text-grey-darken-3 mb-2">
{{ greetingTitle }}
</h2>
<div class="text-body-1 text-grey">
{{ weatherInfo }}
</div>
</div>
<!-- Header Stats -->
<div class="d-flex justify-center justify-md-end gap-6 px-4" style="gap: 24px">
<div class="text-right">
<div class="text-caption text-grey mb-1">{{ todoLabel }}</div>
<div class="text-h5 font-weight-bold">{{ todo }}</div>
</div>
<div class="text-right">
<div class="text-caption text-grey mb-1">{{ projectsLabel }}</div>
<div class="text-h5 font-weight-bold">{{ projects }}</div>
</div>
<div class="text-right">
<div class="text-caption text-grey mb-1">{{ teamLabel }}</div>
<div class="text-h5 font-weight-bold">{{ team }}</div>
</div>
</div>
</div>
</v-card>
</template>
<script setup lang="ts">
interface Props {
userAvatar: string
greetingTitle: string
weatherInfo: string
todo: string
projects: string
team: string
todoLabel?: string
projectsLabel?: string
teamLabel?: string
}
withDefaults(defineProps<Props>(), {
todoLabel: '代辦事項',
projectsLabel: '專案項目',
teamLabel: '團隊成員',
})
</script>
@@ -0,0 +1,38 @@
<template>
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
<v-card-title class="text-subtitle-1 font-weight-bold py-4 border-b">
{{ title }}
</v-card-title>
<v-card-text class="pa-4">
<v-row dense>
<v-col v-for="(nav, i) in navs" :key="i" class="text-center mb-2" cols="4">
<v-btn
class="mb-1"
:color="nav.color"
icon
variant="text"
@click="$emit('nav-click', nav)"
>
<v-icon size="24">{{ nav.icon }}</v-icon>
</v-btn>
<div class="text-caption text-grey-darken-1">{{ nav.title }}</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
navs: Array<{
icon: string
title: string
color: string
}>
}
defineProps<Props>()
defineEmits(['nav-click'])
</script>
@@ -0,0 +1,40 @@
<template>
<v-card class="rounded-lg" elevation="2" v-bind="$attrs">
<v-card-title class="d-flex justify-space-between align-center py-4 border-b">
<span class="text-subtitle-1 font-weight-bold">{{ title }}</span>
</v-card-title>
<v-list class="pa-0" density="compact">
<v-list-item v-for="(todo, i) in todos" :key="i" class="py-2">
<template #prepend>
<v-checkbox-btn
v-model="todo.done"
class="mr-2"
density="compact"
@update:model-value="$emit('toggle-todo', todo, $event)"
></v-checkbox-btn>
</template>
<v-list-item-title :class="{ 'text-decoration-line-through text-grey': todo.done }">
{{ todo.title }}
</v-list-item-title>
<template #append>
<span class="text-caption text-grey">{{ todo.due }}</span>
</template>
</v-list-item>
</v-list>
</v-card>
</template>
<script setup lang="ts">
interface Props {
title: string
todos: Array<{
title: string
due: string
done: boolean
}>
}
defineProps<Props>()
defineEmits(['toggle-todo'])
</script>
@@ -0,0 +1,23 @@
<template>
<v-text-field
color="primary"
density="compact"
hide-details
:label="undefined"
:model-value="modelValue"
:placeholder="placeholder"
variant="outlined"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>
<script setup lang="ts">
defineProps<{
modelValue: unknown
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: unknown]
}>()
</script>
@@ -0,0 +1,25 @@
<template>
<v-select
color="primary"
density="compact"
hide-details
:items="items"
:label="undefined"
:model-value="modelValue"
:placeholder="placeholder"
variant="outlined"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>
<script setup lang="ts">
defineProps<{
modelValue: unknown
placeholder?: string
items?: unknown[]
}>()
const emit = defineEmits<{
'update:modelValue': [value: unknown]
}>()
</script>
@@ -0,0 +1,23 @@
<template>
<v-text-field
color="primary"
density="compact"
hide-details
:label="undefined"
:model-value="modelValue"
:placeholder="placeholder"
variant="outlined"
@update:model-value="emit('update:modelValue', $event)"
/>
</template>
<script setup lang="ts">
defineProps<{
modelValue: unknown
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: unknown]
}>()
</script>
@@ -0,0 +1,45 @@
<template>
<div class="text-center w-100">
<div class="text-body-2">
{{ props.promptText }}
<a
class="text-primary text-decoration-none font-weight-bold ml-1"
:href="props.href || '#'"
:target="props.target"
@click="handleClick"
>
{{ props.linkText }}
</a>
</div>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
promptText: {
type: String,
default: '還沒有帳號?',
},
linkText: {
type: String,
default: '註冊帳號',
},
href: {
type: String,
default: '',
},
target: {
type: String,
default: undefined,
},
})
const emit = defineEmits(['click'])
function handleClick (e: MouseEvent) {
emit('click', e)
if (!props.href) {
e.preventDefault()
}
}
</script>
@@ -0,0 +1,187 @@
<template>
<v-card class="w-100 h-100 d-flex flex-column bg-transparent pa-2 pa-lg-4" elevation="3">
<v-card-title class="text-h6 text-lg-h5 font-weight-bold text-accent mb-4">{{ title }}</v-card-title>
<v-tabs v-model="activeTab" class="mb-3" color="primary" density="comfortable">
<v-tab v-for="tab in normalizedTabs" :key="tab.value" :value="tab.value">
{{ tab.label }}
</v-tab>
</v-tabs>
<div class="announcement-content mb-3">
<v-table v-if="!isSystemTab" density="comfortable" fixed-header height="300">
<thead>
<tr>
<th class="text-left">{{ dateHeader }}</th>
<th class="text-left">{{ schoolHeader }}</th>
<th class="text-left">{{ titleHeader }}</th>
</tr>
</thead>
<tbody>
<tr v-for="item in pageItems" :key="item.id">
<td class="text-no-wrap">{{ item.date }}</td>
<td class="text-no-wrap">{{ item.school }}</td>
<td>
<v-btn
class="px-0 text-none justify-start" color="primary" variant="text"
@click="emit('select-announcement', item)">
{{ item.title }}
</v-btn>
</td>
</tr>
<tr v-if="pageItems.length === 0">
<td class="text-center text-medium-emphasis py-6" :colspan="3">{{ emptyText }}</td>
</tr>
</tbody>
</v-table>
<v-list v-else class="rounded border overflow-y-auto h-100" density="comfortable" lines="two">
<v-list-item v-for="item in systemPageItems" :key="item.id" border="b">
<v-list-item-title class="text-h6 mb-2">
{{ item.content }}
</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="systemPageItems.length === 0" class="h-100">
<v-list-item-title class="text-center text-medium-emphasis">{{ emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
</div>
<div class="d-flex justify-space-between align-center mt-auto pt-3">
<span class="text-caption text-medium-emphasis">
{{ paginationLabel }} {{ totalItems }}
</span>
<v-pagination v-model="page" density="comfortable" :length="pageCount" rounded="circle" />
</div>
</v-card>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
interface AnnouncementTab {
label: string
value: string
}
interface AnnouncementItem {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface SystemAnnouncementItem {
id: string | number
content: string
title?: string
createdAt?: string
}
interface Props {
title?: string
tabs?: AnnouncementTab[]
items?: AnnouncementItem[]
systemAnnouncements?: SystemAnnouncementItem[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
const props = withDefaults(defineProps<Props>(), {
title: '學校甄選簡章公告區',
tabs: () => [{ label: '全部', value: '__all__' }],
items: () => [],
systemAnnouncements: () => [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
})
const emit = defineEmits<{
(event: 'select-announcement', item: AnnouncementItem): void
}>()
const allTabValue = '__all__'
const systemTabValue = '__system__'
const systemTab = computed<AnnouncementTab>(() => ({
label: '系統公告',
value: systemTabValue,
}))
const normalizedTabs = computed<AnnouncementTab[]>(() => {
const baseTabs = props.tabs.length > 0 ? props.tabs : [{ label: props.allTabLabel, value: allTabValue }]
if (baseTabs.some((tab) => tab.value === systemTabValue)) return baseTabs
return [...baseTabs, systemTab.value]
})
const activeTab = ref(normalizedTabs.value[0]?.value ?? allTabValue)
const page = ref(1)
const isSystemTab = computed(() => activeTab.value === systemTabValue)
const filteredItems = computed(() => {
if (activeTab.value === allTabValue) return props.items
return props.items.filter((item) => item.tab === activeTab.value)
})
const totalItems = computed(() => {
if (isSystemTab.value) return props.systemAnnouncements.length
return filteredItems.value.length
})
const pageCount = computed(() => {
const size = Math.max(1, props.itemsPerPage)
return Math.max(1, Math.ceil(totalItems.value / size))
})
const pageItems = computed(() => {
const size = Math.max(1, props.itemsPerPage)
const start = (page.value - 1) * size
return filteredItems.value.slice(start, start + size)
})
const systemPageItems = computed<SystemAnnouncementItem[]>(() => {
const size = Math.max(1, props.itemsPerPage)
const start = (page.value - 1) * size
return props.systemAnnouncements.slice(start, start + size)
})
watch(
normalizedTabs,
(tabs) => {
if (tabs.some((tab) => tab.value === activeTab.value)) return
activeTab.value = tabs[0]?.value ?? allTabValue
},
{ immediate: true }
)
watch(activeTab, () => {
page.value = 1
})
watch(pageCount, (count) => {
if (page.value <= count) return
page.value = count
})
</script>
<style scoped>
.announcement-content {
height: 300px;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<div class="d-flex align-center">
<span class="text-h5 font-weight-bold text-primary">{{ title }}</span>
</div>
</template>
<script setup lang="ts">
defineProps({
title: {
type: String,
default: 'Login PageS',
},
})
</script>
+121
View File
@@ -0,0 +1,121 @@
<template>
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
<v-text-field
v-model="username" bg-color="surface" class="mb-6 mb-md-4" color="primary"
density="comfortable" hide-details :placeholder="props.accPlaceholder" variant="outlined"></v-text-field>
<v-text-field
v-model="password" :append-inner-icon="showPassword ? 'mdi-eye-off' : 'mdi-eye'" 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>
<slot name="verify"></slot>
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
<v-checkbox
v-model="rememberMe" color="primary" density="compact" hide-details
:label="props.rememberMeLabel"></v-checkbox>
<a
class="text-body-2 text-primary text-decoration-none" :href="props.forgotPasswordHref || '#'"
:target="props.forgotPasswordTarget" @click="handleForgotPasswordClick">
{{ props.forgotPasswordText }}
</a>
</div>
<v-btn block class="mb-6 font-weight-bold" color="primary" elevation="0" height="48" size="large" type="submit">
{{ props.submitText }}
</v-btn>
</v-form>
</template>
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue'
const username = ref('')
const password = ref('')
const showPassword = ref(false)
const rememberMe = ref(false)
const props = defineProps({
passwPlaceholder: {
type: String,
default: '請輸入6位數密碼',
},
accPlaceholder: {
type: String,
default: '請輸入帳號',
},
rememberMeLabel: {
type: String,
default: '記住帳號',
},
forgotPasswordText: {
type: String,
default: '忘記密碼?',
},
forgotPasswordHref: {
type: String,
default: '',
},
forgotPasswordTarget: {
type: String,
default: undefined,
},
submitText: {
type: String,
default: '登入',
},
rememberStorageKey: {
type: String,
default: 'sklogin.remember.username',
},
})
const emit = defineEmits(['submit', 'forgot-password'])
onMounted(() => {
const saved = localStorage.getItem(props.rememberStorageKey)
if (saved) {
username.value = saved
rememberMe.value = true
}
})
watch([rememberMe, username], ([nextRemember, nextUsername]) => {
if (!nextRemember) {
localStorage.removeItem(props.rememberStorageKey)
return
}
if (!nextUsername) {
localStorage.removeItem(props.rememberStorageKey)
return
}
localStorage.setItem(props.rememberStorageKey, nextUsername)
})
function handleForgotPasswordClick (e: MouseEvent) {
emit('forgot-password', e)
if (!props.forgotPasswordHref) {
e.preventDefault()
}
}
</script>
<style scoped>
:deep(.v-field--variant-outlined) {
border-radius: 8px;
}
:deep(.v-btn) {
text-transform: none;
border-radius: 8px;
letter-spacing: 0;
}
:deep(.v-checkbox .v-label) {
font-size: 14px;
opacity: 1;
}
</style>
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="login-header-wrapper">
<h2 class="text-h5 text-primary font-weight-bold mb-2">{{ props.welcomeText }}</h2>
<p class="text-subtitle-1 text-secondary">{{ props.welcomeDescription }}</p>
</div>
</template>
<script setup lang="ts">
const props = defineProps({
welcomeText: {
type: String,
default: '歡迎回來 👋🏻',
},
welcomeDescription: {
type: String,
default: '請輸入您的帳號密碼進行登入',
},
})
</script>
<style scoped>
.login-header-wrapper {
height: 140px;
}
</style>
@@ -0,0 +1,44 @@
<template>
<div
class="illustration-container d-flex flex-column align-center justify-center fill-height px-8"
>
<div class="illustration-wrapper mb-8 w-100 d-flex justify-center">
<v-img
v-if="image"
aspect-ratio="16/9"
contain
max-width="600"
:src="image"
width="100%"
></v-img>
</div>
<div class="text-center">
<h1 class="text-h4 font-weight-bold text-secondary mb-4">{{ title }}</h1>
<p class="text-body-1 text-secondary">{{ description }}</p>
</div>
</div>
</template>
<script setup lang="ts">
defineProps({
image: {
type: String,
default: null,
},
title: {
type: String,
default: '這是一個標題',
},
description: {
type: String,
default: '這是一個副標題',
},
})
</script>
<style scoped>
.illustration-container {
width: 100%;
}
</style>
@@ -0,0 +1,72 @@
<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"
@click="toggleTheme"></v-btn>
<!-- <v-btn icon="mdi-dock-window" 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"
variant="text"></v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="locale in localeOptions" :key="locale" :active="locale === props.locale"
@click="handleSelectLocale(locale)">
<v-list-item-title>{{ localeLabels[locale] ?? locale }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<!-- <v-btn icon="mdi-weather-night" variant="text" size="small" color="grey-darken-1"></v-btn> -->
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
interface Props {
locale?: string
locales?: string[]
localeLabels?: Record<string, string>
}
const props = withDefaults(defineProps<Props>(), {
locale: 'zh-TW',
locales: () => ['zh-TW', 'en-US'],
localeLabels: () => ({
'en-US': 'English',
'zh-TW': '中文',
}),
})
const emit = defineEmits(['change-locale', 'toggle-layout'])
const theme = useTheme()
const availableThemeNames = computed(() =>
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
)
function toggleTheme () {
const names = availableThemeNames.value
if (names.length === 0) return
const current = theme.global.name.value
const next = getNextThemeName(names, current)
if (!next) return
theme.change(next)
}
const localeOptions = computed(() =>
props.locales.length > 0 ? props.locales : ['zh-TW', 'en-US']
)
function handleSelectLocale (locale: string) {
if (locale === props.locale) return
emit('change-locale', locale)
}
</script>
+106
View File
@@ -0,0 +1,106 @@
<template>
<v-sheet v-bind="$attrs" class="verify-container mb-6 mb-md-4" color="transparent">
<div v-if="loading && !captchaImage" class="d-flex justify-center align-center py-4">
<v-progress-circular color="primary" indeterminate></v-progress-circular>
</div>
<div v-else class="d-flex align-center gap-2">
<!-- Captcha Image and Refresh -->
<div
class="captcha-wrapper d-flex align-center cursor-pointer me-1 me-md-2" :title="props.refreshTitle"
@click="handleRefresh">
<img v-if="captchaImage" alt="Captcha" class="captcha-img" :src="captchaImage" />
<v-icon class="ms-2" color="grey" icon="mdi-refresh"></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"
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>
</template>
</v-text-field>
</div>
<div v-if="errorMsg" class="text-caption text-error mt-1">
{{ errorMsg }}
</div>
</v-sheet>
</template>
<script setup lang="ts">
import { computed } from 'vue'
interface CaptchaPayload {
imgUrl?: string
id?: string
tokenValue?: string
}
interface Props {
captcha?: CaptchaPayload
modelValue?: string
loading?: boolean
errorMessage?: string
verified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
}
const props = withDefaults(defineProps<Props>(), {
captcha: undefined,
modelValue: '',
loading: false,
errorMessage: '',
verified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
})
const emit = defineEmits<{
(event: 'update:modelValue', value: string): void
(event: 'refresh'): void
}>()
const captchaImage = computed(() => props.captcha?.imgUrl ?? '')
const inputCode = computed({
get: () => props.modelValue,
set: (val: string) => emit('update:modelValue', val),
})
const errorMsg = computed(() => props.errorMessage)
const loading = computed(() => props.loading)
function handleRefresh () {
if (props.verified) return
emit('refresh')
}
</script>
<style scoped>
.verify-container {
width: 100%;
}
.captcha-wrapper {
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
border-radius: 4px;
padding: 0 8px;
height: 40px;
background: rgb(var(--v-theme-surface));
display: flex;
align-items: center;
}
.captcha-img {
height: 100%;
width: auto;
display: block;
}
</style>
+667
View File
@@ -0,0 +1,667 @@
<template>
<v-app v-bind="$attrs" class="sk-admin-layout">
<v-navigation-drawer
v-model="drawer" class="sk-admin-drawer" color="surface" :rail="isRail"
:rail-width="railWidth" :temporary="isMobile" :width="drawerWidth">
<template #prepend>
<!-- 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"
variant="text" @click="toggleSidebar" />
<v-card-text v-if="!isRail" class="sidebar-title flex-grow-1 py-0">
<slot name="title">
<div class="text-subtitle-1 font-weight-bold text-on-surface">
{{ branding.title }}
</div>
<div class="text-caption text-medium-emphasis">
{{ branding.subtitle }}
</div>
</slot>
</v-card-text>
</v-card>
<v-divider />
<!-- User Info -->
<v-card v-if="features.showUserInfo" class="user-info d-flex align-center pa-0 pl-3 py-2" flat>
<v-avatar class="user-avatar" color="primary" size="32" variant="tonal">
<span class="text-subtitle-2 font-weight-bold">{{ userProfile.avatarText }}</span>
</v-avatar>
<v-card-text v-if="!isRail" class="user-details flex-grow-1 py-0">
<div class="user-name text-body-2 font-weight-medium">
{{ userProfile.name }}
</div>
<div class="user-role text-caption text-medium-emphasis">
{{ userProfile.role }}
</div>
</v-card-text>
</v-card>
<v-divider />
<v-sheet v-if="isMobile" class="mobile-menu-subheader d-flex flex-column align-stretch ga-2 px-3 py-2">
<v-btn
v-if="features.showFavorites" class="justify-start text-none"
color="primary" rounded="pill" size="small" :variant="mobileFavoritesPanel ? 'flat' : 'outlined'"
@click="openMobileFavoritesPanel">
<span class="text-on-secondary text-caption font-weight-medium">{{ favoritesConfig.label }}</span>
</v-btn>
<v-btn
v-for="step in mobileMenuLevels" :key="`mobile-level-${step.level}`"
block class="justify-start text-none" :color="getMobileMenuBtnColor(step.level)"
rounded="pill" size="small" :variant="getMobileMenuBtnVariant(step.level)" @click="goToMobileLevel(step.level)">
{{ step.title }}
</v-btn>
</v-sheet>
</template>
<!-- 桌面板選單 -->
<template v-if="!isMobile">
<SkAdminDrawerDesktopMenu
v-model:opened="opened" :is-shrink="isRail" :menu-items="menuItems"
@select="handleSelect" @unshrink="handleUnshrink" />
</template>
<!-- 行動版選單 -->
<template v-if="isMobile">
<SkAdminDrawerMobileFavoritesPanel
v-if="features.showFavorites && mobileFavoritesPanel"
:favorite-items="favoriteItems" @select="handleSelectFavorite" />
<SkAdminDrawerMobileMenuPanel
v-else :mobile-current-items="mobileCurrentItems"
@item-click="handleMobileMenuClick" />
</template>
</v-navigation-drawer>
<v-app-bar ref="appBarRef" class="" height="auto">
<v-row class="flex-column" no-gutters>
<SkAdminAppBarTopCol
:features="features" :is-mobile="isMobile" :logout-label="logoutLabel"
:search-config="searchConfig" :search-value="searchValue" :show-breadcrumb-bar="showBreadcrumbBar"
:show-favorites-bar="showFavoritesBar" :theme-toggle-label="themeToggleLabel" :toolbar-actions="toolbarActions"
:toolbar-counts="toolbarCounts" @action="handleAction"
@logout="emitLogout" @search="triggerSearch" @toggle-drawer="drawer = !drawer"
@toggle-theme="toggleTheme" @update:search-value="searchValue = $event" @update:show-breadcrumb-bar="showBreadcrumbBar = $event"
@update:show-favorites-bar="showFavoritesBar = $event">
<template v-if="$slots.actions" #actions>
<slot name="actions"></slot>
</template>
</SkAdminAppBarTopCol>
<SkAdminAppBarFavoritesCol
:favorite-items="favoriteItems" :favorites-config="favoritesConfig" :features="features"
:is-mobile="isMobile" :show-favorites-bar="showFavoritesBar" @add-favorite="emitAddFavorite"
@remove-favorite="emitRemoveFavorite" @select="handleSelect"
@toggle-favorites-bar="toggleFavoritesBar" />
<SkAdminAppBarBreadcrumbCol
:breadcrumb-items="breadcrumbItems" :features="features" :is-mobile="isMobile"
:show-breadcrumb-bar="showBreadcrumbBar" :show-favorites-bar="showFavoritesBar"
@toggle-favorites-bar="toggleFavoritesBar">
<template v-if="$slots['breadcrumb-actions']" #breadcrumb-actions>
<slot name="breadcrumb-actions"></slot>
</template>
</SkAdminAppBarBreadcrumbCol>
</v-row>
</v-app-bar>
<!--
動態 paddingTop避免可變高度的 v-app-bar 遮住內容
同時固定 v-main 的總高度避免整頁滾動改由內容區域自行滾動
-->
<v-main class="d-flex flex-column overflow-hidden" :style="mainStyle">
<v-container class="content-area" fluid>
<slot></slot>
</v-container>
</v-main>
<v-slide-y-reverse-transition>
<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>
</template>
<v-card-title class="text-subtitle-2">操作說明</v-card-title>
<template #append>
<v-btn
aria-label="關閉說明" icon="mdi-close" size="small" variant="text"
@click="helpWidgetVisible = false" />
</template>
</v-card-item>
<v-divider />
<v-card-text class="text-body-2">
這裡先放暫時說明內容你可以保持此視窗開啟並繼續操作頁面上的其他功能
</v-card-text>
<v-card-actions class="justify-end pt-0">
<v-btn color="primary" size="small" variant="text" @click="helpWidgetVisible = false">
了解
</v-btn>
</v-card-actions>
</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-app>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useDisplay, useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
import SkAdminAppBarBreadcrumbCol from './sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue'
import SkAdminAppBarFavoritesCol from './sk-admin-layout/SkAdminAppBarFavoritesCol.vue'
import SkAdminAppBarTopCol from './sk-admin-layout/SkAdminAppBarTopCol.vue'
import SkAdminDrawerDesktopMenu from './sk-admin-layout/SkAdminDrawerDesktopMenu.vue'
import SkAdminDrawerMobileFavoritesPanel from './sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue'
import SkAdminDrawerMobileMenuPanel from './sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue'
const emit = defineEmits([
'logout',
'select',
'search',
'action',
'toggle-sidebar',
'toggle-theme',
'add-favorite',
'remove-favorite',
'update:isRail',
'update:favoritesBarVisible',
'update:breadcrumbBarVisible',
])
const defaultFeatures = {
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
}
const defaultBreadcrumbConfig = {
homeLabel: '首頁',
homeDisabled: true,
homeIcon: 'mdi-home',
}
const props = defineProps({
systemTitle: { type: String, default: '管理系統' },
systemSubtitle: { type: String, default: 'Campus System' },
themeToggleLabel: { type: String, default: '切換主題' },
logoutLabel: { type: String, default: '登出' },
sidebarToggleLabel: { type: String, default: '切換側欄' },
favoriteHeaderLabel: { type: String, default: '我的最愛' },
favoriteItems: {
type: Array,
default: () => [],
},
menuItems: {
type: Array,
default: () => [
{ title: '首頁', path: '/' },
],
},
userProfile: {
type: Object,
default: () => ({
name: '王小明',
role: '資訊工程系 - 學生',
avatarText: '王',
}),
},
searchConfig: {
type: Object,
default: () => ({
placeholder: '搜尋功能名稱... (試試「成績」、「選課」、「請假」)',
label: '搜尋',
}),
},
toolbarActions: {
type: Object,
default: () => ({
notificationsLabel: '通知',
messagesLabel: '訊息',
helpLabel: '說明',
settingsLabel: '設定',
}),
},
toolbarCounts: {
type: Object,
default: () => ({
notifications: 0,
messages: 0,
}),
},
favoritesConfig: {
type: Object,
default: () => ({
label: '常用',
addLabel: '新增常用',
showAdd: false,
}),
},
breadcrumbConfig: {
type: Object,
default: () => ({
homeLabel: '首頁',
homeDisabled: true,
homeIcon: 'mdi-home',
}),
},
breadcrumbItems: {
type: Array,
default: () => [],
},
favoritesBarVisible: {
type: [Boolean, null],
default: null,
},
breadcrumbBarVisible: {
type: [Boolean, null],
default: null,
},
isRail: {
type: [Boolean, null],
default: null,
},
features: {
type: Object,
default: () => ({
showThemeToggle: false,
showFavorites: true,
showBreadcrumb: true,
showSearch: true,
showToolbarActions: true,
showUserInfo: true,
showMenuHeader: false,
}),
},
drawerConfig: {
type: Object,
default: () => ({
width: 280,
railWidth: 56,
}),
},
})
// Feature toggle: UI 區塊顯示
const features = computed(() => ({ ...defaultFeatures, ...props.features }))
// i18n / constants
const branding = computed(() => ({
title: props.systemTitle,
subtitle: props.systemSubtitle,
}))
// feature toggles & layout
const display = useDisplay()
const isMobile = computed(() => display.mdAndDown.value)
const drawer = ref(true)
const mobileFavoritesPanel = ref(false)
const localIsRail = ref(false)
const isRail = computed({
get: () => (props.isRail ?? localIsRail.value),
set: (value) => {
if (props.isRail === null) {
localIsRail.value = value
return
}
emit('update:isRail', value)
},
})
const opened = ref([])
const appBarRef = ref(null)
const appBarHeight = ref(0)
const helpWidgetVisible = ref(false)
const drawerWidth = computed(() => props.drawerConfig?.width)
const railWidth = computed(() => props.drawerConfig?.railWidth)
// i18n computed text
const searchValue = ref('')
// links/settings refs
const theme = useTheme()
const availableThemeNames = computed(() =>
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
)
// composed computed objects
const breadcrumbConfig = computed(() => ({ ...defaultBreadcrumbConfig, ...props.breadcrumbConfig }))
const breadcrumbItems = computed(() => {
if (props.breadcrumbItems?.length) return props.breadcrumbItems
return [
{
title: breadcrumbConfig.value.homeLabel,
disabled: breadcrumbConfig.value.homeDisabled,
icon: breadcrumbConfig.value.homeIcon,
},
]
})
// event handlers / API
function toggleTheme () {
const names = availableThemeNames.value
if (names.length === 0) return
const current = theme.global.name.value
const next = getNextThemeName(names, current)
if (!next) return
theme.change(next)
emit('toggle-theme', next)
}
function toggleSidebar () {
if (isMobile.value) {
drawer.value = !drawer.value
} else {
isRail.value = !isRail.value
}
emit('toggle-sidebar', { drawer: drawer.value, rail: isRail.value })
}
const emitLogout = () => emit('logout')
// 將工具列按鈕行為轉成統一的 action 事件,交由外層應用處理
function handleAction (type) {
if (type === 'help') {
helpWidgetVisible.value = true
}
emit('action', type)
}
// 以按鈕或 Enter 觸發搜尋,避免每個字都觸發
function triggerSearch () {
const keyword = searchValue.value
emit('search', keyword)
// 觸發後清空欄位,避免彈窗出現仍保留文字
searchValue.value = ''
}
function emitAddFavorite () {
emit('add-favorite')
}
function emitRemoveFavorite (item) {
emit('remove-favorite', item)
}
function handleSelectFavorite (item) {
handleSelect(item)
mobileFavoritesPanel.value = false
}
const mobileMenuPath = ref([])
const mobileCurrentItems = computed(() =>
mobileMenuPath.value.reduce((items, currentItem) => currentItem?.subItems ?? [], props.menuItems || [])
)
const mobileCurrentLevel = computed(() => mobileMenuPath.value.length + 1)
const mobileMenuLevels = computed(() =>
Array.from({ length: mobileCurrentLevel.value }, (_, index) => ({
level: index + 1,
title: index === 0 ? '主選單' : (mobileMenuPath.value[index - 1]?.title ?? `${index + 1}`),
}))
)
function goToMobileLevel (level) {
mobileFavoritesPanel.value = false
mobileMenuPath.value = mobileMenuPath.value.slice(0, Math.max(0, level - 1))
}
function openMobileFavoritesPanel () {
mobileMenuPath.value = []
mobileFavoritesPanel.value = true
}
function handleMobileMenuClick (item) {
if (item?.subItems?.length) {
mobileMenuPath.value = [...mobileMenuPath.value, item]
return
}
handleSelect(item)
}
const localFavoritesBarVisible = ref(true)
const localBreadcrumbBarVisible = ref(true)
const showFavoritesBar = computed({
get: () => (props.favoritesBarVisible ?? localFavoritesBarVisible.value),
set: (value) => {
if (props.favoritesBarVisible === null) {
localFavoritesBarVisible.value = value
return
}
emit('update:favoritesBarVisible', value)
},
})
const showBreadcrumbBar = computed({
get: () => (props.breadcrumbBarVisible ?? localBreadcrumbBarVisible.value),
set: (value) => {
if (props.breadcrumbBarVisible === null) {
localBreadcrumbBarVisible.value = value
return
}
emit('update:breadcrumbBarVisible', value)
},
})
function toggleFavoritesBar (nextValue) {
showFavoritesBar.value = typeof nextValue === 'boolean' ? nextValue : !showFavoritesBar.value
}
let appBarObserver
// 量測 v-app-bar 實際高度(常用功能/麵包屑顯示時會改變)
function updateAppBarHeight () {
const el = appBarRef.value?.$el ?? appBarRef.value
if (!el) return
appBarHeight.value = Math.round(el.getBoundingClientRect().height || 0)
}
onMounted(() => {
// 初次量測高度
updateAppBarHeight()
if (typeof ResizeObserver === 'undefined') return
const el = appBarRef.value?.$el ?? appBarRef.value
if (!el) return
// 監聽高度變化,讓 v-main paddingTop 同步更新
appBarObserver = new ResizeObserver(() => updateAppBarHeight())
appBarObserver.observe(el)
})
onBeforeUnmount(() => {
if (!appBarObserver) return
appBarObserver.disconnect()
appBarObserver = null
})
function handleUnshrink () {
isRail.value = false
}
function handleSelect (item) {
emit('select', item)
if (isMobile.value) drawer.value = false
}
const mainStyle = computed(() => {
const appBarHeightValue = appBarHeight.value
return {
// 以 paddingTop 騰出 appBar 空間,避免內容被遮擋
paddingTop: appBarHeightValue ? `${appBarHeightValue}px` : undefined,
// 固定 v-main 高度,讓內容區塊能在固定高度內滾動
height: '100vh',
minHeight: 0,
flex: '1 1 0',
}
})
// watch(isMobile, (value) => {
// if (value) {
// isRail.value = false
// }
// })
watch(isMobile, (value) => {
if (!value) {
mobileFavoritesPanel.value = false
mobileMenuPath.value = []
}
})
watch(drawer, (value) => {
if (!value) {
mobileFavoritesPanel.value = false
mobileMenuPath.value = []
}
})
function getMobileMenuBtnVariant (level) {
return !mobileFavoritesPanel.value && level === mobileCurrentLevel.value
? 'flat'
: 'outlined'
}
function getMobileMenuBtnColor (level) {
return level === mobileCurrentLevel.value ? 'primary' : 'secondary'
}
</script>
<style scoped>
.sk-admin-layout {
background: rgb(var(--v-theme-background));
}
.sk-admin-drawer {
border-right: 1px solid rgb(var(--v-theme-surface-variant));
}
/* 第二層選單Padding */
:deep(.sk-admin-drawer .v-list-group__items) {
--indent-padding: 12px;
}
/* 第三層選單Padding */
:deep(.sk-admin-drawer .v-list-group__items .v-list-group__items) {
--indent-padding: 20px;
}
.menu-count {
min-width: 28px;
justify-content: center;
}
.content-area {
display: flex;
flex-direction: column;
flex: 1 1 0;
min-height: 0;
padding: 8px;
padding-top: 4px;
background: rgb(var(--v-theme-background));
}
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
:deep(.search-input-wrapper .v-field--appended) {
padding-inline-end: 4px;
}
.top-actions {
display: flex;
align-items: center;
gap: 4px;
}
.favorites-bar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
padding: 12px 16px;
border-radius: 16px;
}
.favorites-label {
margin-right: 4px;
}
.mobile-favorites-panel,
.mobile-menu-panel {
min-height: 0;
height: 100%;
}
.mobile-menu-subheader {
border-bottom: 1px solid rgb(var(--v-theme-surface-variant));
}
.favorites-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.favorite-item,
.favorite-add {
text-transform: none;
}
.mobile-menu-btn {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 200;
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.25);
}
.help-widget {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 220;
width: min(360px, calc(100vw - 32px));
border: 1px solid rgb(var(--v-theme-surface-variant));
box-shadow: 0 12px 24px rgba(var(--v-theme-on-surface), 0.15);
}
.nav-text-overflow {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 確保 v-list-group 的 activator 也有 hover 效果 */
:deep(.v-list-group > .v-list-item) {
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
:deep(.v-list-group > .v-list-item:hover) {
background: rgb(var(--v-theme-on-surface-variant));
color: rgb(var(--v-theme-on-surface));
}
/* 為所有 v-list-item 加上 transition */
:deep(.v-list-item) {
transition: background-color 0.2s ease, color 0.2s ease;
}
/* 確保滾軸邊距 */
:deep(.v-navigation-drawer__content) {
/* scrollbar-gutter: stable; */
}
@supports not (scrollbar-gutter: stable) {
:deep(.v-navigation-drawer__content) {
/* overflow-y: scroll; */
}
}
</style>
+13
View File
@@ -0,0 +1,13 @@
<template>
<v-app>
<v-main>
<v-container class="pa-0" fluid height="100%">
<slot></slot>
</v-container>
</v-main>
</v-app>
</template>
<script setup>
// 完全空白的佈局,僅提供 Vuetify 必要的容器結構
</script>
+251
View File
@@ -0,0 +1,251 @@
<template>
<v-app>
<v-navigation-drawer v-model="drawer">
<v-list v-model:opened="opened" color="primary" density="compact" prepend-gap="8">
<!-- 收藏項目區塊 -->
<template v-if="favoriteItems?.length">
<v-list-subheader class="bg-primary-variant" color="on-primary">
<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"
@click.stop="collapseFavoriteGroups" />
</div>
</v-list-subheader>
<template v-for="item in favoriteItems" :key="item.path ?? item.title">
<!-- 第一層有子項目的群組 -->
<v-list-group v-if="item.subItems?.length" :value="`fav:${item.path ?? item.title}`">
<template #activator="{ props }">
<v-list-item v-bind="props" :link="isNavigable(item)" :to="isNavigable(item) ? item.path : undefined">
<!-- 第一層 title父層 -->
<template #title>
<span class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
</v-list-item>
</template>
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<!-- 第二層無子項目的單一項目 -->
<v-list-item :link="!!subItem.path" :to="subItem.path">
<!-- 第二層 title葉節點 -->
<template #title>
<v-tooltip location="end" :text="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list-group>
<!-- 第一層無子項目的單一項目 -->
<v-list-item v-else :link="!!item.path" :to="item.path">
<!-- 第一層 title葉節點 -->
<template #title>
<v-tooltip location="end" :text="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</template>
<v-list-subheader class="bg-primary" color="on-primary">
<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"
@click.stop="collapseMenuGroups" />
</div>
</v-list-subheader>
<!-- 主選單區塊 -->
<template v-for="item in menuItems" :key="item.path ?? item.title">
<!-- 第一層有子項目的群組 -->
<v-list-group v-if="item.subItems?.length" :value="`menu:${item.path ?? item.title}`">
<template #activator="{ props }">
<v-list-item
v-bind="props" :link="isNavigable(item) && !!item.path"
:to="isNavigable(item) ? item.path : undefined">
<!-- 第一層 title父層 -->
<template #title>
<span class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
</v-list-item>
</template>
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<!-- 第二層有子項目的群組 -->
<v-list-group
v-if="subItem.subItems?.length"
:value="`menu:${item.path ?? item.title}::${subItem.path ?? subItem.title}`">
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps" :link="isNavigable(subItem)"
:to="isNavigable(subItem) ? subItem.path : undefined">
<!-- 第二層 title父層 -->
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
</v-list-item>
</template>
<!-- 第三層最底層項目 -->
<v-list-item
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path" :to="subSubItem.path">
<!-- 第三層 title葉節點 -->
<template #title>
<v-tooltip location="end" :text="subSubItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subSubItem.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<!-- 第二層無子項目的單一項目 -->
<v-list-item v-else :link="!!subItem.path" :to="subItem.path">
<!-- 第二層 title葉節點 -->
<template #title>
<v-tooltip location="end" :text="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list-group>
<!-- 第一層無子項目的單一項目 -->
<v-list-item v-else :link="!!item.path" :to="item.path">
<!-- 第一層 title葉節點 -->
<template #title>
<v-tooltip location="end" :text="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
<v-app-bar density="compact">
<v-app-bar-nav-icon @click="drawer = !drawer" />
<v-toolbar-title>{{ systemTitle }}</v-toolbar-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" />
</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')" />
</template>
</v-tooltip>
</v-app-bar>
<v-main>
<v-container class="pa-2" fluid height="100%">
<slot></slot>
</v-container>
</v-main>
</v-app>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
defineEmits(['logout'])
defineProps({
systemTitle: { type: String, default: '管理系統' },
themeToggleLabel: { type: String, default: '切換主題' },
logoutLabel: { type: String, default: '登出' },
favoriteHeaderLabel: { type: String, default: '我的最愛' },
menuHeaderLabel: { type: String, default: '選單' },
favoriteItems: {
type: Array,
default: () => [],
},
menuItems: {
type: Array,
default: () => [
{ title: '首頁', path: '/' },
{ title: '設定', path: '/settings' },
],
},
})
const drawer = ref(true)
const opened = ref([])
const theme = useTheme()
const availableThemeNames = computed(() =>
Object.keys(theme.themes.value ?? {}).filter((name) => name.startsWith('theme'))
)
function toggleTheme () {
const names = availableThemeNames.value
if (names.length === 0) return
const current = theme.global.name.value
const next = getNextThemeName(names, current)
if (!next) return
theme.change(next)
}
function collapseFavoriteGroups () {
opened.value = opened.value.filter((key) => !String(key).startsWith('fav:'))
}
function collapseMenuGroups () {
opened.value = opened.value.filter((key) => !String(key).startsWith('menu:'))
}
const isNavigable = (item) => item?.navigable !== false
</script>
<style scoped>
.nav-text-overflow {
display: inline-block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 確保 v-list-group 的 activator 也有 hover 效果 */
:deep(.v-list-group > .v-list-item) {
cursor: pointer;
transition: background-color 0.2s ease, color 0.2s ease;
}
:deep(.v-list-group > .v-list-item:hover) {
background: rgb(var(--v-theme-on-surface-variant));
color: rgb(var(--v-theme-on-surface));
}
/* 為所有 v-list-item 加上 transition */
:deep(.v-list-item) {
transition: background-color 0.2s ease, color 0.2s ease;
}
:deep(.v-navigation-drawer__content) {
scrollbar-gutter: stable;
}
@supports not (scrollbar-gutter: stable) {
:deep(.v-navigation-drawer__content) {
overflow-y: scroll;
}
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<template>
<v-app>
<v-app-bar color="surface">
<v-toolbar-title>{{ systemTitle }}</v-toolbar-title>
<v-spacer />
<v-menu
v-model="menuOpen"
:close-on-content-click="false"
:location="menuLocation"
:max-height="menuMaxHeight"
offset="8"
scroll-strategy="reposition"
:width="menuWidth"
>
<template #activator="{ props }">
<v-btn v-bind="props" icon="mdi-dots-vertical" />
</template>
<v-list :density="menuDensity">
<template v-for="(item, index) in menuItems" :key="item?.key ?? item?.path ?? index">
<v-list-group
v-if="smAndDown && item?.subItems?.length"
:value="item?.key ?? item?.path ?? index"
>
<template #activator="{ props }">
<v-list-item v-bind="props" :prepend-icon="item.icon" :title="item.title" />
</template>
<v-list-item
v-for="(subItem, subIndex) in item.subItems"
:key="subItem?.key ?? subItem?.path ?? subIndex"
class="pl-6"
:prepend-icon="subItem.icon"
:title="subItem.title"
@click="handleSelect(subItem)"
/>
</v-list-group>
<v-menu
v-else-if="item?.subItems?.length"
close-delay="120"
:close-on-content-click="false"
location="end top"
offset="0"
open-delay="80"
:open-on-hover="submenuOpenOnHover"
origin="start top"
scroll-strategy="reposition"
submenu
>
<template #activator="{ props }">
<v-list-item
v-bind="props"
append-icon="mdi-chevron-right"
:prepend-icon="item.icon"
:title="item.title"
/>
</template>
<v-list :density="menuDensity">
<v-list-item
v-for="(subItem, subIndex) in item.subItems"
:key="subItem?.key ?? subItem?.path ?? subIndex"
:prepend-icon="subItem.icon"
:title="subItem.title"
@click="handleSelect(subItem)"
/>
</v-list>
</v-menu>
<v-list-item
v-else
:prepend-icon="item.icon"
:title="item.title"
@click="handleSelect(item)"
/>
</template>
</v-list>
</v-menu>
</v-app-bar>
<v-main>
<v-container fluid height="100%">
<slot></slot>
</v-container>
</v-main>
</v-app>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useDisplay } from 'vuetify'
const emit = defineEmits(['select'])
defineProps({
systemTitle: { type: String, default: '簡潔模式' },
menuItems: {
type: Array,
default: () => [],
},
})
const menuOpen = ref(false)
const { smAndDown } = useDisplay()
const menuDensity = computed(() => (smAndDown.value ? 'default' : 'compact'))
const menuWidth = computed(() => (smAndDown.value ? 280 : 240))
const menuMaxHeight = computed(() => (smAndDown.value ? 420 : 360))
const menuLocation = computed(() => (smAndDown.value ? 'bottom end' : 'bottom end'))
const submenuOpenOnHover = computed(() => !smAndDown.value)
function handleSelect (item) {
menuOpen.value = false
emit('select', item)
}
</script>
@@ -0,0 +1,39 @@
<template>
<v-col
v-if="features.showBreadcrumb && showBreadcrumbBar && !isMobile"
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface">
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
<template #prepend>
<v-btn
v-if="features.showFavorites && !showFavoritesBar" class="mr-2" color="primary" size="small"
variant="outlined" @click="emit('toggle-favorites-bar', true)">
常用
</v-btn>
</template>
<template #item="{ item }">
<div class="d-flex align-center ga-1">
<v-icon v-if="item.icon" class="mr-1" size="14">{{ item.icon }}</v-icon>
<span class="text-caption text-no-wrap">{{ item.title }}</span>
</div>
</template>
<template #divider>
<v-icon color="primary-variant" size="12">mdi-chevron-right</v-icon>
</template>
</v-breadcrumbs>
<div class="page-actions">
<slot name="breadcrumb-actions"></slot>
</div>
</v-col>
</template>
<script setup>
defineProps({
features: { type: Object, default: () => ({}) },
showBreadcrumbBar: { type: Boolean, default: true },
isMobile: { type: Boolean, default: false },
breadcrumbItems: { type: Array, default: () => [] },
showFavoritesBar: { type: Boolean, default: true },
})
const emit = defineEmits(['toggle-favorites-bar'])
</script>
@@ -0,0 +1,59 @@
<template>
<v-col
v-if="features.showFavorites && showFavoritesBar && !isMobile"
class="d-flex align-center pr-2 pl-3 py-1 bg-surface">
<div class="favorites-label text-body-2 text-no-wrap pe-2">
{{ favoritesConfig.label }}
</div>
<div class="favorites-list flex-grow-1 d-flex flex-wrap ga-2">
<transition-group class="d-flex flex-wrap ga-2" name="favorite-list" tag="div">
<v-chip
v-for="item in favoriteItems" :key="item.path ?? item.title" class="favorite-item" closable
color="secondary" size="small" variant="outlined" @click="emit('select', item)"
@click:close="emit('remove-favorite', item)">
<v-icon v-if="item.icon" class="me-1" size="16">{{ item.icon }}</v-icon>
<span class="text-caption">{{ item.title }}</span>
</v-chip>
</transition-group>
<v-btn
v-if="favoritesConfig.showAdd" class="favorite-add" color="primary" size="small" variant="outlined"
@click="emit('add-favorite')">
<v-icon class="mr-1" size="16">mdi-plus</v-icon>
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
</v-btn>
</div>
<v-btn color="grey" size="small" variant="text" @click="emit('toggle-favorites-bar', false)">
<v-icon>mdi-eye-off</v-icon>
</v-btn>
</v-col>
</template>
<script setup>
defineProps({
features: { type: Object, default: () => ({}) },
showFavoritesBar: { type: Boolean, default: true },
isMobile: { type: Boolean, default: false },
favoritesConfig: { type: Object, default: () => ({}) },
favoriteItems: { type: Array, default: () => [] },
})
const emit = defineEmits(['select', 'add-favorite', 'remove-favorite', 'toggle-favorites-bar'])
</script>
<style scoped>
.favorite-item {
text-transform: none;
}
.favorite-list-enter-active,
.favorite-list-leave-active,
.favorite-list-move {
transition: all 0.2s ease;
}
.favorite-list-enter-from,
.favorite-list-leave-to {
opacity: 0;
transform: scale(0.92);
}
</style>
@@ -0,0 +1,184 @@
<template>
<v-col class="d-flex align-center bg-surface pr-2 pl-2 pl-m-3 py-1">
<v-btn v-if="isMobile" icon="mdi-menu" size="small" variant="text" @click="emit('toggle-drawer')"></v-btn>
<div v-if="features.showSearch" class="search-input-wrapper">
<v-text-field
v-model="searchValueModel" :aria-label="searchConfig.label" class="search-input" density="compact"
hide-details :placeholder="searchConfig.placeholder" variant="outlined"
@keyup.enter="triggerSearch">
<template v-if="!isMobile" #prepend-inner>
<v-icon size="small">mdi-magnify</v-icon>
</template>
<template #append-inner>
<v-btn :aria-label="searchConfig.label" color="primary" size="small" variant="text" @click="triggerSearch">
開始搜尋
</v-btn>
</template>
</v-text-field>
</div>
<div v-if="features.showToolbarActions" class="top-actions">
<slot name="actions">
<!-- 通知 -->
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
<template #activator="{ props }">
<v-btn
v-bind="props" :aria-label="toolbarActions.notificationsLabel" icon size="small" variant="text"
@click="emit('action', 'notifications')">
<v-badge
v-if="toolbarCounts.notifications" color="error" :content="toolbarCounts.notifications"
offset-x="4" offset-y="-2">
<v-icon>mdi-bell-outline</v-icon>
</v-badge>
<v-icon v-else>mdi-bell-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- 訊息 -->
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
<template #activator="{ props }">
<v-btn
v-bind="props" :aria-label="toolbarActions.messagesLabel" icon size="small" variant="text"
@click="emit('action', 'messages')">
<v-badge
v-if="toolbarCounts.messages" color="warning" :content="toolbarCounts.messages" offset-x="4"
offset-y="-2">
<v-icon>mdi-message-text-outline</v-icon>
</v-badge>
<v-icon v-else>mdi-message-text-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- 說明 -->
<v-tooltip v-if="false" location="bottom" :text="toolbarActions.helpLabel">
<template #activator="{ props }">
<v-btn
v-bind="props" :aria-label="toolbarActions.helpLabel" icon size="small" variant="text"
@click="emit('action', 'help')">
<v-icon>mdi-help</v-icon>
</v-btn>
</template>
</v-tooltip>
<!-- 設定 -->
<v-menu :close-on-content-click="false" location="bottom end">
<template #activator="{ props: menuProps }">
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="{ ...menuProps, ...tooltipProps }" :aria-label="toolbarActions.settingsLabel" icon size="small"
variant="text">
<v-icon>mdi-cog-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</template>
<v-list density="compact" width="180">
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
<v-list-item>
<v-switch v-model="showFavoritesBarModel" color="primary" density="comfortable" hide-details>
<template #label>
<span class="text-body-2" style="width: 8ch;">常用功能</span>
</template>
</v-switch>
</v-list-item>
<v-list-item>
<v-switch v-model="showBreadcrumbBarModel" color="primary" density="comfortable" hide-details>
<template #label>
<span class="text-body-2" style="width: 8ch;">路徑</span>
</template>
</v-switch>
</v-list-item>
</v-list>
</v-menu>
<!-- 登出 -->
<v-tooltip location="bottom" :text="logoutLabel">
<template #activator="{ props }">
<v-btn v-bind="props" :aria-label="logoutLabel" icon size="small" variant="text" @click="emit('logout')">
<v-icon>mdi-logout</v-icon>
</v-btn>
</template>
</v-tooltip>
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
<template #activator="{ props }">
<v-btn v-bind="props" :aria-label="themeToggleLabel" icon variant="text" @click="emit('toggle-theme')">
<v-icon>mdi-palette-outline</v-icon>
</v-btn>
</template>
</v-tooltip>
</slot>
</div>
</v-col>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
isMobile: { type: Boolean, default: false },
features: { type: Object, default: () => ({}) },
searchValue: { type: String, default: '' },
searchConfig: { type: Object, default: () => ({}) },
toolbarActions: { type: Object, default: () => ({}) },
toolbarCounts: { type: Object, default: () => ({}) },
logoutLabel: { type: String, default: '' },
themeToggleLabel: { type: String, default: '' },
showFavoritesBar: { type: Boolean, default: true },
showBreadcrumbBar: { type: Boolean, default: true },
})
const emit = defineEmits([
'toggle-drawer',
'update:searchValue',
'search',
'action',
'logout',
'toggle-theme',
'update:showFavoritesBar',
'update:showBreadcrumbBar',
])
const searchValueModel = computed({
get: () => props.searchValue,
set: (value) => emit('update:searchValue', value),
})
const showFavoritesBarModel = computed({
get: () => props.showFavoritesBar,
set: (value) => emit('update:showFavoritesBar', value),
})
const showBreadcrumbBarModel = computed({
get: () => props.showBreadcrumbBar,
set: (value) => emit('update:showBreadcrumbBar', value),
})
function triggerSearch () {
emit('search')
}
</script>
<style scoped>
.search-input-wrapper {
flex: 1;
display: flex;
align-items: center;
gap: 8px;
}
:deep(.search-input-wrapper .v-field--appended) {
padding-inline-end: 4px;
}
.top-actions {
display: flex;
align-items: center;
gap: 8px;
}
</style>
@@ -0,0 +1,176 @@
<template>
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
<template v-for="item in menuItems" :key="item.path ?? item.title">
<v-list-group v-if="item.subItems?.length" class="menu-group" :value="`menu:${item.path ?? item.title}`">
<template #activator="{ props }">
<v-list-item
v-bind="isShrink ? undefined : props" :class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path" :to="isNavigable(item) ? item.path : undefined" @click="emitSelect(item)">
<template #prepend>
<v-icon v-if="item.icon" size="20">{{ item.icon }}</v-icon>
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
item.title?.charAt(0) }}</v-btn>
</template>
<template #title>
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
<template #append>
<v-chip
v-if="!isShrink && getItemCount(item) > 0" class="menu-count" color="secondary" size="x-small"
variant="tonal">
{{ getItemCount(item) }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<v-list-group
v-if="subItem.subItems?.length"
class="menu-group" :value="`menu:${item.path ?? item.title}::${subItem.path ?? subItem.title}`">
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps" :link="isNavigable(subItem)"
:prepend-icon="subItem.icon || 'mdi-menu-right'" :to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)">
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
<template #append>
<v-chip
v-if="getItemCount(subItem) > 0" class="menu-count" color="secondary" size="x-small"
variant="tonal">
{{ getItemCount(subItem) }}
</v-chip>
</template>
</v-list-item>
</template>
<v-list-item
v-for="subSubItem in subItem.subItems" :key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path" prepend-icon="mdi-circle-small" :to="subSubItem.path"
@click="emitSelect(subSubItem)">
<template #title>
<v-tooltip location="end" :text="subSubItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subSubItem.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-item
v-else :link="!!subItem.path" :prepend-icon="subItem.icon || 'mdi-menu-right'" :to="subItem.path"
@click="emitSelect(subItem)">
<template #title>
<v-tooltip location="end" :text="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list-group>
<v-list-item v-else :class="{ 'px-0': isShrink }" :link="!!item.path" :to="item.path" @click="emitSelect(item)">
<template #prepend>
<v-icon v-if="item.icon" size="20">{{ item.icon }}</v-icon>
<v-btn v-if="isShrink && !item.icon" class="" rounded size="36" spaced="start" variant="text">{{
item.title?.charAt(0) }}</v-btn>
</template>
<template #title>
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list>
</template>
<script setup lang="ts">
import { computed, watch } from 'vue'
interface MenuItem {
title?: string
path?: string
icon?: string
navigable?: boolean
subItems?: MenuItem[]
[key: string]: unknown
}
interface Props {
opened?: string[]
menuItems?: MenuItem[]
isShrink?: boolean
}
const props = withDefaults(defineProps<Props>(), {
opened: () => [],
menuItems: () => [],
isShrink: false,
})
const emit = defineEmits<{
'update:opened': [value: string[]]
select: [item: MenuItem]
unshrink: []
}>()
const openedModel = computed({
get: () => (props.isShrink ? [] : props.opened),
set: (value) => {
if (!props.isShrink) {
emit('update:opened', value)
}
},
})
// 當側邊欄收縮時,自動收起所有展開的子選單
watch(() => props.isShrink, (newVal) => {
if (newVal) {
openedModel.value = []
}
})
const isNavigable = (item: MenuItem) => item?.navigable !== false
function emitSelect (item: MenuItem) {
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
// 這樣可以讓使用者看到完整的選單結構和導航結果
if (props.isShrink) {
emit('unshrink')
}
emit('select', item)
}
function getItemCount (item: MenuItem) {
if (!item?.subItems?.length) return 0
const countLeaf = (list: MenuItem[]): number =>
(list || []).reduce((total: number, current: MenuItem) => {
if (current?.subItems?.length) return total + countLeaf(current.subItems)
return total + 1
}, 0)
return countLeaf(item.subItems)
}
</script>
<style scoped>
.menu-count {
min-width: 28px;
justify-content: center;
}
.nav-text-overflow {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -0,0 +1,29 @@
<template>
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in favoriteItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
@click="emit('select', item)">
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
</v-list-item>
</v-list>
</v-sheet>
</template>
<script setup>
defineProps({
favoriteItems: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['select'])
</script>
<style scoped>
.mobile-favorites-panel {
min-height: 0;
height: 100%;
}
</style>
@@ -0,0 +1,32 @@
<template>
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in mobileCurrentItems" :key="item.path ?? item.title" class="mb-1" rounded="lg"
@click="emit('item-click', item)">
<v-list-item-title class="text-body-2">{{ item.title }}</v-list-item-title>
<template #append>
<v-icon size="18">{{ item.subItems?.length ? 'mdi-chevron-right' : 'mdi-arrow-top-right' }}</v-icon>
</template>
</v-list-item>
</v-list>
</v-sheet>
</template>
<script setup>
defineProps({
mobileCurrentItems: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['item-click'])
</script>
<style scoped>
.mobile-menu-panel {
min-height: 0;
height: 100%;
}
</style>
@@ -0,0 +1,66 @@
<template>
<v-dialog
:max-width="maxWidth" :model-value="modelValue" :persistent="persistent"
@update:model-value="$emit('update:modelValue', $event)">
<v-card>
<v-card-title class="text-h6">{{ title }}</v-card-title>
<v-card-text>
<slot>{{ message }}</slot>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn variant="text" @click="$emit('update:modelValue', false)">取消</v-btn>
<v-btn :color="confirmColor" :loading="confirmLoading" :variant="confirmVariant" @click="$emit('confirm')">
{{ confirmText }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import type { PropType } from 'vue'
defineProps({
modelValue: {
type: Boolean,
required: true,
},
title: {
type: String,
required: true,
},
message: {
type: String,
default: '',
},
confirmText: {
type: String,
default: '確認',
},
confirmColor: {
type: String,
default: 'primary',
},
confirmVariant: {
type: String as PropType<'text' | 'flat' | 'outlined' | 'plain' | 'elevated' | 'tonal'>,
default: 'flat',
},
confirmLoading: {
type: Boolean,
default: false,
},
maxWidth: {
type: [String, Number],
default: 420,
},
persistent: {
type: Boolean,
default: true,
},
})
defineEmits<{
(event: 'update:modelValue', value: boolean): void
(event: 'confirm'): void
}>()
</script>
@@ -0,0 +1,63 @@
<template>
<v-card border class="d-flex flex-column h-100 rounded-0" :class="cardClass" flat :width="width">
<v-card-title class="dialog-title d-flex align-center ga-2">
<div>
<div class="text-h6">{{ dialogTitle }}</div>
<div v-if="dialogSubtitle" class="text-body-2 text-medium-emphasis">{{ dialogSubtitle }}</div>
</div>
<v-spacer />
<v-chip v-if="isViewMode" color="info" size="small" variant="tonal">檢視中</v-chip>
<v-chip v-else-if="isEditMode" color="primary" size="small" variant="tonal">編輯中</v-chip>
<v-chip v-else color="secondary" size="small" variant="tonal">新增中</v-chip>
</v-card-title>
<v-card-subtitle class="dialog-toolbar d-flex align-center py-2 ga-2">
<slot name="toolbar" />
</v-card-subtitle>
<v-divider />
<v-card-text :class="contentClass">
<slot name="content" />
</v-card-text>
<v-divider />
<v-card-actions class="dialog-actions">
<slot name="actions" />
</v-card-actions>
</v-card>
</template>
<script setup lang="ts">
defineProps({
dialogTitle: {
type: String,
required: true,
},
dialogSubtitle: {
type: String,
default: '',
},
isViewMode: {
type: Boolean,
required: true,
},
isEditMode: {
type: Boolean,
required: true,
},
width: {
type: [String, Number],
default: '100%',
},
cardClass: {
type: String,
default: '',
},
contentClass: {
type: String,
default: 'pa-2 flex-grow-1 overflow-y-auto',
},
})
</script>
@@ -0,0 +1,73 @@
<template>
<v-sheet class="d-flex flex-column ga-2 h-100">
<v-card border class="flex-shrink-0" variant="flat">
<v-card-title class="d-flex align-center ga-3">
<span class="text-h6">{{ title }}</span>
<v-spacer />
<v-btn
:icon="mdAndUp ? false : 'mdi-magnify'" :prepend-icon="mdAndUp ? 'mdi-magnify' : 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"
size="small" :text="mdAndUp ? createLabel : false" @click="$emit('create')">
</v-btn>
</v-card-title>
<!-- Desktopinline 展開 -->
<template v-if="mdAndUp">
<v-divider />
<v-card-text v-show="searchPanelOpen" class="px-2 py-1">
<v-row dense>
<slot name="search-fields" />
</v-row>
</v-card-text>
</template>
</v-card>
<slot name="table" />
</v-sheet>
<!-- 手機 / TabletBottom Sheet -->
<v-bottom-sheet v-if="!mdAndUp" :model-value="searchPanelOpen" @update:model-value="$emit('toggle-search')">
<v-card rounded="t-xl">
<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-card-title>
<v-divider />
<v-card-text class="px-2 py-1">
<v-row dense>
<slot name="search-fields" />
</v-row>
</v-card-text>
</v-card>
</v-bottom-sheet>
</template>
<script setup lang="ts">
import { useDisplay } from 'vuetify'
const { mdAndUp } = useDisplay()
defineProps({
title: {
type: String,
default: '學生資料維護',
},
createLabel: {
type: String,
default: '新增資料',
},
searchPanelOpen: {
type: Boolean,
required: true,
},
})
defineEmits<{
(event: 'toggle-search'): void
(event: 'create'): void
}>()
</script>
@@ -0,0 +1,115 @@
<template>
<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"
@click="$emit('prev')" />
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasNextRecord" icon="mdi-chevron-right" 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"
@click="$emit('switch-to-edit')">
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="tonal"
@click="$emit('switch-to-view')">
{{ viewLabel }}
</v-btn>
</div>
<template v-else>
<v-btn
v-if="isViewMode || isEditMode" :disabled="!hasPrevRecord" prepend-icon="mdi-skip-previous" 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"
variant="text" @click="$emit('prev')">
{{ prevLabel }}
</v-btn>
<v-btn
v-if="isViewMode || isEditMode" append-icon="mdi-chevron-right" :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"
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"
@click="$emit('switch-to-edit')">
{{ editLabel }}
</v-btn>
<v-btn
v-if="isEditMode" color="primary" prepend-icon="mdi-eye" size="small" variant="tonal"
@click="$emit('switch-to-view')">
{{ viewLabel }}
</v-btn>
</template>
</template>
<script setup lang="ts">
defineProps({
isViewMode: {
type: Boolean,
required: true,
},
isEditMode: {
type: Boolean,
required: true,
},
hasPrevRecord: {
type: Boolean,
required: true,
},
hasNextRecord: {
type: Boolean,
required: true,
},
mobile: {
type: Boolean,
default: false,
},
firstLabel: {
type: String,
default: '首筆',
},
prevLabel: {
type: String,
default: '上一筆',
},
nextLabel: {
type: String,
default: '下一筆',
},
lastLabel: {
type: String,
default: '末筆',
},
editLabel: {
type: String,
default: '編輯',
},
viewLabel: {
type: String,
default: '檢視',
},
})
defineEmits<{
(event: 'first'): void
(event: 'prev'): void
(event: 'next'): void
(event: 'last'): void
(event: 'switch-to-edit'): void
(event: 'switch-to-view'): void
}>()
</script>
@@ -0,0 +1,183 @@
<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-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ semester?.semesterName || '學期明細' }}
</v-toolbar-title>
</v-toolbar>
<v-divider />
<v-card-text class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
<div v-if="semester" class="pa-4 d-flex flex-column ga-4">
<div class="d-flex flex-column ga-3">
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">學期平均</div>
<div class="text-h6 font-weight-bold text-primary">{{ semester.average }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">班級排名</div>
<div class="text-h6 font-weight-bold">{{ semester.rank }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">總學分</div>
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
</v-card>
</div>
<template v-if="isViewMode">
<v-card v-for="course in semester.courses" :key="course.code" class="pa-3" variant="outlined">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</div>
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
{{ course.score }}
</v-chip>
</div>
<div class="d-flex ga-4 mt-3 text-body-2">
<div>學分 {{ course.credits }}</div>
</div>
</v-card>
<div v-if="semester.courses.length === 0" class="text-center text-medium-emphasis py-6 border border-dashed rounded">
尚無課程資料
</div>
</template>
<template v-else>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details="auto"
label="學期名稱"
:model-value="semester.semesterName"
variant="outlined"
@update:model-value="(value) => $emit('update-semester', semester.id, { semesterName: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details="auto"
label="班級排名"
:model-value="semester.rank"
type="number"
variant="outlined"
@update:model-value="(value) => $emit('update-semester', semester.id, { rank: Number(value) || 0 })"
/>
<v-text-field
density="comfortable"
hide-details="auto"
label="平均分數"
:model-value="semester.average"
readonly
variant="filled"
/>
<div class="d-flex align-center">
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
<v-spacer />
<v-btn
color="primary"
:disabled="isFormLocked"
prepend-icon="mdi-plus"
size="small"
variant="text"
@click="$emit('add-course', semester.id)"
>
加入課程
</v-btn>
</div>
<v-card v-for="(course, idx) in semester.courses" :key="`${course.code}-${idx}`" class="pa-3" variant="outlined">
<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"
:disabled="isFormLocked"
icon="mdi-delete"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, idx, course.name)"
/>
</div>
<div class="d-flex flex-column ga-3">
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="課程名稱"
:model-value="course.name"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { name: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="代碼"
:model-value="course.code"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { code: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="學分"
:model-value="course.credits"
type="number"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { credits: Number(value) || 0 })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="分數"
:model-value="course.score"
type="number"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { score: Number(value) || 0 })"
/>
</div>
</v-card>
<div v-if="semester.courses.length === 0" class="text-center text-medium-emphasis py-6 border border-dashed rounded">
尚無課程資料
</div>
</template>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
const props = defineProps<{
semester: SemesterRecord | null
isViewMode: boolean
isFormLocked: boolean
}>()
defineEmits<{
(event: 'close'): void
(event: 'add-course', semesterId: number): void
(event: 'update-semester', semesterId: number, payload: Partial<SemesterRecord>): void
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
(event: 'delete-course', semesterId: number, courseIndex: number, courseName: string): void
}>()
const totalCredits = computed(() =>
props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
)
</script>
@@ -0,0 +1,250 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
子檔資料示範
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card
v-for="semester in semesters"
:key="semester.id"
class="cursor-pointer"
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select', semester.id)"
>
<v-card-text class="pa-4">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<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>
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<v-chip color="primary" size="small" variant="tonal">平均 {{ semester.average }}</v-chip>
<v-chip size="small" variant="tonal">排名 {{ semester.rank }}</v-chip>
<v-chip size="small" variant="tonal">課程 {{ semester.courses.length }}</v-chip>
</div>
</v-card-text>
</v-card>
</div>
<div v-else class="flex-grow-1" style="min-height: 0">
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
<v-expansion-panels>
<v-expansion-panel v-for="semester in semesters" :key="semester.id">
<v-expansion-panel-title color="background">
<div class="d-flex align-center ga-3 w-100 pr-2">
<span class="font-weight-medium">{{ semester.semesterName }}</span>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex align-center mb-2">
<span class="text-subtitle-1 font-weight-bold text-medium-emphasis">課程列表</span>
<v-spacer />
<v-btn
v-if="!isFormReadonly"
color="primary"
:disabled="isFormLocked"
prepend-icon="mdi-plus"
size="small"
variant="tonal"
@click="$emit('add-course', semester.id)"
>
加入課程
</v-btn>
</div>
<v-table class="border rounded" density="compact" fixed-header>
<thead>
<tr>
<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>
</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>
</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>
</div>
</th>
<th v-if="!isFormReadonly" width="52"></th>
</tr>
</thead>
<tbody>
<tr
v-for="{ course, originalIndex } in getSortedCourses(semester)"
:key="`${semester.id}-${originalIndex}`"
>
<td class="py-0">{{ course.name }}</td>
<td class="align-top py-0">
<span v-if="isFormReadonly">{{ course.credits }}</span>
<v-text-field
v-else
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="course.credits"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', semester.id, originalIndex, {
credits: Number(value) || 0,
})
"
/>
</td>
<td class="align-top py-0">
<span v-if="isFormReadonly">{{ course.score }}</span>
<v-text-field
v-else
density="compact"
:disabled="isFormLocked"
hide-details
hide-spin-buttons
:model-value="course.score"
type="number"
variant="outlined"
@update:model-value="
(value) =>
$emit('update-course', semester.id, originalIndex, {
score: Number(value) || 0,
})
"
/>
</td>
<td v-if="!isFormReadonly" class="px-1 text-center">
<v-btn
color="error"
:disabled="isFormLocked"
icon="mdi-delete-outline"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, originalIndex, course.name)"
/>
</td>
</tr>
<tr v-if="semester.courses.length === 0">
<td
class="text-center text-medium-emphasis py-6"
: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>
<span class="text-caption">尚無課程,點擊「加入課程」新增</span>
</div>
</td>
</tr>
</tbody>
</v-table>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
<div
v-if="semesters.length === 0"
class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded"
>
尚無學期資料
</div>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { ref } from 'vue'
type CourseSortKey = 'name' | 'credits' | 'score'
interface CourseSortState {
key: CourseSortKey
order: 'asc' | 'desc'
}
interface SortedCourseRow {
course: CourseRecord
originalIndex: number
}
defineProps<{
semesters: SemesterRecord[]
isMobile: boolean
isFormReadonly: boolean
isFormLocked: boolean
selectedSemesterId: number | null
}>()
defineEmits<{
(event: 'select', semesterId: number): void
(event: 'add-course', semesterId: number): void
(
event: 'update-course',
semesterId: number,
courseIndex: number,
payload: Partial<CourseRecord>
): void
(event: 'delete-course', semesterId: number, courseIndex: number, courseName: string): void
}>()
const semesterSortStates = ref<Record<number, CourseSortState>>({})
const getSortState = (semesterId: number) => semesterSortStates.value[semesterId]
function toggleSort (semesterId: number, key: CourseSortKey) {
const current = getSortState(semesterId)
semesterSortStates.value[semesterId] =
current?.key === key
? {
key,
order: current.order === 'asc' ? 'desc' : 'asc',
}
: {
key,
order: 'asc',
}
}
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'
}
function compareCourseValue (left: CourseRecord, right: CourseRecord, key: CourseSortKey) {
if (key === 'name') return left.name.localeCompare(right.name, 'zh-Hant')
return left[key] - right[key]
}
function getSortedCourses (semester: SemesterRecord): SortedCourseRow[] {
const rows = semester.courses.map((course, originalIndex) => ({
course,
originalIndex,
}))
const sortState = getSortState(semester.id)
if (!sortState) return rows
return [...rows].sort((left, right) => {
const result = compareCourseValue(left.course, right.course, sortState.key)
if (result === 0) return left.originalIndex - right.originalIndex
return sortState.order === 'asc' ? result : -result
})
}
</script>
@@ -0,0 +1,152 @@
<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-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ semester?.semesterName || '課程明細' }}
</v-toolbar-title>
</v-toolbar>
<v-divider />
<v-card-text class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
<div v-if="semester" class="pa-4 d-flex flex-column ga-4">
<div class="d-flex flex-column ga-3">
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">學期平均</div>
<div class="text-h6 font-weight-bold text-primary">{{ semester.average }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">班級排名</div>
<div class="text-h6 font-weight-bold">{{ semester.rank }}</div>
</v-card>
<v-card class="py-2 px-3" variant="outlined">
<div class="text-caption text-medium-emphasis">總學分</div>
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
</v-card>
</div>
<div class="d-flex align-center">
<div>
<div class="text-subtitle-2 font-weight-bold">課程列表</div>
<div class="text-caption text-medium-emphasis">手機版改用卡片式維護不使用扁平表格</div>
</div>
<v-spacer />
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="isFormLocked"
prepend-icon="mdi-plus"
size="small"
variant="text"
@click="$emit('add-course', semester.id)"
>
加入課程
</v-btn>
</div>
<template v-if="semester.courses.length > 0">
<v-card v-for="(course, idx) in semester.courses" :key="`${course.code}-${idx}`" class="pa-3" variant="outlined">
<template v-if="isViewMode">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</div>
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
{{ course.score }}
</v-chip>
</div>
<div class="d-flex ga-4 mt-3 text-body-2">
<div>學分 {{ course.credits }}</div>
</div>
</template>
<template v-else>
<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"
:disabled="isFormLocked"
icon="mdi-delete"
size="small"
variant="text"
@click="$emit('delete-course', semester.id, idx)"
/>
</div>
<div class="d-flex flex-column ga-3">
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="課程名稱"
:model-value="course.name"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { name: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="代碼"
:model-value="course.code"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { code: String(value) })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="學分"
:model-value="course.credits"
type="number"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { credits: Number(value) || 0 })"
/>
<v-text-field
density="comfortable"
:disabled="isFormLocked"
hide-details
label="分數"
:model-value="course.score"
type="number"
variant="outlined"
@update:model-value="(value) => $emit('update-course', semester.id, idx, { score: Number(value) || 0 })"
/>
</div>
</template>
</v-card>
</template>
<div v-else class="text-center text-medium-emphasis py-6 border border-dashed rounded">
尚無課程資料
</div>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
const props = defineProps<{
semester: SemesterRecord | null
isViewMode: boolean
isFormLocked: boolean
}>()
defineEmits<{
(event: 'close'): void
(event: 'add-course', semesterId: number): void
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
(event: 'delete-course', semesterId: number, courseIndex: number): void
}>()
const totalCredits = computed(() =>
props.semester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
)
</script>
@@ -0,0 +1,142 @@
<template>
<div class="text-subtitle-1 font-weight-bold my-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
子檔資料示範
<v-spacer />
<v-btn
v-if="!isMobile && !isFormReadonly && !isFormLocked" color="primary" prepend-icon="mdi-plus" size="small"
variant="tonal" @click="$emit('add-course')">
新增成績
</v-btn>
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card
v-for="semester in semesters" :key="semester.id" class="cursor-pointer" :class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select-semester', semester.id)">
<v-card-text class="pa-4">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<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>
</div>
<div class="d-flex flex-wrap ga-2 mt-3">
<v-chip color="primary" size="small" variant="tonal">平均 {{ semester.average }}</v-chip>
<v-chip size="small" variant="tonal">排名 {{ semester.rank }}</v-chip>
<v-chip size="small" variant="tonal">課程 {{ semester.courses.length }}</v-chip>
</div>
</v-card-text>
</v-card>
</div>
<div v-else class="flex-grow-1" style="min-height: 0;">
<div class="d-flex flex-column ga-3 overflow-y-auto pr-1 h-100">
<v-card class="flex-shrink-0" variant="flat">
<v-card-text class="pa-2">
<v-data-table
class="border rounded" density="compact" :headers="headers" hide-default-footer
:items="flattenedCourses" :items-per-page="-1">
<template #[`item.semesterName`]="slotProps">
{{ slotProps.item.semesterName }}
</template>
<template #[`item.name`]="slotProps">
{{ slotProps.item.name }}
</template>
<template #[`item.credits`]="slotProps">
<span v-if="isFormReadonly">{{ slotProps.item.credits }}</span>
<v-text-field
v-else density="compact" :disabled="isFormLocked" hide-details
hide-spin-buttons :model-value="slotProps.item.credits" type="number" variant="outlined" @update:model-value="
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { credits: Number(value) || 0 })
" />
</template>
<template #[`item.score`]="slotProps">
<span v-if="isFormReadonly">{{ slotProps.item.score }}</span>
<v-text-field
v-else density="compact" :disabled="isFormLocked" hide-details
hide-spin-buttons :model-value="slotProps.item.score" type="number" variant="outlined" @update:model-value="
(value) => $emit('update-course', slotProps.item.semesterId, slotProps.item.courseIndex, { score: Number(value) || 0 })
" />
</template>
<template #[`item.actions`]="slotProps">
<v-btn
color="error" :disabled="isFormLocked" icon="mdi-delete" size="small" variant="text"
@click="$emit('delete-course', slotProps.item.semesterId, slotProps.item.courseIndex)" />
</template>
</v-data-table>
</v-card-text>
</v-card>
</div>
</div>
<div v-if="semesters.length === 0" class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded">
尚無成績資料
</div>
</template>
<script setup lang="ts">
import type { CourseRecord, SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
const props = defineProps<{
semesters: SemesterRecord[]
isMobile: boolean
isFormReadonly: boolean
isFormLocked: boolean
selectedSemesterId: number | null
}>()
defineEmits<{
(event: 'select-semester', semesterId: number): void
(event: 'add-course'): void
(event: 'update-course', semesterId: number, courseIndex: number, payload: Partial<CourseRecord>): void
(event: 'delete-course', semesterId: number, courseIndex: number): void
}>()
const headers = computed(() => {
const baseHeaders: Array<{ title: string; key: string; sortable: boolean; width?: string }> = [
{ title: '學年學期', key: 'semesterName', sortable: true },
{ title: '課程名稱', key: 'name', sortable: true },
{ title: '學分', key: 'credits', sortable: true, width: '100' },
{ title: '分數', key: 'score', sortable: true, width: '100' },
]
if (!props.isFormReadonly) {
baseHeaders.push({ title: '操作', key: 'actions', sortable: false, width: '60' })
}
return baseHeaders
})
const flattenedCourses = computed(() => {
const result: Array<{
semesterId: number
courseIndex: number
semesterName: string
name: string
credits: number
score: number
}> = []
for (const semester of props.semesters) {
for (const [courseIndex, course] of semester.courses.entries()) {
result.push({
semesterId: semester.id,
courseIndex,
semesterName: semester.semesterName,
name: course.name,
credits: course.credits,
score: course.score,
})
}
}
return result
})
</script>
@@ -0,0 +1,52 @@
<template>
<div class="text-subtitle-1 font-weight-bold mb-3 d-flex align-center">
<v-icon start>mdi-school</v-icon>
子檔資料示範 ({{ semesters.length }})
<v-spacer />
<v-btn v-if="!isViewMode" color="primary" prepend-icon="mdi-plus" size="small" variant="text" @click="$emit('add')">
新增學期
</v-btn>
</div>
<v-row dense>
<v-col v-for="semester in semesters" :key="semester.id" cols="12" :md="isMobile ? 12 : 6">
<v-card
class="cursor-pointer mb-2"
:class="{ 'border-opacity-100': selectedSemesterId === semester.id }"
:color="selectedSemesterId === semester.id ? 'primary' : undefined"
variant="outlined"
@click="$emit('select', semester.id)"
>
<v-list-item density="compact">
<v-list-item-title class="text-body-2 font-weight-bold">{{ semester.semesterName }}</v-list-item-title>
<v-list-item-subtitle class="text-caption">
平均: {{ semester.average }} 排名: {{ semester.rank }}
</v-list-item-subtitle>
<template #append>
<v-icon size="small">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</v-card>
</v-col>
</v-row>
<div v-if="semesters.length === 0" class="text-caption text-center py-6 text-medium-emphasis border border-dashed rounded">
尚無學期資料
</div>
</template>
<script setup lang="ts">
import type { SemesterRecord } from '@/stores/semesters'
defineProps<{
semesters: SemesterRecord[]
selectedSemesterId: number | null
isViewMode: boolean
isMobile: boolean
}>()
defineEmits<{
(event: 'select', id: number): void
(event: 'add'): void
}>()
</script>
@@ -0,0 +1,253 @@
<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-toolbar-title class="text-subtitle-1 font-weight-bold">
{{ selectedSemester ? selectedSemester.semesterName : '學期明細' }}
</v-toolbar-title>
<v-spacer />
<v-btn
v-if="selectedSemester && !isViewMode"
color="error"
prepend-icon="mdi-delete"
size="small"
variant="text"
@click="$emit('delete', selectedSemester.id)"
>
刪除
</v-btn>
<v-btn
v-if="selectedSemester && !isViewMode"
color="primary"
prepend-icon="mdi-pencil"
size="small"
variant="text"
@click="$emit('start-edit')"
>
編輯
</v-btn>
</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-toolbar-title class="text-subtitle-1 font-weight-bold">
編輯學期
</v-toolbar-title>
<v-spacer />
<v-btn variant="text" @click="$emit('save-edit')">儲存</v-btn>
</v-toolbar>
<v-divider />
<v-card-text v-if="!isDetailEditing" class="pa-0 flex-grow-1 overflow-y-auto bg-grey-lighten-5">
<div v-if="selectedSemester" class="h-100 d-flex flex-column">
<div :class="statsClass">
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
<div class="text-caption text-medium-emphasis">學期平均</div>
<div class="text-h6 font-weight-bold text-primary">{{ selectedSemester.average }}</div>
</v-card>
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
<div class="text-caption text-medium-emphasis">班級排名</div>
<div class="text-h6 font-weight-bold">{{ selectedSemester.rank }}</div>
</v-card>
<v-card class="py-2 px-3" :class="statCardClass" variant="outlined">
<div class="text-caption text-medium-emphasis">總學分</div>
<div class="text-h6 font-weight-bold">{{ totalCredits }}</div>
</v-card>
</div>
<v-divider />
<div v-if="isMobile" class="pa-3 d-flex flex-column ga-3">
<v-card v-for="course in selectedSemester.courses" :key="course.code" class="pa-3" variant="outlined">
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-body-1 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</div>
<v-chip :color="course.score < 60 ? 'error' : 'success'" size="small" variant="tonal">
{{ course.score }}
</v-chip>
</div>
<div class="d-flex ga-4 mt-3 text-body-2">
<div>學分 {{ course.credits }}</div>
</div>
</v-card>
<div v-if="selectedSemester.courses.length === 0" class="text-center text-medium-emphasis py-6">
尚無課程資料
</div>
</div>
<v-table v-else class="flex-grow-1">
<thead>
<tr>
<th class="text-left bg-grey-lighten-4">課程名稱</th>
<th class="text-center bg-grey-lighten-4" width="80">學分</th>
<th class="text-right bg-grey-lighten-4" width="80">分數</th>
</tr>
</thead>
<tbody>
<tr v-for="course in selectedSemester.courses" :key="course.code">
<td>
<div class="text-body-2 font-weight-medium">{{ course.name }}</div>
<div class="text-caption text-medium-emphasis">{{ course.code }}</div>
</td>
<td class="text-center">{{ course.credits }}</td>
<td class="text-right font-weight-bold" :class="course.score < 60 ? 'text-error' : 'text-success'">
{{ course.score }}
</td>
</tr>
</tbody>
</v-table>
</div>
</v-card-text>
<v-card-text v-else class="pa-0 flex-grow-1 overflow-y-auto bg-surface">
<div v-if="detailForm" class="pa-4 d-flex flex-column ga-4">
<v-row dense>
<v-col cols="12">
<v-text-field
v-model="detailForm.semesterName"
density="compact"
hide-details="auto"
label="學期名稱"
variant="outlined"
/>
</v-col>
<v-col :cols="isMobile ? 12 : 6">
<v-text-field
v-model.number="detailForm.rank"
density="compact"
hide-details="auto"
label="班級排名"
type="number"
variant="outlined"
/>
</v-col>
<v-col :cols="isMobile ? 12 : 6">
<v-text-field
density="compact"
hide-details="auto"
label="平均分數 (自動計算)"
:model-value="detailForm.average"
readonly
variant="filled"
/>
</v-col>
</v-row>
<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>
</div>
<div v-if="isMobile" class="d-flex flex-column ga-3">
<v-card v-for="(course, idx) in detailForm.courses" :key="idx" class="pa-3" variant="outlined">
<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)" />
</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"
density="compact"
hide-details
label="學分"
type="number"
variant="outlined"
/>
<v-text-field
v-model.number="course.score"
density="compact"
hide-details
label="分數"
type="number"
variant="outlined"
/>
</div>
</v-card>
<div v-if="detailForm.courses.length === 0" class="text-center text-medium-emphasis py-4 border border-dashed rounded">
暫無課程請點擊上方按鈕新增
</div>
</div>
<v-table v-else class="border rounded">
<thead>
<tr>
<th width="40"></th>
<th>課程資訊</th>
<th width="90">學分</th>
<th width="90">分數</th>
</tr>
</thead>
<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)" />
</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" />
</td>
<td class="align-top py-2">
<v-text-field
v-model.number="course.credits"
density="compact"
hide-details
type="number"
variant="outlined"
/>
</td>
<td class="align-top py-2">
<v-text-field v-model.number="course.score" density="compact" hide-details type="number" variant="outlined" />
</td>
</tr>
<tr v-if="detailForm.courses.length === 0">
<td class="text-center text-medium-emphasis py-4" colspan="4">暫無課程請點擊上方按鈕新增</td>
</tr>
</tbody>
</v-table>
</div>
</v-card-text>
</v-card>
</template>
<script setup lang="ts">
import type { SemesterRecord } from '@/stores/semesters'
import { computed } from 'vue'
const props = defineProps<{
selectedSemester: SemesterRecord | null
detailForm: SemesterRecord | null
isViewMode: boolean
isDetailEditing: boolean
isMobile: boolean
}>()
defineEmits<{
(event: 'close'): void
(event: 'start-edit'): void
(event: 'delete', id: number): void
(event: 'cancel-edit'): void
(event: 'save-edit'): void
(event: 'add-course'): void
(event: 'remove-course', index: number): void
}>()
const totalCredits = computed(() =>
props.selectedSemester?.courses.reduce((sum, course) => sum + course.credits, 0) ?? 0,
)
const statsClass = computed(() =>
props.isMobile ? 'pa-3 d-flex flex-column ga-3 bg-surface' : 'pa-3 d-flex ga-3 bg-surface',
)
const statCardClass = computed(() => (props.isMobile ? '' : 'flex-grow-1'))
</script>