docs: simplify page architecture and component guidance

Update the src documentation to emphasize building pages from route views,
composables, sections, and items instead of a dedicated pages layer.

Clarify the recommended data flow and new feature workflow so template users
start from views and only introduce page-driver composables when coordination
logic becomes complex.docs: simplify page architecture and component guidance

Update the src documentation to emphasize building pages from route views,
composables, sections, and items instead of a dedicated pages layer.

Clarify the recommended data flow and new feature workflow so template users
start from views and only introduce page-driver composables when coordination
logic becomes complex.
This commit is contained in:
skytek_xinliang
2026-05-27 11:50:40 +08:00
parent ad00f5c195
commit 7b99087cbb
25 changed files with 2797 additions and 3174 deletions
+11 -13
View File
@@ -1,19 +1,19 @@
# Src Guide # Src Guide
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view、page component 與 page driver 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。 `src` 是 template 使用者主要修改的區域。新增功能時,先從 route view composable 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
## 資料流 ## 資料流
```txt ```txt
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item router -> AppShell -> layout -> view -> Section -> Item
page driver / command composable -> store -> service composable -> store -> service
``` ```
## 主要目錄 ## 主要目錄
- `views/`route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md` - `views/`route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`
- `components/`Vue UI 元件,依 pages / sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md` - `components/`Vue UI 元件,依 sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`
- `composables/`page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md` - `composables/`page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`
- `router/`route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md` - `router/`route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`
- `shell/`AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md` - `shell/`AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`
@@ -60,7 +60,6 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
- `components/PageIndex.vue` - `components/PageIndex.vue`
- `components/PageMaint.vue` - `components/PageMaint.vue`
- `components/maint/*` - `components/maint/*`
- `components/pages/*Maintenance.vue`
- `components/sections/*` - `components/sections/*`
- `components/items/*` - `components/items/*`
- `composables/page-drivers/*MaintenancePage.ts` - `composables/page-drivers/*MaintenancePage.ts`
@@ -74,14 +73,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
## 新功能流程 ## 新功能流程
1. 新增或修改 `views/*` route entry。 1. 新增或修改 `views/*` route entry,直接在 view 裡組裝 page model 與 UI
2. 若有完整頁面 UI,新增 `components/pages/PageXxx.vue` 2. 若有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝
3.有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝 page model 3.畫面有獨立區塊,拆到 `components/sections/*`
4.畫面有獨立區塊,拆到 `components/sections/*` 4. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`
5. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*` 5. 跨頁共享狀態才新增或修改 `stores/*`
6. 跨頁共享狀態才新增或修改 `stores/*` 6. 外部 API 放在 `services/modules/*`
7. 外部 API 放在 `services/modules/*` 7. `router/routes.ts` 新增 route
8.`router/routes.ts` 新增 route。
## 驗證 ## 驗證
+12 -46
View File
@@ -1,55 +1,21 @@
# Src Guide # Components Guide
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view、page component 與 page driver 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core `components` 放 Vue UI 元件,依 sections / items / layouts / base 分層。頁面不再有獨立的 page component 層 — 頁面 UI 直接寫在 `views/*`
Template Core 與 Demo/Example 的完整清單見 `src/README.md` ## 子目錄
## 資料流 - `sections/`:獨立畫面區塊(搜尋面板、資料表格、表單面板),決定佈局,不關心單筆內容。詳見 `src/components/sections/GUIDE.md`
- `items/`:單一資料單位的純粹呈現,不管理狀態。詳見 `src/components/items/GUIDE.md`
- `layouts/`App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`
```txt `PageMaint.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/` 頂層。
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
page driver / command composable -> store -> service
```
## 主要目錄 ## 規則
- `views/`route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md` - 元件不直接 import store 或 service
- `components/`Vue UI 元件,依 pages / sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md` - 元件以 props 接收資料,以 emits 回報使用者意圖
- `composables/`page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md` - 可複用元件不含 domain 名稱(如 `student``course`
- `router/`route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`
- `shell/`AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`
- `stores/`:跨頁共享狀態與快取。詳見 `src/stores/GUIDE.md`
- `services/`HTTP client、API module、token/session、錯誤處理。詳見 `src/services/GUIDE.md`
- `language/`Vue I18n 文案。詳見 `src/language/GUIDE.md`
## 依 pageKind 選擇起點
`.spec.json``maintenanceContract.pageKind` 決定使用哪一種 demo 架構。完整欄位對照見 `docs/llm-development-guide.md` 的「`.spec.json` 對照指南」。
| pageKind | 參考 Demo | 讀取 GUIDE |
| ------------- | ------------------------------------------ | -------------------------------------------------------- |
| `query` | `src/views/demos/SectionQueryPageDemo.vue` | `src/components/sections/GUIDE.md`SectionQueryPage |
| `application` | `src/views/demos/SectionFormPageDemo.vue` | `src/components/sections/GUIDE.md`SectionFormPage |
| `maintenance` | `src/views/maint/*` | `src/views/maint/README.md` + `src/views/maint/GUIDE.md` |
| `auth` | `src/views/Login.vue` | `src/views/GUIDE.md` |
| `print` | query 或 application demo | 同 query / application |
| `chooser` | 不適用 demo | 轉為 route group 或 tab |
## 新功能流程
1.`pageKind` 選擇最接近的 demo。
2.`src/views/<domain>/` 新增 route view(薄層,只掛 page driver)。
3.`src/composables/page-drivers/` 新增 page driver composable。
4.`src/components/pages/` 新增 page component。
5. 若畫面有獨立區塊,拆到 `src/components/sections/*`
6. 若區塊內有欄位群組,拆到 `src/components/items/*`
7.`src/services/modules/` 新增 API module。
8.`src/models/` 定義 page model 與 domain model 型別。
9.`src/router/routes.ts` 新增 route。
10.`src/language/` 新增語系文案。
跨頁共享狀態才新增或修改 `src/stores/*`
## 驗證 ## 驗證
@@ -1,13 +0,0 @@
<script setup lang="ts">
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
import type { MaintenancePageModel } from '@/models/page'
defineProps<{
page: MaintenancePageModel
}>()
</script>
<template>
<!-- Page component 接收 page model再把頁面標題轉交給既有 editable grid feature component -->
<EditableStudentGrid :title="page.title" />
</template>
-16
View File
@@ -1,16 +0,0 @@
<script setup lang="ts">
export interface FunctionPageModel {
fncId: string
}
defineProps<{
page: FunctionPageModel
}>()
</script>
<template>
<!-- Page component 只呈現 page driver 解析後的功能代碼不直接讀 route params -->
<v-sheet height="100%" width="100%">
{{ page.fncId }}
</v-sheet>
</template>
-32
View File
@@ -1,32 +0,0 @@
<script setup lang="ts">
import PageIndex from '@/components/PageIndex.vue'
import type { HomeNewsItem, HomePageModel, HomeQuickItem } from '@/composables/page-drivers/useHomePage'
defineProps<{
page: HomePageModel
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"
:quick-items="page.quickItems"
:selected-news="selectedNews"
@message-center="emit('message-center')"
@news="emit('news', $event)"
@quick="emit('quick', $event)"
/>
</template>
-37
View File
@@ -1,37 +0,0 @@
<script setup lang="ts">
import PageMaint from '@/components/PageMaint.vue'
import type { MaintenancePageModel } from '@/models/page'
defineProps<{
page: MaintenancePageModel
}>()
// PageMaintenance 只轉發維護頁使用者意圖,CRUD 副作用交給 page driver / command composable。
const emit = defineEmits<{
(e: 'create'): void
(e: 'edit', record: unknown): void
(e: 'view', record: unknown): void
(e: 'delete', record: unknown): void
(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"
@toggle-search="searchPanelOpen = !searchPanelOpen"
@create="emit('create')"
>
<template #search-fields>
<slot name="search-fields" />
</template>
<template #table>
<slot name="table" />
</template>
</PageMaint>
</template>
@@ -1,483 +0,0 @@
<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 })
// 主檔表單、子檔表單與搜尋條件都由 page driver 持有,Page component 只透過 v-model 回寫。
const search = defineModel<{
studentId: string
name: string
department: string
grade: number | null
status: string
}>('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
(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 提供維護頁外殼主從頁在 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"
:departments="departments"
:grade-options="gradeOptions"
:statuses="statuses"
@reset="emit('reset-search')"
/>
</template>
<!-- 主檔表格沿用 SectionDataTable列操作只 emit 使用者意圖 -->
<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
@@ -1,89 +0,0 @@
<script setup lang="ts">
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import SectionFormPage from '@/components/sections/SectionFormPage.vue'
import type {
DemoFormState,
SectionsDemoPageModel,
} from '@/composables/page-drivers/useSectionsDemoPage'
defineProps<{
page: SectionsDemoPageModel
}>()
// 表單內容由 page driver 持有,Page component 只透過 v-model 呈現與回寫。
const demoForm = defineModel<DemoFormState>('demoForm', { required: true })
// 送出、清除、返回都往上 emit,讓 page driver 統一處理訊息與副作用。
const emit = defineEmits<{
(e: 'back'): void
(e: 'reset'): void
(e: 'submit'): void
}>()
</script>
<template>
<SectionFormPage
back-label="回到列表"
reset-label="清除"
submit-label="送出"
:message="page.formMessage"
title="SectionFormPage 報表申請"
@back="emit('back')"
@reset="emit('reset')"
@submit="emit('submit')"
>
<!-- SectionFormPage 決定表單頁外殼fields slot 放實際欄位組合 -->
<template #fields>
<v-row density="compact">
<v-col cols="12" md="6">
<BaseFormTextField v-model="demoForm.title" label="標題" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect v-model="demoForm.owner" label="單位" :items="page.ownerOptions" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect v-model="demoForm.category" label="類型" :items="page.categoryOptions" />
</v-col>
<v-col cols="12">
<BaseFormTextField v-model="demoForm.description" label="說明" />
</v-col>
</v-row>
</template>
<!-- sections slot 放表單主欄位以外的子區段例如明細表格或補充資訊 -->
<template #sections>
<v-card class="mb-2">
<v-card-title class="text-title-medium font-weight-bold">明細</v-card-title>
<v-card-text>
<v-table density="compact">
<thead>
<tr>
<th>欄位</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>單位</td>
<td>{{ demoForm.owner }}</td>
</tr>
<tr>
<td>類型</td>
<td>{{ demoForm.category }}</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</template>
<!-- notices slot 放配合事項讓外殼固定內容由頁面決定 -->
<template #notices>
<v-list class="bg-yellow-lighten-5" density="compact">
<v-list-item>送出前確認標題與單位</v-list-item>
<v-list-item>表單狀態由 page driver 管理</v-list-item>
</v-list>
</template>
</SectionFormPage>
</template>
@@ -1,69 +0,0 @@
<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>
-14
View File
@@ -1,14 +0,0 @@
<script setup lang="ts">
export interface SettingsPageModel {
title: string
}
defineProps<{
page: SettingsPageModel
}>()
</script>
<template>
<!-- Page component 只呈現 page driver 組好的設定頁 model -->
<div>{{ page.title }}</div>
</template>
@@ -383,8 +383,11 @@ export function useMasterDetailAMaintenancePage() {
} }
return { return {
confirmSave,
currentPage, currentPage,
departments, departments,
detailForm,
flow,
formState, formState,
gradeOptions, gradeOptions,
itemsPerPage, itemsPerPage,
@@ -396,7 +399,9 @@ export function useMasterDetailAMaintenancePage() {
pageCount, pageCount,
pageModel, pageModel,
pageSummary, pageSummary,
requestSaveConfirmation,
resetSearch, resetSearch,
scrollToField,
search, search,
searchPanelOpen, searchPanelOpen,
snackbarVisible, snackbarVisible,
+4 -4
View File
@@ -1,15 +1,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import PageFunction from '@/components/pages/PageFunction.vue'
import type { FunctionPageModel } from '@/components/pages/PageFunction.vue'
const route = useRoute() const route = useRoute()
const pageModel = computed<FunctionPageModel>(() => ({ const pageModel = computed(() => ({
fncId: String(route.params.fncId ?? ''), fncId: String(route.params.fncId ?? ''),
})) }))
</script> </script>
<template> <template>
<PageFunction :page="pageModel" /> <v-sheet height="100%" width="100%">
{{ pageModel.fncId }}
</v-sheet>
</template> </template>
+24 -9
View File
@@ -1,32 +1,47 @@
# Views Guide # Views Guide
`views` 是 route entry。View 應維持薄層,負責掛載 page driver 與 page component,不承載大段 UI、dialog、表單欄位或 store mutation 細節 `views` 是 route entry。View 自含 page model 組裝與頁面 UI,若邏輯複雜才抽到 page driver composable
## 規則 ## 規則
- 使用 `<script setup lang="ts">` - 使用 `<script setup lang="ts">`
- 直接 route component 放在 `src/views``src/views/<feature>` - 直接 route component 放在 `src/views``src/views/<feature>`
- 一般 view 目標 < 80 行。
- route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。 - route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。
- 不直接 import 或包住 `MainLayout.vue` - 不直接 import 或包住 `MainLayout.vue`
- 不直接定義大型 `<v-dialog>``<v-overlay>`、大型表格或大型表單 - 複雜 UI 拆到 `components/sections/*``components/items/*`
## 建議形狀 ## 建議形狀
簡單頁面:直接在 view 組裝 page model 與 template。
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import PageReports from '@/components/pages/PageReports.vue' import { computed } from 'vue'
import { useReportsPage } from '@/composables/page-drivers/useReportsPage' const pageModel = computed(() => ({ title: '我的頁面' }))
const { pageModel } = useReportsPage()
</script> </script>
<template> <template>
<PageReports :page="pageModel" /> <v-card>{{ pageModel.title }}</v-card>
</template> </template>
``` ```
若頁面只是簡單的 `computed` 組裝(無搜尋、無 dialog、無複雜事件),直接在 view 寫 page model,不需要建立 page driver。以 destructure 方式取用 composable 回傳值,模板不寫 `.value` 複雜頁面:透過 page driver composable 協調多個資料來源
```vue
<script setup lang="ts">
import PageMaint from '@/components/PageMaint.vue'
import { useXxxPage } from '@/composables/page-drivers/useXxxPage'
const { pageModel, search, handleSave, ... } = useXxxPage()
</script>
<template>
<PageMaint :title="pageModel.title" @create="handleCreate">
<template #table>...</template>
</PageMaint>
</template>
```
以 destructure 方式取用 composable 回傳值,模板不寫 `.value`
## Login.vue 開關 ## Login.vue 開關
+7 -6
View File
@@ -1,17 +1,18 @@
<script setup lang="ts"> <script setup lang="ts">
import PageHome from '@/components/pages/PageHome.vue' import PageIndex from '@/components/PageIndex.vue'
import { useHomePage } from '@/composables/page-drivers/useHomePage' import { useHomePage } from '@/composables/page-drivers/useHomePage'
const { handleMessageCenter, handleNews, handleQuick, isNewsDialogOpen, pageModel, selectedNews } = useHomePage() const { handleMessageCenter, handleNews, handleQuick, isNewsDialogOpen, pageModel, selectedNews } = useHomePage()
</script> </script>
<template> <template>
<PageHome <PageIndex
v-model:news-dialog-open="isNewsDialogOpen" v-model:is-news-dialog-open="isNewsDialogOpen"
:page="pageModel" :news-items="pageModel.newsItems"
:quick-items="pageModel.quickItems"
:selected-news="selectedNews" :selected-news="selectedNews"
@message-center="handleMessageCenter" @message-center="handleMessageCenter"
@news="handleNews" @news="handleNews($event)"
@quick="handleQuick" @quick="handleQuick($event)"
/> />
</template> </template>
+2 -4
View File
@@ -1,13 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import PageSettings from '@/components/pages/PageSettings.vue'
import type { SettingsPageModel } from '@/components/pages/PageSettings.vue'
const pageModel = computed<SettingsPageModel>(() => ({ const pageModel = computed(() => ({
title: '設定頁面', title: '設定頁面',
})) }))
</script> </script>
<template> <template>
<PageSettings :page="pageModel" /> <div>{{ pageModel.title }}</div>
</template> </template>
+60 -5
View File
@@ -1,16 +1,71 @@
<script setup lang="ts"> <script setup lang="ts">
import PageSectionFormPageDemo from '@/components/pages/PageSectionFormPageDemo.vue' import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import SectionFormPage from '@/components/sections/SectionFormPage.vue'
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage' import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
const { demoForm, handleFormBack, handleFormSubmit, pageModel, resetDemoForm } = useSectionsDemoPage() const { demoForm, handleFormBack, handleFormSubmit, pageModel, resetDemoForm } = useSectionsDemoPage()
</script> </script>
<template> <template>
<PageSectionFormPageDemo <SectionFormPage
v-model:demo-form="demoForm" back-label="回到列表"
:page="pageModel" reset-label="清除"
submit-label="送出"
:message="pageModel.formMessage"
title="SectionFormPage 報表申請"
@back="handleFormBack" @back="handleFormBack"
@reset="resetDemoForm" @reset="resetDemoForm"
@submit="handleFormSubmit" @submit="handleFormSubmit"
/> >
<template #fields>
<v-row density="compact">
<v-col cols="12" md="6">
<BaseFormTextField v-model="demoForm.title" label="標題" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect v-model="demoForm.owner" label="單位" :items="pageModel.ownerOptions" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect v-model="demoForm.category" label="類型" :items="pageModel.categoryOptions" />
</v-col>
<v-col cols="12">
<BaseFormTextField v-model="demoForm.description" label="說明" />
</v-col>
</v-row>
</template>
<template #sections>
<v-card class="mb-2">
<v-card-title class="text-title-medium font-weight-bold">明細</v-card-title>
<v-card-text>
<v-table density="compact">
<thead>
<tr>
<th>欄位</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>單位</td>
<td>{{ demoForm.owner }}</td>
</tr>
<tr>
<td>類型</td>
<td>{{ demoForm.category }}</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</template>
<template #notices>
<v-list class="bg-yellow-lighten-5" density="compact">
<v-list-item>送出前確認標題與單位</v-list-item>
<v-list-item>表單狀態由 page driver 管理</v-list-item>
</v-list>
</template>
</SectionFormPage>
</template> </template>
+43 -5
View File
@@ -1,15 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import PageSectionQueryPageDemo from '@/components/pages/PageSectionQueryPageDemo.vue' import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import SectionQueryPage from '@/components/sections/SectionQueryPage.vue'
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage' import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
const { handleQueryBack, handleQuerySearch, pageModel, queryFilters } = useSectionsDemoPage() const { handleQueryBack, handleQuerySearch, pageModel, queryFilters } = useSectionsDemoPage()
</script> </script>
<template> <template>
<PageSectionQueryPageDemo <SectionQueryPage
v-model:query-filters="queryFilters" back-label="回到列表"
:page="pageModel" title="查詢頁DEMO"
@back="handleQueryBack" @back="handleQueryBack"
@search="handleQuerySearch" @search="handleQuerySearch"
/> >
<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="pageModel.ownerOptions" />
</v-col>
</template>
<template #results>
<v-alert v-if="pageModel.queryMessage" class="mb-3" type="success" variant="tonal">
{{ pageModel.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="pageModel.reports.length === 0">
<td class="text-center" colspan="4">尚無查詢結果</td>
</tr>
<tr v-for="row in pageModel.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> </template>
+2 -2
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue' import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
import type { MaintenancePageModel } from '@/models/page' import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students' import { useStudentStore } from '@/stores/students'
@@ -15,5 +15,5 @@ const pageModel = computed<MaintenancePageModel>(() => ({
</script> </script>
<template> <template>
<PageEditableGridMaintenance :page="pageModel" /> <EditableStudentGrid :title="pageModel.title" />
</template> </template>
+3 -5
View File
@@ -1,12 +1,12 @@
# Maintenance Views Guide # Maintenance Views Guide
`views/maint` 是維護頁 demo。所有檔案都應是薄 route entry實際 UI 與流程分別放在 `components/pages``components/sections``components/items``composables/page-drivers` `views/maint` 是維護頁 demo。所有檔案都是自含的 route entryUI 與流程直接在 view 中組合 `PageMaint``components/sections``components/items` 與 composable。
## 目前範本 ## 目前範本
- `SingleRecord.vue`:單主檔 CRUD + dialog。 - `SingleRecord.vue`:單主檔 CRUD + dialog(使用 page driver composable
- `EditableGrid.vue`:可編輯表格。 - `EditableGrid.vue`:可編輯表格。
- `MasterDetailA.vue`:主檔 + 側邊明細 panel。 - `MasterDetailA.vue`:主檔 + 側邊明細 panel(使用 page driver composable
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。 - `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
- `MasterDetailC.vue`:主檔 + 簡化明細清單。 - `MasterDetailC.vue`:主檔 + 簡化明細清單。
@@ -15,8 +15,6 @@
複製維護頁時同步調整: 複製維護頁時同步調整:
- `router/routes.ts``path``name``component``meta.layout` - `router/routes.ts``path``name``component``meta.layout`
- page driver 名稱與 import
- page component 名稱與 import
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則 - 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
- store、service、model、語系、menu/favorites/breadcrumb 相關資料 - store、service、model、語系、menu/favorites/breadcrumb 相關資料
+346 -13
View File
@@ -1,38 +1,371 @@
<script setup lang="ts"> <script setup lang="ts">
import PageMasterDetailAMaintenance from '@/components/pages/PageMasterDetailAMaintenance.vue' 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 SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
import { useMasterDetailAMaintenancePage } from '@/composables/page-drivers/useMasterDetailAMaintenancePage' import { useMasterDetailAMaintenancePage } from '@/composables/page-drivers/useMasterDetailAMaintenancePage'
const { const {
currentPage, formState, itemsPerPage, masterDetailEvents, masterDetailProps, confirmSave, currentPage, departments, detailForm, flow, formState,
openAddDialog, openEditDialog, openViewDialog, pageCount, pageModel, pageSummary, gradeOptions, itemsPerPage, masterDetailEvents, masterDetailProps,
resetSearch, search, searchPanelOpen, snackbarVisible, students, tableHeaders, openAddDialog, openEditDialog, openViewDialog, pageCount, pageModel,
pageSummary, requestSaveConfirmation, resetSearch, scrollToField,
search, searchPanelOpen, snackbarVisible, statuses, students, tableHeaders,
} = useMasterDetailAMaintenancePage() } = useMasterDetailAMaintenancePage()
</script> </script>
<template> <template>
<PageMasterDetailAMaintenance <PageMaint
v-model:search="search" :search-panel-open="searchPanelOpen"
v-model:search-panel-open="searchPanelOpen" :title="pageModel.title"
v-bind="masterDetailProps" @toggle-search="searchPanelOpen = !searchPanelOpen"
@create="openAddDialog"
>
<template #search-fields>
<SectionSearchPanel
v-model="search"
:departments="masterDetailProps.departments"
:grade-options="masterDetailProps.gradeOptions"
:statuses="masterDetailProps.statuses"
@reset="resetSearch"
/>
</template>
<template #table>
<SectionDataTable
:current-page="currentPage" :current-page="currentPage"
:grade-label="formState.gradeLabel" :grade-label="formState.gradeLabel"
:headers="tableHeaders" :headers="tableHeaders"
:items="students" :items="students"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
:page="pageModel"
:page-count="pageCount" :page-count="pageCount"
:page-summary="pageSummary" :page-summary="pageSummary"
:row-props="formState.rowProps" :row-props="formState.rowProps"
:status-color="formState.statusColor" :status-color="formState.statusColor"
@create="openAddDialog" @delete="flow.requestDeleteConfirmation($event)"
@edit="openEditDialog" @edit="openEditDialog($event)"
@reset-search="resetSearch"
@update:current-page="currentPage = $event" @update:current-page="currentPage = $event"
@view="openViewDialog" @view="openViewDialog($event)"
/>
</template>
</PageMaint>
<teleport to="body">
<v-overlay
class="dialog-overlay"
:close-on-content-click="false"
:model-value="masterDetailProps.dialogVisible"
scrim="rgba(0, 0, 0, 0.45)"
scroll-strategy="block"
@update:model-value="flow.handleDialogVisibility($event)"
>
<div class="dialog-panel" :class="{ 'is-mobile': masterDetailProps.isMobile }">
<div
v-if="!masterDetailProps.isMobile || masterDetailProps.activeMobilePanel === 'detail'"
class="detail-panel-wrapper"
:class="{ 'is-active': !!masterDetailProps.selectedSemesterId, 'is-mobile': masterDetailProps.isMobile }"
>
<DetailSidePanel
v-model:detail-form="detailForm"
:is-detail-editing="masterDetailProps.isDetailEditing"
:is-mobile="masterDetailProps.isMobile"
:is-view-mode="masterDetailProps.isViewMode"
:selected-semester="masterDetailProps.selectedSemester"
v-on="masterDetailEvents" v-on="masterDetailEvents"
/> />
</div>
<MntDialogCard
v-if="!masterDetailProps.isMobile || masterDetailProps.activeMobilePanel === 'master'"
:dialog-subtitle="masterDetailProps.dialogSubtitle"
:dialog-title="masterDetailProps.dialogTitle"
:is-edit-mode="masterDetailProps.isEditMode"
:is-view-mode="masterDetailProps.isViewMode"
:width="masterDetailProps.isMobile ? '100%' : 760"
>
<template #toolbar>
<MntRecordNavToolbar
:has-next-record="masterDetailProps.hasNextRecord"
:has-prev-record="masterDetailProps.hasPrevRecord"
:is-edit-mode="masterDetailProps.isEditMode"
:is-view-mode="masterDetailProps.isViewMode"
:mobile="masterDetailProps.isMobile"
v-on="masterDetailEvents"
/>
</template>
<template #content>
<v-alert
v-if="masterDetailProps.errorSummary.length > 0 && !masterDetailProps.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 masterDetailProps.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="masterDetailProps.isLoading"
class="mt-4"
type="subtitle,paragraph"
width="100%"
/>
<v-form
v-else
:class="{ 'form-readonly': masterDetailProps.isFormReadonly }"
@submit.prevent="requestSaveConfirmation()"
>
<MasterFileFormFields
:departments="masterDetailProps.departments"
:enroll-years="masterDetailProps.enrollYears"
:field-errors="masterDetailProps.fieldErrors"
:form="formState.form.value"
:grade-options="masterDetailProps.gradeOptions"
:is-form-locked="masterDetailProps.isFormLocked"
:is-form-readonly="masterDetailProps.isFormReadonly"
:statuses="masterDetailProps.statuses"
v-on="masterDetailEvents"
/>
<v-divider />
<DetailNavigation
:is-mobile="masterDetailProps.isMobile"
:is-view-mode="masterDetailProps.isViewMode"
:selected-semester-id="masterDetailProps.selectedSemesterId"
:semesters="masterDetailProps.semesters"
v-on="masterDetailEvents"
/>
</v-form>
</template>
<template #actions>
<template v-if="masterDetailProps.isMobile">
<v-btn :disabled="masterDetailProps.isSaving" variant="text" @click="flow.requestCloseDialog()">取消</v-btn>
<v-btn
v-if="masterDetailProps.isEditMode"
color="error"
:disabled="masterDetailProps.isSaving"
variant="tonal"
@click="flow.requestDeleteCurrent()"
>
刪除
</v-btn>
<v-btn
v-if="!masterDetailProps.isViewMode"
color="primary"
:disabled="!masterDetailProps.isDirty || masterDetailProps.isLoading"
:loading="masterDetailProps.isSaving"
variant="flat"
@click="requestSaveConfirmation()"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="flow.requestCloseDialog()">關閉</v-btn>
</template>
<template v-else>
<v-spacer />
<v-btn :disabled="masterDetailProps.isSaving" variant="text" @click="flow.requestCloseDialog()">取消</v-btn>
<v-btn
v-if="masterDetailProps.isEditMode"
color="error"
:disabled="masterDetailProps.isSaving"
variant="tonal"
@click="flow.requestDeleteCurrent()"
>
刪除
</v-btn>
<v-btn
v-if="!masterDetailProps.isViewMode"
color="primary"
:disabled="!masterDetailProps.isDirty || masterDetailProps.isLoading"
:loading="masterDetailProps.isSaving"
variant="flat"
@click="requestSaveConfirmation()"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="flow.requestCloseDialog()">關閉</v-btn>
</template>
</template>
</MntDialogCard>
</div>
</v-overlay>
</teleport>
<ConfirmDialog
:model-value="masterDetailProps.confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="flow.confirmClose()"
@update:model-value="flow.confirmCloseVisible.value = $event"
/>
<ConfirmDialog
:model-value="masterDetailProps.confirmSaveVisible"
:confirm-loading="masterDetailProps.isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="confirmSave()"
@update:model-value="flow.confirmSaveVisible.value = $event"
>
<div v-if="masterDetailProps.saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in masterDetailProps.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="masterDetailProps.confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${masterDetailProps.pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="flow.confirmDelete()"
@update:model-value="flow.confirmDeleteVisible.value = $event"
/>
<ConfirmDialog
:model-value="masterDetailProps.confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="flow.confirmSwitch()"
@update:model-value="flow.confirmSwitchVisible.value = $event"
/>
<ConfirmDialog
:model-value="masterDetailProps.confirmNavigateVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="flow.confirmNavigate()"
@update:model-value="flow.confirmNavigateVisible.value = $event"
/>
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200"> <v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
儲存成功 儲存成功
</v-snackbar> </v-snackbar>
</template> </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
+6 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageMaintenance from '@/components/pages/PageMaintenance.vue' import PageMaint from '@/components/PageMaint.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue' import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue' import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue' import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
@@ -14,9 +14,10 @@ const {
</script> </script>
<template> <template>
<PageMaintenance <PageMaint
v-model:search-panel-open="searchPanelOpen" :title="pageModel.title"
:page="pageModel" :search-panel-open="searchPanelOpen"
@toggle-search="searchPanelOpen = !searchPanelOpen"
@create="commands.openAddDialog" @create="commands.openAddDialog"
> >
<template #search-fields> <template #search-fields>
@@ -44,7 +45,7 @@ const {
@view="commands.openViewDialog" @view="commands.openViewDialog"
/> />
</template> </template>
</PageMaintenance> </PageMaint>
<SectionFormPanel <SectionFormPanel
v-bind="formPanelProps" v-bind="formPanelProps"