docs(pages): clarify page driver component boundaries

Add inline comments to page components documenting how page models,
v-model state, and emitted user intents flow through the page driver.

This clarifies that page components remain presentation-focused while
routing, dialog state, CRUD side effects, and command handling stay in
the page driver or related composables.docs(pages): clarify page driver component boundaries

Add inline comments to page components documenting how page models,
v-model state, and emitted user intents flow through the page driver.

This clarifies that page components remain presentation-focused while
routing, dialog state, CRUD side effects, and command handling stay in
the page driver or related composables.
This commit is contained in:
skytek_xinliang
2026-05-22 15:09:54 +08:00
parent 9e8cf28d77
commit cad44db4c7
15 changed files with 556 additions and 1 deletions
@@ -8,5 +8,6 @@ defineProps<{
</script>
<template>
<!-- Page component 接收 page model再把頁面標題轉交給既有 editable grid feature component -->
<EditableStudentGrid :title="page.title" />
</template>
+1
View File
@@ -7,6 +7,7 @@ defineProps<{
</script>
<template>
<!-- Page component 只呈現 page driver 解析後的功能代碼不直接讀 route params -->
<v-sheet height="100%" width="100%">
{{ page.fncId }}
</v-sheet>
+3
View File
@@ -7,16 +7,19 @@ defineProps<{
selectedNews: HomeNewsItem | null
}>()
// 首頁互動都往上 emit,讓 page driver 統一處理 dialog、訊息中心與 snackbar。
const emit = defineEmits<{
news: [item: HomeNewsItem]
'message-center': []
quick: [item: HomeQuickItem]
}>()
// 新聞 dialog 開關是 PageIndex 的雙向 UI 狀態,由 view/page driver 持有。
const isNewsDialogOpen = defineModel<boolean>('newsDialogOpen', { default: false })
</script>
<template>
<!-- PageHome 作為 page component page model 拆給既有 PageIndex 外殼與事件 -->
<PageIndex
v-model:is-news-dialog-open="isNewsDialogOpen"
:news-items="page.newsItems"
+3
View File
@@ -6,6 +6,7 @@ defineProps<{
page: MaintenancePageModel
}>()
// PageMaintenance 只轉發維護頁使用者意圖,CRUD 副作用交給 page driver / command composable。
const emit = defineEmits<{
(e: 'create'): void
(e: 'edit', record: unknown): void
@@ -14,10 +15,12 @@ const emit = defineEmits<{
(e: 'search', criteria: Record<string, unknown>): void
}>()
// 搜尋面板開關是頁面 UI 狀態,用 v-model 交回 view/page driver。
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { default: false })
</script>
<template>
<!-- PageMaint 提供維護頁外殼搜尋欄位與表格內容由 slot 交給各頁組合 -->
<PageMaint
:title="page.title"
:search-panel-open="searchPanelOpen"
@@ -73,6 +73,7 @@ defineProps<{
const form = defineModel<StudentFormState>('form', { required: true })
const detailFormModel = defineModel<SemesterRecord | null>('detailForm', { required: true })
// 主檔表單、子檔表單與搜尋條件都由 page driver 持有,Page component 只透過 v-model 回寫。
const search = defineModel<{
studentId: string
name: string
@@ -82,6 +83,7 @@ const search = defineModel<{
}>('search', { required: true })
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { required: true })
// 主從維護頁的 CRUD 與導覽意圖都往上 emit,讓 page driver / command composable 統一處理。
const emit = defineEmits<{
(e: 'add-semester'): void
(e: 'cancel-detail-edit'): void
@@ -122,11 +124,13 @@ const emit = defineEmits<{
</script>
<template>
<!-- PageMaintenance 提供維護頁外殼主從頁在 slots 中組合搜尋表格與子檔內容 -->
<PageMaintenance
v-model:search-panel-open="searchPanelOpen"
:page="page"
@create="emit('create')"
>
<!-- 搜尋欄位沿用 SectionSearchPanel搜尋條件透過 v-model 回到 page driver -->
<template #search-fields>
<SectionSearchPanel
v-model="search"
@@ -136,6 +140,7 @@ const emit = defineEmits<{
@reset="emit('reset-search')"
/>
</template>
<!-- 主檔表格沿用 SectionDataTable列操作只 emit 使用者意圖 -->
<template #table>
<SectionDataTable
:current-page="currentPage"
@@ -1,10 +1,12 @@
<template>
<!-- Page component 組合 PageMaint 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<page-maint
:search-panel-open="searchPanelOpen"
:title="page.title"
@create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen"
>
<!-- 搜尋欄位放在 PageMaint search-fields slot讓外殼固定欄位由頁面決定 -->
<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>
@@ -78,6 +80,7 @@
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<!-- table slot 放主檔表格與列操作操作事件再交給頁面流程函式處理 -->
<template #table>
<v-data-table
v-model:page="currentPage"
@@ -1,10 +1,12 @@
<template>
<!-- Page component 組合 PageMaint 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<page-maint
:search-panel-open="searchPanelOpen"
:title="page.title"
@create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen"
>
<!-- 搜尋欄位放在 PageMaint search-fields slot讓外殼固定欄位由頁面決定 -->
<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>
@@ -78,6 +80,7 @@
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
<!-- table slot 放主檔表格與列操作操作事件再交給頁面流程函式處理 -->
<template #table>
<v-data-table
v-model:page="currentPage"
@@ -0,0 +1,69 @@
<script setup lang="ts">
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import SectionQueryPage from '@/components/sections/SectionQueryPage.vue'
import type {
ReportFilters,
SectionsDemoPageModel,
} from '@/composables/page-drivers/useSectionsDemoPage'
defineProps<{
page: SectionsDemoPageModel
}>()
// Page component 只接收 page driver 組好的 page model;查詢條件用 v-model 回寫給 view/page driver。
const queryFilters = defineModel<ReportFilters>('queryFilters', { required: true })
// 使用者意圖往上 emit,由 page driver 決定查詢、返回等副作用。
const emit = defineEmits<{
(e: 'back'): void
(e: 'search'): void
}>()
</script>
<template>
<SectionQueryPage
back-label="回到列表"
title="查詢頁DEMO"
@back="emit('back')"
@search="emit('search')"
>
<!-- SectionQueryPage 決定查詢頁外殼欄位內容由 filters slot 交給頁面自行組合 -->
<template #filters>
<v-col cols="12" md="4">
<BaseFormTextField v-model="queryFilters.keyword" label="關鍵字" />
</v-col>
<v-col cols="12" md="4">
<BaseFormSelect v-model="queryFilters.owner" label="單位" :items="page.ownerOptions" />
</v-col>
</template>
<!-- results slot 放查詢結果資料仍由 page model 提供這裡只負責呈現 -->
<template #results>
<v-alert v-if="page.queryMessage" class="mb-3" type="success" variant="tonal">
{{ page.queryMessage }}
</v-alert>
<v-table density="compact">
<thead class="bg-primary">
<tr>
<th>名稱</th>
<th>單位</th>
<th>狀態</th>
<th>更新日</th>
</tr>
</thead>
<tbody>
<tr v-if="page.reports.length === 0">
<td class="text-center" colspan="4">尚無查詢結果</td>
</tr>
<tr v-for="row in page.reports" :key="row.id">
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
<td>{{ row.status }}</td>
<td>{{ row.updatedAt }}</td>
</tr>
</tbody>
</v-table>
</template>
</SectionQueryPage>
</template>
+1
View File
@@ -7,5 +7,6 @@ defineProps<{
</script>
<template>
<!-- Page component 只呈現 page driver 組好的設定頁 model -->
<div>{{ page.title }}</div>
</template>
+1 -1
View File
@@ -18,7 +18,7 @@ const emit = defineEmits<{
<template>
<v-container fluid class="pt-2 px-1">
<v-card>
<v-card class="mb-2">
<v-card-title class="text-title-large bg-primary">{{ title }}</v-card-title>
<v-card-text class="pa-4">
<v-alert v-if="error" class="mb-4" type="error" variant="tonal">{{ error }}</v-alert>
+17
View File
@@ -29,6 +29,23 @@ const fixedMenuItems: LayoutMenuItem[] = [
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
],
},
{
title: '範例頁面',
navigable: false,
subItems: [
{
title: 'SectionQueryPage',
icon: mdiFileDocumentOutline,
path: '/demos/sections/query-page',
},
{ title: 'SectionFormPage', icon: mdiFileDocumentOutline, path: '/demos/sections/form-page' },
{
title: 'SectionSearchPanel',
icon: mdiFileDocumentOutline,
path: '/demos/sections/search-panel',
},
],
},
{ title: '登入頁', path: '/login' },
]
@@ -0,0 +1,410 @@
import { computed, ref, watch } from 'vue'
import type { StudentRecord } from '@/models/student'
import { useSnackbarStore } from '@/stores/snackbar'
import type {
SaveSummaryItem,
StudentFormState,
} from '@/composables/maint/useStudentMaintenanceForm'
export interface ReportSummary {
id: number
title: string
owner: string
status: string
updatedAt: string
}
export interface ReportFilters {
keyword: string
owner: string
}
export interface DemoFormState {
title: string
owner: string
category: string
description: string
}
export interface MaintenanceSearchState {
studentId: string
name: string
department: string
grade: number | null
status: string
}
export interface SectionsDemoPageModel {
title: string
ownerOptions: string[]
categoryOptions: string[]
queryMessage: string
formMessage: string
reports: ReportSummary[]
departments: string[]
gradeOptions: GradeOption[]
enrollYears: number[]
statuses: string[]
maintenanceHeaders: Array<Record<string, unknown>>
maintenanceItems: StudentRecord[]
maintenanceItemsPerPage: number
maintenancePageCount: number
maintenancePageSummary: string
formPanelProps: FormPanelProps
}
interface GradeOption {
title: string
value: number
}
type FieldErrors = Record<keyof StudentFormState, string[]>
interface FormPanelProps {
confirmCloseVisible: boolean
confirmDeleteVisible: boolean
confirmNavigateVisible: boolean
confirmSaveVisible: boolean
confirmSwitchVisible: boolean
departments: string[]
dialogSubtitle: string
dialogTitle: string
dialogVisible: boolean
enrollYears: number[]
errorSummary: Array<{ field: string; message: string }>
fieldErrors: FieldErrors
gradeOptions: GradeOption[]
hasNextRecord: boolean
hasPrevRecord: boolean
isDirty: boolean
isEditMode: boolean
isFormLocked: boolean
isFormReadonly: boolean
isLoading: boolean
isSaving: boolean
isViewMode: boolean
pendingDeleteLabel: string
saveSummary: SaveSummaryItem[]
statuses: string[]
}
const reports: ReportSummary[] = [
{ id: 1, title: '學生統計', owner: '教務處', status: '已發布', updatedAt: '2026-05-01' },
{ id: 2, title: '課程統計', owner: '課務組', status: '草稿', updatedAt: '2026-05-08' },
{ id: 3, title: '系統使用量', owner: '資訊中心', status: '已發布', updatedAt: '2026-05-15' },
]
const ownerOptions = ['全部', '教務處', '課務組', '資訊中心']
const categoryOptions = ['一般報表', '申請表單', '維護資料']
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions: GradeOption[] = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
]
const enrollYears = [2026, 2025, 2024, 2023]
const statuses = ['在學', '休學', '畢業']
const maintenanceItemsPerPage = 5
const students: StudentRecord[] = [
{
id: 1,
studentId: 'S2026001',
name: '王小明',
department: '資訊工程',
grade: 1,
enrollYear: 2026,
credits: 18,
advisor: '陳教授',
email: 'ming@example.edu',
phone: '0912000001',
status: '在學',
},
{
id: 2,
studentId: 'S2025007',
name: '林雅婷',
department: '企業管理',
grade: 2,
enrollYear: 2025,
credits: 42,
advisor: '李教授',
email: 'yating@example.edu',
phone: '0912000002',
status: '在學',
},
{
id: 3,
studentId: 'S2024012',
name: '張志豪',
department: '應用外語',
grade: 3,
enrollYear: 2024,
credits: 86,
advisor: '黃教授',
email: 'zhihao@example.edu',
phone: '0912000003',
status: '休學',
},
]
const maintenanceHeaders = [
{ title: '學號', key: 'studentId', sortable: true, width: 120 },
{ title: '姓名', key: 'name', sortable: true, width: 100 },
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{ title: '操作', key: 'actions', sortable: false, width: 220 },
]
const defaultQueryFilters: ReportFilters = {
keyword: '',
owner: '全部',
}
const defaultDemoForm: DemoFormState = {
title: '',
owner: '教務處',
category: '一般報表',
description: '',
}
const defaultMaintenanceSearch: MaintenanceSearchState = {
studentId: '',
name: '',
department: '',
grade: null,
status: '',
}
const defaultFormPanelForm: StudentFormState = {
studentId: '',
name: '',
department: departments[0] ?? '',
grade: gradeOptions[0]?.value ?? 1,
enrollYear: enrollYears[0] ?? 2026,
credits: 0,
advisor: '',
email: '',
phone: '',
status: statuses[0] ?? '',
}
function createEmptyFieldErrors(): FieldErrors {
return {
studentId: [],
name: [],
department: [],
grade: [],
enrollYear: [],
credits: [],
advisor: [],
email: [],
phone: [],
status: [],
}
}
export function useSectionsDemoPage() {
const snackbar = useSnackbarStore()
const queryFilters = ref<ReportFilters>({ ...defaultQueryFilters })
const demoForm = ref<DemoFormState>({ ...defaultDemoForm })
const maintenanceSearch = ref<MaintenanceSearchState>({ ...defaultMaintenanceSearch })
const maintenanceCurrentPage = ref(1)
const formPanelVisible = ref(false)
const formPanelForm = ref<StudentFormState>({ ...defaultFormPanelForm })
const fieldErrors = ref<FieldErrors>(createEmptyFieldErrors())
const queryMessage = ref('')
const formMessage = ref('')
const filteredReports = computed(() => {
const keyword = queryFilters.value.keyword.trim().toLowerCase()
const owner = queryFilters.value.owner
return reports.filter((item) => {
const keywordMatched =
!keyword ||
item.title.toLowerCase().includes(keyword) ||
item.owner.toLowerCase().includes(keyword)
const ownerMatched = owner === '全部' || item.owner === owner
return keywordMatched && ownerMatched
})
})
const maintenanceItems = computed(() => {
const keywordId = maintenanceSearch.value.studentId.trim().toLowerCase()
const keywordName = maintenanceSearch.value.name.trim().toLowerCase()
return students.filter((item) => {
const idMatched = !keywordId || item.studentId.toLowerCase().includes(keywordId)
const nameMatched = !keywordName || item.name.toLowerCase().includes(keywordName)
const departmentMatched =
!maintenanceSearch.value.department || item.department === maintenanceSearch.value.department
const gradeMatched =
maintenanceSearch.value.grade == null || item.grade === maintenanceSearch.value.grade
const statusMatched = !maintenanceSearch.value.status || item.status === maintenanceSearch.value.status
return idMatched && nameMatched && departmentMatched && gradeMatched && statusMatched
})
})
const maintenancePageCount = computed(() =>
Math.max(1, Math.ceil(maintenanceItems.value.length / maintenanceItemsPerPage))
)
const maintenancePageSummary = computed(() => {
const total = maintenanceItems.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (maintenanceCurrentPage.value - 1) * maintenanceItemsPerPage + 1
const end = Math.min(maintenanceCurrentPage.value * maintenanceItemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
const isFormPanelDirty = computed(
() => JSON.stringify(formPanelForm.value) !== JSON.stringify(defaultFormPanelForm)
)
const formPanelProps = computed<FormPanelProps>(() => ({
confirmCloseVisible: false,
confirmDeleteVisible: false,
confirmNavigateVisible: false,
confirmSaveVisible: false,
confirmSwitchVisible: false,
departments,
dialogSubtitle: formPanelForm.value.studentId || '尚未輸入學號',
dialogTitle: 'SectionFormPanel 範例',
dialogVisible: formPanelVisible.value,
enrollYears,
errorSummary: [],
fieldErrors: fieldErrors.value,
gradeOptions,
hasNextRecord: false,
hasPrevRecord: false,
isDirty: isFormPanelDirty.value,
isEditMode: false,
isFormLocked: false,
isFormReadonly: false,
isLoading: false,
isSaving: false,
isViewMode: false,
pendingDeleteLabel: formPanelForm.value.name || '目前資料',
saveSummary: [],
statuses,
}))
const pageModel = computed<SectionsDemoPageModel>(() => ({
title: '新增頁面與 Section 範例',
ownerOptions,
categoryOptions,
queryMessage: queryMessage.value,
formMessage: formMessage.value,
reports: filteredReports.value,
departments,
gradeOptions,
enrollYears,
statuses,
maintenanceHeaders,
maintenanceItems: maintenanceItems.value,
maintenanceItemsPerPage,
maintenancePageCount: maintenancePageCount.value,
maintenancePageSummary: maintenancePageSummary.value,
formPanelProps: formPanelProps.value,
}))
watch(maintenancePageCount, (value) => {
if (maintenanceCurrentPage.value > value) maintenanceCurrentPage.value = value
})
function handleQuerySearch() {
queryMessage.value = `查詢完成,共 ${filteredReports.value.length}`
}
function handleQueryBack() {
snackbar.show({ message: '查詢頁返回事件', color: 'info' })
}
function handleFormSubmit() {
formMessage.value = demoForm.value.title.trim()
? `已送出:${demoForm.value.title.trim()}`
: '請輸入標題後再送出'
}
function resetDemoForm() {
demoForm.value = { ...defaultDemoForm }
formMessage.value = ''
}
function handleFormBack() {
snackbar.show({ message: '表單頁返回事件', color: 'info' })
}
function resetMaintenanceSearch() {
maintenanceSearch.value = { ...defaultMaintenanceSearch }
maintenanceCurrentPage.value = 1
}
function handleMaintenanceAction(action: string, record: StudentRecord) {
snackbar.show({ message: `${action}${record.studentId} ${record.name}`, color: 'info' })
}
function openFormPanel() {
formPanelVisible.value = true
}
function closeFormPanel() {
formPanelVisible.value = false
}
function handleFormPanelVisibleChange(value: boolean) {
formPanelVisible.value = value
}
function handleFormPanelSave() {
formPanelVisible.value = false
snackbar.show({ message: 'SectionFormPanel 儲存事件', color: 'success' })
}
function clearFormPanelFieldError(field: keyof StudentFormState | string) {
const key = field as keyof StudentFormState
if (!fieldErrors.value[key]?.length) return
fieldErrors.value[key] = []
}
function gradeLabel(grade: number) {
return gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
}
function statusColor(status: string) {
if (status === '在學') return 'success'
if (status === '休學') return 'warning'
if (status === '畢業') return 'secondary'
return 'default'
}
function rowProps() {
return {}
}
return {
demoForm,
formPanelForm,
maintenanceCurrentPage,
maintenanceSearch,
pageModel,
queryFilters,
clearFormPanelFieldError,
closeFormPanel,
gradeLabel,
handleFormBack,
handleFormPanelSave,
handleFormPanelVisibleChange,
handleFormSubmit,
handleMaintenanceAction,
handleQueryBack,
handleQuerySearch,
openFormPanel,
resetDemoForm,
resetMaintenanceSearch,
rowProps,
statusColor,
}
}
+22
View File
@@ -49,6 +49,28 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/maint/EditableGrid.vue'),
meta: { layout: 'default' },
},
{
path: '/demos/sections',
redirect: '/demos/sections/query-page',
},
{
path: '/demos/sections/query-page',
name: 'demo-section-query-page',
component: () => import('@/views/demos/SectionQueryPageDemo.vue'),
meta: { title: 'SectionQueryPage 示範', layout: 'default' },
},
{
path: '/demos/sections/form-page',
name: 'demo-section-form-page',
component: () => import('@/views/demos/SectionFormPageDemo.vue'),
meta: { title: 'SectionFormPage 示範', layout: 'default' },
},
{
path: '/demos/sections/search-panel',
name: 'demo-section-search-panel',
component: () => import('@/views/demos/SectionSearchPanelDemo.vue'),
meta: { title: 'SectionSearchPanel 示範', layout: 'default' },
},
{
path: '/:fncId([0-9A-Z]{5,6})',
name: 'fnc-page',
+1
View File
@@ -39,5 +39,6 @@ const page = useReportsPage()
## 子目錄
- `views/demos` 是一般頁面與 section 使用方式的 demo route entry,仍需維持薄 view。
- `views/maint` 是 maintenance demo route entry。詳見 `src/views/maint/GUIDE.md`
- `views/errors` 是錯誤頁入口,通常使用 `meta.layout = 'none'`。每個錯誤頁(`Forbidden.vue``ServerError.vue``NotFound.vue` 等)只傳入 props 給共用的 `ErrorShell.vue`,不再各自重複佈局邏輯。`ErrorShell.vue` 提供標題、圖示、顏色、描述、後端訊息、操作按鈕(返回上頁 / 回首頁 / 前往登入)等 slots。
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import PageSectionQueryPageDemo from '@/components/pages/PageSectionQueryPageDemo.vue'
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
// Demo view 維持薄層,只掛 page driver,並把 page model / actions 傳給 page component。
const page = useSectionsDemoPage()
</script>
<template>
<PageSectionQueryPageDemo
v-model:query-filters="page.queryFilters.value"
:page="page.pageModel.value"
@back="page.handleQueryBack"
@search="page.handleQuerySearch"
/>
</template>