refactor(login): compose page from focused login components

Split the login page into smaller reusable components for branding,
toolbar, header, form, announcements, and mobile layout behavior. This
keeps the view responsible for orchestration while moving UI sections into
focused components.

Update page creation docs to reflect the simplified flow where views render
sections/items directly and composables coordinate store/service access when
needed.refactor(login): compose page from focused login components

Split the login page into smaller reusable components for branding,
toolbar, header, form, announcements, and mobile layout behavior. This
keeps the view responsible for orchestration while moving UI sections into
focused components.

Update page creation docs to reflect the simplified flow where views render
sections/items directly and composables coordinate store/service access when
needed.
This commit is contained in:
skytek_xinliang
2026-05-27 13:43:43 +08:00
parent 7b99087cbb
commit 7b0cfe4448
18 changed files with 614 additions and 1007 deletions
+7 -53
View File
@@ -7,9 +7,9 @@
目前新增一般頁面的預設資料流:
```txt
router -> view -> (page driver) -> page component -> sections/items
store/composable -> service
router -> view -> sections/items
composable -> store -> service
```
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
@@ -22,7 +22,6 @@ Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page mod
<!-- src/views/reports/Reports.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import PageReports from '@/components/pages/PageReports.vue'
import { useSnackbarStore } from '@/stores/snackbar'
export interface ReportSummary {
@@ -55,54 +54,9 @@ function openReport(row: ReportSummary) {
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`
## 2. 新增 page component
完整頁面主畫面放在 `src/components/pages`,檔名使用 `Page` 前綴。component 以 props 接收資料,以 emit 回報使用者事件,不直接處理 route 或底層 HTTP。
```vue
<!-- src/components/pages/PageReports.vue -->
<script setup lang="ts">
import type { ReportSummary } from '@/views/reports/Reports.vue'
defineProps<{
page: { title: string; rows: ReportSummary[] }
}>()
const emit = defineEmits<{
open: [row: ReportSummary]
}>()
</script>
<template>
<v-card flat>
<v-card-title class="text-h6">{{ page.title }}</v-card-title>
<v-table>
<thead>
<tr>
<th>名稱</th>
<th>負責單位</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in page.rows" :key="row.id">
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
<td class="text-right">
<v-btn color="primary" size="small" variant="text" @click="emit('open', row)">
開啟
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</template>
```
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`
## 3. 加入 route
## 2. 加入 route
route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
@@ -125,7 +79,7 @@ route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMa
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
- 若只是新增 route,通常不需要修改 `MainLayout.vue``src/shell/*`
## 4. 需要 API 時新增 service module
## 3. 需要 API 時新增 service module
```ts
// src/services/modules/reports.ts
@@ -148,7 +102,7 @@ service 只封裝 HTTP 細節,不持有 UI 狀態。
`httpClient``baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`
## 5. 需要共享狀態時新增 store
## 4. 需要共享狀態時新增 store
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
@@ -180,7 +134,7 @@ export const useReportsStore = defineStore('reports', () => {
})
```
## 6. 驗證
## 5. 驗證
至少執行:
+31 -44
View File
@@ -24,17 +24,11 @@ Read only when needed: [analyse now](./analyse-now.md)
│ App Shell │
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
└──────────────────────────┬──────────────────────────────────┘
page model (reactive)
reactive / props
┌─────────────────────────────────────────────────────────────┐
Page Driver
│ (views/*.vue — 極薄,只負責:組裝 page model / 事件轉發)
└──────────────────────────┬──────────────────────────────────┘
│ props / emits
┌─────────────────────────────────────────────────────────────┐
│ Page Component │
│ (PageXxx.vue — 組裝完整頁面,決定 Section 順序與 override) │
View
│ (views/*.vue — 自含 page model、頁面 UI 與 section 組合)
└──────────────────────────┬──────────────────────────────────┘
│ section data
@@ -58,7 +52,7 @@ Read only when needed: [analyse now](./analyse-now.md)
### 3.2 Page Model 作為主要資料單位
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 usePageDriver() 取得 page model,傳給 Page component」。
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 composable 取得 page model,組裝 section 元件」。
- Page model 可以來自:
- store(已有快取)
- service(直接 API
@@ -130,15 +124,11 @@ src/
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
├── views/ ← 維持:Page Driver(極薄)
├── views/ ← 維持:自含頁面,邏輯與 UI 同檔
│ └── maint/
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + 掛載 PageMaintDriver
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + MaintShell 外殼
├── components/
│ ├── pages/ ← 新增:Page Component 層
│ │ ├── PageMaintenance.vue
│ │ └── PageReport.vue
│ │
│ ├── sections/ ← 新增:Section / Shelf 層
│ │ ├── SectionSearchPanel.vue
│ │ ├── SectionDataTable.vue
@@ -193,10 +183,10 @@ src/
```vue
<!-- views/maint/SingleRecord.vue優化後 -->
<script setup lang="ts">
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
import MaintShell from '@/components/MaintShell.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const { pageModel, commands, formPanelProps, formPanelEvents } = useSingleRecordMaintenancePage()
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
</script>
<template>
@@ -204,31 +194,25 @@ const { pageModel, commands, formPanelProps, formPanelEvents } = useSingleRecord
</template>
```
#### Layer 3: Page Component`src/components/pages/`
#### Layer 3: View`src/views/`
- **職責**組裝完整頁面的 section 順序、處理 page-level slot override、分發頁面級事件
- **命名**一律 `Page` 前綴
- **對齊**App Store 的 `ProductPage.svelte``TodayPage.svelte``DefaultPage.svelte`
- **職責**自含頁面的完整入口 — 組裝 page model、協調 composable、撰寫頁面 template
- **禁止**頁面 UI 不再拆到另一個 page component 層
- **對齊**標準 Vue SPA 慣例
```vue
<!-- components/pages/PageMaintenance.vue -->
<template>
<PageMaintShell :title="page.title">
<template #search>
<SectionSearchPanel :fields="page.searchFields" @search="emit('search', $event)" />
</template>
<template #content>
<SectionDataTable :records="page.records" @edit="emit('edit', $event)" />
</template>
</PageMaintShell>
<!-- views/maint/SingleRecord.vue -->
<script setup lang="ts">
import MaintShell from '@/components/MaintShell.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
<!-- 頁面級 dialog 外掛內容再拆到 SectionFormPanel -->
<SectionFormPanel
v-model="formVisible"
:mode="formMode"
:record="activeRecord"
@save="emit('save', $event)"
/>
const { pageModel, searchPanelOpen, commands, search, students, ... } = useSingleRecordMaintenancePage()
</script>
<template>
<MaintShell :title="pageModel.title" @create="commands.openAddDialog">
<template #table>...</template>
</MaintShell>
</template>
```
@@ -343,7 +327,7 @@ views/xxx.vue
- `src/shell/GlobalOverlays.vue`snackbar、搜尋 dialog、訊息 dialog。
3. [x] 新增 `src/components/pages/`:建立第一個 `PageMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
- 定義 `MaintenancePageModel` props 與 `create/edit/view/delete/search` emits。
- 使用 `PageMaint.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
- 使用 `MaintShell.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例。
- 協調搜尋條件、分頁與 `pageModel`
- 提供 `load()``resetSearch()` 供 Page Driver 呼叫。
@@ -399,7 +383,11 @@ views/xxx.vue
2. [x] `App.vue` 最終只保留 shell 掛載。
- `src/App.vue` 縮減為 7 行,只掛載 `AppShell`
- `src/shell/AppShell.vue` 承接 layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
- `src/composables/layout/useAppShell.ts` 承接 menu 合併、favorite、breadcrumb、layout action、手動/強制登出流程。
- `src/composables/layout/useAppShell.ts` 承接 menu 合併、favorite、breadcrumb、layout action、手動/強制登出流程。
### Phase 5:移除 Page Component 層 ✅ 已完成
> 所有 page component 已合併回對應的 view`src/components/pages/` 目錄已刪除。page driver 簡化為僅複雜頁面才使用的選配層,view 回歸標準 Vue SPA 慣例:自含 page model + 頁面 UI + section 組合。
---
@@ -408,8 +396,7 @@ views/xxx.vue
| 層級 | 目錄 | 檔名前綴/範例 |
|------|------|---------------|
| App Shell | `src/shell/` | `AppShell.vue``GlobalOverlays.vue` |
| Page Driver | `src/views/` | `SingleRecord.vue`route view,不改名) |
| Page Component | `src/components/pages/` | `PageMaintenance.vue` |
| View(自含頁面) | `src/views/` | `SingleRecord.vue` |
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue``SectionSearchPanel.vue` |
| Item / Atom | `src/components/items/` | `ItemDataRow.vue``ItemFormField.vue` |
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
@@ -425,7 +412,7 @@ views/xxx.vue
## 七、對齊檢查清單(新增/重構時使用)
- [ ] 這個 view 超過 100 行了嗎?→ 考慮抽出 Page Component
- [ ] 這個 view 超過 200 行了嗎?→ 考慮抽出 Section 或 composable
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
+10 -12
View File
@@ -10,7 +10,7 @@
1. `src/GUIDE.md`
2. `docs/architecture-strategy.md`
3.`maintenanceContract.pageKind` 閱讀對應的 `src/**/GUIDE.md`(查 `src/GUIDE.md` 中的「依 pageKind 選擇起點」表格
3.`maintenanceContract.pageKind` 閱讀對應的 demo 與 `src/**/GUIDE.md`(查 `docs/architecture-strategy.md` 的分層說明
4. `docs/add-page-example.md`(需要新增頁面時)
`frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md``src/**/GUIDE.md` 為準。
@@ -40,7 +40,6 @@
一般功能需求優先修改:
- `src/views/*`
- `src/components/pages/*`
- `src/components/sections/*`
- `src/components/items/*`
- `src/composables/page-drivers/*`
@@ -72,7 +71,7 @@
- 是否碰到 template core。
- 是否已有同類型範例可沿用。
- 是否需要新增 route。
- 是否應拆成 page / section / item。
- 是否應拆成 section / item。
- 是否應新增 page driver 或 command composable。
- 是否需要 store,或只需要頁面內 state。
- 是否應定義新的 model 型別(`src/models/`)。
@@ -97,11 +96,13 @@
### query(查詢頁)→ `SectionQueryPage`
參考:`src/views/demos/SectionQueryPageDemo.vue``src/components/pages/PageSectionQueryPageDemo.vue``src/composables/page-drivers/useSectionsDemoPage.ts`
參考:`src/views/demos/SectionQueryPageDemo.vue``src/composables/page-drivers/useSectionsDemoPage.ts`
架構:
```
View (薄層) → composable (page driver) → PageSectionQueryPageDemo → SectionQueryPage
View(自含 page model + UI → SectionQueryPage
composable (page driver)
```
**composable 必須回傳:**
@@ -124,11 +125,13 @@ View (薄層) → composable (page driver) → PageSectionQueryPageDemo → Sect
### application(申請/表單頁)→ `SectionFormPage`
參考:`src/views/demos/SectionFormPageDemo.vue``src/components/pages/PageSectionFormPageDemo.vue`
參考:`src/views/demos/SectionFormPageDemo.vue`
架構:
```
View (薄層) → composable (page driver) → PageSectionFormPageDemo → SectionFormPage
View(自含 page model + UI → SectionFormPage
composable (page driver)
```
**composable 必須回傳:**
@@ -145,11 +148,6 @@ View (薄層) → composable (page driver) → PageSectionFormPageDemo → Secti
- `apiCatalog.fieldRules` 中的 `field``rule` 決定必填、長度、格式驗證
- 型別轉換依 `field.type`number 欄位不可包成 string 送出
**page component emits**
- `@submit` → 呼叫 `handleFormSubmit`
- `@reset` → 呼叫 `resetForm`
- `@back` → 呼叫 `handleFormBack`
### maintenance(維護/CRUD 頁)→ `maint/*`
參考:`src/views/maint/README.md` — 依資料結構選擇最接近的範本(EditableGrid / SingleRecord / MasterDetail A/B/C