Compare commits

..

6 Commits

Author SHA1 Message Date
skytek_xinliang 915f3b7f2f fix: abort機制 2026-06-01 14:50:47 +08:00
skytek_xinliang f61432ad8a fixing adn docing 2026-06-01 14:44:39 +08:00
skytek_xinliang 7b0cfe4448 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.
2026-05-27 13:43:43 +08:00
skytek_xinliang 7b99087cbb 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.
2026-05-27 11:50:40 +08:00
skytek_xinliang ad00f5c195 docs: clarify optional page drivers in page guide
Update documentation to show that simple pages can define page models
directly in views without creating a page driver. Adjust examples,
section numbering, and naming guidance to better distinguish simple view
state from reusable page-driver patterns.docs: clarify optional page drivers in page guide

Update documentation to show that simple pages can define page models
directly in views without creating a page driver. Adjust examples,
section numbering, and naming guidance to better distinguish simple view
state from reusable page-driver patterns.
2026-05-27 11:18:19 +08:00
skytek_xinliang b8664b5c3e refactor: simplify page models and view driver usage
Move simple page models into page components and build trivial computed
models directly in views to avoid unnecessary page drivers. Update views
to destructure page driver returns and rely on template ref unwrapping,
and document the guidance for when page drivers should be introduced.refactor: simplify page models and view driver usage

Move simple page models into page components and build trivial computed
models directly in views to avoid unnecessary page drivers. Update views
to destructure page driver returns and rely on template ref unwrapping,
and document the guidance for when page drivers should be introduced.
2026-05-27 11:10:34 +08:00
56 changed files with 3585 additions and 4571 deletions
+3 -3
View File
@@ -4,11 +4,11 @@ registry=https://registry.npmjs.org/
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
# save-exact=true
# 安全防禦:禁止安裝發布未滿 7 天的套件 (預防供應鏈攻擊)
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
# npm v11.10+
min-release-age=7
min-release-age=4
# pnpm
minimum-release-age=10080
minimum-release-age=5760
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
# engine-strict=true
+1 -1
View File
@@ -20,7 +20,7 @@
- `src/services/modules/<domain>.ts` — service modules
- Examples of correct vs. incorrect naming:
-`PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
-`useStudentMaintenancePage.ts` → ✅ `useMaintenancePage.ts`
-`useStudentMaintenancePage.ts` → ✅ `useSingleRecordMaintenancePage.ts`
-`ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
-`useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
-`models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
+17 -85
View File
@@ -7,17 +7,20 @@
目前新增一般頁面的預設資料流:
```txt
router -> view -> page driver -> page component -> sections/items
router -> view -> sections/items
store/composable -> service
composable -> store -> service
```
## 1. 新增 page driver
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver
頁面資料、事件與暫時 UI state 優先放在 page driverview 只負責掛載。
## 1. 新增 view(含 page model
```ts
// src/composables/page-drivers/useReportsPage.ts
簡單頁面的 page model 直接在 view 裡用 `computed` 組裝,不需要額外建立 page driver。
```vue
<!-- src/views/reports/Reports.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSnackbarStore } from '@/stores/snackbar'
@@ -27,21 +30,14 @@ export interface ReportSummary {
owner: string
}
export interface ReportsPageModel {
title: string
rows: ReportSummary[]
}
const initialRows: ReportSummary[] = [
{ id: 1, title: '學生統計', owner: '教務處' },
{ id: 2, title: '課程統計', owner: '課務組' },
]
export function useReportsPage() {
const snackbar = useSnackbarStore()
const rows = ref<ReportSummary[]>(initialRows)
const pageModel = computed<ReportsPageModel>(() => ({
const pageModel = computed(() => ({
title: '報表清單',
rows: rows.value,
}))
@@ -49,82 +45,18 @@ export function useReportsPage() {
function openReport(row: ReportSummary) {
snackbar.show({ message: `開啟:${row.title}`, color: 'info' })
}
return {
pageModel,
openReport,
}
}
```
若資料來自 APIpage driver 可呼叫 store 或 composable;底層 HTTP 細節仍放在 `services/modules/*`
## 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, ReportsPageModel } from '@/composables/page-drivers/useReportsPage'
defineProps<{
page: ReportsPageModel
}>()
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>
<PageReports :page="pageModel" @open="openReport" />
</template>
```
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`
## 3. 新增 route view
view 維持薄層,只呼叫 page driver 並掛載 page component。
```vue
<!-- src/views/reports/Reports.vue -->
<script setup lang="ts">
import PageReports from '@/components/pages/PageReports.vue'
import { useReportsPage } from '@/composables/page-drivers/useReportsPage'
const page = useReportsPage()
</script>
<template>
<PageReports :page="page.pageModel.value" @open="page.openReport" />
</template>
```
## 4. 加入 route
## 2. 加入 route
route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
@@ -147,7 +79,7 @@ route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMa
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
- 若只是新增 route,通常不需要修改 `MainLayout.vue``src/shell/*`
## 5. 需要 API 時新增 service module
## 3. 需要 API 時新增 service module
```ts
// src/services/modules/reports.ts
@@ -170,7 +102,7 @@ service 只封裝 HTTP 細節,不持有 UI 狀態。
`httpClient``baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`
## 6. 需要共享狀態時新增 store
## 4. 需要共享狀態時新增 store
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
@@ -202,7 +134,7 @@ export const useReportsStore = defineStore('reports', () => {
})
```
## 7. 驗證
## 5. 驗證
至少執行:
-65
View File
@@ -1,65 +0,0 @@
## 二、我們專案的現況診斷
本文件是 `docs/architecture-strategy.md` 第二章的現況快照。分層細節以 `docs/architecture-strategy.md``src/**/GUIDE.md` 為準。
### 2.1 App Shell 已拆分
`App.vue` 目前只掛載 `src/shell/AppShell.vue`,不再承擔 layout props、tabs、搜尋 dialog、訊息 dialog 或 snackbar 的具體組裝。
目前責任分布:
| 職責 | 目前位置 |
|------|----------|
| Layout 切換 | `src/shell/AppShell.vue` |
| Tabs / keep-alive router-view | `src/shell/AppTabs.vue` |
| Breadcrumb / favorites / menu wiring | `src/composables/layout/useAppShell.ts` + `AppShell.vue` |
| Search Dialog / Message Dialog / Snackbar | `src/shell/GlobalOverlays.vue` |
| Logout / force logout | `src/composables/layout/useAppShell.ts` |
| HTTP Toast | `src/services/http-toast.ts` + `GlobalOverlays.vue` |
### 2.2 Views 已大幅變薄
維護頁與一般頁面目前多數已轉為 route-level wiring
- `Home.vue`:呼叫 `useHomePage()`,掛載 `PageHome`
- `Settings.vue`:呼叫 `useSettingsPage()`,掛載 `PageSettings`
- `FncPage.vue`:呼叫 `useFunctionPage()`,掛載 `PageFunction`
- `views/maint/*`:呼叫對應 page driver,掛載 `components/pages/*Maintenance.vue`
`SingleRecord.vue` 已不再直接管理 store mutation、大型 dialog 模板、表格分頁與 CRUD 細節;這些流程已移到 page driver、section component、item component 與 command composable。
`Login.vue` 是 template core 例外,仍負責登入頁組合、功能開關、小型提示 dialog 與登入流程協調。登入頁的 captcha、announcement、忘記密碼與記住帳號流程已透過 composable / props / emits 拆分,後續調整應維持該模式。
### 2.3 Page Driver / Command / Page Component 已落地
目前已存在的主要分層:
```txt
view -> page driver -> page component -> section/item
command/store/service
```
- `src/composables/page-drivers/*`:組裝 page model、route/query 轉換與頁面事件。
- `src/composables/commands/useCrudCommands.ts`:承接維護頁 CRUD 命令流程。
- `src/components/pages/*`:完整頁面的主畫面組裝。
- `src/components/sections/*`:搜尋區、表格區、表單 dialog/panel、表單/查詢頁外殼。
- `src/components/items/*`:欄位群組或單筆資料呈現。
### 2.4 Dialog 與區塊拆分狀態
維護頁的大型 dialog 與表單欄位已從 view 抽出:
- `SectionFormPanel.vue`:維護頁表單 overlay/dialog shell。
- `MntDialogCard.vue``MntRecordNavToolbar.vue`:維護頁 dialog 內部骨架。
- `ItemFormFieldGroup.vue`:表單欄位群組。
新增頁面時,若只是小型提示 dialog 且只屬於單一路由,可先留在 page driver / page component。若 dialog 包含大型表單、確認流程或可重用骨架,優先抽到 section 或 feature component。
### 2.5 仍需注意的邊界
- `src/models/page.ts` 目前主要服務 maintenance page model;部分頁面仍在各自 page driver 內定義局部 page model 型別。
- `components/maint/*` 與 maintenance page components 屬於 demo / maintenance 領域,不應直接升格為全域 base 元件。
- `src/components/base` 目前只放跨頁共用基礎元件,例如 `DraggableDialog``BaseFormTextField``BaseFormSelect`
- `src/stores/app.ts` 仍是 Pinia scaffold,尚未承擔實際 app state。
- 一般功能需求不應修改 `App.vue``src/shell/*`、layout、router guard 或 HTTP core,除非需求明確牽涉這些 template core。
+49 -69
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
@@ -67,23 +61,15 @@ Read only when needed: [analyse now](./analyse-now.md)
範例:
```ts
// src/composables/usePageDriver.ts
export function useMaintenancePage() {
// views/maint/Example.vue — 簡單頁面直接在 view 組裝 page model
const studentStore = useStudentStore()
const { records, loading, error, load } = useCrudDriver({
store: studentStore,
loadAction: () => studentStore.fetchStudents(),
})
const pageModel = computed(() => ({
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '單筆資料維護',
records: records.value,
loading: loading.value,
error: error.value,
records: studentStore.students,
loading: false,
error: null,
}))
return { pageModel, load }
}
```
### 3.3 查詢(Query)與命令(Command)分離
@@ -138,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
@@ -164,8 +146,8 @@ src/
│ └── DraggableDialog.vue
├── composables/
│ ├── page-drivers/ ← 新增:頁面資料協調
│ │ └── useMaintenancePage.ts
│ ├── page-drivers/ ← 新增:頁面資料協調(僅複雜頁面需要)
│ │ └── useSingleRecordMaintenancePage.ts
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
│ │ └── useCrudCommands.ts
│ ├── forms/ ← 維持/重組:表單狀態機
@@ -201,14 +183,10 @@ src/
```vue
<!-- views/maint/SingleRecord.vue優化後 -->
<script setup lang="ts">
import { useMaintenancePage } from '@/composables/page-drivers/useMaintenancePage'
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
import MaintShell from '@/components/maint/MaintShell.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const { pageModel, load } = useMaintenancePage({
title: '單筆資料維護',
records: [],
})
load()
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
</script>
<template>
@@ -216,31 +194,25 @@ load()
</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/maint/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>
```
@@ -355,10 +327,11 @@ 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 開放,不綁定特定領域型別。
4. [x] 新增 `src/composables/page-drivers/`:建立 `useMaintenancePage.ts`
- 透過 options 傳入 title 與 records協調搜尋條件、分頁與 `pageModel`
- 使用 `MaintShell.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例
- 協調搜尋條件、分頁與 `pageModel`
- 提供 `load()``resetSearch()` 供 Page Driver 呼叫。
- 後續已刪除純包裝型 driver(如 `useMaintenancePage`)。僅當頁面需要協調多個 composable 時才建立 page driver。
### Phase 2:遷移最厚的 viewSingleRecord.vue ✅ 已完成
@@ -374,7 +347,7 @@ views/xxx.vue
- View 中不再直接定義 `<teleport>``<v-overlay>` 或多個確認 dialog。
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`
- 只呈現欄位與欄位錯誤,透過 `v-model``clear-field-error` 與上層互動。
6. [x] 將 CRUD command 流程抽出到 `src/composables/commands/useCrudCommands.ts`
6. [x] 將 CRUD command 流程抽出到 `src/composables/useCrudCommands.ts`
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
@@ -382,6 +355,8 @@ views/xxx.vue
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
> 後續簡化時,B/C/EditableGrid 的薄 page driver 已 inline 回 view,只保留有真實複雜邏輯的 driver。
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`
@@ -399,6 +374,8 @@ views/xxx.vue
### Phase 4:非 maintenance 頁面統一 ✅ 已完成
> 後續簡化時,Settings/FncPage 的薄 page driver 已 inline 回 view,型別移至 page component 自身。
1. [x] `Home.vue``Settings.vue``FncPage.vue` 套用 Page Driver + Page Component 模式。
- `src/views/Home.vue` 縮減為 17 行,新增 `src/components/pages/PageHome.vue``src/composables/page-drivers/useHomePage.ts`
- `src/views/Settings.vue` 縮減為 10 行,新增 `src/components/pages/PageSettings.vue``src/composables/page-drivers/useSettingsPage.ts`
@@ -408,6 +385,10 @@ views/xxx.vue
- `src/shell/AppShell.vue` 承接 layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
- `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 組合。
---
## 六、命名規範總結
@@ -415,14 +396,13 @@ 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`(維持) |
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
| Page Driver Composable | `src/composables/page-drivers/` | `useMaintenancePage.ts` |
| Command Composable | `src/composables/commands/` | `useCrudCommands.ts` |
| Page Driver Composable | `src/composables/page-drivers/` | `useSingleRecordMaintenancePage.ts` |
| Command Composable | `src/composables/` | `useCrudCommands.ts` |
| Form Composable | `src/composables/forms/` | `useForm.ts` |
| Domain Store | `src/stores/` | `students.ts`(維持) |
| Service Module | `src/services/modules/` | `students.ts`(維持) |
@@ -432,7 +412,7 @@ views/xxx.vue
## 七、對齊檢查清單(新增/重構時使用)
- [ ] 這個 view 超過 100 行了嗎?→ 考慮抽出 Page Component
- [ ] 這個 view 超過 200 行了嗎?→ 考慮抽出 Section 或 composable
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
+11 -13
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,11 +40,10 @@
一般功能需求優先修改:
- `src/views/*`
- `src/components/pages/*`
- `src/components/sections/*`
- `src/components/items/*`
- `src/composables/page-drivers/*`
- `src/composables/commands/*`
- `src/composables/useCrudCommands.ts`
- `src/stores/*`
- `src/services/modules/*`
- `src/router/routes.ts`
@@ -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
-81
View File
@@ -1,81 +0,0 @@
## 一、Apple App Store 專案的核心架構特徵
### 1.1 單一業務邏輯門面(Jet Facade)
```
browser.ts → bootstrap → Jet.load → runtime + objectGraph
UI 層僅透過 jet.dispatch(intent) / jet.perform(action) 溝通
```
- **Jet** 封裝所有業務邏輯:路由、資料取得、動作分發、metrics。
- UI 層**不直接**呼叫 API、不直接操作 storage、不直接操作 history。
- 所有外部依賴(fetch、storage、locale、user)統一注入 `Dependencies`,再組裝成 `ObjectGraph`
### 1.2 Intent / Action 分離(查詢與命令)
| 類型 | 職責 | 回傳值 | 例子 |
|------|------|--------|------|
| **Intent** | 取得頁面資料(Query | `Promise<Page>` | `RouteUrlIntent` → 回傳 `ProductPage` |
| **Action** | 執行副作用(Command | `'performed' \| 'unsupported'` | `FlowAction` → 導航到新頁面 |
- `FlowAction` 是主要導航機制:內含 `destination Intent` + `pageUrl` + `presentationContext`
- Action handler 註冊採用**型別註冊制**:`jet.onAction('flowAction', handler)`
### 1.3 Page Model 驅動 UI(資料驅動)
```ts
// App.svelte 的介面極簡
export let page: Promise<Page> | Page
```
- 整個應用由單一 `page` prop 驅動。
- `PageResolver` 處理 `Promise<Page>` 的 loading / error 狀態。
- `Page.svelte` 用 type guard 分發到對應的 page component`isProductPage(page)``<ProductPage>`
- **Page 是 union type**,不是 route-based 的硬編碼映射。
### 1.4 Shelf / Item 分層(容器與內容分離)
```
Page (TodayPage / ProductPage / ...)
└── Shelf[] (水平捲軸 / 網格)
└── ShelfItemLayout (佈局抽象:HorizontalShelf or Grid)
└── Item (BrickItem / LargeLockupItem / ...)
```
- **Shelf** = 容器邏輯:決定是水平捲軸還是網格、rowsPerColumn、邊框。
- **ShelfItemLayout** = 佈局中介:根據 `isHorizontal` 選擇 `HorizontalShelf``Grid`
- **Item** = 純粹內容渲染:只關心單一資料單位的呈現,不知道自己是水平還是網格。
- **FallbackShelf** = 優雅的降級策略:遇到未實作的 shelf 類型顯示 placeholder,不 crash。
### 1.5 Svelte Context 作為跨層依賴注入
```ts
// bootstrap.ts
context.set('jet', jet)
context.set('i18n', i18nStore)
// 深層元件
const jet = getJet() // 從 Svelte Context 取得
const i18n = getI18n() // 從 Svelte Context 取得
```
- 避免 props drillingJet、i18n、accessibility layout、today-card layout 都透過 context 傳遞。
- Context 在啟動時注入,生命周期與應用一致,不是用來傳遞 UI 狀態的。
### 1.6 命令式外殼 + 聲明式 UI
```ts
// browser.ts(命令式啟動層)
const app = new App({ target: container, context, hydrate: true })
registerActionHandlers({
jet,
updateApp: (props) => app.$set(props), // 橋接命令式 → 聲明式
})
```
- 導航、歷史管理、scroll 復原由命令式的 action handler 處理。
- UI 渲染完全聲明式,只接收 `page``isFirstPage` 兩個 prop。
---
+13 -16
View File
@@ -1,19 +1,19 @@
# 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
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`
- `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`
- `router/`route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`
- `shell/`AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`
@@ -57,15 +57,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
- `views/FncPage.vue`
- `views/Settings.vue`
- `views/maint/*`
- `components/PageIndex.vue`
- `components/PageMaint.vue`
- `components/maint/MaintShell.vue`
- `components/maint/*`
- `components/pages/*Maintenance.vue`
- `components/sections/*`
- `components/items/*`
- `composables/page-drivers/*MaintenancePage.ts`
- `composables/maint/*`
- `composables/commands/*`
- `composables/useCrudCommands.ts`
- `stores/students.ts`
- `stores/semesters.ts`
- demo assets 與 demo language keys
@@ -74,14 +72,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
## 新功能流程
1. 新增或修改 `views/*` route entry。
2. 若有完整頁面 UI,新增 `components/pages/PageXxx.vue`
3.有頁面資料協調或 route param 轉換,新增 `composables/page-drivers/useXxxPage.ts`
4.畫面有獨立區塊,拆到 `components/sections/*`
5. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`
6. 跨頁共享狀態才新增或修改 `stores/*`
7. 外部 API 放在 `services/modules/*`
8.`router/routes.ts` 新增 route。
1. 新增或修改 `views/*` route entry,直接在 view 裡組裝 page model 與 UI
2. 若有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝
3.畫面有獨立區塊,拆到 `components/sections/*`
4. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`
5. 跨頁共享狀態才新增或修改 `stores/*`
6. 外部 API 放在 `services/modules/*`
7. `router/routes.ts` 新增 route
## 驗證
+3 -3
View File
@@ -84,7 +84,7 @@ Layout composables
`src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/components/PageLogin.vue``src/components/login/*`
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/views/Login.vue``src/components/login/*`
`src/services/modules/auth.ts``src/services/modules/menu.ts` 是預設後端契約的 API adapter。接新後端時常需要調整,但不要把 UI 狀態放進 service module。
@@ -93,11 +93,11 @@ Layout composables
以下內容偏向示範資料與範例頁,建立新專案時可依需求替換或刪除:
- `src/views/Home.vue`
- `src/components/PageIndex.vue`
- `src/views/maint/*`
- `src/components/maint/*`
- `src/composables/maint/*`
- `src/components/PageMaint.vue`
- `src/components/maint/MaintShell.vue`
- `src/stores/students.ts`
- `src/stores/semesters.ts`
- `src/views/FncPage.vue`
+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
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
page driver / command composable -> store -> service
```
`MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/maint/`
## 主要目錄
## 規則
- `views/`route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`
- `components/`Vue UI 元件,依 pages / sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`
- `composables/`page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`
- `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/*`
- 元件不直接 import store 或 service
- 元件以 props 接收資料,以 emits 回報使用者意圖
- 可複用元件不含 domain 名稱(如 `student``course`
## 驗證
-234
View File
@@ -1,234 +0,0 @@
<template>
<v-container class="pa-0" fluid>
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
<v-sheet
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
color="surface"
>
<v-avatar color="primary" size="52" variant="tonal">
<span class="text-h5">👋</span>
</v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<!--
使用 v-data-iterator 保留一致的列表輸出結構
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架
-->
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
<!--
Vuetify 會把原始資料包進 wrapper
這裡統一解包可避免模板層散落型別判斷
-->
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="emit('news', resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="emit('message-center')"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="emit('quick', item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<!--
這個 dialog 只做消息內容呈現
開關狀態仍交給 view 管理避免頁面元件自行持有流程狀態
-->
<v-dialog
:model-value="props.isNewsDialogOpen"
max-width="640"
@update:model-value="emit('update:isNewsDialogOpen', $event)"
>
<v-card v-if="props.selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ props.selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
{{ props.selectedNews.dept }} · {{ props.selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ props.selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
interface NewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
interface QuickItem {
icon: string
title: string
}
const props = defineProps<{
newsItems: NewsItem[]
quickItems: QuickItem[]
selectedNews: NewsItem | null
isNewsDialogOpen: boolean
}>()
const emit = defineEmits<{
news: [item: NewsItem]
'message-center': []
quick: [item: QuickItem]
'update:isNewsDialogOpen': [value: boolean]
}>()
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
function resolveNewsItem(wrapped: unknown): NewsItem {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return (wrapped as { raw: NewsItem }).raw
}
return wrapped as NewsItem
}
</script>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
-548
View File
@@ -1,548 +0,0 @@
<template>
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
<!-- Side Layouts -->
<v-row
v-if="props.layout !== 'card'"
class="fill-height"
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
no-gutters
>
<!-- Illustration Column -->
<v-col
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
cols="12"
lg="8"
sm="6"
>
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="props.branding.title" />
</div>
<v-sheet
v-if="props.withAnnouncement"
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
max-width="680"
rounded="lg"
width="100%"
>
<LoginAnnouncementBoard
:all-tab-label="props.announcementBoard.allTabLabel"
:date-header="props.announcementBoard.dateHeader"
:empty-text="props.announcementBoard.emptyText"
:items="props.announcementBoard.items"
:items-per-page="props.announcementBoard.itemsPerPage"
:pagination-label="props.announcementBoard.paginationLabel"
:school-header="props.announcementBoard.schoolHeader"
:system-announcements="props.announcementBoard.systemAnnouncements"
:tabs="props.announcementBoard.tabs"
:title="props.announcementBoard.title"
:title-header="props.announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement"
/>
</v-sheet>
</v-col>
<v-col
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
cols="12"
lg="4"
sm="6"
>
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
<v-banner
class="d-sm-none mb-2"
density="comfortable"
lines="one"
:mobile="false"
:stacked="false"
>
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon
class="mobile-banner-icon"
color="primary"
size="small"
:icon="mdiBullhornVariantOutline"
/>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none"
color="primary"
size="small"
variant="text"
@click="mobileAnnouncementSheetVisible = true"
>
{{ props.mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
>
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
:with-forgot-password="props.form.withForgotPassword"
:with-remember-account="props.form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</div>
</v-col>
</v-row>
<!-- Card Layout (Centered) -->
<v-row
v-else
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
no-gutters
>
<v-card
class="rounded-lg"
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
elevation="10"
max-width="450"
width="100%"
>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-md-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
:with-forgot-password="props.form.withForgotPassword"
:with-remember-account="props.form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet
v-if="props.withAnnouncement"
v-model="mobileAnnouncementSheetVisible"
class="d-sm-none"
>
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ props.mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span
>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ props.mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
</template>
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, ref } from 'vue'
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
import LoginBrand from './login/LoginBrand.vue'
import LoginForm from './login/LoginForm.vue'
import LoginHeader from './login/LoginHeader.vue'
import LoginToolBar from './login/LoginToolBar.vue'
import LoginVerify from './login/LoginVerify.vue'
interface BrandingConfig {
title?: string
organization?: string
}
interface IllustrationConfig {
image?: string | null
title?: string
description?: string
}
interface HeaderConfig {
welcomeText?: string
welcomeDescription?: string
}
interface AnnouncementTabConfig {
label: string
value: string
}
interface AnnouncementItemConfig {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface AnnouncementBoardConfig {
title?: string
tabs?: AnnouncementTabConfig[]
items?: AnnouncementItemConfig[]
systemAnnouncements?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
interface MobileAnnouncementConfig {
items?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
show?: boolean
viewAllText?: string
listTitle?: string
closeText?: string
emptyText?: string
}
interface ForgotPasswordConfig {
text?: string
href?: string
target?: string
}
interface FormConfig {
accPlaceholder?: string
passwPlaceholder?: string
rememberMeLabel?: string
submitText?: string
rememberStorageKey?: string
withForgotPassword?: boolean
withRememberAccount?: boolean
withCaptcha?: boolean
captcha?: {
imgUrl?: string
id?: string
tokenValue?: string
}
captchaValue?: string
captchaLoading?: boolean
captchaErrorMessage?: string
captchaVerified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
forgotPassword: ForgotPasswordConfig
}
interface ToolBarConfig {
show?: boolean
locale?: string
locales?: string[]
}
interface Props {
layout: 'side-left' | 'side-right' | 'card'
withAnnouncement?: boolean
branding: BrandingConfig
illustration: IllustrationConfig
announcementBoard: AnnouncementBoardConfig
mobileAnnouncement: MobileAnnouncementConfig
header: HeaderConfig
form: FormConfig
toolbar: ToolBarConfig
}
const props = withDefaults(defineProps<Props>(), {
layout: 'side-left',
withAnnouncement: true,
branding: () => ({
title: 'Skyteck Login',
organization: 'school',
}),
illustration: () => ({
image: null,
title: 'Login',
description: 'Login to your account',
}),
announcementBoard: () => ({
title: '學校公告區',
tabs: [
{ label: '全部', value: '__all__' },
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: [
{
id: 'announcement-1',
date: '2024-03-19',
school: '市立實踐國中',
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
tab: 'junior',
},
],
systemAnnouncements: [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
}),
mobileAnnouncement: () => ({
items: [],
show: false,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}),
header: () => ({
welcomeText: 'Welcome back 👋🏻',
welcomeDescription: 'Please enter your account password to login',
}),
form: () => ({
accPlaceholder: '請輸入帳號',
passwPlaceholder: '請輸入密碼',
rememberMeLabel: '記住帳號',
submitText: '登入',
rememberStorageKey: 'sklogin.remember.username',
withForgotPassword: true,
withRememberAccount: true,
withCaptcha: true,
captcha: undefined,
captchaValue: '',
captchaLoading: false,
captchaErrorMessage: '',
captchaVerified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
forgotPassword: {
text: '忘記密碼?',
href: '',
target: undefined,
},
}),
toolbar: () => ({
show: true,
locale: 'zh-TW',
locales: ['zh-TW', 'en-US'],
}),
})
const emit = defineEmits([
'submit',
'change-locale',
'forgot-password',
'captcha-refresh',
'captcha-change',
'toggle-layout',
'select-announcement',
])
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (!props.withAnnouncement) return false
if (props.mobileAnnouncement.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => {
return `layout-${props.layout}`
})
function handleLogin(formData: Record<string, unknown>) {
emit('submit', formData)
}
function handleCaptchaRefresh() {
emit('captcha-refresh')
}
function handleCaptchaChange(value: string) {
emit('captcha-change', value)
}
function handleChangeLocale(nextLocale: string) {
emit('change-locale', nextLocale)
}
function handleToggleLayout() {
emit('toggle-layout')
}
function handleForgotPassword(e: MouseEvent) {
emit('forgot-password', e)
}
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
emit('select-announcement', item)
}
</script>
<style scoped>
:deep(.v-banner__prepend) {
align-self: center;
margin-inline-end: 16px;
}
:deep(.v-banner-actions) {
align-self: center;
}
.mobile-banner-icon {
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
transform-origin: center;
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-banner-icon {
animation: none;
}
}
.illustration-panel {
background: linear-gradient(
135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%
);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
/* Specific styles for side-right to flip border */
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
@@ -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>
-14
View File
@@ -1,14 +0,0 @@
<script setup lang="ts">
import type { FunctionPageModel } from '@/composables/page-drivers/useFunctionPage'
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>
-12
View File
@@ -1,12 +0,0 @@
<script setup lang="ts">
import type { SettingsPageModel } from '@/composables/page-drivers/useSettingsPage'
defineProps<{
page: SettingsPageModel
}>()
</script>
<template>
<!-- Page component 只呈現 page driver 組好的設定頁 model -->
<div>{{ page.title }}</div>
</template>
@@ -1,7 +1,6 @@
<script setup lang="ts">
interface Props {
title: string
backLabel?: string
error?: string
loading?: boolean
message?: string
@@ -10,7 +9,6 @@ interface Props {
}
withDefaults(defineProps<Props>(), {
backLabel: '返回',
resetLabel: '清除',
submitLabel: '存檔',
})
@@ -50,7 +48,6 @@ const emit = defineEmits<{
{{ submitLabel }}
</v-btn>
<v-btn type="button" variant="tonal" @click="emit('reset')">{{ resetLabel }}</v-btn>
<v-btn type="button" variant="text" @click="emit('back')">{{ backLabel }}</v-btn>
</v-row>
</v-card>
</v-container>
+1 -7
View File
@@ -6,9 +6,7 @@ interface Props {
loading?: boolean
}
withDefaults(defineProps<Props>(), {
backLabel: '返回',
})
withDefaults(defineProps<Props>(), {})
const emit = defineEmits<{
search: []
@@ -38,9 +36,5 @@ const emit = defineEmits<{
</v-card>
<slot name="sections" />
<v-row class="pa-4">
<v-btn variant="tonal" @click="emit('back')">{{ backLabel }}</v-btn>
</v-row>
</v-container>
</template>
+18 -15
View File
@@ -4,12 +4,13 @@
## 子目錄
- `page-drivers/`:頁面資料協調與 page model 組裝。
- `commands/`:命令式副作用流程,例如 create/edit/save/delete。
- `layout/`AppShell / layout 狀態與事件協調。
- `page-drivers/`:頁面資料協調與 page model 組裝(僅複雜頁面使用)
- `maint/`maintenance demo 的表單、CRUD、editable grid 狀態。
- `layout/`AppShell / layout 狀態與事件協調。
頂層也可放通用 composable,例如 `useApiCall.ts`:封裝 loading / data / error / execute 模式、自動 snackbar 錯誤提示與取消請求過濾。適合為單一 API 呼叫提供輕量狀態管理,但不替代 page driver 或 command composable。
頂層放通用 composable
- `useApiCall.ts`:封裝 loading / data / error / execute 模式。
- `useCrudCommands.ts`:通用 CRUD 狀態機(新增 / 編輯 / 檢視 / 儲存 / highlight)。
## 新增規則
@@ -22,20 +23,22 @@
## Page Driver
Page driver 負責
Page driver 只應在「需要協調多個 composable / store / route」時才成立。若頁面邏輯只有
- 組裝一個 `computed` page model3-5 個欄位)
- 沒有搜尋、沒有 dialog、沒有複雜事件
則**不要建立 page driver**,直接在 view 裡寫 `computed` 即可。
當需要 page driver 時,它負責:
- route param/query 轉成頁面資料
- 組裝 page model
- 組裝 page component 需要的 props/events
- 協調 store、command composable、表單 composable
- 組裝 page component 需要的 props/events
View 只呼叫 page driver 並掛載 page component。
View 以 destructure 方式取用 page driver 回傳值:
```ts
const { pageModel, search, handleSubmit } = useXxxPage()
```
模板中直接使用,不寫 `.value``:page="pageModel"``v-model="search"`
## Commands
Command composable 負責副作用流程,不負責畫面布局:
- 新增 / 編輯 / 刪除 / 儲存
- 儲存前確認
- 成功後重新載入或 highlight
- 與 store/service 的 mutation 流程
@@ -1,19 +0,0 @@
import { computed } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
export function useEditableGridMaintenancePage() {
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '可編輯表格維護示範',
records: studentStore.students,
loading: false,
error: null,
}))
return {
pageModel,
}
}
@@ -1,18 +0,0 @@
import { computed } from 'vue'
import { useRoute } from 'vue-router'
export interface FunctionPageModel {
fncId: string
}
export function useFunctionPage() {
const route = useRoute()
const pageModel = computed<FunctionPageModel>(() => ({
fncId: String(route.params.fncId ?? ''),
}))
return {
pageModel,
}
}
@@ -1,46 +0,0 @@
import { computed, ref } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
export interface UseMaintenancePageOptions {
title: string
records: unknown[]
itemsPerPage?: number
}
export function useMaintenancePage(options: UseMaintenancePageOptions) {
const search = ref<Record<string, unknown>>({})
const searchPanelOpen = ref(false)
const currentPage = ref(1)
const itemsPerPage = options.itemsPerPage ?? 10
const pageCount = computed(() =>
Math.max(1, Math.ceil(options.records.length / itemsPerPage))
)
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: options.title,
records: options.records,
loading: false,
error: null,
}))
function load() {
// 由呼叫方在 load 中觸發資料載入;未來可擴充為非同步
}
function resetSearch() {
search.value = {}
}
return {
pageModel,
search,
searchPanelOpen,
currentPage,
itemsPerPage,
pageCount,
load,
resetSearch,
}
}
@@ -383,8 +383,11 @@ export function useMasterDetailAMaintenancePage() {
}
return {
confirmSave,
currentPage,
departments,
detailForm,
flow,
formState,
gradeOptions,
itemsPerPage,
@@ -396,7 +399,9 @@ export function useMasterDetailAMaintenancePage() {
pageCount,
pageModel,
pageSummary,
requestSaveConfirmation,
resetSearch,
scrollToField,
search,
searchPanelOpen,
snackbarVisible,
@@ -1,20 +0,0 @@
import { computed } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
export function useMasterDetailBMaintenancePage() {
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範B',
records: studentStore.students,
loading: false,
error: null,
}))
return {
pageModel,
}
}
@@ -1,20 +0,0 @@
import { computed } from 'vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
export function useMasterDetailCMaintenancePage() {
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範C',
records: studentStore.students,
loading: false,
error: null,
}))
return {
pageModel,
}
}
@@ -1,13 +0,0 @@
import { computed } from 'vue'
export interface SettingsPageModel {
title: string
}
export function useSettingsPage() {
const pageModel = computed<SettingsPageModel>(() => ({
title: '設定頁面',
}))
return { pageModel }
}
@@ -1,6 +1,6 @@
import { computed, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useCrudCommands } from '@/composables/commands/useCrudCommands'
import { useCrudCommands } from '@/composables/useCrudCommands'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import {
type StudentFormState,
+2
View File
@@ -143,6 +143,8 @@ export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
schoolHeader: '公告學校',
titleHeader: '公告標題',
paginationLabel: '總筆數:',
allTabLabel: '全部',
emptyText: '目前沒有公告資料',
}))
const selectedAnnouncement = computed(() => {
+1 -1
View File
@@ -23,4 +23,4 @@
- 各頁面的 specific model 擴展 `BasePageModel`(例如 `MaintenancePageModel``type``records`)。
- `PageModel` union 供 page component props 型別使用。
新增頁面類型時,先擴充 `PageModel` union 再新增對應的 page driver
新增頁面類型時,先擴充 `PageModel` union。若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),再建立對應的 page driver;簡單頁面直接在 view 用 `computed` 組裝 page model 即可
+1
View File
@@ -32,3 +32,4 @@ service module 不需要自行 catch 並處理錯誤,交由 interceptors/hooks
- JSON payload 用 `json`FormData 用 `body`
- 取消請求使用原生 `AbortController``signal`
- token 注入與 401 force logout 集中在 hooks,不在單一 API module 重寫。
- 重複請求取消策略(key 命名、何時 abort、何時清理)由 store/composable 決定,service module 不應持有 controller map。
+6
View File
@@ -84,3 +84,9 @@ token 由 `tokenService` 作為單一來源:
## 請求取消
需要取消請求時,由 store 或 composable 建立 `AbortController`service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
建議做法:
- 在 store/composable 以 key 管理同類請求(例如 `auth/login``menu/get-menu`)。
- 發新請求前先取消同 key 舊請求,避免競態與多餘流量。
- 請求結束後於 `finally` 清理該 key;離開流程(如 `clear``logout`)時清理全部 key。
+7
View File
@@ -21,3 +21,10 @@
## 資料流
store 可以呼叫 service module。component 不應繞過 store/composable 直接處理 token、session 或 HTTP hooks。
## 請求取消慣例
- 需要避免重複提交或快速切換造成的舊請求殘留時,在 store 層管理 `AbortController`
- 同一類請求使用固定 key(例如 `auth/login``menu/get-menu`),新請求前先取消舊請求。
- service module 只接收 `signal`,不管理 controller lifecycle。
- store 在 `finally` 清理該 key,在 `clear/logout` 清理全部 key。
+6 -6
View File
@@ -5,6 +5,7 @@ import { normalizeError } from '@/services/error'
import { authApi } from '@/services/modules/auth'
import { tokenService } from '@/services/token'
import { useMenuStore } from '@/stores/menu'
import { createRequestControllerManager } from '@/stores/request-controller'
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
@@ -56,22 +57,20 @@ export const useAuthStore = defineStore('auth', () => {
const token = tokenService.token
const loading = ref(false)
const error = ref<string | null>(null)
// 只針對 login 取消重複請求,避免競態與重複提交
const loginController = ref<AbortController | null>(null)
const requestControllerManager = createRequestControllerManager()
const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
loginController.value?.abort()
loginController.value = new AbortController()
const signal = requestControllerManager.replace('auth/login')
loading.value = true
error.value = null
try {
const requestBody = createLoginRequestBody(payload)
const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
const requestOptions = { signal: loginController.value.signal }
const requestOptions = { signal }
const { data } =
requestFormat === 'json'
? await authApi.loginWithJson(requestBody, requestOptions)
@@ -125,11 +124,12 @@ export const useAuthStore = defineStore('auth', () => {
throw normalizedError
} finally {
loading.value = false
loginController.value = null
requestControllerManager.clear('auth/login')
}
}
const logout = () => {
requestControllerManager.clearAll()
user.value = null
tokenService.clearToken()
useMenuStore().clear()
+9 -2
View File
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { normalizeError } from '@/services/error'
import { menuApi, type MenuNode } from '@/services/modules/menu'
import { createRequestControllerManager } from '@/stores/request-controller'
export interface LayoutMenuItem {
title: string
@@ -17,6 +18,7 @@ export const useMenuStore = defineStore('menu', () => {
const isRail = ref(false)
const error = ref<string | null>(null)
const loading = ref(false)
const requestControllerManager = createRequestControllerManager()
const menuStorageKey = 'sk_playground_menu'
const favoriteStorageKey = 'sk_playground_favorite'
@@ -180,6 +182,7 @@ export const useMenuStore = defineStore('menu', () => {
})
const clear = () => {
requestControllerManager.clearAll()
menu.value = []
favorite.value = []
isRail.value = false
@@ -190,9 +193,10 @@ export const useMenuStore = defineStore('menu', () => {
}
const getMenu = async (id: string) => {
const signal = requestControllerManager.replace('menu/get-menu')
try {
loading.value = true
const res = await menuApi.getMenu({ userID: id })
const res = await menuApi.getMenu({ userID: id }, { signal })
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) {
const normalizedError = normalizeError(error_)
@@ -202,13 +206,15 @@ export const useMenuStore = defineStore('menu', () => {
throw normalizedError
} finally {
loading.value = false
requestControllerManager.clear('menu/get-menu')
}
}
const getFavorite = async (id: string) => {
const signal = requestControllerManager.replace('menu/get-favorite')
try {
loading.value = true
const res = await menuApi.getFavorite({ userID: id })
const res = await menuApi.getFavorite({ userID: id }, { signal })
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) {
const normalizedError = normalizeError(error_)
@@ -218,6 +224,7 @@ export const useMenuStore = defineStore('menu', () => {
throw normalizedError
} finally {
loading.value = false
requestControllerManager.clear('menu/get-favorite')
}
}
+33
View File
@@ -0,0 +1,33 @@
export interface RequestControllerManager {
replace: (key: string) => AbortSignal
clear: (key: string) => void
clearAll: () => void
}
export function createRequestControllerManager(): RequestControllerManager {
const controllers = new Map<string, AbortController>()
const replace = (key: string): AbortSignal => {
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
return controller.signal
}
const clear = (key: string) => {
controllers.delete(key)
}
const clearAll = () => {
controllers.forEach((controller) => {
controller.abort()
})
controllers.clear()
}
return {
replace,
clear,
clearAll,
}
}
+9 -4
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import PageFunction from '@/components/pages/PageFunction.vue'
import { useFunctionPage } from '@/composables/page-drivers/useFunctionPage'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const { pageModel } = useFunctionPage()
const route = useRoute()
const pageModel = computed(() => ({
fncId: String(route.params.fncId ?? ''),
}))
</script>
<template>
<PageFunction :page="pageModel" />
<v-sheet height="100%" width="100%">
{{ pageModel.fncId }}
</v-sheet>
</template>
+27 -10
View File
@@ -1,41 +1,58 @@
# 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">`
- 直接 route component 放在 `src/views``src/views/<feature>`
- 一般 view 目標 < 80 行。
- route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。
- 不直接 import 或包住 `MainLayout.vue`
- 不直接定義大型 `<v-dialog>``<v-overlay>`、大型表格或大型表單
- 複雜 UI 拆到 `components/sections/*``components/items/*`
## 建議形狀
簡單頁面:直接在 view 組裝 page model 與 template。
```vue
<script setup lang="ts">
import PageReports from '@/components/pages/PageReports.vue'
import { useReportsPage } from '@/composables/page-drivers/useReportsPage'
const page = useReportsPage()
import { computed } from 'vue'
const pageModel = computed(() => ({ title: '我的頁面' }))
</script>
<template>
<PageReports :page="page.pageModel.value" />
<v-card>{{ pageModel.title }}</v-card>
</template>
```
複雜頁面:透過 page driver composable 協調多個資料來源。
```vue
<script setup lang="ts">
import MaintShell from '@/components/maint/MaintShell.vue'
import { useXxxPage } from '@/composables/page-drivers/useXxxPage'
const { pageModel, search, handleSave, ... } = useXxxPage()
</script>
<template>
<MaintShell :title="pageModel.title" @create="handleCreate">
<template #table>...</template>
</MaintShell>
</template>
```
以 destructure 方式取用 composable 回傳值,模板不寫 `.value`
## Login.vue 開關
`Login.vue` 是登入頁的組合層,登入頁功能開關集中在 view 內宣告,透過 `PageLogin` / composable 往下傳遞,不在子元件各自決定是否啟用。
`Login.vue` 是登入頁的完整入口,登入頁功能開關集中在 view 內宣告,透過 composable 往下傳遞,不在子元件各自決定是否啟用。
- `withCaptcha`:控制驗證碼 UI、captcha API 載入/刷新,以及登入 payload 是否帶 captcha 資料。關閉時不應發出 captcha API,也不應檢查或送出 captcha 欄位。
- `withAnnouncement`:控制公告 UI、公告 mock data/composable 資料流與公告詳情互動。關閉時公告板、手機公告列與公告對話框資料來源都應停用。
- `withForgotPassword`:控制忘記密碼連結與事件。關閉時 UI 不顯示,也不應觸發忘記密碼事件。
- `withRememberAccount`:控制記住帳號 UI 與 localStorage 讀寫。關閉時不顯示 checkbox、不讀寫記住帳號 storage,送出資料固定視為未記住帳號。
新增登入頁選配功能時,優先維持同樣模式:view 宣告開關、composable 負責資料流與 side effect、page/form component 只依 props 呈現 UI 與發出事件。
新增登入頁選配功能時,優先維持同樣模式:view 宣告開關、composable 負責資料流與 side effect、form component 只依 props 呈現 UI 與發出事件。
## 子目錄
+196 -10
View File
@@ -1,17 +1,203 @@
<script setup lang="ts">
import PageHome from '@/components/pages/PageHome.vue'
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
import { useHomePage } from '@/composables/page-drivers/useHomePage'
const page = useHomePage()
const { handleMessageCenter, handleNews, handleQuick, isNewsDialogOpen, pageModel, selectedNews } = useHomePage()
interface NewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
function resolveNewsItem(wrapped: unknown): NewsItem {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return (wrapped as { raw: NewsItem }).raw
}
return wrapped as NewsItem
}
</script>
<template>
<PageHome
v-model:news-dialog-open="page.isNewsDialogOpen.value"
:page="page.pageModel.value"
:selected-news="page.selectedNews.value"
@message-center="page.handleMessageCenter"
@news="page.handleNews"
@quick="page.handleQuick"
/>
<v-container class="pa-0" fluid>
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
<v-sheet
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
color="surface"
>
<v-avatar color="primary" size="52" variant="tonal">
<span class="text-h5">👋</span>
</v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<v-data-iterator class="mt-2" item-key="id" :items="pageModel.newsItems" :items-per-page="-1">
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="handleNews(resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="handleMessageCenter"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in pageModel.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="handleQuick(item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<v-dialog
v-model="isNewsDialogOpen"
max-width="640"
>
<v-card v-if="selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ selectedNews.month }} {{ selectedNews.date }} ·
{{ selectedNews.dept }} · {{ selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="isNewsDialogOpen = false">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
+342 -78
View File
@@ -1,63 +1,15 @@
<template>
<page-login
:announcement-board="announcementBoard"
:branding="branding"
:form="form"
:header="header"
:illustration="illustration"
:layout="formPositionLayout"
:mobile-announcement="mobileAnnouncement"
:toolbar="toolbar"
:with-announcement="withAnnouncement"
@captcha-change="handleCaptchaChange"
@captcha-refresh="handleCaptchaRefresh"
@change-locale="handleChangeLocale"
@forgot-password="handleForgotPassword"
@select-announcement="handleSelectAnnouncement"
@submit="onLogin"
@toggle-layout="handleToggleLayout"
/>
<v-dialog v-model="dialogVisible" width="360">
<v-card>
<v-card-title>{{ dialogTitle }}</v-card-title>
<v-card-text>{{ dialogMessage }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
{{ t('common.ok') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="announcementDialogVisible" max-width="720">
<v-card>
<v-card-title class="text-h6">
{{ selectedAnnouncement?.title }}
</v-card-title>
<v-card-subtitle class="pt-2">
{{ selectedAnnouncement?.date }} {{ selectedAnnouncement?.school }}
</v-card-subtitle>
<v-card-text class="text-body-1">
{{ selectedAnnouncementDetail }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import HyakkaouAcademyImage from '@/assets/logo.png'
import PageLogin from '@/components/PageLogin.vue'
import LoginAnnouncementBoard from '@/components/login/LoginAnnouncementBoard.vue'
import LoginBrand from '@/components/login/LoginBrand.vue'
import LoginForm from '@/components/login/LoginForm.vue'
import LoginHeader from '@/components/login/LoginHeader.vue'
import LoginToolBar from '@/components/login/LoginToolBar.vue'
import LoginVerify from '@/components/login/LoginVerify.vue'
import {
type LoginAnnouncementListItem,
useLoginAnnouncements,
@@ -69,7 +21,6 @@ import { useSnackbarStore } from '@/stores/snackbar'
type LayoutType = 'side-left' | 'side-right' | 'card'
// i18n
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
@@ -77,21 +28,14 @@ const authStore = useAuthStore()
const menuStore = useMenuStore()
const snackbarStore = useSnackbarStore()
// 語系選項
const locales = ['zh-TW', 'en-US']
// 插圖圖片來源
const illustrationImage = ref(HyakkaouAcademyImage)
// 功能開關與版型
const formPositionLayout = ref<LayoutType>('side-left')
// 是否啟用公告
const withAnnouncement = ref(true)
const withForgotPassword = ref(true)
const withRememberAccount = ref(true)
// 功能開關:是否啟用驗證碼
const withCaptcha = ref(true)
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
const {
@@ -101,7 +45,6 @@ const {
selectedAnnouncementDetail,
} = loginAnnouncements
// 文字內容(i18n
const title = computed(() => t('pages.login.title'))
const organization = computed(() => t('pages.login.organization'))
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
@@ -117,19 +60,14 @@ const verifyText = computed(() => t('pages.login.verifyText'))
const captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
// 連結與儲存設定
// 忘記密碼連結(由 form.forgotPassword 設定)
const forgotPasswordHref = ref('/forgot-password')
const forgotPasswordTarget = ref<string | undefined>(undefined)
// 記住帳號的 localStorage key
const rememberStorageKey = ref('login.remember.username')
// 驗證與對話框狀態
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMessage = ref('')
const announcementDialogVisible = ref(false)
// 內容組合(傳入 PageLogin
const branding = computed(() => ({
title: title.value,
organization: organization.value,
@@ -146,7 +84,6 @@ const header = computed(() => ({
welcomeDescription: welcomeDescription.value,
}))
// 表單區塊設定(含功能開關)
const form = computed(() => ({
accPlaceholder: accPlaceholder.value,
passwPlaceholder: passwPlaceholder.value,
@@ -158,7 +95,6 @@ const form = computed(() => ({
rememberStorageKey: rememberStorageKey.value,
withForgotPassword: withForgotPassword.value,
withRememberAccount: withRememberAccount.value,
// 功能開關:是否顯示驗證碼
withCaptcha: withCaptcha.value,
captcha: loginCaptcha.formCaptcha.value,
captchaValue: loginCaptcha.captchaValue.value,
@@ -172,18 +108,26 @@ const form = computed(() => ({
},
}))
// 右上工具列設定(含顯示開關)
const toolbar = computed(() => ({
// 功能開關:是否顯示語系切換工具列
show: true,
locale: locale.value,
locales,
}))
// 事件處理
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => mobileAnnouncement.value.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (!withAnnouncement.value) return false
if (mobileAnnouncement.value.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => `layout-${formPositionLayout.value}`)
function handleForgotPassword(e: MouseEvent) {
if (!withForgotPassword.value) return
console.log('Forgot Password Click:', e)
}
@@ -239,8 +183,6 @@ async function onLogin(data: Record<string, unknown>) {
menuStore.getMenu(authStore.user?.id ?? '')
// menuStore.getFavorite(authStore.user?.id ?? '')
snackbarStore.show({
message: t('pages.login.alert.loginSuccess'),
color: 'success',
@@ -264,3 +206,325 @@ onMounted(() => {
void loginCaptcha.loadCaptcha().catch(() => undefined)
})
</script>
<template>
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
<v-row
v-if="formPositionLayout !== 'card'"
class="fill-height"
:class="{ 'flex-row-reverse': formPositionLayout === 'side-right' }"
no-gutters
>
<v-col
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
cols="12"
lg="8"
sm="6"
>
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="branding.title" />
</div>
<v-sheet
v-if="withAnnouncement"
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
max-width="680"
rounded="lg"
width="100%"
>
<LoginAnnouncementBoard
:all-tab-label="announcementBoard.allTabLabel"
:date-header="announcementBoard.dateHeader"
:empty-text="announcementBoard.emptyText"
:items="announcementBoard.items"
:items-per-page="announcementBoard.itemsPerPage"
:pagination-label="announcementBoard.paginationLabel"
:school-header="announcementBoard.schoolHeader"
:system-announcements="announcementBoard.systemAnnouncements"
:tabs="announcementBoard.tabs"
:title="announcementBoard.title"
:title-header="announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement"
/>
</v-sheet>
</v-col>
<v-col
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
cols="12"
lg="4"
sm="6"
>
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
<v-banner
class="d-sm-none mb-2"
density="comfortable"
lines="one"
:mobile="false"
:stacked="false"
>
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon
class="mobile-banner-icon"
color="primary"
size="small"
:icon="mdiBullhornVariantOutline"
/>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none"
color="primary"
size="small"
variant="text"
@click="mobileAnnouncementSheetVisible = true"
>
{{ mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="toolbar.show"
:locale="toolbar.locale"
:locales="toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
>
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block"
:welcome-description="header.welcomeDescription"
:welcome-text="header.welcomeText"
/>
<LoginForm
:acc-placeholder="form.accPlaceholder"
:forgot-password-href="form.forgotPassword.href"
:forgot-password-target="form.forgotPassword.target"
:forgot-password-text="form.forgotPassword.text"
:passw-placeholder="form.passwPlaceholder"
:remember-me-label="form.rememberMeLabel"
:remember-storage-key="form.rememberStorageKey"
:submit-text="form.submitText"
:with-forgot-password="form.withForgotPassword"
:with-remember-account="form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="onLogin"
>
<template v-if="form.withCaptcha" #verify>
<LoginVerify
:captcha="form.captcha"
:captcha-placeholder="form.captchaPlaceholder"
:error-message="form.captchaErrorMessage"
:loading="form.captchaLoading"
:model-value="form.captchaValue"
:refresh-title="form.refreshTitle"
:verified="form.captchaVerified"
:verify-text="form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
</div>
</div>
</v-col>
</v-row>
<v-row
v-else
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
no-gutters
>
<v-card
class="rounded-lg"
:class="toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
elevation="10"
max-width="450"
width="100%"
>
<LoginToolBar
v-if="toolbar.show"
:locale="toolbar.locale"
:locales="toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="branding.title" />
</div>
<LoginHeader
class="d-none d-md-block"
:welcome-description="header.welcomeDescription"
:welcome-text="header.welcomeText"
/>
<LoginForm
:acc-placeholder="form.accPlaceholder"
:forgot-password-href="form.forgotPassword.href"
:forgot-password-target="form.forgotPassword.target"
:forgot-password-text="form.forgotPassword.text"
:passw-placeholder="form.passwPlaceholder"
:remember-me-label="form.rememberMeLabel"
:remember-storage-key="form.rememberStorageKey"
:submit-text="form.submitText"
:with-forgot-password="form.withForgotPassword"
:with-remember-account="form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="onLogin"
>
<template v-if="form.withCaptcha" #verify>
<LoginVerify
:captcha="form.captcha"
:captcha-placeholder="form.captchaPlaceholder"
:error-message="form.captchaErrorMessage"
:loading="form.captchaLoading"
:model-value="form.captchaValue"
:refresh-title="form.refreshTitle"
:verified="form.captchaVerified"
:verify-text="form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet
v-if="withAnnouncement"
v-model="mobileAnnouncementSheetVisible"
class="d-sm-none"
>
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span
>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
<v-dialog v-model="dialogVisible" width="360">
<v-card>
<v-card-title>{{ dialogTitle }}</v-card-title>
<v-card-text>{{ dialogMessage }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
{{ t('common.ok') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="announcementDialogVisible" max-width="720">
<v-card>
<v-card-title class="text-h6">
{{ selectedAnnouncement?.title }}
</v-card-title>
<v-card-subtitle class="pt-2">
{{ selectedAnnouncement?.date }} {{ selectedAnnouncement?.school }}
</v-card-subtitle>
<v-card-text class="text-body-1">
{{ selectedAnnouncementDetail }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
:deep(.v-banner__prepend) {
align-self: center;
margin-inline-end: 16px;
}
:deep(.v-banner-actions) {
align-self: center;
}
.mobile-banner-icon {
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
transform-origin: center;
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-banner-icon {
animation: none;
}
}
.illustration-panel {
background: linear-gradient(
135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%
);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
+5 -4
View File
@@ -1,10 +1,11 @@
<script setup lang="ts">
import PageSettings from '@/components/pages/PageSettings.vue'
import { useSettingsPage } from '@/composables/page-drivers/useSettingsPage'
import { computed } from 'vue'
const { pageModel } = useSettingsPage()
const pageModel = computed(() => ({
title: '設定頁面',
}))
</script>
<template>
<PageSettings :page="pageModel" />
<div>{{ pageModel.title }}</div>
</template>
+67 -9
View File
@@ -1,17 +1,75 @@
<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'
// Demo view 維持薄層,只掛 page driver,並把 page model / actions 傳給 page component。
const page = useSectionsDemoPage()
const { demoForm, handleFormBack, handleFormSubmit, pageModel, resetDemoForm } =
useSectionsDemoPage()
</script>
<template>
<PageSectionFormPageDemo
v-model:demo-form="page.demoForm.value"
:page="page.pageModel.value"
@back="page.handleFormBack"
@reset="page.resetDemoForm"
@submit="page.handleFormSubmit"
<SectionFormPage
reset-label="清除"
submit-label="送出"
:message="pageModel.formMessage"
title="SectionFormPage 報表申請"
@back="handleFormBack"
@reset="resetDemoForm"
@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>
+41 -9
View File
@@ -1,16 +1,48 @@
<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'
// Demo view 維持薄層,只掛 page driver,並把 page model / actions 傳給 page component。
const page = useSectionsDemoPage()
const { handleQueryBack, handleQuerySearch, pageModel, queryFilters } = useSectionsDemoPage()
</script>
<template>
<PageSectionQueryPageDemo
v-model:query-filters="page.queryFilters.value"
:page="page.pageModel.value"
@back="page.handleQueryBack"
@search="page.handleQuerySearch"
/>
<SectionQueryPage title="查詢頁DEMO" @back="handleQueryBack" @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>
+13 -4
View File
@@ -1,10 +1,19 @@
<script setup lang="ts">
import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue'
import { useEditableGridMaintenancePage } from '@/composables/page-drivers/useEditableGridMaintenancePage'
import { computed } from 'vue'
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
const page = useEditableGridMaintenancePage()
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '可編輯表格維護示範',
records: studentStore.students,
loading: false,
error: null,
}))
</script>
<template>
<PageEditableGridMaintenance :page="page.pageModel.value" />
<EditableStudentGrid :title="pageModel.title" />
</template>
+3 -5
View File
@@ -1,12 +1,12 @@
# Maintenance Views Guide
`views/maint` 是維護頁 demo。所有檔案都應是薄 route entry實際 UI 與流程分別放在 `components/pages``components/sections``components/items``composables/page-drivers`
`views/maint` 是維護頁 demo。所有檔案都是自含的 route entryUI 與流程直接在 view 中組合 `components/maint/MaintShell``components/sections``components/items` 與 composable。
## 目前範本
- `SingleRecord.vue`:單主檔 CRUD + dialog。
- `SingleRecord.vue`:單主檔 CRUD + dialog(使用 page driver composable
- `EditableGrid.vue`:可編輯表格。
- `MasterDetailA.vue`:主檔 + 側邊明細 panel。
- `MasterDetailA.vue`:主檔 + 側邊明細 panel(使用 page driver composable
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
- `MasterDetailC.vue`:主檔 + 簡化明細清單。
@@ -15,8 +15,6 @@
複製維護頁時同步調整:
- `router/routes.ts``path``name``component``meta.layout`
- page driver 名稱與 import
- page component 名稱與 import
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
- store、service、model、語系、menu/favorites/breadcrumb 相關資料
+360 -23
View File
@@ -1,34 +1,371 @@
<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 MaintShell from '@/components/maint/MaintShell.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
import { useMasterDetailAMaintenancePage } from '@/composables/page-drivers/useMasterDetailAMaintenancePage'
const page = useMasterDetailAMaintenancePage()
const {
confirmSave, currentPage, departments, detailForm, flow, formState,
gradeOptions, itemsPerPage, masterDetailEvents, masterDetailProps,
openAddDialog, openEditDialog, openViewDialog, pageCount, pageModel,
pageSummary, requestSaveConfirmation, resetSearch, scrollToField,
search, searchPanelOpen, snackbarVisible, statuses, students, tableHeaders,
} = useMasterDetailAMaintenancePage()
</script>
<template>
<PageMasterDetailAMaintenance
v-model:search="page.search.value"
v-model:search-panel-open="page.searchPanelOpen.value"
v-bind="page.masterDetailProps.value"
:current-page="page.currentPage.value"
:grade-label="page.formState.gradeLabel"
:headers="page.tableHeaders.value"
:items="page.students.value"
:items-per-page="page.itemsPerPage"
:page="page.pageModel.value"
:page-count="page.pageCount.value"
:page-summary="page.pageSummary.value"
:row-props="page.formState.rowProps"
:status-color="page.formState.statusColor"
@create="page.openAddDialog"
@edit="page.openEditDialog"
@reset-search="page.resetSearch"
@update:current-page="page.currentPage.value = $event"
@view="page.openViewDialog"
v-on="page.masterDetailEvents"
<MaintShell
:search-panel-open="searchPanelOpen"
:title="pageModel.title"
@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"
: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>
</MaintShell>
<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-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
<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>
</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
+33 -27
View File
@@ -1,52 +1,58 @@
<script setup lang="ts">
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
import MaintShell from '@/components/maint/MaintShell.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const page = useSingleRecordMaintenancePage()
const {
commands, currentPage, departments, flow, formPanelEvents, formPanelProps,
formState, gradeOptions, itemsPerPage, pageCount, pageModel, pageSummary,
resetSearch, search, searchPanelOpen, snackbarVisible,
statuses, students, tableHeaders,
} = useSingleRecordMaintenancePage()
</script>
<template>
<PageMaintenance
v-model:search-panel-open="page.searchPanelOpen.value"
:page="page.pageModel.value"
@create="page.commands.openAddDialog"
<MaintShell
:title="pageModel.title"
:search-panel-open="searchPanelOpen"
@toggle-search="searchPanelOpen = !searchPanelOpen"
@create="commands.openAddDialog"
>
<template #search-fields>
<SectionSearchPanel
v-model="page.search.value"
:departments="page.departments"
:grade-options="page.gradeOptions"
:statuses="page.statuses"
@reset="page.resetSearch"
v-model="search"
:departments="departments"
:grade-options="gradeOptions"
:statuses="statuses"
@reset="resetSearch"
/>
</template>
<template #table>
<SectionDataTable
v-model:current-page="page.currentPage.value"
:grade-label="page.formState.gradeLabel"
:headers="page.tableHeaders.value"
:items="page.students.value"
:items-per-page="page.itemsPerPage"
:page-count="page.pageCount.value"
:page-summary="page.pageSummary.value"
:row-props="page.formState.rowProps"
:status-color="page.formState.statusColor"
@delete="page.flow.requestDeleteConfirmation"
@edit="page.commands.openEditDialog"
@view="page.commands.openViewDialog"
v-model: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"
@edit="commands.openEditDialog"
@view="commands.openViewDialog"
/>
</template>
</PageMaintenance>
</MaintShell>
<SectionFormPanel
v-bind="page.formPanelProps.value"
v-on="page.formPanelEvents"
v-bind="formPanelProps"
v-on="formPanelEvents"
/>
<v-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
儲存成功
</v-snackbar>
</template>