diff --git a/src/components/pages/PageEditableGridMaintenance.vue b/src/components/pages/PageEditableGridMaintenance.vue
index 177cc43..56c84b6 100644
--- a/src/components/pages/PageEditableGridMaintenance.vue
+++ b/src/components/pages/PageEditableGridMaintenance.vue
@@ -8,5 +8,6 @@ defineProps<{
+
diff --git a/src/components/pages/PageFunction.vue b/src/components/pages/PageFunction.vue
index 13c4691..d518c93 100644
--- a/src/components/pages/PageFunction.vue
+++ b/src/components/pages/PageFunction.vue
@@ -7,6 +7,7 @@ defineProps<{
+
{{ page.fncId }}
diff --git a/src/components/pages/PageHome.vue b/src/components/pages/PageHome.vue
index 5f3534f..f49eee5 100644
--- a/src/components/pages/PageHome.vue
+++ b/src/components/pages/PageHome.vue
@@ -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('newsDialogOpen', { default: false })
+
()
+// 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): void
}>()
+// 搜尋面板開關是頁面 UI 狀態,用 v-model 交回 view/page driver。
const searchPanelOpen = defineModel('searchPanelOpen', { default: false })
+
('form', { required: true })
const detailFormModel = defineModel('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('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<{
+
+
+
+
+
學號
@@ -78,6 +80,7 @@
查詢
+
+
+
學號
@@ -78,6 +80,7 @@
查詢
+
+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('queryFilters', { required: true })
+
+// 使用者意圖往上 emit,由 page driver 決定查詢、返回等副作用。
+const emit = defineEmits<{
+ (e: 'back'): void
+ (e: 'search'): void
+}>()
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ page.queryMessage }}
+
+
+
+
+ | 名稱 |
+ 單位 |
+ 狀態 |
+ 更新日 |
+
+
+
+
+ | 尚無查詢結果 |
+
+
+ | {{ row.title }} |
+ {{ row.owner }} |
+ {{ row.status }} |
+ {{ row.updatedAt }} |
+
+
+
+
+
+
diff --git a/src/components/pages/PageSettings.vue b/src/components/pages/PageSettings.vue
index 01abcd4..29fe0f4 100644
--- a/src/components/pages/PageSettings.vue
+++ b/src/components/pages/PageSettings.vue
@@ -7,5 +7,6 @@ defineProps<{
+
{{ page.title }}
diff --git a/src/components/sections/SectionQueryPage.vue b/src/components/sections/SectionQueryPage.vue
index 115012c..ee39688 100644
--- a/src/components/sections/SectionQueryPage.vue
+++ b/src/components/sections/SectionQueryPage.vue
@@ -18,7 +18,7 @@ const emit = defineEmits<{
-
+
{{ title }}
{{ error }}
diff --git a/src/composables/layout/useAppShell.ts b/src/composables/layout/useAppShell.ts
index 6dfdb16..62895d6 100644
--- a/src/composables/layout/useAppShell.ts
+++ b/src/composables/layout/useAppShell.ts
@@ -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' },
]
diff --git a/src/composables/page-drivers/useSectionsDemoPage.ts b/src/composables/page-drivers/useSectionsDemoPage.ts
new file mode 100644
index 0000000..f9232d6
--- /dev/null
+++ b/src/composables/page-drivers/useSectionsDemoPage.ts
@@ -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>
+ maintenanceItems: StudentRecord[]
+ maintenanceItemsPerPage: number
+ maintenancePageCount: number
+ maintenancePageSummary: string
+ formPanelProps: FormPanelProps
+}
+
+interface GradeOption {
+ title: string
+ value: number
+}
+
+type FieldErrors = Record
+
+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({ ...defaultQueryFilters })
+ const demoForm = ref({ ...defaultDemoForm })
+ const maintenanceSearch = ref({ ...defaultMaintenanceSearch })
+ const maintenanceCurrentPage = ref(1)
+ const formPanelVisible = ref(false)
+ const formPanelForm = ref({ ...defaultFormPanelForm })
+ const fieldErrors = ref(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(() => ({
+ 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(() => ({
+ 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,
+ }
+}
diff --git a/src/router/routes.ts b/src/router/routes.ts
index 9e64c0e..08cea1b 100644
--- a/src/router/routes.ts
+++ b/src/router/routes.ts
@@ -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',
diff --git a/src/views/GUIDE.md b/src/views/GUIDE.md
index a9ae71f..1f6f819 100644
--- a/src/views/GUIDE.md
+++ b/src/views/GUIDE.md
@@ -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。
diff --git a/src/views/demos/SectionQueryPageDemo.vue b/src/views/demos/SectionQueryPageDemo.vue
new file mode 100644
index 0000000..e1d16e4
--- /dev/null
+++ b/src/views/demos/SectionQueryPageDemo.vue
@@ -0,0 +1,16 @@
+
+
+
+
+