docs: document template naming and maintenance refactor
Update agent and LLM guidance to reference the architecture strategy and add a template naming rule that keeps reusable abstractions domain-neutral. Mark maintenance Phase 3 as complete and document the page driver/page component refactors for EditableGrid and MasterDetail variants.docs: document template naming and maintenance refactor Update agent and LLM guidance to reference the architecture strategy and add a template naming rule that keeps reusable abstractions domain-neutral. Mark maintenance Phase 3 as complete and document the page driver/page component refactors for EditableGrid and MasterDetail variants.
This commit is contained in:
@@ -4,12 +4,26 @@
|
|||||||
- Follow the existing code style and patterns.
|
- Follow the existing code style and patterns.
|
||||||
- Use pnpm for running project commands.
|
- Use pnpm for running project commands.
|
||||||
- Keep code in TypeScript unless migration is required.
|
- Keep code in TypeScript unless migration is required.
|
||||||
- When refactoring or creating new components, review `docs/frontend-layering.md` first and follow its layering and responsibility guidelines.
|
- When refactoring or creating new components, review `docs/architecture-strategy.md` first and follow its layering and responsibility guidelines.
|
||||||
- When a change affects LLM editing boundaries, page creation flow, layout usage, login-page boundaries, or frontend layering rules, update `docs/llm-development-guide.md` in the same change.
|
- When a change affects LLM editing boundaries, page creation flow, layout usage, login-page boundaries, or frontend layering rules, update `docs/llm-development-guide.md` in the same change.
|
||||||
- For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause.
|
- For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause.
|
||||||
- Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits.
|
- Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits.
|
||||||
- When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient.
|
- When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient.
|
||||||
|
|
||||||
|
## Naming Generalization Rule
|
||||||
|
- This project is a **template** intended to be reused across different data domains (student, course, teacher, etc.).
|
||||||
|
- **Reusable abstractions** (Page Components, Sections, Items, generic composables, base components) **must not contain domain-specific names** (e.g., `Student`, `Course`) in their file names, type names, or export names.
|
||||||
|
- Domain-specific names are **only allowed** in:
|
||||||
|
- `src/models/<domain>.ts` — domain models
|
||||||
|
- `src/stores/<domain>.ts` — domain stores
|
||||||
|
- `src/services/modules/<domain>.ts` — service modules
|
||||||
|
- Examples of correct vs. incorrect naming:
|
||||||
|
- ❌ `PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
|
||||||
|
- ❌ `useStudentMaintenancePage.ts` → ✅ `useMaintenancePage.ts`
|
||||||
|
- ❌ `ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
|
||||||
|
- ❌ `useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
|
||||||
|
- ✅ `models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
- Framework: Vue 3 + Vite
|
- Framework: Vue 3 + Vite
|
||||||
- UI Library: Vuetify
|
- UI Library: Vuetify
|
||||||
|
|||||||
@@ -380,10 +380,22 @@ views/xxx.vue
|
|||||||
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
||||||
- `SingleRecord.vue` 不再直接操作 `studentStore`。
|
- `SingleRecord.vue` 不再直接操作 `studentStore`。
|
||||||
|
|
||||||
### Phase 3:推廣到所有 maintenance 頁面
|
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
|
||||||
|
|
||||||
1. `EditableGrid.vue`、`MasterDetailA/B/C.vue` 依同樣模式重構。
|
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
|
||||||
2. 建立通用的 `useCrudPageDriver()` 與 `useCrudCommands()`,減少重複。
|
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
|
||||||
|
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`。
|
||||||
|
- 新增 `src/components/pages/PageEditableGridMaintenance.vue`,保留既有 `src/components/maint/EditableGrid.vue` 作為主要內容元件。
|
||||||
|
2. [x] `MasterDetailA.vue` 依 Page Driver + Page Component 模式重構。
|
||||||
|
- `src/views/maint/MasterDetailA.vue` 縮減為 34 行。
|
||||||
|
- 新增 `src/composables/page-drivers/useMasterDetailAMaintenancePage.ts`。
|
||||||
|
- 新增 `src/components/pages/PageMasterDetailAMaintenance.vue` 承接原本主從維護 UI。
|
||||||
|
3. [x] `MasterDetailB.vue`、`MasterDetailC.vue` 依 Page Driver + Page Component 模式重構。
|
||||||
|
- `src/views/maint/MasterDetailB.vue` 與 `src/views/maint/MasterDetailC.vue` 均縮減為 10 行。
|
||||||
|
- 新增 `src/composables/page-drivers/useMasterDetailBMaintenancePage.ts`、`src/composables/page-drivers/useMasterDetailCMaintenancePage.ts`。
|
||||||
|
- 新增 `src/components/pages/PageMasterDetailBMaintenance.vue`、`src/components/pages/PageMasterDetailCMaintenance.vue`。
|
||||||
|
4. [x] 通用方向已落地為「每頁 page driver + page component」與既有 `useCrudCommands()`。
|
||||||
|
- Phase 3 未再抽出跨 A/B/C 的大型共用 driver,避免在 demo 變體尚未收斂前建立過早抽象。
|
||||||
|
|
||||||
### Phase 4:非 maintenance 頁面統一
|
### Phase 4:非 maintenance 頁面統一
|
||||||
|
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
|
|||||||
- 新 route:參考 `src/router/routes.ts`
|
- 新 route:參考 `src/router/routes.ts`
|
||||||
- 一般被主 layout 包住的頁面:參考 `src/views/Home.vue`、`src/views/maint/EditableGrid.vue`
|
- 一般被主 layout 包住的頁面:參考 `src/views/Home.vue`、`src/views/maint/EditableGrid.vue`
|
||||||
- 登入相關 UI:參考 `src/components/PageLogin.vue` 與 `src/components/login/*`
|
- 登入相關 UI:參考 `src/components/PageLogin.vue` 與 `src/components/login/*`
|
||||||
- 維護頁:優先參考 `src/views/maint/SingleRecord.vue`、`src/components/pages/PageMaintenance.vue`、`src/components/sections/*`、`src/components/items/*`、`src/composables/page-drivers/useSingleRecordMaintenancePage.ts`、`src/composables/commands/useCrudCommands.ts`
|
- 維護頁:優先參考 `src/views/maint/SingleRecord.vue`、`src/views/maint/EditableGrid.vue`、`src/views/maint/MasterDetailA.vue`、`src/views/maint/MasterDetailB.vue`、`src/views/maint/MasterDetailC.vue` 與對應的 `src/components/pages/*Maintenance.vue`、`src/composables/page-drivers/*MaintenancePage.ts`
|
||||||
- 舊維護頁:`EditableGrid.vue`、`MasterDetailA/B/C.vue` 尚未套用完整 Page Driver + Section + Item 分層,參考前先確認是否正在做 Phase 3 遷移。
|
- 單筆維護欄位/表格/dialog 拆分:參考 `src/components/sections/*`、`src/components/items/*`、`src/composables/commands/useCrudCommands.ts`
|
||||||
- 維護頁範本選擇:參考 `src/views/maint/README.md`
|
- 維護頁範本選擇:參考 `src/views/maint/README.md`
|
||||||
- API 呼叫:參考 `src/services/modules/*` 與使用它們的 store/composable
|
- API 呼叫:參考 `src/services/modules/*` 與使用它們的 store/composable
|
||||||
- 全域提示:參考 `src/stores/snackbar.ts` 與 `src/composables/useApiCall.ts`
|
- 全域提示:參考 `src/stores/snackbar.ts` 與 `src/composables/useApiCall.ts`
|
||||||
@@ -255,6 +255,10 @@ route 集中放在 `src/router/routes.ts`。不要在 view 或 component 裡臨
|
|||||||
- `src/composables/layout/useThemeToggle.ts`:提供主題切換流程
|
- `src/composables/layout/useThemeToggle.ts`:提供主題切換流程
|
||||||
- `src/composables/page-drivers/useMaintenancePage.ts`:提供通用 maintenance page model 基礎狀態
|
- `src/composables/page-drivers/useMaintenancePage.ts`:提供通用 maintenance page model 基礎狀態
|
||||||
- `src/composables/page-drivers/useSingleRecordMaintenancePage.ts`:協調單筆維護 demo 頁面的 page model、section props/events、表單、表格與 command
|
- `src/composables/page-drivers/useSingleRecordMaintenancePage.ts`:協調單筆維護 demo 頁面的 page model、section props/events、表單、表格與 command
|
||||||
|
- `src/composables/page-drivers/useEditableGridMaintenancePage.ts`:協調可編輯表格 demo 頁面的 page model
|
||||||
|
- `src/composables/page-drivers/useMasterDetailAMaintenancePage.ts`:協調主從維護 A demo 的 page model、主檔/明細狀態與 dialog event wiring
|
||||||
|
- `src/composables/page-drivers/useMasterDetailBMaintenancePage.ts`:協調主從維護 B demo 的 page model、主檔/明細狀態與 dialog event wiring
|
||||||
|
- `src/composables/page-drivers/useMasterDetailCMaintenancePage.ts`:協調主從維護 C demo 的 page model、主檔/明細狀態與 dialog event wiring
|
||||||
- `src/composables/commands/useCrudCommands.ts`:提供 CRUD command 流程,讓 view 不直接執行 store mutation 細節
|
- `src/composables/commands/useCrudCommands.ts`:提供 CRUD command 流程,讓 view 不直接執行 store mutation 細節
|
||||||
- `src/composables/maint/useMaintenanceCrudFlow.ts`:提供維護頁 CRUD 流程狀態
|
- `src/composables/maint/useMaintenanceCrudFlow.ts`:提供維護頁 CRUD 流程狀態
|
||||||
- `src/composables/maint/useStudentMaintenanceForm.ts`:提供學生維護表單狀態
|
- `src/composables/maint/useStudentMaintenanceForm.ts`:提供學生維護表單狀態
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<v-card variant="flat">
|
<v-card variant="flat">
|
||||||
<v-card-title class="d-flex flex-wrap align-center py-0 ga-2">
|
<v-card-title class="d-flex flex-wrap align-center py-0 ga-2">
|
||||||
<span class="text-h6">可編輯表格維護示範</span>
|
<span class="text-h6">{{ title }}</span>
|
||||||
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
|
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
|
||||||
{{ hasAnyChange ? '有未儲存變更' : '已同步' }}
|
{{ hasAnyChange ? '有未儲存變更' : '已同步' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -377,6 +377,15 @@ import { computed, ref, watch } from 'vue'
|
|||||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||||
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
|
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: '可編輯表格維護示範',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
departments,
|
departments,
|
||||||
enrollYears,
|
enrollYears,
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
page: MaintenancePageModel
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<EditableStudentGrid :title="page.title" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
||||||
|
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
||||||
|
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
|
||||||
|
import type {
|
||||||
|
SaveSummaryItem,
|
||||||
|
StudentFormState,
|
||||||
|
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
import type { SemesterRecord } from '@/stores/semesters'
|
||||||
|
import type { StudentRecord } from '@/stores/students'
|
||||||
|
|
||||||
|
interface FieldErrorItem {
|
||||||
|
field: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GradeOption {
|
||||||
|
title: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
activeMobilePanel: 'master' | 'detail'
|
||||||
|
confirmCloseVisible: boolean
|
||||||
|
confirmDeleteVisible: boolean
|
||||||
|
confirmNavigateVisible: boolean
|
||||||
|
confirmSaveVisible: boolean
|
||||||
|
confirmSwitchVisible: boolean
|
||||||
|
currentPage: number
|
||||||
|
departments: string[]
|
||||||
|
detailForm: SemesterRecord | null
|
||||||
|
dialogSubtitle: string
|
||||||
|
dialogTitle: string
|
||||||
|
dialogVisible: boolean
|
||||||
|
enrollYears: number[]
|
||||||
|
errorSummary: FieldErrorItem[]
|
||||||
|
fieldErrors: Record<keyof StudentFormState, string[]>
|
||||||
|
gradeLabel: (grade: number) => string
|
||||||
|
gradeOptions: GradeOption[]
|
||||||
|
hasNextRecord: boolean
|
||||||
|
hasPrevRecord: boolean
|
||||||
|
headers: any[]
|
||||||
|
isDetailEditing: boolean
|
||||||
|
isDirty: boolean
|
||||||
|
isEditMode: boolean
|
||||||
|
isFormLocked: boolean
|
||||||
|
isFormReadonly: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
isMobile: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
isViewMode: boolean
|
||||||
|
items: StudentRecord[]
|
||||||
|
itemsPerPage: number
|
||||||
|
page: MaintenancePageModel
|
||||||
|
pageCount: number
|
||||||
|
pageSummary: string
|
||||||
|
pendingDeleteLabel: string
|
||||||
|
rowProps: (data: { item: StudentRecord }) => Record<string, string>
|
||||||
|
saveSummary: SaveSummaryItem[]
|
||||||
|
selectedSemester: SemesterRecord | null
|
||||||
|
selectedSemesterId: number | null
|
||||||
|
semesters: SemesterRecord[]
|
||||||
|
statusColor: (status: string) => string
|
||||||
|
statuses: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = defineModel<StudentFormState>('form', { required: true })
|
||||||
|
const detailFormModel = defineModel<SemesterRecord | null>('detailForm', { required: true })
|
||||||
|
const search = defineModel<{
|
||||||
|
studentId: string
|
||||||
|
name: string
|
||||||
|
department: string
|
||||||
|
grade: number | null
|
||||||
|
status: string
|
||||||
|
}>('search', { required: true })
|
||||||
|
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { required: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'add-semester'): void
|
||||||
|
(e: 'cancel-detail-edit'): void
|
||||||
|
(e: 'clear-field-error', field: keyof StudentFormState): void
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'close-detail-panel'): void
|
||||||
|
(e: 'confirm-close'): void
|
||||||
|
(e: 'confirm-delete'): void
|
||||||
|
(e: 'confirm-navigate'): void
|
||||||
|
(e: 'confirm-save'): void
|
||||||
|
(e: 'confirm-switch'): void
|
||||||
|
(e: 'create'): void
|
||||||
|
(e: 'delete', record: StudentRecord): void
|
||||||
|
(e: 'delete-current'): void
|
||||||
|
(e: 'delete-semester', id: number): void
|
||||||
|
(e: 'dialog-visible-change', value: boolean): void
|
||||||
|
(e: 'edit', record: StudentRecord): void
|
||||||
|
(e: 'first'): void
|
||||||
|
(e: 'last'): void
|
||||||
|
(e: 'next'): void
|
||||||
|
(e: 'prev'): void
|
||||||
|
(e: 'reset-search'): void
|
||||||
|
(e: 'save'): void
|
||||||
|
(e: 'save-detail-edit'): void
|
||||||
|
(e: 'scroll-to-field', field: string): void
|
||||||
|
(e: 'select-semester', id: number): void
|
||||||
|
(e: 'start-detail-edit'): void
|
||||||
|
(e: 'switch-to-edit'): void
|
||||||
|
(e: 'switch-to-view'): void
|
||||||
|
(e: 'update:confirmCloseVisible', value: boolean): void
|
||||||
|
(e: 'update:confirmDeleteVisible', value: boolean): void
|
||||||
|
(e: 'update:confirmNavigateVisible', value: boolean): void
|
||||||
|
(e: 'update:confirmSaveVisible', value: boolean): void
|
||||||
|
(e: 'update:confirmSwitchVisible', value: boolean): void
|
||||||
|
(e: 'update:currentPage', page: number): void
|
||||||
|
(e: 'view', record: StudentRecord): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageMaintenance
|
||||||
|
v-model:search-panel-open="searchPanelOpen"
|
||||||
|
:page="page"
|
||||||
|
@create="emit('create')"
|
||||||
|
>
|
||||||
|
<template #search-fields>
|
||||||
|
<SectionSearchPanel
|
||||||
|
v-model="search"
|
||||||
|
:departments="departments"
|
||||||
|
:grade-options="gradeOptions"
|
||||||
|
:statuses="statuses"
|
||||||
|
@reset="emit('reset-search')"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #table>
|
||||||
|
<SectionDataTable
|
||||||
|
:current-page="currentPage"
|
||||||
|
:grade-label="gradeLabel"
|
||||||
|
:headers="headers"
|
||||||
|
:items="items"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:page-count="pageCount"
|
||||||
|
:page-summary="pageSummary"
|
||||||
|
:row-props="rowProps"
|
||||||
|
:status-color="statusColor"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
|
@update:current-page="emit('update:currentPage', $event)"
|
||||||
|
@view="emit('view', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</PageMaintenance>
|
||||||
|
|
||||||
|
<teleport to="body">
|
||||||
|
<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="emit('dialog-visible-change', $event)"
|
||||||
|
>
|
||||||
|
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
||||||
|
<div
|
||||||
|
v-if="!isMobile || activeMobilePanel === 'detail'"
|
||||||
|
class="detail-panel-wrapper"
|
||||||
|
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
|
||||||
|
>
|
||||||
|
<DetailSidePanel
|
||||||
|
v-model:detail-form="detailFormModel"
|
||||||
|
:is-detail-editing="isDetailEditing"
|
||||||
|
:is-mobile="isMobile"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
:selected-semester="selectedSemester"
|
||||||
|
@cancel-edit="emit('cancel-detail-edit')"
|
||||||
|
@close="emit('close-detail-panel')"
|
||||||
|
@delete="emit('delete-semester', $event)"
|
||||||
|
@save-edit="emit('save-detail-edit')"
|
||||||
|
@start-edit="emit('start-detail-edit')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MntDialogCard
|
||||||
|
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>
|
||||||
|
<MntRecordNavToolbar
|
||||||
|
:has-next-record="hasNextRecord"
|
||||||
|
:has-prev-record="hasPrevRecord"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
:mobile="isMobile"
|
||||||
|
@first="emit('first')"
|
||||||
|
@last="emit('last')"
|
||||||
|
@next="emit('next')"
|
||||||
|
@prev="emit('prev')"
|
||||||
|
@switch-to-edit="emit('switch-to-edit')"
|
||||||
|
@switch-to-view="emit('switch-to-view')"
|
||||||
|
/>
|
||||||
|
</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="emit('scroll-to-field', error.field)"
|
||||||
|
>
|
||||||
|
{{ error.message }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-skeleton-loader
|
||||||
|
v-if="isLoading"
|
||||||
|
class="mt-4"
|
||||||
|
type="subtitle,paragraph"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-form
|
||||||
|
v-else
|
||||||
|
:class="{ 'form-readonly': isFormReadonly }"
|
||||||
|
@submit.prevent="emit('save')"
|
||||||
|
>
|
||||||
|
<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="emit('clear-field-error', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<DetailNavigation
|
||||||
|
:is-mobile="isMobile"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
:selected-semester-id="selectedSemesterId"
|
||||||
|
:semesters="semesters"
|
||||||
|
@add="emit('add-semester')"
|
||||||
|
@select="emit('select-semester', $event)"
|
||||||
|
/>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<template v-if="isMobile">
|
||||||
|
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="emit('delete-current')"
|
||||||
|
>
|
||||||
|
刪除
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!isViewMode"
|
||||||
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
儲存
|
||||||
|
</v-btn>
|
||||||
|
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="isEditMode"
|
||||||
|
color="error"
|
||||||
|
:disabled="isSaving"
|
||||||
|
variant="tonal"
|
||||||
|
@click="emit('delete-current')"
|
||||||
|
>
|
||||||
|
刪除
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="!isViewMode"
|
||||||
|
color="primary"
|
||||||
|
:disabled="!isDirty || isLoading"
|
||||||
|
:loading="isSaving"
|
||||||
|
variant="flat"
|
||||||
|
@click="emit('save')"
|
||||||
|
>
|
||||||
|
儲存
|
||||||
|
</v-btn>
|
||||||
|
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</MntDialogCard>
|
||||||
|
</div>
|
||||||
|
</v-overlay>
|
||||||
|
</teleport>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:model-value="confirmCloseVisible"
|
||||||
|
confirm-color="error"
|
||||||
|
confirm-text="關閉不儲存"
|
||||||
|
message="目前有尚未儲存的內容,確定要關閉嗎?"
|
||||||
|
title="未儲存變更"
|
||||||
|
@confirm="emit('confirm-close')"
|
||||||
|
@update:model-value="emit('update:confirmCloseVisible', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:model-value="confirmSaveVisible"
|
||||||
|
:confirm-loading="isSaving"
|
||||||
|
confirm-text="確認儲存"
|
||||||
|
max-width="520"
|
||||||
|
title="確認儲存變更"
|
||||||
|
@confirm="emit('confirm-save')"
|
||||||
|
@update:model-value="emit('update:confirmSaveVisible', $event)"
|
||||||
|
>
|
||||||
|
<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
|
||||||
|
:model-value="confirmDeleteVisible"
|
||||||
|
confirm-color="error"
|
||||||
|
confirm-text="確定刪除"
|
||||||
|
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
|
||||||
|
title="確認刪除"
|
||||||
|
@confirm="emit('confirm-delete')"
|
||||||
|
@update:model-value="emit('update:confirmDeleteVisible', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:model-value="confirmSwitchVisible"
|
||||||
|
confirm-text="確定切換"
|
||||||
|
max-width="480"
|
||||||
|
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
|
||||||
|
title="未儲存變更"
|
||||||
|
@confirm="emit('confirm-switch')"
|
||||||
|
@update:model-value="emit('update:confirmSwitchVisible', $event)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:model-value="confirmNavigateVisible"
|
||||||
|
confirm-text="確定切換"
|
||||||
|
max-width="480"
|
||||||
|
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
|
||||||
|
title="未儲存變更"
|
||||||
|
@confirm="emit('confirm-navigate')"
|
||||||
|
@update:model-value="emit('update:confirmNavigateVisible', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-readonly :deep(.v-field) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dialog-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel > .v-card {
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
import { useStudentStore } from '@/stores/students'
|
||||||
|
|
||||||
|
export function useEditableGridMaintenancePage() {
|
||||||
|
const studentStore = useStudentStore()
|
||||||
|
|
||||||
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||||
|
type: 'maintenance',
|
||||||
|
title: '可編輯表格維護示範',
|
||||||
|
records: studentStore.students,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,407 @@
|
|||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||||
|
import {
|
||||||
|
type StudentFormState,
|
||||||
|
useStudentMaintenanceForm,
|
||||||
|
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
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 itemsPerPage = 10
|
||||||
|
|
||||||
|
type StudentPayload = Omit<StudentRecord, 'id'>
|
||||||
|
|
||||||
|
function toFormPayload(student: StudentRecord): StudentFormState {
|
||||||
|
return {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSavePayload(form: StudentFormState): StudentPayload {
|
||||||
|
return {
|
||||||
|
studentId: form.studentId.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
department: form.department,
|
||||||
|
grade: form.grade,
|
||||||
|
enrollYear: form.enrollYear,
|
||||||
|
credits: form.credits,
|
||||||
|
advisor: form.advisor.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
status: form.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMasterDetailAMaintenancePage() {
|
||||||
|
const studentStore = useStudentStore()
|
||||||
|
const semesterStore = useSemesterStore()
|
||||||
|
const students = computed(() => studentStore.students)
|
||||||
|
const { smAndUp } = useDisplay()
|
||||||
|
const isMobile = computed(() => !smAndUp.value)
|
||||||
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||||
|
type: 'maintenance',
|
||||||
|
title: '主從資料維護示範A',
|
||||||
|
records: students.value,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
const search = ref({ studentId: '', name: '', department: '', grade: null as number | null, status: '' })
|
||||||
|
const searchPanelOpen = ref(false)
|
||||||
|
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)
|
||||||
|
const snackbarVisible = ref(false)
|
||||||
|
const highlightedId = ref<number | null>(null)
|
||||||
|
const loadSequence = ref(0)
|
||||||
|
const studentSemesters = ref<SemesterRecord[]>([])
|
||||||
|
const selectedSemesterId = ref<number | null>(null)
|
||||||
|
const activeMobilePanel = ref<'master' | 'detail'>('master')
|
||||||
|
const selectedSemester = computed(
|
||||||
|
() => studentSemesters.value.find((semester) => semester.id === selectedSemesterId.value) || null
|
||||||
|
)
|
||||||
|
const isDetailEditing = ref(false)
|
||||||
|
const detailForm = ref<SemesterRecord | null>(null)
|
||||||
|
const formState = useStudentMaintenanceForm({
|
||||||
|
departments,
|
||||||
|
gradeOptions,
|
||||||
|
enrollYears,
|
||||||
|
statuses,
|
||||||
|
students,
|
||||||
|
editingId,
|
||||||
|
highlightedId,
|
||||||
|
})
|
||||||
|
const isFormLocked = computed(() => isLoading.value || isSaving.value)
|
||||||
|
const tableHeaders = computed(() => [
|
||||||
|
{ 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' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
function resetDetailState() {
|
||||||
|
selectedSemesterId.value = null
|
||||||
|
activeMobilePanel.value = 'master'
|
||||||
|
isDetailEditing.value = false
|
||||||
|
detailForm.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSemesters() {
|
||||||
|
if (editingId.value) {
|
||||||
|
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddSemester() {
|
||||||
|
if (!editingId.value) return
|
||||||
|
const newSemester = semesterStore.addSemester(editingId.value)
|
||||||
|
refreshSemesters()
|
||||||
|
selectedSemesterId.value = newSemester.id
|
||||||
|
activeMobilePanel.value = 'detail'
|
||||||
|
startDetailEdit()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDeleteSemester(id: number) {
|
||||||
|
if (!confirm('確定要刪除此學期紀錄嗎?')) return
|
||||||
|
semesterStore.removeSemester(id)
|
||||||
|
refreshSemesters()
|
||||||
|
if (selectedSemesterId.value === id) {
|
||||||
|
resetDetailState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?.id) return
|
||||||
|
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
|
||||||
|
refreshSemesters()
|
||||||
|
isDetailEditing.value = false
|
||||||
|
detailForm.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAddDialog() {
|
||||||
|
loadSequence.value += 1
|
||||||
|
dialogMode.value = 'create'
|
||||||
|
editingId.value = null
|
||||||
|
studentSemesters.value = []
|
||||||
|
resetDetailState()
|
||||||
|
formState.resetForm()
|
||||||
|
isLoading.value = false
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecord(student: StudentRecord, mode: 'edit' | 'view') {
|
||||||
|
loadSequence.value += 1
|
||||||
|
const sequence = loadSequence.value
|
||||||
|
dialogMode.value = mode
|
||||||
|
editingId.value = student.id
|
||||||
|
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
|
||||||
|
resetDetailState()
|
||||||
|
dialogVisible.value = true
|
||||||
|
isLoading.value = true
|
||||||
|
formState.clearAllErrors()
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
||||||
|
formState.setForm(toFormPayload(student))
|
||||||
|
formState.syncInitialForm()
|
||||||
|
isLoading.value = false
|
||||||
|
}, 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(student: StudentRecord) {
|
||||||
|
loadRecord(student, 'edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openViewDialog(student: StudentRecord) {
|
||||||
|
loadRecord(student, 'view')
|
||||||
|
}
|
||||||
|
|
||||||
|
const flow = useMaintenanceCrudFlow<StudentRecord>({
|
||||||
|
records: students,
|
||||||
|
editingId,
|
||||||
|
dialogMode,
|
||||||
|
dialogVisible,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
isDirty: formState.isDirty,
|
||||||
|
clearAllErrors: formState.clearAllErrors,
|
||||||
|
resetForm: formState.resetForm,
|
||||||
|
openEditDialog,
|
||||||
|
openViewDialog,
|
||||||
|
removeRecord: (id) => {
|
||||||
|
studentStore.removeStudent(id)
|
||||||
|
semesterStore.removeByStudentId(id)
|
||||||
|
},
|
||||||
|
describeRecord: (student) => `${student.studentId} ${student.name}`,
|
||||||
|
onCloseReset: resetDetailState,
|
||||||
|
})
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (dialogMode.value === 'view') return '檢視主檔資料示範'
|
||||||
|
if (dialogMode.value === 'edit') return '修改主檔資料示範'
|
||||||
|
return '新增主檔資料示範'
|
||||||
|
})
|
||||||
|
const dialogSubtitle = computed(() => {
|
||||||
|
if (!editingId.value) return ''
|
||||||
|
return `${formState.form.value.studentId || '未填學號'}・${formState.form.value.name || '未填姓名'}`
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(pageCount, (value) => {
|
||||||
|
if (currentPage.value > value) currentPage.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSaveConfirmation() {
|
||||||
|
if (isSaving.value || isLoading.value || !formState.isDirty.value || flow.isViewMode.value) return
|
||||||
|
formState.clearAllErrors()
|
||||||
|
const errors = formState.validateForm()
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const error of errors) {
|
||||||
|
formState.fieldErrors.value[error.field] = [error.message]
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const firstError = errors[0]
|
||||||
|
if (firstError) scrollToField(firstError.field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flow.confirmSaveVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSave() {
|
||||||
|
flow.confirmSaveVisible.value = false
|
||||||
|
await saveStudent()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveStudent() {
|
||||||
|
if (isSaving.value || isLoading.value) return
|
||||||
|
isSaving.value = true
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, 450))
|
||||||
|
const payload = toSavePayload(formState.form.value)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
formState.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
|
||||||
|
}
|
||||||
|
|
||||||
|
const masterDetailProps = computed(() => ({
|
||||||
|
activeMobilePanel: activeMobilePanel.value,
|
||||||
|
confirmCloseVisible: flow.confirmCloseVisible.value,
|
||||||
|
confirmDeleteVisible: flow.confirmDeleteVisible.value,
|
||||||
|
confirmNavigateVisible: flow.confirmNavigateVisible.value,
|
||||||
|
confirmSaveVisible: flow.confirmSaveVisible.value,
|
||||||
|
confirmSwitchVisible: flow.confirmSwitchVisible.value,
|
||||||
|
departments,
|
||||||
|
detailForm: detailForm.value,
|
||||||
|
dialogSubtitle: dialogSubtitle.value,
|
||||||
|
dialogTitle: dialogTitle.value,
|
||||||
|
dialogVisible: dialogVisible.value,
|
||||||
|
enrollYears,
|
||||||
|
errorSummary: formState.errorSummary.value,
|
||||||
|
fieldErrors: formState.fieldErrors.value,
|
||||||
|
form: formState.form.value,
|
||||||
|
gradeOptions,
|
||||||
|
hasNextRecord: flow.hasNextRecord.value,
|
||||||
|
hasPrevRecord: flow.hasPrevRecord.value,
|
||||||
|
isDetailEditing: isDetailEditing.value,
|
||||||
|
isDirty: formState.isDirty.value,
|
||||||
|
isEditMode: flow.isEditMode.value,
|
||||||
|
isFormLocked: isFormLocked.value,
|
||||||
|
isFormReadonly: flow.isViewMode.value,
|
||||||
|
isLoading: isLoading.value,
|
||||||
|
isMobile: isMobile.value,
|
||||||
|
isSaving: isSaving.value,
|
||||||
|
isViewMode: flow.isViewMode.value,
|
||||||
|
pendingDeleteLabel: flow.pendingDeleteLabel.value,
|
||||||
|
saveSummary: formState.saveSummary.value,
|
||||||
|
selectedSemester: selectedSemester.value,
|
||||||
|
selectedSemesterId: selectedSemesterId.value,
|
||||||
|
semesters: studentSemesters.value,
|
||||||
|
statuses,
|
||||||
|
}))
|
||||||
|
const masterDetailEvents = {
|
||||||
|
'add-semester': handleAddSemester,
|
||||||
|
'cancel-detail-edit': cancelDetailEdit,
|
||||||
|
'clear-field-error': formState.clearFieldError,
|
||||||
|
close: flow.requestCloseDialog,
|
||||||
|
'close-detail-panel': closeDetailPanel,
|
||||||
|
'confirm-close': flow.confirmClose,
|
||||||
|
'confirm-delete': flow.confirmDelete,
|
||||||
|
'confirm-navigate': flow.confirmNavigate,
|
||||||
|
'confirm-save': confirmSave,
|
||||||
|
'confirm-switch': flow.confirmSwitch,
|
||||||
|
delete: flow.requestDeleteConfirmation,
|
||||||
|
'delete-current': flow.requestDeleteCurrent,
|
||||||
|
'delete-semester': handleDeleteSemester,
|
||||||
|
'dialog-visible-change': flow.handleDialogVisibility,
|
||||||
|
first: () => flow.openEdgeRecord('first'),
|
||||||
|
last: () => flow.openEdgeRecord('last'),
|
||||||
|
next: () => flow.openAdjacentRecord('next'),
|
||||||
|
prev: () => flow.openAdjacentRecord('prev'),
|
||||||
|
save: requestSaveConfirmation,
|
||||||
|
'save-detail-edit': saveDetailEdit,
|
||||||
|
'scroll-to-field': scrollToField,
|
||||||
|
'select-semester': handleSemesterSelect,
|
||||||
|
'start-detail-edit': startDetailEdit,
|
||||||
|
'switch-to-edit': flow.switchToEditMode,
|
||||||
|
'switch-to-view': flow.switchToViewMode,
|
||||||
|
'update:confirmCloseVisible': (value: boolean) => (flow.confirmCloseVisible.value = value),
|
||||||
|
'update:confirmDeleteVisible': (value: boolean) => (flow.confirmDeleteVisible.value = value),
|
||||||
|
'update:confirmNavigateVisible': (value: boolean) => (flow.confirmNavigateVisible.value = value),
|
||||||
|
'update:confirmSaveVisible': (value: boolean) => (flow.confirmSaveVisible.value = value),
|
||||||
|
'update:confirmSwitchVisible': (value: boolean) => (flow.confirmSwitchVisible.value = value),
|
||||||
|
'update:detailForm': (value: SemesterRecord | null) => (detailForm.value = value),
|
||||||
|
'update:form': (value: StudentFormState) => (formState.form.value = value),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage,
|
||||||
|
departments,
|
||||||
|
formState,
|
||||||
|
gradeOptions,
|
||||||
|
itemsPerPage,
|
||||||
|
masterDetailEvents,
|
||||||
|
masterDetailProps,
|
||||||
|
openAddDialog,
|
||||||
|
openEditDialog,
|
||||||
|
openViewDialog,
|
||||||
|
pageCount,
|
||||||
|
pageModel,
|
||||||
|
pageSummary,
|
||||||
|
resetSearch,
|
||||||
|
search,
|
||||||
|
searchPanelOpen,
|
||||||
|
snackbarVisible,
|
||||||
|
statuses,
|
||||||
|
students,
|
||||||
|
tableHeaders,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
import { useStudentStore } from '@/stores/students'
|
||||||
|
|
||||||
|
export function useMasterDetailBMaintenancePage() {
|
||||||
|
const studentStore = useStudentStore()
|
||||||
|
|
||||||
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||||
|
type: 'maintenance',
|
||||||
|
title: '主從資料維護示範B',
|
||||||
|
records: studentStore.students,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
import { useStudentStore } from '@/stores/students'
|
||||||
|
|
||||||
|
export function useMasterDetailCMaintenancePage() {
|
||||||
|
const studentStore = useStudentStore()
|
||||||
|
|
||||||
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||||
|
type: 'maintenance',
|
||||||
|
title: '主從資料維護示範C',
|
||||||
|
records: studentStore.students,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
pageModel,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
|
||||||
<EditableStudentGrid />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
|
import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue'
|
||||||
|
import { useEditableGridMaintenancePage } from '@/composables/page-drivers/useEditableGridMaintenancePage'
|
||||||
|
|
||||||
|
const page = useEditableGridMaintenancePage()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PageEditableGridMaintenance :page="page.pageModel.value" />
|
||||||
|
</template>
|
||||||
|
|||||||
+28
-1022
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user