docs: reorganize architecture strategy documentation
Split current project diagnostics into a dedicated analysis document and trim the main architecture strategy to focus on core guidance. This makes the documentation easier to navigate and separates observed issues from recommended architectural principles.docs: reorganize architecture strategy documentation Split current project diagnostics into a dedicated analysis document and trim the main architecture strategy to focus on core guidance. This makes the documentation easier to navigate and separates observed issues from recommended architectural principles.
This commit is contained in:
@@ -0,0 +1,47 @@
|
|||||||
|
## 二、我們專案的現況診斷
|
||||||
|
|
||||||
|
### 2.1 App.vue 過度臃腫(~590 行)
|
||||||
|
|
||||||
|
| 職責 | 行數 | 應屬層級 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Layout 切換 | ~20 | App Shell |
|
||||||
|
| Tabs 管理 | ~80 | Page Driver |
|
||||||
|
| Breadcrumb 組裝 | ~40 | Layout |
|
||||||
|
| Favorites 管理 | ~60 | Store |
|
||||||
|
| Search Dialog | ~80 | App Shell / Widget |
|
||||||
|
| Message Dialog | ~60 | App Shell / Widget |
|
||||||
|
| Snackbar | ~10 | Global Overlay |
|
||||||
|
| Logout / Force logout | ~30 | Auth Flow |
|
||||||
|
| HTTP Toast | ~20 | Service Layer |
|
||||||
|
|
||||||
|
- **問題**:App.vue 同時承擔 App Shell、Page Driver、Global Widget、Auth Flow 四種責任。
|
||||||
|
- **對比**:App Store 的 `App.svelte` 只有 161 行,只負責 `Navigation + PageResolver + Footer`。
|
||||||
|
|
||||||
|
### 2.2 Views 過厚(SingleRecord.vue ~830 行)
|
||||||
|
|
||||||
|
- 混雜:表格呈現、搜尋表單、dialog 模板、表單狀態、CRUD 流程、驗證邏輯、分頁、snackbar。
|
||||||
|
- **對比**:App Store 的 `ProductPage.svelte` 只有 77 行,只負責「把 page 轉成 DefaultPageRequirements + 一個 slot override」。
|
||||||
|
|
||||||
|
### 2.3 缺乏統一的頁面資料門面
|
||||||
|
|
||||||
|
```
|
||||||
|
現況:
|
||||||
|
view → store → service(直接鏈式呼叫)
|
||||||
|
view 自己管理 loading / error / dialog visible
|
||||||
|
|
||||||
|
App Store:
|
||||||
|
UI → jet.dispatch(intent) → runtime → controller → page model
|
||||||
|
UI 只接收 page model,不管理載入狀態
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 Dialog 狀態與模板內嵌於 View
|
||||||
|
|
||||||
|
- `SingleRecord.vue` 內含 5 個 `ConfirmDialog` 實例 + 1 個大 form overlay。
|
||||||
|
- 任何 dialog 更動都需要修改 view 檔案。
|
||||||
|
|
||||||
|
### 2.5 沒有容器/內容分離的 Section 層
|
||||||
|
|
||||||
|
- 表格、表單、搜尋區塊都是直接寫在 view 或 page component 中。
|
||||||
|
- 缺乏類似 `ShelfItemLayout` 的通用佈局抽象:「這一區是水平捲軸還是網格」應該由容器決定,裡面的內容元件不應該知道。
|
||||||
|
|
||||||
|
---
|
||||||
+61
-157
@@ -1,139 +1,19 @@
|
|||||||
# 資料流與元件分層優化策略
|
# 資料流與元件分層優化策略
|
||||||
|
|
||||||
> 分析標的:`~/code/apps.apple.com-main`(Apple App Store Web — Svelte + Jet 架構)
|
|
||||||
> 適用目標:`/home/carl/git/skt-vuetify-templates`(Vue 3 + Vuetify + Pinia)
|
|
||||||
> 原則:不強制遷移既有程式碼,但所有新增功能與重構以本文件為最高準則。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、Apple App Store 專案的核心架構特徵
|
## 一、Apple App Store 專案的核心架構特徵
|
||||||
|
|
||||||
### 1.1 單一業務邏輯門面(Jet Facade)
|
1 單一業務邏輯門面
|
||||||
|
2 Intent / Action 分離(查詢與命令)
|
||||||
|
3 Page Model 驅動 UI(資料驅動)
|
||||||
|
4 Shelf / Item 分層(容器與內容分離)
|
||||||
|
5 Svelte Context 作為跨層依賴注入
|
||||||
|
6 命令式外殼 + 聲明式 UI
|
||||||
|
|
||||||
```
|
Read only when needed: [what apple do](./what-apple-do.md)
|
||||||
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 drilling:Jet、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。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、我們專案的現況診斷
|
## 二、我們專案的現況診斷
|
||||||
|
|
||||||
### 2.1 App.vue 過度臃腫(~590 行)
|
Read only when needed: [analyse now](./analyse-now.md)
|
||||||
|
|
||||||
| 職責 | 行數 | 應屬層級 |
|
|
||||||
|------|------|----------|
|
|
||||||
| Layout 切換 | ~20 | App Shell |
|
|
||||||
| Tabs 管理 | ~80 | Page Driver |
|
|
||||||
| Breadcrumb 組裝 | ~40 | Layout |
|
|
||||||
| Favorites 管理 | ~60 | Store |
|
|
||||||
| Search Dialog | ~80 | App Shell / Widget |
|
|
||||||
| Message Dialog | ~60 | App Shell / Widget |
|
|
||||||
| Snackbar | ~10 | Global Overlay |
|
|
||||||
| Logout / Force logout | ~30 | Auth Flow |
|
|
||||||
| HTTP Toast | ~20 | Service Layer |
|
|
||||||
|
|
||||||
- **問題**:App.vue 同時承擔 App Shell、Page Driver、Global Widget、Auth Flow 四種責任。
|
|
||||||
- **對比**:App Store 的 `App.svelte` 只有 161 行,只負責 `Navigation + PageResolver + Footer`。
|
|
||||||
|
|
||||||
### 2.2 Views 過厚(SingleRecord.vue ~830 行)
|
|
||||||
|
|
||||||
- 混雜:表格呈現、搜尋表單、dialog 模板、表單狀態、CRUD 流程、驗證邏輯、分頁、snackbar。
|
|
||||||
- **對比**:App Store 的 `ProductPage.svelte` 只有 77 行,只負責「把 page 轉成 DefaultPageRequirements + 一個 slot override」。
|
|
||||||
|
|
||||||
### 2.3 缺乏統一的頁面資料門面
|
|
||||||
|
|
||||||
```
|
|
||||||
現況:
|
|
||||||
view → store → service(直接鏈式呼叫)
|
|
||||||
view 自己管理 loading / error / dialog visible
|
|
||||||
|
|
||||||
App Store:
|
|
||||||
UI → jet.dispatch(intent) → runtime → controller → page model
|
|
||||||
UI 只接收 page model,不管理載入狀態
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.4 Dialog 狀態與模板內嵌於 View
|
|
||||||
|
|
||||||
- `SingleRecord.vue` 內含 5 個 `ConfirmDialog` 實例 + 1 個大 form overlay。
|
|
||||||
- 任何 dialog 更動都需要修改 view 檔案。
|
|
||||||
|
|
||||||
### 2.5 沒有容器/內容分離的 Section 層
|
|
||||||
|
|
||||||
- 表格、表單、搜尋區塊都是直接寫在 view 或 page component 中。
|
|
||||||
- 缺乏類似 `ShelfItemLayout` 的通用佈局抽象:「這一區是水平捲軸還是網格」應該由容器決定,裡面的內容元件不應該知道。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、優化後的資料流策略
|
## 三、優化後的資料流策略
|
||||||
|
|
||||||
@@ -188,7 +68,7 @@ App Store:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/composables/usePageDriver.ts
|
// src/composables/usePageDriver.ts
|
||||||
export function useStudentMaintenancePage() {
|
export function useMaintenancePage() {
|
||||||
const studentStore = useStudentStore()
|
const studentStore = useStudentStore()
|
||||||
const { records, loading, error, load } = useCrudDriver({
|
const { records, loading, error, load } = useCrudDriver({
|
||||||
store: studentStore,
|
store: studentStore,
|
||||||
@@ -264,7 +144,7 @@ src/
|
|||||||
│
|
│
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── pages/ ← 新增:Page Component 層
|
│ ├── pages/ ← 新增:Page Component 層
|
||||||
│ │ ├── PageStudentMaintenance.vue
|
│ │ ├── PageMaintenance.vue
|
||||||
│ │ └── PageReport.vue
|
│ │ └── PageReport.vue
|
||||||
│ │
|
│ │
|
||||||
│ ├── sections/ ← 新增:Section / Shelf 層
|
│ ├── sections/ ← 新增:Section / Shelf 層
|
||||||
@@ -273,7 +153,7 @@ src/
|
|||||||
│ │ └── SectionFormPanel.vue
|
│ │ └── SectionFormPanel.vue
|
||||||
│ │
|
│ │
|
||||||
│ ├── items/ ← 新增:Item / Atom 層(領域獨立)
|
│ ├── items/ ← 新增:Item / Atom 層(領域獨立)
|
||||||
│ │ ├── ItemStudentRow.vue
|
│ │ ├── ItemDataRow.vue
|
||||||
│ │ └── ItemFormField.vue
|
│ │ └── ItemFormField.vue
|
||||||
│ │
|
│ │
|
||||||
│ ├── layouts/ ← 維持:App Shell Layout
|
│ ├── layouts/ ← 維持:App Shell Layout
|
||||||
@@ -285,11 +165,11 @@ src/
|
|||||||
│
|
│
|
||||||
├── composables/
|
├── composables/
|
||||||
│ ├── page-drivers/ ← 新增:頁面資料協調
|
│ ├── page-drivers/ ← 新增:頁面資料協調
|
||||||
│ │ └── useStudentMaintenancePage.ts
|
│ │ └── useMaintenancePage.ts
|
||||||
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
|
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
|
||||||
│ │ └── useStudentCrudCommands.ts
|
│ │ └── useCrudCommands.ts
|
||||||
│ ├── forms/ ← 維持/重組:表單狀態機
|
│ ├── forms/ ← 維持/重組:表單狀態機
|
||||||
│ │ └── useStudentForm.ts
|
│ │ └── useForm.ts
|
||||||
│ └── layout/ ← 維持
|
│ └── layout/ ← 維持
|
||||||
│
|
│
|
||||||
├── models/ ← 新增:領域模型與 Page Union
|
├── models/ ← 新增:領域模型與 Page Union
|
||||||
@@ -321,15 +201,18 @@ src/
|
|||||||
```vue
|
```vue
|
||||||
<!-- views/maint/SingleRecord.vue(優化後) -->
|
<!-- views/maint/SingleRecord.vue(優化後) -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useStudentMaintenancePage } from '@/composables/page-drivers/useStudentMaintenancePage'
|
import { useMaintenancePage } from '@/composables/page-drivers/useMaintenancePage'
|
||||||
import PageStudentMaintenance from '@/components/pages/PageStudentMaintenance.vue'
|
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
||||||
|
|
||||||
const { pageModel, load } = useStudentMaintenancePage()
|
const { pageModel, load } = useMaintenancePage({
|
||||||
|
title: '單筆資料維護',
|
||||||
|
records: [],
|
||||||
|
})
|
||||||
load()
|
load()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageStudentMaintenance :page="pageModel" />
|
<PageMaintenance :page="pageModel" />
|
||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -340,7 +223,7 @@ load()
|
|||||||
- **對齊**:App Store 的 `ProductPage.svelte`、`TodayPage.svelte`、`DefaultPage.svelte`。
|
- **對齊**:App Store 的 `ProductPage.svelte`、`TodayPage.svelte`、`DefaultPage.svelte`。
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- components/pages/PageStudentMaintenance.vue -->
|
<!-- components/pages/PageMaintenance.vue -->
|
||||||
<template>
|
<template>
|
||||||
<PageMaintShell :title="page.title">
|
<PageMaintShell :title="page.title">
|
||||||
<template #search>
|
<template #search>
|
||||||
@@ -392,7 +275,7 @@ load()
|
|||||||
- **對齊**:App Store 的 `BrickItem.svelte`、`LargeLockupItem.svelte`。
|
- **對齊**:App Store 的 `BrickItem.svelte`、`LargeLockupItem.svelte`。
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- ItemStudentRow.vue -->
|
<!-- ItemDataRow.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="d-flex ga-2">
|
<div class="d-flex ga-2">
|
||||||
<v-chip size="small" :color="statusColor(data.status)">
|
<v-chip size="small" :color="statusColor(data.status)">
|
||||||
@@ -407,7 +290,7 @@ load()
|
|||||||
|
|
||||||
| 場景 | 容器(Section) | 內容(Item) |
|
| 場景 | 容器(Section) | 內容(Item) |
|
||||||
|------|-----------------|--------------|
|
|------|-----------------|--------------|
|
||||||
| 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemStudentRow`(決定單列呈現) |
|
| 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemDataRow`(決定單列呈現) |
|
||||||
| 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) |
|
| 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) |
|
||||||
| 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD) | `ItemProductCard`(卡片內容) |
|
| 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD) | `ItemProductCard`(卡片內容) |
|
||||||
| 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions) | `ItemFormFieldGroup`(欄位群組) |
|
| 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions) | `ItemFormFieldGroup`(欄位群組) |
|
||||||
@@ -461,20 +344,41 @@ views/xxx.vue
|
|||||||
|
|
||||||
## 五、重構優先順序與遷移路徑
|
## 五、重構優先順序與遷移路徑
|
||||||
|
|
||||||
### Phase 1:建立基礎設施(不動既有 view)
|
### Phase 1:建立基礎設施(不動既有 view) ✅ 已完成
|
||||||
|
|
||||||
1. 新增 `src/models/`:定義領域模型與 Page union type。
|
1. [x] 新增 `src/models/`:定義領域模型與 Page union type。
|
||||||
2. 新增 `src/shell/`:從 `App.vue` 抽出 `GlobalOverlays.vue` 與 `AppTabs.vue`。
|
- `src/models/student.ts`:抽出 `StudentRecord`,`stores/students.ts` 改為 re-export 以保持向後相容。
|
||||||
3. 新增 `src/components/pages/`:建立第一個 `PageStudentMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
|
- `src/models/page.ts`:定義 `BasePageModel`、`MaintenancePageModel` 與 `PageModel` union。
|
||||||
4. 新增 `src/composables/page-drivers/`:建立 `useStudentMaintenancePage.ts`。
|
2. [x] 新增 `src/shell/`:從 `App.vue` 抽出 `GlobalOverlays.vue` 與 `AppTabs.vue`。
|
||||||
|
- `src/shell/AppShell.vue`:layout 切換與全域 overlay 掛載點。
|
||||||
|
- `src/shell/AppTabs.vue`:頁籤管理與 router-view 容器。
|
||||||
|
- `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`。
|
||||||
|
- 提供 `load()` 與 `resetSearch()` 供 Page Driver 呼叫。
|
||||||
|
|
||||||
### Phase 2:遷移最厚的 view(SingleRecord.vue)
|
### Phase 2:遷移最厚的 view(SingleRecord.vue) ✅ 已完成
|
||||||
|
|
||||||
1. 將 `SingleRecord.vue` 的模板抽出到 `PageStudentMaintenance.vue`。
|
1. [x] 將 `SingleRecord.vue` 縮減為 route-level Page Driver。
|
||||||
2. 將 dialog 抽出到 `SectionFormPanel.vue`。
|
- 目前只負責呼叫 `useSingleRecordMaintenancePage()`,並組裝 `PageMaintenance`、`SectionSearchPanel`、`SectionDataTable`、`SectionFormPanel`。
|
||||||
3. 將表單欄位抽出到 `ItemFormFieldGroup.vue` 或 `ItemFormField.vue[]`。
|
- 行數已由 921 行縮減至 52 行,達成 < 80 行目標。
|
||||||
4. 將 CRUD 流程抽出到 `composables/commands/useStudentCrudCommands.ts`。
|
2. [x] 將搜尋區塊抽出到 `src/components/sections/SectionSearchPanel.vue`。
|
||||||
5. `SingleRecord.vue` 目標縮減到 < 80 行。
|
- 負責搜尋欄位佈局與 reset 事件,不直接操作 store。
|
||||||
|
3. [x] 將表格與分頁抽出到 `src/components/sections/SectionDataTable.vue`。
|
||||||
|
- 負責 headers、資料列 slot、操作按鈕與分頁 footer,透過 emit 回傳 view/edit/delete/page 事件。
|
||||||
|
4. [x] 將 dialog 抽出到 `src/components/sections/SectionFormPanel.vue`。
|
||||||
|
- 包含側邊 overlay、`MntDialogCard`、record navigation toolbar 與確認 dialog。
|
||||||
|
- 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`。
|
||||||
|
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
|
||||||
|
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
|
||||||
|
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
||||||
|
- `SingleRecord.vue` 不再直接操作 `studentStore`。
|
||||||
|
|
||||||
### Phase 3:推廣到所有 maintenance 頁面
|
### Phase 3:推廣到所有 maintenance 頁面
|
||||||
|
|
||||||
@@ -494,14 +398,14 @@ views/xxx.vue
|
|||||||
|------|------|---------------|
|
|------|------|---------------|
|
||||||
| App Shell | `src/shell/` | `AppShell.vue`、`GlobalOverlays.vue` |
|
| App Shell | `src/shell/` | `AppShell.vue`、`GlobalOverlays.vue` |
|
||||||
| Page Driver | `src/views/` | `SingleRecord.vue`(route view,不改名) |
|
| Page Driver | `src/views/` | `SingleRecord.vue`(route view,不改名) |
|
||||||
| Page Component | `src/components/pages/` | `PageStudentMaintenance.vue` |
|
| Page Component | `src/components/pages/` | `PageMaintenance.vue` |
|
||||||
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue`、`SectionSearchPanel.vue` |
|
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue`、`SectionSearchPanel.vue` |
|
||||||
| Item / Atom | `src/components/items/` | `ItemStudentRow.vue`、`ItemFormField.vue` |
|
| Item / Atom | `src/components/items/` | `ItemDataRow.vue`、`ItemFormField.vue` |
|
||||||
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
||||||
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
||||||
| Page Driver Composable | `src/composables/page-drivers/` | `useStudentMaintenancePage.ts` |
|
| Page Driver Composable | `src/composables/page-drivers/` | `useMaintenancePage.ts` |
|
||||||
| Command Composable | `src/composables/commands/` | `useStudentCrudCommands.ts` |
|
| Command Composable | `src/composables/commands/` | `useCrudCommands.ts` |
|
||||||
| Form Composable | `src/composables/forms/` | `useStudentForm.ts` |
|
| Form Composable | `src/composables/forms/` | `useForm.ts` |
|
||||||
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
||||||
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
||||||
| Domain Model | `src/models/` | `student.ts`、`page.ts` |
|
| Domain Model | `src/models/` | `student.ts`、`page.ts` |
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
1. `README.md`
|
1. `README.md`
|
||||||
2. `src/README.md`
|
2. `src/README.md`
|
||||||
3. `docs/add-page-example.md`
|
3. `docs/add-page-example.md`
|
||||||
4. `docs/frontend-layering.md`
|
4. `docs/architecture-strategy.md`(新增功能與重構的最高準則)
|
||||||
|
5. `docs/frontend-layering.md`(歷史參考,已被 `architecture-strategy.md` 取代)
|
||||||
|
|
||||||
## 預設不可修改的檔案
|
## 預設不可修改的檔案
|
||||||
|
|
||||||
@@ -44,16 +45,22 @@
|
|||||||
目前主要資料流是:
|
目前主要資料流是:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
router -> App.vue -> layout -> view -> component -> composable/store -> service
|
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
|
||||||
|
↓
|
||||||
|
page driver / command composable -> store -> service
|
||||||
```
|
```
|
||||||
|
|
||||||
判斷原則:
|
判斷原則:
|
||||||
|
|
||||||
- `router` 決定 route、layout meta、auth meta 與錯誤頁入口
|
- `router` 決定 route、layout meta、auth meta 與錯誤頁入口
|
||||||
- `App.vue` 組裝 layout props、全域 UI 與 layout event
|
- `AppShell` 組裝 layout props、全域 UI 與 layout event
|
||||||
- `views` 承接路由入口、頁面資料協調與頁面事件協調
|
- `views` 承接路由入口、頁面資料協調與頁面事件協調,應維持薄層
|
||||||
- `components` 承接畫面呈現、props/emits 與可拆分 UI 區塊
|
- `components/pages` 組裝完整頁面與 page-level slot
|
||||||
- `composables` 承接可重用流程、頁面狀態機或較複雜的 UI state
|
- `components/sections` 承接容器佈局,例如搜尋區、表格、dialog shell
|
||||||
|
- `components/items` 承接單一資料單位或欄位群組呈現
|
||||||
|
- `composables/page-drivers` 承接頁面資料協調與 page model 組裝
|
||||||
|
- `composables/commands` 承接命令式副作用流程,例如 create/edit/save/delete
|
||||||
|
- `composables` 其他子目錄承接可重用流程、頁面狀態機或較複雜的 UI state
|
||||||
- `stores` 承接跨頁共享狀態、快取與全域顯示狀態
|
- `stores` 承接跨頁共享狀態、快取與全域顯示狀態
|
||||||
- `services` 承接 HTTP client、API 模組、token、session 與錯誤處理
|
- `services` 承接 HTTP client、API 模組、token、session 與錯誤處理
|
||||||
|
|
||||||
@@ -62,7 +69,8 @@ router -> App.vue -> layout -> view -> component -> composable/store -> service
|
|||||||
- 新 route:參考 `src/router/routes.ts`
|
- 新 route:參考 `src/router/routes.ts`
|
||||||
- 一般被主 layout 包住的頁面:參考 `src/views/Home.vue`、`src/views/maint/EditableGrid.vue`
|
- 一般被主 layout 包住的頁面:參考 `src/views/Home.vue`、`src/views/maint/EditableGrid.vue`
|
||||||
- 登入相關 UI:參考 `src/components/PageLogin.vue` 與 `src/components/login/*`
|
- 登入相關 UI:參考 `src/components/PageLogin.vue` 與 `src/components/login/*`
|
||||||
- 維護頁:參考 `src/views/maint/*`、`src/components/maint/*`、`src/composables/maint/*`
|
- 維護頁:優先參考 `src/views/maint/SingleRecord.vue`、`src/components/pages/PageMaintenance.vue`、`src/components/sections/*`、`src/components/items/*`、`src/composables/page-drivers/useSingleRecordMaintenancePage.ts`、`src/composables/commands/useCrudCommands.ts`
|
||||||
|
- 舊維護頁:`EditableGrid.vue`、`MasterDetailA/B/C.vue` 尚未套用完整 Page Driver + Section + Item 分層,參考前先確認是否正在做 Phase 3 遷移。
|
||||||
- 維護頁範本選擇:參考 `src/views/maint/README.md`
|
- 維護頁範本選擇:參考 `src/views/maint/README.md`
|
||||||
- API 呼叫:參考 `src/services/modules/*` 與使用它們的 store/composable
|
- API 呼叫:參考 `src/services/modules/*` 與使用它們的 store/composable
|
||||||
- 全域提示:參考 `src/stores/snackbar.ts` 與 `src/composables/useApiCall.ts`
|
- 全域提示:參考 `src/stores/snackbar.ts` 與 `src/composables/useApiCall.ts`
|
||||||
@@ -101,7 +109,12 @@ router -> App.vue -> layout -> view -> component -> composable/store -> service
|
|||||||
- `src/components/PageIndex.vue`
|
- `src/components/PageIndex.vue`
|
||||||
- `src/views/maint/*`
|
- `src/views/maint/*`
|
||||||
- `src/components/maint/*`
|
- `src/components/maint/*`
|
||||||
|
- `src/components/pages/PageMaintenance.vue`
|
||||||
|
- `src/components/sections/*`
|
||||||
|
- `src/components/items/*`
|
||||||
- `src/composables/maint/*`
|
- `src/composables/maint/*`
|
||||||
|
- `src/composables/page-drivers/*`
|
||||||
|
- `src/composables/commands/*`
|
||||||
- `src/components/PageMaint.vue`
|
- `src/components/PageMaint.vue`
|
||||||
- `src/stores/students.ts`
|
- `src/stores/students.ts`
|
||||||
- `src/stores/semesters.ts`
|
- `src/stores/semesters.ts`
|
||||||
@@ -202,7 +215,16 @@ router -> App.vue -> layout -> view -> component -> composable/store -> service
|
|||||||
- emit 使用者事件
|
- emit 使用者事件
|
||||||
- 拆分表單、列表、工具列、dialog 等 UI
|
- 拆分表單、列表、工具列、dialog 等 UI
|
||||||
|
|
||||||
不要把完整功能長期塞在單一 view。當畫面有多個區塊、表單、列表、dialog 或重複 UI 時,應拆到 feature components。
|
不要把完整功能長期塞在單一 view。當畫面有多個區塊、表單、列表、dialog 或重複 UI 時,應拆到 page / section / item components。
|
||||||
|
|
||||||
|
維護頁分層優先順序:
|
||||||
|
|
||||||
|
1. `views/maint/*.vue`:只做 route-level wiring,目標 < 80 行。
|
||||||
|
2. `components/pages/PageMaintenance.vue`:組裝維護頁外殼與主要 slot。
|
||||||
|
3. `components/sections/*`:管理搜尋、表格、dialog shell 等區塊佈局。
|
||||||
|
4. `components/items/*`:管理欄位群組或單一資料呈現,不直接呼叫 store/service。
|
||||||
|
5. `composables/page-drivers/*`:集中 page model、分頁、搜尋、表單狀態與事件 wiring。
|
||||||
|
6. `composables/commands/*`:集中新增、載入、儲存、刪除等 command 流程。
|
||||||
|
|
||||||
## Router 安排規則
|
## Router 安排規則
|
||||||
|
|
||||||
@@ -231,11 +253,14 @@ route 集中放在 `src/router/routes.ts`。不要在 view 或 component 裡臨
|
|||||||
- `src/composables/useApiCall.ts`:包裝可重用 API 呼叫狀態與錯誤提示流程
|
- `src/composables/useApiCall.ts`:包裝可重用 API 呼叫狀態與錯誤提示流程
|
||||||
- `src/composables/layout/useAdminLayoutState.ts`:提供 layout shell 所需的狀態組裝
|
- `src/composables/layout/useAdminLayoutState.ts`:提供 layout shell 所需的狀態組裝
|
||||||
- `src/composables/layout/useThemeToggle.ts`:提供主題切換流程
|
- `src/composables/layout/useThemeToggle.ts`:提供主題切換流程
|
||||||
|
- `src/composables/page-drivers/useMaintenancePage.ts`:提供通用 maintenance page model 基礎狀態
|
||||||
|
- `src/composables/page-drivers/useSingleRecordMaintenancePage.ts`:協調單筆維護 demo 頁面的 page model、section props/events、表單、表格與 command
|
||||||
|
- `src/composables/commands/useCrudCommands.ts`:提供 CRUD command 流程,讓 view 不直接執行 store mutation 細節
|
||||||
- `src/composables/maint/useMaintenanceCrudFlow.ts`:提供維護頁 CRUD 流程狀態
|
- `src/composables/maint/useMaintenanceCrudFlow.ts`:提供維護頁 CRUD 流程狀態
|
||||||
- `src/composables/maint/useStudentMaintenanceForm.ts`:提供學生維護表單狀態
|
- `src/composables/maint/useStudentMaintenanceForm.ts`:提供學生維護表單狀態
|
||||||
- `src/composables/maint/useEditableStudentGrid.ts`:提供 editable grid 狀態
|
- `src/composables/maint/useEditableStudentGrid.ts`:提供 editable grid 狀態
|
||||||
|
|
||||||
新增 composable 時,用 `useXxx.ts` 命名。若只服務單一 feature,放在 `src/composables/<feature>`;若確定跨 feature 使用,才放在 `src/composables` 根目錄。
|
新增 composable 時,用 `useXxx.ts` 命名。若是頁面資料協調,放在 `src/composables/page-drivers`;若是命令式副作用流程,放在 `src/composables/commands`;若只服務單一 feature,放在 `src/composables/<feature>`;若確定跨 feature 使用,才放在 `src/composables` 根目錄。
|
||||||
|
|
||||||
## Store 與 Service 資料流規則
|
## Store 與 Service 資料流規則
|
||||||
|
|
||||||
@@ -289,7 +314,7 @@ import { mdiAccount } from '@mdi/js'
|
|||||||
- 是否只需要新增或修改 view
|
- 是否只需要新增或修改 view
|
||||||
- 是否需要新增 route
|
- 是否需要新增 route
|
||||||
- 是否需要拆 feature component
|
- 是否需要拆 feature component
|
||||||
- 是否已閱讀 `docs/frontend-layering.md`
|
- 是否已閱讀 `docs/architecture-strategy.md`
|
||||||
- 是否已參考 `src/` 裡相同類型的既有範例
|
- 是否已參考 `src/` 裡相同類型的既有範例
|
||||||
- 是否符合 Vue 3、Composition API、`<script setup lang="ts">` 的既有寫法
|
- 是否符合 Vue 3、Composition API、`<script setup lang="ts">` 的既有寫法
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
## 一、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 drilling:Jet、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。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { StudentFormState } from '@/composables/maint/useStudentMaintenanceForm'
|
||||||
|
|
||||||
|
interface GradeOption {
|
||||||
|
title: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
departments: string[]
|
||||||
|
enrollYears: number[]
|
||||||
|
fieldErrors: Record<keyof StudentFormState, string[]>
|
||||||
|
gradeOptions: GradeOption[]
|
||||||
|
isFormLocked: boolean
|
||||||
|
isFormReadonly: boolean
|
||||||
|
statuses: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = defineModel<StudentFormState>({ required: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'clear-field-error', field: keyof StudentFormState): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-row density="compact">
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
id="field-studentId"
|
||||||
|
v-model="form.studentId"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.studentId"
|
||||||
|
label="學號"
|
||||||
|
placeholder="例如:S2024008"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'studentId')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
id="field-name"
|
||||||
|
v-model="form.name"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.name"
|
||||||
|
label="姓名"
|
||||||
|
placeholder="例如:陳怡君"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'name')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
id="field-department"
|
||||||
|
v-model="form.department"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.department"
|
||||||
|
:items="departments"
|
||||||
|
label="系所"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'department')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
id="field-grade"
|
||||||
|
v-model="form.grade"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.grade"
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="gradeOptions"
|
||||||
|
label="年級"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'grade')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
id="field-enrollYear"
|
||||||
|
v-model="form.enrollYear"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.enrollYear"
|
||||||
|
:items="enrollYears"
|
||||||
|
label="入學年度"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'enrollYear')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
id="field-credits"
|
||||||
|
v-model.number="form.credits"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.credits"
|
||||||
|
label="已修學分"
|
||||||
|
min="0"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
type="number"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'credits')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
id="field-advisor"
|
||||||
|
v-model="form.advisor"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.advisor"
|
||||||
|
label="指導老師"
|
||||||
|
placeholder="例如:林教授"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'advisor')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
id="field-email"
|
||||||
|
v-model="form.email"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.email"
|
||||||
|
label="Email"
|
||||||
|
placeholder="name@school.edu"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'email')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-text-field
|
||||||
|
id="field-phone"
|
||||||
|
v-model="form.phone"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.phone"
|
||||||
|
label="電話"
|
||||||
|
placeholder="例如:02-2345-6789"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'phone')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="6">
|
||||||
|
<v-select
|
||||||
|
id="field-status"
|
||||||
|
v-model="form.status"
|
||||||
|
density="comfortable"
|
||||||
|
:disabled="isFormLocked"
|
||||||
|
:error-messages="fieldErrors.status"
|
||||||
|
:items="statuses"
|
||||||
|
label="狀態"
|
||||||
|
:readonly="isFormReadonly"
|
||||||
|
variant="outlined"
|
||||||
|
@update:model-value="emit('clear-field-error', 'status')"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { mdiDelete, mdiEye, mdiPencil } from '@mdi/js'
|
||||||
|
import type { StudentRecord } from '@/models/student'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
currentPage: number
|
||||||
|
gradeLabel: (grade: number) => string
|
||||||
|
headers: any[]
|
||||||
|
items: StudentRecord[]
|
||||||
|
itemsPerPage: number
|
||||||
|
pageCount: number
|
||||||
|
pageSummary: string
|
||||||
|
rowProps: (data: { item: StudentRecord }) => Record<string, string>
|
||||||
|
statusColor: (status: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'update:currentPage', page: number): void
|
||||||
|
(e: 'view', record: StudentRecord): void
|
||||||
|
(e: 'edit', record: StudentRecord): void
|
||||||
|
(e: 'delete', record: StudentRecord): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-data-table
|
||||||
|
class="student-table"
|
||||||
|
density="compact"
|
||||||
|
fixed-header
|
||||||
|
:headers="headers"
|
||||||
|
height="100%"
|
||||||
|
hide-default-footer
|
||||||
|
:items="items"
|
||||||
|
:items-per-page="itemsPerPage"
|
||||||
|
:page="currentPage"
|
||||||
|
:row-props="rowProps"
|
||||||
|
@update:page="emit('update:currentPage', $event)"
|
||||||
|
>
|
||||||
|
<template #[`item.grade`]="{ item }">
|
||||||
|
{{ gradeLabel(item.grade) }}
|
||||||
|
</template>
|
||||||
|
<template #[`item.status`]="{ item }">
|
||||||
|
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
||||||
|
{{ item.status }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
<template #[`item.actions`]="{ item }">
|
||||||
|
<div class="d-flex ga-2">
|
||||||
|
<v-btn
|
||||||
|
color="info"
|
||||||
|
:prepend-icon="mdiEye"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('view', item)"
|
||||||
|
>
|
||||||
|
檢視
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="primary"
|
||||||
|
:prepend-icon="mdiPencil"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('edit', item)"
|
||||||
|
>
|
||||||
|
修改
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error"
|
||||||
|
:prepend-icon="mdiDelete"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('delete', item)"
|
||||||
|
>
|
||||||
|
刪除
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #bottom>
|
||||||
|
<div class="d-flex align-center justify-space-between px-4 py-3">
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
{{ pageSummary }}
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('update:currentPage', 1)"
|
||||||
|
>
|
||||||
|
第一頁
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('update:currentPage', currentPage - 1)"
|
||||||
|
>
|
||||||
|
上一頁
|
||||||
|
</v-btn>
|
||||||
|
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('update:currentPage', currentPage + 1)"
|
||||||
|
>
|
||||||
|
下一頁
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
:disabled="currentPage >= pageCount"
|
||||||
|
size="small"
|
||||||
|
variant="text"
|
||||||
|
@click="emit('update:currentPage', pageCount)"
|
||||||
|
>
|
||||||
|
最後頁
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-table {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-table :deep(table) {
|
||||||
|
min-width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-table :deep(th),
|
||||||
|
.student-table :deep(td) {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-table :deep(.v-data-table-column--fixed),
|
||||||
|
.student-table :deep(.v-data-table-column--fixed-end) {
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: -5px;
|
||||||
|
bottom: 0;
|
||||||
|
width: 5px;
|
||||||
|
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-table :deep(.v-data-table-footer) {
|
||||||
|
padding: 4px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr.is-highlighted {
|
||||||
|
animation: row-highlight 1.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes row-highlight {
|
||||||
|
0% {
|
||||||
|
background-color: rgba(var(--v-theme-primary), 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||||
|
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
||||||
|
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
||||||
|
import ItemFormFieldGroup from '@/components/items/ItemFormFieldGroup.vue'
|
||||||
|
import type {
|
||||||
|
SaveSummaryItem,
|
||||||
|
StudentFormState,
|
||||||
|
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||||
|
|
||||||
|
interface FieldErrorItem {
|
||||||
|
field: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GradeOption {
|
||||||
|
title: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
confirmCloseVisible: boolean
|
||||||
|
confirmDeleteVisible: boolean
|
||||||
|
confirmNavigateVisible: boolean
|
||||||
|
confirmSaveVisible: boolean
|
||||||
|
confirmSwitchVisible: boolean
|
||||||
|
departments: string[]
|
||||||
|
dialogSubtitle: string
|
||||||
|
dialogTitle: string
|
||||||
|
dialogVisible: boolean
|
||||||
|
enrollYears: number[]
|
||||||
|
errorSummary: FieldErrorItem[]
|
||||||
|
fieldErrors: Record<keyof StudentFormState, string[]>
|
||||||
|
gradeOptions: GradeOption[]
|
||||||
|
hasNextRecord: boolean
|
||||||
|
hasPrevRecord: boolean
|
||||||
|
isDirty: boolean
|
||||||
|
isEditMode: boolean
|
||||||
|
isFormLocked: boolean
|
||||||
|
isFormReadonly: boolean
|
||||||
|
isLoading: boolean
|
||||||
|
isSaving: boolean
|
||||||
|
isViewMode: boolean
|
||||||
|
pendingDeleteLabel: string
|
||||||
|
saveSummary: SaveSummaryItem[]
|
||||||
|
statuses: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = defineModel<StudentFormState>('form', { required: true })
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(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: 'dialog-visible-change', value: boolean): void
|
||||||
|
(e: 'clear-field-error', field: keyof StudentFormState): void
|
||||||
|
(e: 'close'): void
|
||||||
|
(e: 'confirm-close'): void
|
||||||
|
(e: 'confirm-delete'): void
|
||||||
|
(e: 'confirm-navigate'): void
|
||||||
|
(e: 'confirm-save'): void
|
||||||
|
(e: 'confirm-switch'): void
|
||||||
|
(e: 'delete-current'): void
|
||||||
|
(e: 'first'): void
|
||||||
|
(e: 'last'): void
|
||||||
|
(e: 'next'): void
|
||||||
|
(e: 'prev'): void
|
||||||
|
(e: 'save'): void
|
||||||
|
(e: 'scroll-to-field', field: string): void
|
||||||
|
(e: 'switch-to-edit'): void
|
||||||
|
(e: 'switch-to-view'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<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">
|
||||||
|
<MntDialogCard
|
||||||
|
content-class="pa-2 flex-grow-1 overflow-y-auto"
|
||||||
|
:dialog-subtitle="dialogSubtitle"
|
||||||
|
:dialog-title="dialogTitle"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<MntRecordNavToolbar
|
||||||
|
edit-label="進入編輯"
|
||||||
|
first-label="第一筆"
|
||||||
|
:has-next-record="hasNextRecord"
|
||||||
|
:has-prev-record="hasPrevRecord"
|
||||||
|
:is-edit-mode="isEditMode"
|
||||||
|
:is-view-mode="isViewMode"
|
||||||
|
last-label="最後一筆"
|
||||||
|
view-label="回到檢視"
|
||||||
|
@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')"
|
||||||
|
>
|
||||||
|
<ItemFormFieldGroup
|
||||||
|
v-model="form"
|
||||||
|
:departments="departments"
|
||||||
|
:enroll-years="enrollYears"
|
||||||
|
:field-errors="fieldErrors"
|
||||||
|
:grade-options="gradeOptions"
|
||||||
|
:is-form-locked="isFormLocked"
|
||||||
|
:is-form-readonly="isFormReadonly"
|
||||||
|
:statuses="statuses"
|
||||||
|
@clear-field-error="emit('clear-field-error', $event)"
|
||||||
|
/>
|
||||||
|
</v-form>
|
||||||
|
</template>
|
||||||
|
<template #actions>
|
||||||
|
<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>
|
||||||
|
</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: 760px;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
padding: 12px;
|
||||||
|
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-readonly :deep(.v-field) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { mdiBroom, mdiMagnify } from '@mdi/js'
|
||||||
|
|
||||||
|
interface GradeOption {
|
||||||
|
title: string
|
||||||
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchState {
|
||||||
|
studentId: string
|
||||||
|
name: string
|
||||||
|
department: string
|
||||||
|
grade: number | null
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
departments: string[]
|
||||||
|
gradeOptions: GradeOption[]
|
||||||
|
statuses: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const search = defineModel<SearchState>({ required: true })
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: 'reset'): void
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<v-col cols="12" md="2">
|
||||||
|
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
||||||
|
<v-text-field
|
||||||
|
id="search-student-id"
|
||||||
|
v-model="search.studentId"
|
||||||
|
aria-labelledby="search-student-id-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchStudentId"
|
||||||
|
placeholder="例如:S2024001"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="2">
|
||||||
|
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
||||||
|
<v-text-field
|
||||||
|
id="search-name"
|
||||||
|
v-model="search.name"
|
||||||
|
aria-labelledby="search-name-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
name="searchName"
|
||||||
|
placeholder="例如:王小明"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="2">
|
||||||
|
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
||||||
|
<v-select
|
||||||
|
id="search-department"
|
||||||
|
v-model="search.department"
|
||||||
|
aria-labelledby="search-department-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="departments"
|
||||||
|
name="searchDepartment"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="2">
|
||||||
|
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
||||||
|
<v-select
|
||||||
|
id="search-grade"
|
||||||
|
v-model="search.grade"
|
||||||
|
aria-labelledby="search-grade-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
item-title="title"
|
||||||
|
item-value="value"
|
||||||
|
:items="gradeOptions"
|
||||||
|
name="searchGrade"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="12" md="2">
|
||||||
|
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
||||||
|
<v-select
|
||||||
|
id="search-status"
|
||||||
|
v-model="search.status"
|
||||||
|
aria-labelledby="search-status-label"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
:items="statuses"
|
||||||
|
name="searchStatus"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
</v-col>
|
||||||
|
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||||
|
<v-btn :prepend-icon="mdiBroom" variant="text" @click="$emit('reset')">清除</v-btn>
|
||||||
|
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { nextTick, type Ref } from 'vue'
|
||||||
|
|
||||||
|
interface UseCrudCommandsOptions<TRecord extends { id: number }, TPayload> {
|
||||||
|
clearAllErrors: () => void
|
||||||
|
dialogMode: Ref<'create' | 'edit' | 'view'>
|
||||||
|
dialogVisible: Ref<boolean>
|
||||||
|
editingId: Ref<number | null>
|
||||||
|
fieldErrors: Ref<Record<string, string[]>>
|
||||||
|
form: Ref<TPayload>
|
||||||
|
highlightedId: Ref<number | null>
|
||||||
|
isDirty: Readonly<Ref<boolean>>
|
||||||
|
isLoading: Ref<boolean>
|
||||||
|
isSaving: Ref<boolean>
|
||||||
|
isViewMode: Readonly<Ref<boolean>>
|
||||||
|
loadDelay?: number
|
||||||
|
loadSequence: Ref<number>
|
||||||
|
resetForm: () => void
|
||||||
|
saveDelay?: number
|
||||||
|
scrollToField: (field: string) => void
|
||||||
|
setForm: (payload: TPayload) => void
|
||||||
|
syncInitialForm: () => void
|
||||||
|
toFormPayload: (record: TRecord) => TPayload
|
||||||
|
toSavePayload: (form: TPayload) => TPayload
|
||||||
|
updateRecord: (id: number, payload: TPayload) => unknown
|
||||||
|
createRecord: (payload: TPayload) => number
|
||||||
|
validateForm: () => Array<{ field: string; message: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCrudCommands<TRecord extends { id: number }, TPayload>(
|
||||||
|
options: UseCrudCommandsOptions<TRecord, TPayload>
|
||||||
|
) {
|
||||||
|
function openAddDialog() {
|
||||||
|
options.loadSequence.value += 1
|
||||||
|
options.dialogMode.value = 'create'
|
||||||
|
options.editingId.value = null
|
||||||
|
options.resetForm()
|
||||||
|
options.isLoading.value = false
|
||||||
|
options.dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadRecord(record: TRecord, mode: 'edit' | 'view') {
|
||||||
|
options.loadSequence.value += 1
|
||||||
|
const sequence = options.loadSequence.value
|
||||||
|
options.dialogMode.value = mode
|
||||||
|
options.editingId.value = record.id
|
||||||
|
options.dialogVisible.value = true
|
||||||
|
options.isLoading.value = true
|
||||||
|
options.clearAllErrors()
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (sequence !== options.loadSequence.value || !options.dialogVisible.value) return
|
||||||
|
options.setForm(options.toFormPayload(record))
|
||||||
|
options.syncInitialForm()
|
||||||
|
options.isLoading.value = false
|
||||||
|
}, options.loadDelay ?? 350)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditDialog(record: TRecord) {
|
||||||
|
loadRecord(record, 'edit')
|
||||||
|
}
|
||||||
|
|
||||||
|
function openViewDialog(record: TRecord) {
|
||||||
|
loadRecord(record, 'view')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestSaveConfirmation(confirmSaveVisible: Ref<boolean>) {
|
||||||
|
if (
|
||||||
|
options.isSaving.value ||
|
||||||
|
options.isLoading.value ||
|
||||||
|
!options.isDirty.value ||
|
||||||
|
options.isViewMode.value
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
options.clearAllErrors()
|
||||||
|
|
||||||
|
const errors = options.validateForm()
|
||||||
|
if (errors.length > 0) {
|
||||||
|
for (const error of errors) {
|
||||||
|
options.fieldErrors.value[error.field] = [error.message]
|
||||||
|
}
|
||||||
|
await nextTick()
|
||||||
|
const firstError = errors[0]
|
||||||
|
if (firstError) options.scrollToField(firstError.field)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmSaveVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmSave(confirmSaveVisible: Ref<boolean>) {
|
||||||
|
confirmSaveVisible.value = false
|
||||||
|
return saveRecord()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRecord() {
|
||||||
|
if (options.isSaving.value || options.isLoading.value) return
|
||||||
|
options.isSaving.value = true
|
||||||
|
await new Promise((resolve) => window.setTimeout(resolve, options.saveDelay ?? 450))
|
||||||
|
const payload = options.toSavePayload(options.form.value)
|
||||||
|
|
||||||
|
if (options.editingId.value) {
|
||||||
|
const updated = options.updateRecord(options.editingId.value, payload)
|
||||||
|
if (updated) options.highlightedId.value = options.editingId.value
|
||||||
|
} else {
|
||||||
|
const createdId = options.createRecord(payload)
|
||||||
|
options.highlightedId.value = createdId
|
||||||
|
}
|
||||||
|
|
||||||
|
options.syncInitialForm()
|
||||||
|
options.dialogVisible.value = false
|
||||||
|
options.isSaving.value = false
|
||||||
|
window.setTimeout(() => {
|
||||||
|
options.highlightedId.value = null
|
||||||
|
}, 1600)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
confirmSave,
|
||||||
|
openAddDialog,
|
||||||
|
openEditDialog,
|
||||||
|
openViewDialog,
|
||||||
|
requestSaveConfirmation,
|
||||||
|
saveRecord,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ interface UseMaintenanceCrudFlowOptions<T extends { id: number }> {
|
|||||||
onAfterDelete?: (deletedId: number) => void
|
onAfterDelete?: (deletedId: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
|
export interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
|
||||||
confirmCloseVisible: Ref<boolean>
|
confirmCloseVisible: Ref<boolean>
|
||||||
confirmSaveVisible: Ref<boolean>
|
confirmSaveVisible: Ref<boolean>
|
||||||
confirmDeleteVisible: Ref<boolean>
|
confirmDeleteVisible: Ref<boolean>
|
||||||
|
|||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
|
import { useCrudCommands } from '@/composables/commands/useCrudCommands'
|
||||||
|
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||||
|
import {
|
||||||
|
type StudentFormState,
|
||||||
|
useStudentMaintenanceForm,
|
||||||
|
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||||
|
import type { MaintenancePageModel } from '@/models/page'
|
||||||
|
import { type StudentRecord, useStudentStore } from '@/stores/students'
|
||||||
|
|
||||||
|
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
|
||||||
|
const gradeOptions = [
|
||||||
|
{ title: '大一', value: 1 },
|
||||||
|
{ title: '大二', value: 2 },
|
||||||
|
{ title: '大三', value: 3 },
|
||||||
|
{ title: '大四', value: 4 },
|
||||||
|
{ title: '研究所', value: 5 },
|
||||||
|
]
|
||||||
|
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
|
||||||
|
const statuses = ['在學', '休學', '畢業']
|
||||||
|
const itemsPerPage = 10
|
||||||
|
|
||||||
|
type StudentPayload = Omit<StudentRecord, 'id'>
|
||||||
|
|
||||||
|
function toFormPayload(student: StudentRecord): StudentFormState {
|
||||||
|
return {
|
||||||
|
studentId: student.studentId,
|
||||||
|
name: student.name,
|
||||||
|
department: student.department,
|
||||||
|
grade: student.grade,
|
||||||
|
enrollYear: student.enrollYear,
|
||||||
|
credits: student.credits,
|
||||||
|
advisor: student.advisor,
|
||||||
|
email: student.email,
|
||||||
|
phone: student.phone,
|
||||||
|
status: student.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSavePayload(form: StudentFormState): StudentPayload {
|
||||||
|
return {
|
||||||
|
studentId: form.studentId.trim(),
|
||||||
|
name: form.name.trim(),
|
||||||
|
department: form.department,
|
||||||
|
grade: form.grade,
|
||||||
|
enrollYear: form.enrollYear,
|
||||||
|
credits: form.credits,
|
||||||
|
advisor: form.advisor.trim(),
|
||||||
|
email: form.email.trim(),
|
||||||
|
phone: form.phone.trim(),
|
||||||
|
status: form.status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSingleRecordMaintenancePage() {
|
||||||
|
const studentStore = useStudentStore()
|
||||||
|
const students = computed(() => studentStore.students)
|
||||||
|
const { smAndUp } = useDisplay()
|
||||||
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||||
|
type: 'maintenance',
|
||||||
|
title: '單筆資料維護示範',
|
||||||
|
records: students.value,
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
}))
|
||||||
|
const search = ref({ studentId: '', name: '', department: '', grade: null as number | null, status: '' })
|
||||||
|
const searchPanelOpen = ref(false)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
|
||||||
|
const pageSummary = computed(() => {
|
||||||
|
const total = students.value.length
|
||||||
|
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
||||||
|
const start = (currentPage.value - 1) * itemsPerPage + 1
|
||||||
|
const end = Math.min(currentPage.value * itemsPerPage, total)
|
||||||
|
return `第 ${start}-${end} 筆 / 共 ${total} 筆`
|
||||||
|
})
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const highlightedId = ref<number | null>(null)
|
||||||
|
const loadSequence = ref(0)
|
||||||
|
const snackbarVisible = ref(false)
|
||||||
|
const formState = useStudentMaintenanceForm({
|
||||||
|
departments,
|
||||||
|
gradeOptions,
|
||||||
|
enrollYears,
|
||||||
|
statuses,
|
||||||
|
students,
|
||||||
|
editingId,
|
||||||
|
highlightedId,
|
||||||
|
})
|
||||||
|
const isFormLocked = computed(() => isLoading.value || isSaving.value)
|
||||||
|
const tableHeaders = computed(() => [
|
||||||
|
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && ('start' as const), width: 120 },
|
||||||
|
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && ('start' as const), width: 100 },
|
||||||
|
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
||||||
|
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
||||||
|
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
||||||
|
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
|
||||||
|
{ title: 'Email', key: 'email', sortable: true, width: 200 },
|
||||||
|
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
||||||
|
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
||||||
|
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
||||||
|
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && ('end' as const), width: 'auto', cellProps: { class: 'px-0 bg-background' } },
|
||||||
|
])
|
||||||
|
|
||||||
|
function resetSearch() {
|
||||||
|
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToField(field: string) {
|
||||||
|
const target = document.getElementById(`field-${field}`)
|
||||||
|
if (!target) return
|
||||||
|
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = useCrudCommands<StudentRecord, StudentFormState>({
|
||||||
|
...formState,
|
||||||
|
dialogMode,
|
||||||
|
dialogVisible,
|
||||||
|
editingId,
|
||||||
|
highlightedId,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
isViewMode: computed(() => dialogMode.value === 'view'),
|
||||||
|
loadSequence,
|
||||||
|
scrollToField,
|
||||||
|
toFormPayload,
|
||||||
|
toSavePayload,
|
||||||
|
updateRecord: (id, payload) => studentStore.updateStudent(id, payload),
|
||||||
|
createRecord: (payload) => studentStore.addStudent(payload),
|
||||||
|
})
|
||||||
|
const flow = useMaintenanceCrudFlow<StudentRecord>({
|
||||||
|
records: students,
|
||||||
|
editingId,
|
||||||
|
dialogMode,
|
||||||
|
dialogVisible,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
isDirty: formState.isDirty,
|
||||||
|
clearAllErrors: formState.clearAllErrors,
|
||||||
|
resetForm: formState.resetForm,
|
||||||
|
openEditDialog: commands.openEditDialog,
|
||||||
|
openViewDialog: commands.openViewDialog,
|
||||||
|
removeRecord: (id) => studentStore.removeStudent(id),
|
||||||
|
describeRecord: (student) => `${student.studentId} ${student.name}`,
|
||||||
|
})
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (dialogMode.value === 'view') return '檢視資料示範'
|
||||||
|
if (dialogMode.value === 'edit') return '修改資料示範'
|
||||||
|
return '新增資料示範'
|
||||||
|
})
|
||||||
|
const dialogSubtitle = computed(() => (!editingId.value ? '' : `${formState.form.value.studentId || '未填學號'}・${formState.form.value.name || '未填姓名'}`))
|
||||||
|
|
||||||
|
watch(pageCount, (value) => {
|
||||||
|
if (currentPage.value > value) currentPage.value = value
|
||||||
|
})
|
||||||
|
|
||||||
|
async function requestSaveConfirmation() {
|
||||||
|
await commands.requestSaveConfirmation(flow.confirmSaveVisible)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmSave() {
|
||||||
|
await commands.confirmSave(flow.confirmSaveVisible)
|
||||||
|
snackbarVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const formPanelProps = computed(() => ({
|
||||||
|
confirmCloseVisible: flow.confirmCloseVisible.value,
|
||||||
|
confirmDeleteVisible: flow.confirmDeleteVisible.value,
|
||||||
|
confirmNavigateVisible: flow.confirmNavigateVisible.value,
|
||||||
|
confirmSaveVisible: flow.confirmSaveVisible.value,
|
||||||
|
confirmSwitchVisible: flow.confirmSwitchVisible.value,
|
||||||
|
departments,
|
||||||
|
dialogSubtitle: dialogSubtitle.value,
|
||||||
|
dialogTitle: dialogTitle.value,
|
||||||
|
dialogVisible: dialogVisible.value,
|
||||||
|
enrollYears,
|
||||||
|
errorSummary: formState.errorSummary.value,
|
||||||
|
fieldErrors: formState.fieldErrors.value,
|
||||||
|
form: formState.form.value,
|
||||||
|
gradeOptions,
|
||||||
|
hasNextRecord: flow.hasNextRecord.value,
|
||||||
|
hasPrevRecord: flow.hasPrevRecord.value,
|
||||||
|
isDirty: formState.isDirty.value,
|
||||||
|
isEditMode: flow.isEditMode.value,
|
||||||
|
isFormLocked: isFormLocked.value,
|
||||||
|
isFormReadonly: flow.isViewMode.value,
|
||||||
|
isLoading: isLoading.value,
|
||||||
|
isSaving: isSaving.value,
|
||||||
|
isViewMode: flow.isViewMode.value,
|
||||||
|
pendingDeleteLabel: flow.pendingDeleteLabel.value,
|
||||||
|
saveSummary: formState.saveSummary.value,
|
||||||
|
statuses,
|
||||||
|
}))
|
||||||
|
const formPanelEvents = {
|
||||||
|
'clear-field-error': formState.clearFieldError,
|
||||||
|
close: flow.requestCloseDialog,
|
||||||
|
'confirm-close': flow.confirmClose,
|
||||||
|
'confirm-delete': flow.confirmDelete,
|
||||||
|
'confirm-navigate': flow.confirmNavigate,
|
||||||
|
'confirm-save': confirmSave,
|
||||||
|
'confirm-switch': flow.confirmSwitch,
|
||||||
|
'delete-current': flow.requestDeleteCurrent,
|
||||||
|
'dialog-visible-change': flow.handleDialogVisibility,
|
||||||
|
first: () => flow.openEdgeRecord('first'),
|
||||||
|
last: () => flow.openEdgeRecord('last'),
|
||||||
|
next: () => flow.openAdjacentRecord('next'),
|
||||||
|
prev: () => flow.openAdjacentRecord('prev'),
|
||||||
|
save: requestSaveConfirmation,
|
||||||
|
'scroll-to-field': scrollToField,
|
||||||
|
'switch-to-edit': flow.switchToEditMode,
|
||||||
|
'switch-to-view': flow.switchToViewMode,
|
||||||
|
'update:confirmCloseVisible': (value: boolean) => (flow.confirmCloseVisible.value = value),
|
||||||
|
'update:confirmDeleteVisible': (value: boolean) => (flow.confirmDeleteVisible.value = value),
|
||||||
|
'update:confirmNavigateVisible': (value: boolean) => (flow.confirmNavigateVisible.value = value),
|
||||||
|
'update:confirmSaveVisible': (value: boolean) => (flow.confirmSaveVisible.value = value),
|
||||||
|
'update:confirmSwitchVisible': (value: boolean) => (flow.confirmSwitchVisible.value = value),
|
||||||
|
'update:form': (value: StudentFormState) => (formState.form.value = value),
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
commands,
|
||||||
|
currentPage,
|
||||||
|
departments,
|
||||||
|
dialogMode,
|
||||||
|
dialogSubtitle,
|
||||||
|
dialogTitle,
|
||||||
|
dialogVisible,
|
||||||
|
enrollYears,
|
||||||
|
flow,
|
||||||
|
formState,
|
||||||
|
formPanelEvents,
|
||||||
|
formPanelProps,
|
||||||
|
gradeOptions,
|
||||||
|
isFormLocked,
|
||||||
|
isFormReadonly: flow.isViewMode,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
itemsPerPage,
|
||||||
|
pageCount,
|
||||||
|
pageModel,
|
||||||
|
pageSummary,
|
||||||
|
requestSaveConfirmation,
|
||||||
|
confirmSave,
|
||||||
|
resetSearch,
|
||||||
|
scrollToField,
|
||||||
|
search,
|
||||||
|
searchPanelOpen,
|
||||||
|
snackbarVisible,
|
||||||
|
statuses,
|
||||||
|
students,
|
||||||
|
tableHeaders,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,921 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PageMaintenance from '@/components/pages/PageMaintenance.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()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<page-maint
|
<PageMaintenance
|
||||||
:search-panel-open="searchPanelOpen"
|
v-model:search-panel-open="page.searchPanelOpen.value"
|
||||||
:title="`單筆資料維護示範`"
|
:page="page.pageModel.value"
|
||||||
@create="openAddDialog"
|
@create="page.commands.openAddDialog"
|
||||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
|
||||||
>
|
>
|
||||||
<template #search-fields>
|
<template #search-fields>
|
||||||
<v-col cols="12" md="2">
|
<SectionSearchPanel
|
||||||
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
|
v-model="page.search.value"
|
||||||
<v-text-field
|
:departments="page.departments"
|
||||||
id="search-student-id"
|
:grade-options="page.gradeOptions"
|
||||||
v-model="search.studentId"
|
:statuses="page.statuses"
|
||||||
aria-labelledby="search-student-id-label"
|
@reset="page.resetSearch"
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
name="searchStudentId"
|
|
||||||
placeholder="例如:S2024001"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="2">
|
|
||||||
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
|
|
||||||
<v-text-field
|
|
||||||
id="search-name"
|
|
||||||
v-model="search.name"
|
|
||||||
aria-labelledby="search-name-label"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
name="searchName"
|
|
||||||
placeholder="例如:王小明"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="2">
|
|
||||||
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
|
|
||||||
<v-select
|
|
||||||
id="search-department"
|
|
||||||
v-model="search.department"
|
|
||||||
aria-labelledby="search-department-label"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
:items="departments"
|
|
||||||
name="searchDepartment"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="2">
|
|
||||||
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
|
|
||||||
<v-select
|
|
||||||
id="search-grade"
|
|
||||||
v-model="search.grade"
|
|
||||||
aria-labelledby="search-grade-label"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
:items="gradeOptions"
|
|
||||||
name="searchGrade"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="2">
|
|
||||||
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
|
|
||||||
<v-select
|
|
||||||
id="search-status"
|
|
||||||
v-model="search.status"
|
|
||||||
aria-labelledby="search-status-label"
|
|
||||||
density="compact"
|
|
||||||
hide-details
|
|
||||||
:items="statuses"
|
|
||||||
name="searchStatus"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
|
||||||
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
|
|
||||||
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
|
|
||||||
</v-col>
|
|
||||||
</template>
|
</template>
|
||||||
<template #table>
|
<template #table>
|
||||||
<v-data-table
|
<SectionDataTable
|
||||||
v-model:page="currentPage"
|
v-model:current-page="page.currentPage.value"
|
||||||
class="student-table"
|
:grade-label="page.formState.gradeLabel"
|
||||||
density="compact"
|
:headers="page.tableHeaders.value"
|
||||||
fixed-header
|
:items="page.students.value"
|
||||||
:headers="tableHeaders"
|
:items-per-page="page.itemsPerPage"
|
||||||
height="100%"
|
:page-count="page.pageCount.value"
|
||||||
hide-default-footer
|
:page-summary="page.pageSummary.value"
|
||||||
:items="students"
|
:row-props="page.formState.rowProps"
|
||||||
:items-per-page="itemsPerPage"
|
:status-color="page.formState.statusColor"
|
||||||
:row-props="rowProps"
|
@delete="page.flow.requestDeleteConfirmation"
|
||||||
>
|
@edit="page.commands.openEditDialog"
|
||||||
<template #[`item.grade`]="{ item }">
|
@view="page.commands.openViewDialog"
|
||||||
{{ gradeLabel(item.grade) }}
|
|
||||||
</template>
|
|
||||||
<template #[`item.status`]="{ item }">
|
|
||||||
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
|
|
||||||
{{ item.status }}
|
|
||||||
</v-chip>
|
|
||||||
</template>
|
|
||||||
<template #[`item.actions`]="{ item }">
|
|
||||||
<div class="d-flex ga-2">
|
|
||||||
<v-btn
|
|
||||||
color="info"
|
|
||||||
:prepend-icon="mdiEye"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="openViewDialog(item)"
|
|
||||||
>
|
|
||||||
檢視
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
color="primary"
|
|
||||||
:prepend-icon="mdiPencil"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="openEditDialog(item)"
|
|
||||||
>
|
|
||||||
修改
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
color="error"
|
|
||||||
:prepend-icon="mdiDelete"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="requestDeleteConfirmation(item)"
|
|
||||||
>
|
|
||||||
刪除
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #bottom>
|
|
||||||
<div class="d-flex align-center justify-space-between px-4 py-3">
|
|
||||||
<div class="text-body-2 text-medium-emphasis">
|
|
||||||
{{ pageSummary }}
|
|
||||||
</div>
|
|
||||||
<div class="d-flex align-center ga-2">
|
|
||||||
<v-btn
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="currentPage = 1"
|
|
||||||
>
|
|
||||||
第一頁
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="currentPage -= 1"
|
|
||||||
>
|
|
||||||
上一頁
|
|
||||||
</v-btn>
|
|
||||||
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
|
|
||||||
<v-btn
|
|
||||||
:disabled="currentPage >= pageCount"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="currentPage += 1"
|
|
||||||
>
|
|
||||||
下一頁
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
:disabled="currentPage >= pageCount"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="currentPage = pageCount"
|
|
||||||
>
|
|
||||||
最後頁
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</v-data-table>
|
|
||||||
</template>
|
|
||||||
</page-maint>
|
|
||||||
|
|
||||||
<!-- 新增 / 編輯 / 檢視側邊欄 -->
|
|
||||||
<teleport to="body">
|
|
||||||
<!-- 包成元件需要傳高度寬度給dialog-panel -->
|
|
||||||
<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="handleDialogVisibility"
|
|
||||||
>
|
|
||||||
<div class="dialog-panel">
|
|
||||||
<mnt-dialog-card
|
|
||||||
content-class="pa-2 flex-grow-1 overflow-y-auto"
|
|
||||||
:dialog-subtitle="dialogSubtitle"
|
|
||||||
:dialog-title="dialogTitle"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:is-view-mode="isViewMode"
|
|
||||||
width="100%"
|
|
||||||
>
|
|
||||||
<template #toolbar>
|
|
||||||
<mnt-record-nav-toolbar
|
|
||||||
edit-label="進入編輯"
|
|
||||||
first-label="第一筆"
|
|
||||||
:has-next-record="hasNextRecord"
|
|
||||||
:has-prev-record="hasPrevRecord"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:is-view-mode="isViewMode"
|
|
||||||
last-label="最後一筆"
|
|
||||||
view-label="回到檢視"
|
|
||||||
@first="openEdgeRecord('first')"
|
|
||||||
@last="openEdgeRecord('last')"
|
|
||||||
@next="openAdjacentRecord('next')"
|
|
||||||
@prev="openAdjacentRecord('prev')"
|
|
||||||
@switch-to-edit="switchToEditMode"
|
|
||||||
@switch-to-view="switchToViewMode"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template #content>
|
</PageMaintenance>
|
||||||
<!-- 儲存前驗證錯誤摘要 -->
|
|
||||||
<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="scrollToField(error.field)"
|
|
||||||
>
|
|
||||||
{{ error.message }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
<!-- 編輯/檢視載入中骨架 -->
|
<SectionFormPanel
|
||||||
<v-skeleton-loader
|
v-bind="page.formPanelProps.value"
|
||||||
v-if="isLoading"
|
v-on="page.formPanelEvents"
|
||||||
class="mt-4"
|
|
||||||
type="subtitle,paragraph"
|
|
||||||
width="100%"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 表單:檢視模式使用 readonly,避免 focus 狀態 -->
|
<v-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
|
||||||
<v-form
|
|
||||||
v-else
|
|
||||||
:class="{ 'form-readonly': isFormReadonly }"
|
|
||||||
@submit.prevent="requestSaveConfirmation"
|
|
||||||
>
|
|
||||||
<v-row density="compact">
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
id="field-studentId"
|
|
||||||
v-model="form.studentId"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.studentId"
|
|
||||||
label="學號"
|
|
||||||
placeholder="例如:S2024008"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('studentId')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
id="field-name"
|
|
||||||
v-model="form.name"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.name"
|
|
||||||
label="姓名"
|
|
||||||
placeholder="例如:陳怡君"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('name')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
id="field-department"
|
|
||||||
v-model="form.department"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.department"
|
|
||||||
:items="departments"
|
|
||||||
label="系所"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('department')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
id="field-grade"
|
|
||||||
v-model="form.grade"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.grade"
|
|
||||||
item-title="title"
|
|
||||||
item-value="value"
|
|
||||||
:items="gradeOptions"
|
|
||||||
label="年級"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('grade')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
id="field-enrollYear"
|
|
||||||
v-model="form.enrollYear"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.enrollYear"
|
|
||||||
:items="enrollYears"
|
|
||||||
label="入學年度"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('enrollYear')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
id="field-credits"
|
|
||||||
v-model.number="form.credits"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.credits"
|
|
||||||
label="已修學分"
|
|
||||||
min="0"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
type="number"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('credits')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
id="field-advisor"
|
|
||||||
v-model="form.advisor"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.advisor"
|
|
||||||
label="指導老師"
|
|
||||||
placeholder="例如:林教授"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('advisor')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
id="field-email"
|
|
||||||
v-model="form.email"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.email"
|
|
||||||
label="Email"
|
|
||||||
placeholder="name@school.edu"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('email')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-text-field
|
|
||||||
id="field-phone"
|
|
||||||
v-model="form.phone"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.phone"
|
|
||||||
label="電話"
|
|
||||||
placeholder="例如:02-2345-6789"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('phone')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-select
|
|
||||||
id="field-status"
|
|
||||||
v-model="form.status"
|
|
||||||
density="comfortable"
|
|
||||||
:disabled="isFormLocked"
|
|
||||||
:error-messages="fieldErrors.status"
|
|
||||||
:items="statuses"
|
|
||||||
label="狀態"
|
|
||||||
:readonly="isFormReadonly"
|
|
||||||
variant="outlined"
|
|
||||||
@update:model-value="clearFieldError('status')"
|
|
||||||
/>
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</v-form>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="isEditMode"
|
|
||||||
color="error"
|
|
||||||
:disabled="isSaving"
|
|
||||||
variant="tonal"
|
|
||||||
@click="requestDeleteCurrent"
|
|
||||||
>
|
|
||||||
刪除
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="!isViewMode"
|
|
||||||
color="primary"
|
|
||||||
:disabled="!isDirty || isLoading"
|
|
||||||
:loading="isSaving"
|
|
||||||
variant="flat"
|
|
||||||
@click="requestSaveConfirmation"
|
|
||||||
>
|
|
||||||
儲存
|
|
||||||
</v-btn>
|
|
||||||
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
|
|
||||||
</template>
|
|
||||||
</mnt-dialog-card>
|
|
||||||
</div>
|
|
||||||
</v-overlay>
|
|
||||||
</teleport>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model="confirmCloseVisible"
|
|
||||||
confirm-color="error"
|
|
||||||
confirm-text="關閉不儲存"
|
|
||||||
message="目前有尚未儲存的內容,確定要關閉嗎?"
|
|
||||||
title="未儲存變更"
|
|
||||||
@confirm="confirmClose"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model="confirmSaveVisible"
|
|
||||||
:confirm-loading="isSaving"
|
|
||||||
confirm-text="確認儲存"
|
|
||||||
max-width="520"
|
|
||||||
title="確認儲存變更"
|
|
||||||
@confirm="confirmSave"
|
|
||||||
>
|
|
||||||
<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
|
|
||||||
v-model="confirmDeleteVisible"
|
|
||||||
confirm-color="error"
|
|
||||||
confirm-text="確定刪除"
|
|
||||||
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
|
|
||||||
title="確認刪除"
|
|
||||||
@confirm="confirmDelete"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model="confirmSwitchVisible"
|
|
||||||
confirm-text="確定切換"
|
|
||||||
max-width="480"
|
|
||||||
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
|
|
||||||
title="未儲存變更"
|
|
||||||
@confirm="confirmSwitch"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
v-model="confirmNavigateVisible"
|
|
||||||
confirm-text="確定切換"
|
|
||||||
max-width="480"
|
|
||||||
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
|
|
||||||
title="未儲存變更"
|
|
||||||
@confirm="confirmNavigate"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 成功提示 -->
|
|
||||||
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
|
|
||||||
儲存成功
|
儲存成功
|
||||||
</v-snackbar>
|
</v-snackbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
|
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
|
||||||
import { useDisplay } from 'vuetify'
|
|
||||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
|
||||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
|
||||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
|
||||||
import PageMaint from '@/components/PageMaint.vue'
|
|
||||||
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
|
||||||
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
|
|
||||||
import { type StudentRecord, useStudentStore } from '@/stores/students'
|
|
||||||
|
|
||||||
// 下拉選項:系所/年級/入學年度/狀態
|
|
||||||
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
|
|
||||||
const gradeOptions = [
|
|
||||||
{ title: '大一', value: 1 },
|
|
||||||
{ title: '大二', value: 2 },
|
|
||||||
{ title: '大三', value: 3 },
|
|
||||||
{ title: '大四', value: 4 },
|
|
||||||
{ title: '研究所', value: 5 },
|
|
||||||
]
|
|
||||||
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
|
|
||||||
const statuses = ['在學', '休學', '畢業']
|
|
||||||
|
|
||||||
const { smAndUp } = useDisplay()
|
|
||||||
|
|
||||||
// 表格欄位設定(含固定欄與排序)
|
|
||||||
const tableHeaders = computed(() => [
|
|
||||||
{
|
|
||||||
title: '學號',
|
|
||||||
key: 'studentId',
|
|
||||||
sortable: true,
|
|
||||||
fixed: smAndUp.value && ('start' as const),
|
|
||||||
width: 120,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '姓名',
|
|
||||||
key: 'name',
|
|
||||||
sortable: true,
|
|
||||||
fixed: smAndUp.value && ('start' as const),
|
|
||||||
width: 100,
|
|
||||||
},
|
|
||||||
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
|
||||||
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
|
||||||
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
|
|
||||||
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
|
|
||||||
{ title: 'Email', key: 'email', sortable: true, width: 200 },
|
|
||||||
{ title: '電話', key: 'phone', sortable: true, width: 140 },
|
|
||||||
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
|
|
||||||
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
|
||||||
{
|
|
||||||
title: '操作',
|
|
||||||
key: 'actions',
|
|
||||||
sortable: false,
|
|
||||||
fixed: smAndUp.value && ('end' as const),
|
|
||||||
width: 'auto',
|
|
||||||
cellProps: { class: 'px-0 bg-background' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// 查詢條件(示意用,未接 API)
|
|
||||||
const search = ref({
|
|
||||||
studentId: '',
|
|
||||||
name: '',
|
|
||||||
department: '',
|
|
||||||
grade: null as number | null,
|
|
||||||
status: '',
|
|
||||||
})
|
|
||||||
// 查詢區塊是否展開
|
|
||||||
const searchPanelOpen = ref(false)
|
|
||||||
|
|
||||||
// 透過 store 管理 Demo 資料與 CRUD
|
|
||||||
const studentStore = useStudentStore()
|
|
||||||
const students = computed(() => studentStore.students)
|
|
||||||
type StudentPayload = Omit<StudentRecord, 'id'>
|
|
||||||
const itemsPerPage = 10
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
|
|
||||||
const pageSummary = computed(() => {
|
|
||||||
const total = students.value.length
|
|
||||||
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
|
||||||
|
|
||||||
const start = (currentPage.value - 1) * itemsPerPage + 1
|
|
||||||
const end = Math.min(currentPage.value * itemsPerPage, total)
|
|
||||||
return `第 ${start}-${end} 筆 / 共 ${total} 筆`
|
|
||||||
})
|
|
||||||
|
|
||||||
// 彈窗狀態與流程控制
|
|
||||||
const dialogVisible = ref(false)
|
|
||||||
const editingId = ref<number | null>(null)
|
|
||||||
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
|
|
||||||
const isLoading = ref(false)
|
|
||||||
const isSaving = ref(false)
|
|
||||||
// UI 回饋
|
|
||||||
const snackbarVisible = ref(false)
|
|
||||||
const highlightedId = ref<number | null>(null)
|
|
||||||
// 防止快速切換導致的異步覆蓋
|
|
||||||
const loadSequence = ref(0)
|
|
||||||
const {
|
|
||||||
errorSummary,
|
|
||||||
fieldErrors,
|
|
||||||
form,
|
|
||||||
isDirty,
|
|
||||||
saveSummary,
|
|
||||||
clearAllErrors,
|
|
||||||
clearFieldError,
|
|
||||||
gradeLabel,
|
|
||||||
resetForm,
|
|
||||||
rowProps,
|
|
||||||
setForm,
|
|
||||||
statusColor,
|
|
||||||
syncInitialForm,
|
|
||||||
validateForm,
|
|
||||||
} = useStudentMaintenanceForm({
|
|
||||||
departments,
|
|
||||||
gradeOptions,
|
|
||||||
enrollYears,
|
|
||||||
statuses,
|
|
||||||
students,
|
|
||||||
editingId,
|
|
||||||
highlightedId,
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表單欄位簡單排版(無分組)
|
|
||||||
|
|
||||||
// 彈窗標題/副標題
|
|
||||||
const dialogTitle = computed(() => {
|
|
||||||
if (dialogMode.value === 'view') return '檢視資料示範'
|
|
||||||
if (dialogMode.value === 'edit') return '修改資料示範'
|
|
||||||
return '新增資料示範'
|
|
||||||
})
|
|
||||||
const dialogSubtitle = computed(() => {
|
|
||||||
if (!editingId.value) return ''
|
|
||||||
return `${form.value.studentId || '未填學號'}・${form.value.name || '未填姓名'}`
|
|
||||||
})
|
|
||||||
// 是否有修改(用於啟用儲存與提示)
|
|
||||||
// 載入/儲存時鎖定、檢視模式 readonly
|
|
||||||
const isFormLocked = computed(() => isLoading.value || isSaving.value)
|
|
||||||
const {
|
|
||||||
confirmClose,
|
|
||||||
confirmCloseVisible,
|
|
||||||
confirmDelete,
|
|
||||||
confirmDeleteVisible,
|
|
||||||
confirmNavigate,
|
|
||||||
confirmNavigateVisible,
|
|
||||||
confirmSaveVisible,
|
|
||||||
confirmSwitch,
|
|
||||||
confirmSwitchVisible,
|
|
||||||
handleDialogVisibility,
|
|
||||||
hasNextRecord,
|
|
||||||
hasPrevRecord,
|
|
||||||
isEditMode,
|
|
||||||
isViewMode,
|
|
||||||
openAdjacentRecord,
|
|
||||||
openEdgeRecord,
|
|
||||||
pendingDeleteLabel,
|
|
||||||
requestCloseDialog,
|
|
||||||
requestDeleteConfirmation,
|
|
||||||
requestDeleteCurrent,
|
|
||||||
switchToEditMode,
|
|
||||||
switchToViewMode,
|
|
||||||
} = useMaintenanceCrudFlow<StudentRecord>({
|
|
||||||
records: students,
|
|
||||||
editingId,
|
|
||||||
dialogMode,
|
|
||||||
dialogVisible,
|
|
||||||
isLoading,
|
|
||||||
isSaving,
|
|
||||||
isDirty,
|
|
||||||
clearAllErrors,
|
|
||||||
resetForm,
|
|
||||||
openEditDialog,
|
|
||||||
openViewDialog,
|
|
||||||
removeRecord: (id) => {
|
|
||||||
studentStore.removeStudent(id)
|
|
||||||
},
|
|
||||||
describeRecord: (student) => `${student.studentId} ${student.name}`,
|
|
||||||
})
|
|
||||||
const isFormReadonly = computed(() => isViewMode.value)
|
|
||||||
|
|
||||||
// 重設查詢條件
|
|
||||||
function resetSearch() {
|
|
||||||
search.value = {
|
|
||||||
studentId: '',
|
|
||||||
name: '',
|
|
||||||
department: '',
|
|
||||||
grade: null,
|
|
||||||
status: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(pageCount, (value) => {
|
|
||||||
if (currentPage.value > value) {
|
|
||||||
currentPage.value = value
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 新增:開啟彈窗,使用預設值
|
|
||||||
function openAddDialog() {
|
|
||||||
loadSequence.value += 1
|
|
||||||
dialogMode.value = 'create'
|
|
||||||
editingId.value = null
|
|
||||||
resetForm()
|
|
||||||
isLoading.value = false
|
|
||||||
dialogVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 編輯:先開彈窗,資料載入後填入
|
|
||||||
function openEditDialog(student: StudentRecord) {
|
|
||||||
loadSequence.value += 1
|
|
||||||
const sequence = loadSequence.value
|
|
||||||
dialogMode.value = 'edit'
|
|
||||||
editingId.value = student.id
|
|
||||||
dialogVisible.value = true
|
|
||||||
isLoading.value = true
|
|
||||||
clearAllErrors()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
|
||||||
setForm({
|
|
||||||
studentId: student.studentId,
|
|
||||||
name: student.name,
|
|
||||||
department: student.department,
|
|
||||||
grade: student.grade,
|
|
||||||
enrollYear: student.enrollYear,
|
|
||||||
credits: student.credits,
|
|
||||||
advisor: student.advisor,
|
|
||||||
email: student.email,
|
|
||||||
phone: student.phone,
|
|
||||||
status: student.status,
|
|
||||||
})
|
|
||||||
syncInitialForm()
|
|
||||||
isLoading.value = false
|
|
||||||
}, 350)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 檢視:只讀模式並預設展開所有分組
|
|
||||||
function openViewDialog(student: StudentRecord) {
|
|
||||||
loadSequence.value += 1
|
|
||||||
const sequence = loadSequence.value
|
|
||||||
dialogMode.value = 'view'
|
|
||||||
editingId.value = student.id
|
|
||||||
dialogVisible.value = true
|
|
||||||
isLoading.value = true
|
|
||||||
clearAllErrors()
|
|
||||||
setTimeout(() => {
|
|
||||||
if (sequence !== loadSequence.value || !dialogVisible.value) return
|
|
||||||
setForm({
|
|
||||||
studentId: student.studentId,
|
|
||||||
name: student.name,
|
|
||||||
department: student.department,
|
|
||||||
grade: student.grade,
|
|
||||||
enrollYear: student.enrollYear,
|
|
||||||
credits: student.credits,
|
|
||||||
advisor: student.advisor,
|
|
||||||
email: student.email,
|
|
||||||
phone: student.phone,
|
|
||||||
status: student.status,
|
|
||||||
})
|
|
||||||
syncInitialForm()
|
|
||||||
isLoading.value = false
|
|
||||||
}, 350)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 先檢核再提示儲存確認
|
|
||||||
async function requestSaveConfirmation() {
|
|
||||||
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
|
|
||||||
clearAllErrors()
|
|
||||||
|
|
||||||
const errors = validateForm()
|
|
||||||
if (errors.length > 0) {
|
|
||||||
for (const error of errors) {
|
|
||||||
fieldErrors.value[error.field] = [error.message]
|
|
||||||
}
|
|
||||||
await nextTick()
|
|
||||||
const firstError = errors[0]
|
|
||||||
if (firstError) scrollToField(firstError.field)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
confirmSaveVisible.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 儲存確認後才真正送出
|
|
||||||
function confirmSave() {
|
|
||||||
confirmSaveVisible.value = false
|
|
||||||
saveStudent()
|
|
||||||
}
|
|
||||||
|
|
||||||
// 寫入資料(Demo:直接更新列表)
|
|
||||||
async function saveStudent() {
|
|
||||||
if (isSaving.value || isLoading.value) return
|
|
||||||
isSaving.value = true
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 450))
|
|
||||||
const payload = {
|
|
||||||
studentId: form.value.studentId.trim(),
|
|
||||||
name: form.value.name.trim(),
|
|
||||||
department: form.value.department,
|
|
||||||
grade: form.value.grade,
|
|
||||||
enrollYear: form.value.enrollYear,
|
|
||||||
credits: form.value.credits,
|
|
||||||
advisor: form.value.advisor.trim(),
|
|
||||||
email: form.value.email.trim(),
|
|
||||||
phone: form.value.phone.trim(),
|
|
||||||
status: form.value.status,
|
|
||||||
} as StudentPayload
|
|
||||||
|
|
||||||
if (editingId.value) {
|
|
||||||
const updated = studentStore.updateStudent(editingId.value, payload)
|
|
||||||
if (updated) highlightedId.value = editingId.value
|
|
||||||
} else {
|
|
||||||
const createdId = studentStore.addStudent(payload)
|
|
||||||
highlightedId.value = createdId
|
|
||||||
}
|
|
||||||
|
|
||||||
syncInitialForm()
|
|
||||||
dialogVisible.value = false
|
|
||||||
snackbarVisible.value = true
|
|
||||||
isSaving.value = false
|
|
||||||
window.setTimeout(() => {
|
|
||||||
highlightedId.value = null
|
|
||||||
}, 1600)
|
|
||||||
}
|
|
||||||
|
|
||||||
function scrollToField(field: string) {
|
|
||||||
const target = document.getElementById(`field-${field}`)
|
|
||||||
if (!target) return
|
|
||||||
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dialog-overlay :deep(.v-overlay__content) {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100vw;
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel {
|
|
||||||
width: 760px;
|
|
||||||
max-width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
background: rgb(var(--v-theme-surface));
|
|
||||||
padding: 12px;
|
|
||||||
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-actions {
|
|
||||||
position: sticky;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(var(--v-theme-surface), 0.95);
|
|
||||||
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-title {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
background: rgba(var(--v-theme-surface), 0.95);
|
|
||||||
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-table {
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-table :deep(table) {
|
|
||||||
min-width: 1400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-table :deep(th),
|
|
||||||
.student-table :deep(td) {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-table :deep(.v-data-table-column--fixed),
|
|
||||||
.student-table :deep(.v-data-table-column--fixed-end) {
|
|
||||||
background: rgb(var(--v-theme-surface));
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: -5px;
|
|
||||||
bottom: 0;
|
|
||||||
width: 5px;
|
|
||||||
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.student-table :deep(.v-data-table-footer) {
|
|
||||||
padding: 4px 0 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 直線 */
|
|
||||||
/* .student-table :deep(.v-data-table-column--first-fixed-end),
|
|
||||||
.student-table :deep(.v-data-table-column--last-fixed) {
|
|
||||||
border-left: none !important;
|
|
||||||
border-right: none !important;
|
|
||||||
} */
|
|
||||||
|
|
||||||
.form-readonly :deep(.v-field) {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody tr.is-highlighted {
|
|
||||||
animation: row-highlight 1.6s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes row-highlight {
|
|
||||||
0% {
|
|
||||||
background-color: rgba(var(--v-theme-primary), 0.18);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user