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:
+12
-14
@@ -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
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 entry,UI 與流程直接在 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 相關資料
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
:current-page="currentPage"
|
|
||||||
:grade-label="formState.gradeLabel"
|
|
||||||
:headers="tableHeaders"
|
|
||||||
:items="students"
|
|
||||||
:items-per-page="itemsPerPage"
|
|
||||||
:page="pageModel"
|
|
||||||
:page-count="pageCount"
|
|
||||||
:page-summary="pageSummary"
|
|
||||||
:row-props="formState.rowProps"
|
|
||||||
:status-color="formState.statusColor"
|
|
||||||
@create="openAddDialog"
|
@create="openAddDialog"
|
||||||
@edit="openEditDialog"
|
>
|
||||||
@reset-search="resetSearch"
|
<template #search-fields>
|
||||||
@update:current-page="currentPage = $event"
|
<SectionSearchPanel
|
||||||
@view="openViewDialog"
|
v-model="search"
|
||||||
v-on="masterDetailEvents"
|
:departments="masterDetailProps.departments"
|
||||||
|
:grade-options="masterDetailProps.gradeOptions"
|
||||||
|
:statuses="masterDetailProps.statuses"
|
||||||
|
@reset="resetSearch"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #table>
|
||||||
|
<SectionDataTable
|
||||||
|
:current-page="currentPage"
|
||||||
|
:grade-label="formState.gradeLabel"
|
||||||
|
:headers="tableHeaders"
|
||||||
|
:items="students"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:page-count="pageCount"
|
||||||
|
:page-summary="pageSummary"
|
||||||
|
:row-props="formState.rowProps"
|
||||||
|
:status-color="formState.statusColor"
|
||||||
|
@delete="flow.requestDeleteConfirmation($event)"
|
||||||
|
@edit="openEditDialog($event)"
|
||||||
|
@update:current-page="currentPage = $event"
|
||||||
|
@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"
|
||||||
|
/>
|
||||||
|
</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
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user