Files
skt-vuetify-templates/src/views/maint/MasterDetailA.vue
T
2026-03-30 15:04:27 +08:00

1029 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<page-maint
:search-panel-open="searchPanelOpen"
:title="`主從資料維護示範A`"
@create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen"
>
<template #search-fields>
<v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
<v-text-field
id="search-student-id"
v-model="search.studentId"
aria-labelledby="search-student-id-label"
density="compact"
hide-details
name="searchStudentId"
placeholder="例如:S2024001"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
<v-text-field
id="search-name"
v-model="search.name"
aria-labelledby="search-name-label"
density="compact"
hide-details
name="searchName"
placeholder="例如:王小明"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
<v-select
id="search-department"
v-model="search.department"
aria-labelledby="search-department-label"
density="compact"
hide-details
:items="departments"
name="searchDepartment"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
<v-select
id="search-grade"
v-model="search.grade"
aria-labelledby="search-grade-label"
density="compact"
hide-details
item-title="title"
item-value="value"
:items="gradeOptions"
name="searchGrade"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
<v-select
id="search-status"
v-model="search.status"
aria-labelledby="search-status-label"
density="compact"
hide-details
:items="statuses"
name="searchStatus"
variant="outlined"
/>
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<template #table>
<v-data-table
v-model:page="currentPage"
class="student-table"
density="compact"
fixed-header
:headers="tableHeaders"
height="100%"
hide-default-footer
:items="students"
:items-per-page="itemsPerPage"
:row-props="rowProps"
>
<template #[`item.grade`]="{ item }">
{{ gradeLabel(item.grade) }}
</template>
<template #[`item.status`]="{ item }">
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
{{ item.status }}
</v-chip>
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-1">
<v-btn
color="info"
:prepend-icon="mdiEye"
size="small"
variant="text"
@click="openViewDialog(item)"
>
檢視
</v-btn>
<v-btn
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="text"
@click="openEditDialog(item)"
>
修改
</v-btn>
<v-btn
color="error"
:prepend-icon="mdiDelete"
size="small"
variant="text"
@click="requestDeleteConfirmation(item)"
>
刪除
</v-btn>
</div>
</template>
<template #bottom>
<div class="d-flex align-center justify-space-between px-4 py-3">
<div class="text-body-2 text-medium-emphasis">
{{ pageSummary }}
</div>
<div class="d-flex align-center ga-2">
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="currentPage = 1"
>第一頁</v-btn
>
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="currentPage -= 1"
>上一頁</v-btn
>
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="currentPage += 1"
>下一頁</v-btn
>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="currentPage = pageCount"
>最後頁</v-btn
>
</div>
</div>
</template>
</v-data-table>
</template>
</page-maint>
<!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
<teleport to="body">
<!-- 包成元件需要傳高度寬度給dialog-panel -->
<v-overlay
class="dialog-overlay"
:close-on-content-click="false"
:model-value="dialogVisible"
scrim="rgba(0, 0, 0, 0.45)"
scroll-strategy="block"
@update:model-value="handleDialogVisibility"
>
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
<!-- 子檔區塊 (Detail Card)學期成績明細 -->
<!-- 說明點選主檔的學期後會從左側滑出顯示該學期的詳細課程成績 -->
<div
v-if="!isMobile || activeMobilePanel === 'detail'"
class="detail-panel-wrapper"
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
>
<DetailSidePanel
v-model:detail-form="detailForm"
:is-detail-editing="isDetailEditing"
:is-mobile="isMobile"
:is-view-mode="isViewMode"
:selected-semester="selectedSemester"
@cancel-edit="cancelDetailEdit"
@close="closeDetailPanel"
@delete="handleDeleteSemester"
@save-edit="saveDetailEdit"
@start-edit="startDetailEdit"
/>
</div>
<!-- 主檔區塊 (Master Card)學生基本資料與學期列表 -->
<!-- 說明固定在視窗右側包含學生表單與學期清單 -->
<mnt-dialog-card
v-if="!isMobile || activeMobilePanel === 'master'"
:dialog-subtitle="dialogSubtitle"
:dialog-title="dialogTitle"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
:width="isMobile ? '100%' : 760"
>
<template #toolbar>
<mnt-record-nav-toolbar
:has-next-record="hasNextRecord"
:has-prev-record="hasPrevRecord"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
:mobile="isMobile"
@first="openEdgeRecord('first')"
@last="openEdgeRecord('last')"
@next="openAdjacentRecord('next')"
@prev="openAdjacentRecord('prev')"
@switch-to-edit="switchToEditMode"
@switch-to-view="switchToViewMode"
/>
</template>
<template #content>
<!-- 錯誤提示當表單驗證未通過時顯示 -->
<v-alert
v-if="errorSummary.length > 0 && !isLoading"
class="mb-4"
type="error"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
<div class="d-flex flex-column ga-1">
<v-btn
v-for="error in errorSummary"
:key="error.field"
color="error"
size="small"
variant="text"
@click="scrollToField(error.field)"
>
{{ error.message }}
</v-btn>
</div>
</v-alert>
<!-- 載入中骨架畫面 -->
<v-skeleton-loader
v-if="isLoading"
class="mt-4"
type="subtitle,paragraph"
width="100%"
/>
<!-- 學生主檔表單檢視模式時會自動套用 readonly 樣式 -->
<v-form
v-else
:class="{ 'form-readonly': isFormReadonly }"
@submit.prevent="requestSaveConfirmation"
>
<MasterFileFormFields
:departments="departments"
:enroll-years="enrollYears"
:field-errors="fieldErrors"
:form="form"
:grade-options="gradeOptions"
:is-form-locked="isFormLocked"
:is-form-readonly="isFormReadonly"
:statuses="statuses"
@clear-field="clearFieldError"
/>
<v-divider />
<!-- 學期成績紀錄區塊 -->
<!-- 說明顯示該學生的所有學期紀錄並提供新增與選取功能 -->
<DetailNavigation
:is-mobile="isMobile"
:is-view-mode="isViewMode"
:selected-semester-id="selectedSemesterId"
:semesters="studentSemesters"
@add="handleAddSemester"
@select="handleSemesterSelect"
/>
</v-form>
</template>
<template #actions>
<template v-if="isMobile">
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="requestDeleteCurrent"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="requestSaveConfirmation"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
</template>
<template v-else>
<v-spacer />
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="requestDeleteCurrent"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="requestSaveConfirmation"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
</template>
</template>
</mnt-dialog-card>
</div>
</v-overlay>
</teleport>
<ConfirmDialog
v-model="confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="confirmClose"
/>
<ConfirmDialog
v-model="confirmSaveVisible"
:confirm-loading="isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="confirmSave"
>
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
<div v-if="item.before !== null" class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</ConfirmDialog>
<ConfirmDialog
v-model="confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="confirmDelete"
/>
<ConfirmDialog
v-model="confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmSwitch"
/>
<ConfirmDialog
v-model="confirmNavigateVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmNavigate"
/>
<!-- 成功提示 -->
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
儲存成功
</v-snackbar>
</template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import DetailNavigation from '@/components/maint/master-detail/DetailNavigation.vue'
import DetailSidePanel from '@/components/maint/master-detail/DetailSidePanel.vue'
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type SemesterRecord, useSemesterStore } from '@/stores/semesters'
import { type StudentRecord, useStudentStore } from '@/stores/students'
// 下拉選項:系所/年級/入學年度/狀態
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const { smAndUp } = useDisplay()
const isMobile = computed(() => !smAndUp.value)
// 表格欄位設定(含固定欄與排序)
const tableHeaders = [
{
title: '學號',
key: 'studentId',
sortable: true,
fixed: smAndUp.value && ('start' as const),
width: 120,
},
{
title: '姓名',
key: 'name',
sortable: true,
fixed: smAndUp.value && ('start' as const),
width: 100,
},
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '電話', key: 'phone', sortable: true, width: 140 },
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{
title: '操作',
key: 'actions',
sortable: false,
fixed: smAndUp.value && ('end' as const),
width: 'auto',
cellProps: { class: 'px-0 bg-background' },
},
]
// 查詢條件(示意用,未接 API)
const search = ref({
studentId: '',
name: '',
department: '',
grade: null as number | null,
status: '',
})
// 查詢區塊是否展開
const searchPanelOpen = ref(false)
// 透過 store 管理 Demo 資料與 CRUD
const studentStore = useStudentStore()
const semesterStore = useSemesterStore()
const students = computed(() => studentStore.students)
type StudentPayload = Omit<StudentRecord, 'id'>
const itemsPerPage = 10
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = students.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
// 彈窗狀態與流程控制
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const isLoading = ref(false)
const isSaving = ref(false)
// UI 回饋
const snackbarVisible = ref(false)
const highlightedId = ref<number | null>(null)
// 防止快速切換導致的異步覆蓋
const loadSequence = ref(0)
// Master-Detail 擴充
const studentSemesters = ref<SemesterRecord[]>([])
const selectedSemesterId = ref<number | null>(null)
const activeMobilePanel = ref<'master' | 'detail'>('master')
const selectedSemester = computed(
() => studentSemesters.value.find((s) => s.id === selectedSemesterId.value) || null
)
// Detail Editing State (子檔編輯狀態管理)
const isDetailEditing = ref(false)
const detailForm = ref<SemesterRecord | null>(null)
// 輔助函式:重新載入當前學生的學期資料
function refreshSemesters() {
if (editingId.value) {
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
}
}
// 處理新增學期
function handleAddSemester() {
if (!editingId.value) return
const newSem = semesterStore.addSemester(editingId.value)
refreshSemesters()
selectedSemesterId.value = newSem.id
activeMobilePanel.value = 'detail'
startDetailEdit() // 新增後直接進入編輯模式
}
// 處理刪除學期
function handleDeleteSemester(id: number) {
// 簡單確認對話框 (實作時可改用更美觀的 Modal)
if (!confirm('確定要刪除此學期紀錄嗎?')) return
semesterStore.removeSemester(id)
refreshSemesters()
// 若刪除的是當前選取的學期,則取消選取並關閉編輯模式
if (selectedSemesterId.value === id) {
selectedSemesterId.value = null
isDetailEditing.value = false
activeMobilePanel.value = 'master'
}
}
// 開始編輯子檔 (複製資料到暫存表單)
function startDetailEdit() {
if (!selectedSemester.value) return
// 需要深拷貝來避免直接改到原始資料,且保留巢狀結構的語意
detailForm.value = structuredClone(selectedSemester.value)
isDetailEditing.value = true
}
// 取消編輯子檔
function cancelDetailEdit() {
isDetailEditing.value = false
detailForm.value = null
if (isMobile.value && selectedSemesterId.value === null) {
activeMobilePanel.value = 'master'
}
}
// 儲存子檔變更
function saveDetailEdit() {
if (!detailForm.value || !detailForm.value.id) return
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
refreshSemesters()
isDetailEditing.value = false
detailForm.value = null
}
const {
errorSummary,
fieldErrors,
form,
isDirty,
saveSummary,
clearAllErrors,
clearFieldError,
gradeLabel,
resetForm,
rowProps,
setForm,
statusColor,
syncInitialForm,
validateForm,
} = useStudentMaintenanceForm({
departments,
gradeOptions,
enrollYears,
statuses,
students,
editingId,
highlightedId,
})
// 表單欄位簡單排版(無分組)
// 彈窗標題/副標題
const dialogTitle = computed(() => {
if (dialogMode.value === 'view') return '檢視主檔資料示範'
if (dialogMode.value === 'edit') return '修改主檔資料示範'
return '新增主檔資料示範'
})
const dialogSubtitle = computed(() => {
if (!editingId.value) return ''
return `${form.value.studentId || '未填學號'}${form.value.name || '未填姓名'}`
})
// 是否有修改(用於啟用儲存與提示)
// 載入/儲存時鎖定、檢視模式 readonly
const isFormLocked = computed(() => isLoading.value || isSaving.value)
const {
confirmClose,
confirmCloseVisible,
confirmDelete,
confirmDeleteVisible,
confirmNavigate,
confirmNavigateVisible,
confirmSaveVisible,
confirmSwitch,
confirmSwitchVisible,
handleDialogVisibility,
hasNextRecord,
hasPrevRecord,
isEditMode,
isViewMode,
openAdjacentRecord,
openEdgeRecord,
pendingDeleteLabel,
requestCloseDialog,
requestDeleteConfirmation,
requestDeleteCurrent,
switchToEditMode,
switchToViewMode,
} = useMaintenanceCrudFlow<StudentRecord>({
records: students,
editingId,
dialogMode,
dialogVisible,
isLoading,
isSaving,
isDirty,
clearAllErrors,
resetForm,
openEditDialog,
openViewDialog,
removeRecord: (id) => {
studentStore.removeStudent(id)
semesterStore.removeByStudentId(id)
},
describeRecord: (student) => `${student.studentId} ${student.name}`,
onCloseReset: () => {
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
isDetailEditing.value = false
detailForm.value = null
},
})
const isFormReadonly = computed(() => isViewMode.value)
// 重設查詢條件
function resetSearch() {
search.value = {
studentId: '',
name: '',
department: '',
grade: null,
status: '',
}
}
watch(pageCount, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
// 新增:開啟彈窗,使用預設值
function openAddDialog() {
loadSequence.value += 1
dialogMode.value = 'create'
editingId.value = null
studentSemesters.value = []
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
resetForm()
isDetailEditing.value = false
isLoading.value = false
dialogVisible.value = true
}
// 編輯:先開彈窗,資料載入後填入
function openEditDialog(student: StudentRecord) {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = 'edit'
editingId.value = student.id
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
dialogVisible.value = true
isLoading.value = true
clearAllErrors()
setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
setForm({
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
})
syncInitialForm()
isLoading.value = false
}, 350)
}
// 檢視:只讀模式並預設展開所有分組
function openViewDialog(student: StudentRecord) {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = 'view'
editingId.value = student.id
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
dialogVisible.value = true
isLoading.value = true
clearAllErrors()
setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
setForm({
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
})
syncInitialForm()
isLoading.value = false
}, 350)
}
// 先檢核再提示儲存確認
async function requestSaveConfirmation() {
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
clearAllErrors()
const errors = validateForm()
if (errors.length > 0) {
for (const error of errors) {
fieldErrors.value[error.field] = [error.message]
}
await nextTick()
const firstError = errors[0]
if (firstError) scrollToField(firstError.field)
return
}
confirmSaveVisible.value = true
}
// 儲存確認後才真正送出
function confirmSave() {
confirmSaveVisible.value = false
saveStudent()
}
// 寫入資料(Demo:直接更新列表)
async function saveStudent() {
if (isSaving.value || isLoading.value) return
isSaving.value = true
await new Promise((resolve) => setTimeout(resolve, 450))
const payload = {
studentId: form.value.studentId.trim(),
name: form.value.name.trim(),
department: form.value.department,
grade: form.value.grade,
enrollYear: form.value.enrollYear,
credits: form.value.credits,
advisor: form.value.advisor.trim(),
email: form.value.email.trim(),
phone: form.value.phone.trim(),
status: form.value.status,
} as StudentPayload
if (editingId.value) {
const updated = studentStore.updateStudent(editingId.value, payload)
if (updated) highlightedId.value = editingId.value
} else {
const createdId = studentStore.addStudent(payload)
semesterStore.generateForStudent(createdId)
highlightedId.value = createdId
}
syncInitialForm()
dialogVisible.value = false
snackbarVisible.value = true
isSaving.value = false
window.setTimeout(() => {
highlightedId.value = null
}, 1600)
}
function scrollToField(field: string) {
const target = document.getElementById(`field-${field}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function handleSemesterSelect(id: number) {
if (isMobile.value) {
selectedSemesterId.value = id
activeMobilePanel.value = 'detail'
return
}
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
}
function closeDetailPanel() {
isDetailEditing.value = false
detailForm.value = null
if (isMobile.value) {
activeMobilePanel.value = 'master'
return
}
selectedSemesterId.value = null
}
</script>
<style scoped>
.dialog-overlay :deep(.v-overlay__content) {
height: 100vh;
display: flex;
justify-content: flex-end;
width: 100vw;
max-width: 100vw;
}
.dialog-panel {
width: auto;
max-width: 100%;
height: 100vh;
background: transparent;
padding: 0;
display: flex;
background: transparent;
padding: 0;
display: flex;
}
.dialog-panel > .v-card {
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
}
.detail-panel-wrapper {
width: 0;
opacity: 0;
overflow: hidden;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
margin-right: 0;
}
.detail-panel-wrapper.is-active {
width: 600px;
opacity: 1;
margin-right: 0;
}
.dialog-panel.is-mobile {
width: 100%;
}
.dialog-panel.is-mobile :deep(.dialog-title) {
padding: 16px 20px 12px;
}
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
padding: 8px 12px;
}
.dialog-panel.is-mobile :deep(.dialog-actions) {
gap: 8px;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
}
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
flex: 1 1 0;
min-width: 0;
}
.dialog-panel.is-mobile :deep(.v-card-text) {
padding-bottom: 88px;
}
.detail-panel-wrapper.is-mobile {
width: 100%;
opacity: 1;
overflow: visible;
}
.detail-panel-wrapper.is-mobile.is-active {
width: 100%;
}
@media (max-width: 600px) {
.dialog-panel {
width: 100%;
}
.dialog-panel > .v-card {
width: 100%;
box-shadow: none;
}
.student-table :deep(table) {
min-width: 960px;
}
}
.dialog-actions {
position: sticky;
bottom: 0;
background: rgba(var(--v-theme-surface), 0.95);
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 1;
}
.dialog-title {
position: sticky;
top: 0;
background: rgba(var(--v-theme-surface), 0.95);
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 2;
}
.student-table {
overflow: auto;
}
.student-table :deep(table) {
min-width: 1400px;
}
.student-table :deep(th),
.student-table :deep(td) {
white-space: nowrap;
}
.student-table :deep(.v-data-table-column--fixed),
.student-table :deep(.v-data-table-column--fixed-end) {
background: rgb(var(--v-theme-surface));
}
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
content: '';
position: absolute;
top: 0;
right: -5px;
bottom: 0;
width: 5px;
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
}
.student-table :deep(.v-data-table-footer) {
padding: 4px 0 0;
}
/* 直線 */
/* .student-table :deep(.v-data-table-column--first-fixed-end),
.student-table :deep(.v-data-table-column--last-fixed) {
border-left: none !important;
border-right: none !important;
} */
.form-readonly :deep(.v-field) {
pointer-events: none;
}
tbody tr.is-highlighted {
animation: row-highlight 1.6s ease-out;
}
@keyframes row-highlight {
0% {
background-color: rgba(var(--v-theme-primary), 0.18);
}
100% {
background-color: transparent;
}
}
</style>