Compare commits
13 Commits
f3eb9782c6
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa49d78a84 | |||
| afbdea6b13 | |||
| 915f3b7f2f | |||
| f61432ad8a | |||
| 7b0cfe4448 | |||
| 7b99087cbb | |||
| ad00f5c195 | |||
| b8664b5c3e | |||
| 799b16578d | |||
| b5be5b4448 | |||
| ec62fcee51 | |||
| cad44db4c7 | |||
| 9e8cf28d77 |
@@ -4,11 +4,11 @@ registry=https://registry.npmjs.org/
|
||||
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
|
||||
# save-exact=true
|
||||
|
||||
# 安全防禦:禁止安裝發布未滿 7 天的套件 (預防供應鏈攻擊)
|
||||
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
|
||||
# npm v11.10+
|
||||
min-release-age=7
|
||||
min-release-age=4
|
||||
# pnpm
|
||||
minimum-release-age=10080
|
||||
minimum-release-age=5760
|
||||
|
||||
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
|
||||
# engine-strict=true
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
- `src/services/modules/<domain>.ts` — service modules
|
||||
- Examples of correct vs. incorrect naming:
|
||||
- ❌ `PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
|
||||
- ❌ `useStudentMaintenancePage.ts` → ✅ `useMaintenancePage.ts`
|
||||
- ❌ `useStudentMaintenancePage.ts` → ✅ `useSingleRecordMaintenancePage.ts`
|
||||
- ❌ `ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
|
||||
- ❌ `useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
|
||||
- ✅ `models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
|
||||
|
||||
+50
-52
@@ -4,66 +4,59 @@
|
||||
|
||||
範例功能:`reports`
|
||||
|
||||
## 1. 新增 route view
|
||||
目前新增一般頁面的預設資料流:
|
||||
|
||||
```txt
|
||||
router -> view -> sections/items
|
||||
↓
|
||||
composable -> store -> service
|
||||
```
|
||||
|
||||
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
|
||||
|
||||
## 1. 新增 view(含 page model)
|
||||
|
||||
簡單頁面的 page model 直接在 view 裡用 `computed` 組裝,不需要額外建立 page driver。
|
||||
|
||||
```vue
|
||||
<!-- src/views/reports/Reports.vue -->
|
||||
<script setup lang="ts">
|
||||
import ReportsTable from '@/components/reports/ReportsTable.vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useSnackbarStore } from '@/stores/snackbar'
|
||||
|
||||
const rows = [
|
||||
{ id: 1, title: '學生統計', owner: '教務處' },
|
||||
{ id: 2, title: '課程統計', owner: '課務組' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ReportsTable :rows="rows" />
|
||||
</template>
|
||||
```
|
||||
|
||||
view 的責任是頁面資料組裝與事件協調。畫面區塊交給 feature component。
|
||||
|
||||
## 2. 新增 feature component
|
||||
|
||||
```vue
|
||||
<!-- src/components/reports/ReportsTable.vue -->
|
||||
<script setup lang="ts">
|
||||
interface ReportRow {
|
||||
export interface ReportSummary {
|
||||
id: number
|
||||
title: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
rows: ReportRow[]
|
||||
}>()
|
||||
const initialRows: ReportSummary[] = [
|
||||
{ id: 1, title: '學生統計', owner: '教務處' },
|
||||
{ id: 2, title: '課程統計', owner: '課務組' },
|
||||
]
|
||||
|
||||
const snackbar = useSnackbarStore()
|
||||
const rows = ref<ReportSummary[]>(initialRows)
|
||||
const pageModel = computed(() => ({
|
||||
title: '報表清單',
|
||||
rows: rows.value,
|
||||
}))
|
||||
|
||||
function openReport(row: ReportSummary) {
|
||||
snackbar.show({ message: `開啟:${row.title}`, color: 'info' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card flat>
|
||||
<v-card-title class="text-h6">報表清單</v-card-title>
|
||||
<v-table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名稱</th>
|
||||
<th>負責單位</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="row.id">
|
||||
<td>{{ row.title }}</td>
|
||||
<td>{{ row.owner }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card>
|
||||
<PageReports :page="pageModel" @open="openReport" />
|
||||
</template>
|
||||
```
|
||||
|
||||
component 以 props 接收資料,以 emit 回報事件。不要在 component 裡直接處理 route 或底層 HTTP。
|
||||
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`。
|
||||
|
||||
## 3. 加入 route
|
||||
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`。
|
||||
|
||||
## 2. 加入 route
|
||||
|
||||
route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
|
||||
|
||||
@@ -84,9 +77,9 @@ route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMa
|
||||
- menu 來源目前由 `src/stores/menu.ts` 轉換後端選單資料。
|
||||
- breadcrumb 會依 route path、menu/favorite items 與 fallback title 產生。
|
||||
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
|
||||
- 若只是新增 route,通常不需要修改 `MainLayout.vue`。
|
||||
- 若只是新增 route,通常不需要修改 `MainLayout.vue` 或 `src/shell/*`。
|
||||
|
||||
## 4. 需要 API 時新增 service module
|
||||
## 3. 需要 API 時新增 service module
|
||||
|
||||
```ts
|
||||
// src/services/modules/reports.ts
|
||||
@@ -107,9 +100,11 @@ export const reportsApi = {
|
||||
|
||||
service 只封裝 HTTP 細節,不持有 UI 狀態。
|
||||
|
||||
`httpClient` 的 `baseURL` 來自 `VITE_API_BASE_URL`。template 預設值見 `.env.example`,通常使用 `/service/api` 搭配 Vite proxy。
|
||||
`httpClient` 的 `baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`。
|
||||
|
||||
## 5. 需要共享狀態時新增 store
|
||||
## 4. 需要共享狀態時新增 store
|
||||
|
||||
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
|
||||
|
||||
```ts
|
||||
// src/stores/reports.ts
|
||||
@@ -139,15 +134,18 @@ export const useReportsStore = defineStore('reports', () => {
|
||||
})
|
||||
```
|
||||
|
||||
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
|
||||
|
||||
## 6. 驗證
|
||||
## 5. 驗證
|
||||
|
||||
至少執行:
|
||||
|
||||
```bash
|
||||
pnpm type-check
|
||||
pnpm build
|
||||
pnpm -s type-check
|
||||
```
|
||||
|
||||
需要確認建置產物時再執行:
|
||||
|
||||
```bash
|
||||
pnpm -s build
|
||||
```
|
||||
|
||||
若有 route、layout 或主要互動流程變更,再啟動 dev server 並用瀏覽器確認。
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
## 二、我們專案的現況診斷
|
||||
|
||||
### 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` 的通用佈局抽象:「這一區是水平捲軸還是網格」應該由容器決定,裡面的內容元件不應該知道。
|
||||
|
||||
---
|
||||
@@ -24,17 +24,11 @@ Read only when needed: [analyse now](./analyse-now.md)
|
||||
│ App Shell │
|
||||
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ page model (reactive)
|
||||
│ reactive / props
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Page Driver │
|
||||
│ (views/*.vue — 極薄,只負責:組裝 page model / 事件轉發) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ props / emits
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Page Component │
|
||||
│ (PageXxx.vue — 組裝完整頁面,決定 Section 順序與 override) │
|
||||
│ View │
|
||||
│ (views/*.vue — 自含 page model、頁面 UI 與 section 組合) │
|
||||
└──────────────────────────┬──────────────────────────────────┘
|
||||
│ section data
|
||||
▼
|
||||
@@ -58,7 +52,7 @@ Read only when needed: [analyse now](./analyse-now.md)
|
||||
### 3.2 Page Model 作為主要資料單位
|
||||
|
||||
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
|
||||
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 usePageDriver() 取得 page model,傳給 Page component」。
|
||||
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 composable 取得 page model,組裝 section 元件」。
|
||||
- Page model 可以來自:
|
||||
- store(已有快取)
|
||||
- service(直接 API)
|
||||
@@ -67,23 +61,15 @@ Read only when needed: [analyse now](./analyse-now.md)
|
||||
範例:
|
||||
|
||||
```ts
|
||||
// src/composables/usePageDriver.ts
|
||||
export function useMaintenancePage() {
|
||||
const studentStore = useStudentStore()
|
||||
const { records, loading, error, load } = useCrudDriver({
|
||||
store: studentStore,
|
||||
loadAction: () => studentStore.fetchStudents(),
|
||||
})
|
||||
|
||||
const pageModel = computed(() => ({
|
||||
title: '單筆資料維護',
|
||||
records: records.value,
|
||||
loading: loading.value,
|
||||
error: error.value,
|
||||
}))
|
||||
|
||||
return { pageModel, load }
|
||||
}
|
||||
// views/maint/Example.vue — 簡單頁面直接在 view 組裝 page model
|
||||
const studentStore = useStudentStore()
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '單筆資料維護',
|
||||
records: studentStore.students,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
```
|
||||
|
||||
### 3.3 查詢(Query)與命令(Command)分離
|
||||
@@ -138,17 +124,13 @@ src/
|
||||
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
|
||||
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
|
||||
│
|
||||
├── views/ ← 維持:Page Driver(極薄)
|
||||
├── views/ ← 維持:自含頁面,邏輯與 UI 同檔
|
||||
│ └── maint/
|
||||
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + 掛載 PageMaintDriver
|
||||
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + MaintShell 外殼
|
||||
│
|
||||
├── components/
|
||||
│ ├── pages/ ← 新增:Page Component 層
|
||||
│ │ ├── PageMaintenance.vue
|
||||
│ │ └── PageReport.vue
|
||||
│ │
|
||||
│ ├── sections/ ← 新增:Section / Shelf 層
|
||||
│ │ ├── SectionSearchPanel.vue
|
||||
│ │ ├
|
||||
│ │ ├── SectionDataTable.vue
|
||||
│ │ └── SectionFormPanel.vue
|
||||
│ │
|
||||
@@ -164,8 +146,8 @@ src/
|
||||
│ └── DraggableDialog.vue
|
||||
│
|
||||
├── composables/
|
||||
│ ├── page-drivers/ ← 新增:頁面資料協調
|
||||
│ │ └── useMaintenancePage.ts
|
||||
│ ├── page-drivers/ ← 新增:頁面資料協調(僅複雜頁面需要)
|
||||
│ │ └── useSingleRecordMaintenancePage.ts
|
||||
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
|
||||
│ │ └── useCrudCommands.ts
|
||||
│ ├── forms/ ← 維持/重組:表單狀態機
|
||||
@@ -201,14 +183,10 @@ src/
|
||||
```vue
|
||||
<!-- views/maint/SingleRecord.vue(優化後) -->
|
||||
<script setup lang="ts">
|
||||
import { useMaintenancePage } from '@/composables/page-drivers/useMaintenancePage'
|
||||
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const { pageModel, load } = useMaintenancePage({
|
||||
title: '單筆資料維護',
|
||||
records: [],
|
||||
})
|
||||
load()
|
||||
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -216,31 +194,25 @@ load()
|
||||
</template>
|
||||
```
|
||||
|
||||
#### Layer 3: Page Component(`src/components/pages/`)
|
||||
#### Layer 3: View(`src/views/`)
|
||||
|
||||
- **職責**:組裝完整頁面的 section 順序、處理 page-level slot override、分發頁面級事件。
|
||||
- **命名**:一律 `Page` 前綴。
|
||||
- **對齊**:App Store 的 `ProductPage.svelte`、`TodayPage.svelte`、`DefaultPage.svelte`。
|
||||
- **職責**:自含頁面的完整入口 — 組裝 page model、協調 composable、撰寫頁面 template。
|
||||
- **禁止**:頁面 UI 不再拆到另一個 page component 層。
|
||||
- **對齊**:標準 Vue SPA 慣例。
|
||||
|
||||
```vue
|
||||
<!-- components/pages/PageMaintenance.vue -->
|
||||
<template>
|
||||
<PageMaintShell :title="page.title">
|
||||
<template #search>
|
||||
<SectionSearchPanel :fields="page.searchFields" @search="emit('search', $event)" />
|
||||
</template>
|
||||
<template #content>
|
||||
<SectionDataTable :records="page.records" @edit="emit('edit', $event)" />
|
||||
</template>
|
||||
</PageMaintShell>
|
||||
<!-- views/maint/SingleRecord.vue -->
|
||||
<script setup lang="ts">
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
<!-- 頁面級 dialog 外掛,內容再拆到 SectionFormPanel -->
|
||||
<SectionFormPanel
|
||||
v-model="formVisible"
|
||||
:mode="formMode"
|
||||
:record="activeRecord"
|
||||
@save="emit('save', $event)"
|
||||
/>
|
||||
const { pageModel, searchPanelOpen, commands, search, students, ... } = useSingleRecordMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MaintShell :title="pageModel.title" @create="commands.openAddDialog">
|
||||
<template #table>...</template>
|
||||
</MaintShell>
|
||||
</template>
|
||||
```
|
||||
|
||||
@@ -355,10 +327,11 @@ views/xxx.vue
|
||||
- `src/shell/GlobalOverlays.vue`:snackbar、搜尋 dialog、訊息 dialog。
|
||||
3. [x] 新增 `src/components/pages/`:建立第一個 `PageMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
|
||||
- 定義 `MaintenancePageModel` props 與 `create/edit/view/delete/search` emits。
|
||||
- 使用 `PageMaint.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
|
||||
4. [x] 新增 `src/composables/page-drivers/`:建立 `useMaintenancePage.ts`。
|
||||
- 透過 options 傳入 title 與 records,協調搜尋條件、分頁與 `pageModel`。
|
||||
- 提供 `load()` 與 `resetSearch()` 供 Page Driver 呼叫。
|
||||
- 使用 `MaintShell.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
|
||||
4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例。
|
||||
- 協調搜尋條件、分頁與 `pageModel`。
|
||||
- 提供 `load()` 與 `resetSearch()` 供 Page Driver 呼叫。
|
||||
- 後續已刪除純包裝型 driver(如 `useMaintenancePage`)。僅當頁面需要協調多個 composable 時才建立 page driver。
|
||||
|
||||
### Phase 2:遷移最厚的 view(SingleRecord.vue) ✅ 已完成
|
||||
|
||||
@@ -374,7 +347,7 @@ views/xxx.vue
|
||||
- View 中不再直接定義 `<teleport>`、`<v-overlay>` 或多個確認 dialog。
|
||||
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`。
|
||||
- 只呈現欄位與欄位錯誤,透過 `v-model` 與 `clear-field-error` 與上層互動。
|
||||
6. [x] 將 CRUD command 流程抽出到 `src/composables/commands/useCrudCommands.ts`。
|
||||
6. [x] 將 CRUD command 流程抽出到 `src/composables/useCrudCommands.ts`。
|
||||
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
|
||||
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
|
||||
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
||||
@@ -382,6 +355,8 @@ views/xxx.vue
|
||||
|
||||
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
|
||||
|
||||
> 後續簡化時,B/C/EditableGrid 的薄 page driver 已 inline 回 view,只保留有真實複雜邏輯的 driver。
|
||||
|
||||
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
|
||||
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
|
||||
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`。
|
||||
@@ -399,6 +374,8 @@ views/xxx.vue
|
||||
|
||||
### Phase 4:非 maintenance 頁面統一 ✅ 已完成
|
||||
|
||||
> 後續簡化時,Settings/FncPage 的薄 page driver 已 inline 回 view,型別移至 page component 自身。
|
||||
|
||||
1. [x] `Home.vue`、`Settings.vue`、`FncPage.vue` 套用 Page Driver + Page Component 模式。
|
||||
- `src/views/Home.vue` 縮減為 17 行,新增 `src/components/pages/PageHome.vue` 與 `src/composables/page-drivers/useHomePage.ts`。
|
||||
- `src/views/Settings.vue` 縮減為 10 行,新增 `src/components/pages/PageSettings.vue` 與 `src/composables/page-drivers/useSettingsPage.ts`。
|
||||
@@ -406,7 +383,11 @@ views/xxx.vue
|
||||
2. [x] `App.vue` 最終只保留 shell 掛載。
|
||||
- `src/App.vue` 縮減為 7 行,只掛載 `AppShell`。
|
||||
- `src/shell/AppShell.vue` 承接 layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
|
||||
- `src/composables/layout/useAppShell.ts` 承接 menu 合併、favorite、breadcrumb、layout action、手動/強制登出流程。
|
||||
- `src/composables/layout/useAppShell.ts` 承接 menu 合併、favorite、breadcrumb、layout action、手動/強制登出流程。
|
||||
|
||||
### Phase 5:移除 Page Component 層 ✅ 已完成
|
||||
|
||||
> 所有 page component 已合併回對應的 view,`src/components/pages/` 目錄已刪除。page driver 簡化為僅複雜頁面才使用的選配層,view 回歸標準 Vue SPA 慣例:自含 page model + 頁面 UI + section 組合。
|
||||
|
||||
---
|
||||
|
||||
@@ -415,14 +396,13 @@ views/xxx.vue
|
||||
| 層級 | 目錄 | 檔名前綴/範例 |
|
||||
|------|------|---------------|
|
||||
| App Shell | `src/shell/` | `AppShell.vue`、`GlobalOverlays.vue` |
|
||||
| Page Driver | `src/views/` | `SingleRecord.vue`(route view,不改名) |
|
||||
| Page Component | `src/components/pages/` | `PageMaintenance.vue` |
|
||||
| View(自含頁面) | `src/views/` | `SingleRecord.vue` |
|
||||
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue`、`SectionSearchPanel.vue` |
|
||||
| Item / Atom | `src/components/items/` | `ItemDataRow.vue`、`ItemFormField.vue` |
|
||||
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
||||
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
||||
| Page Driver Composable | `src/composables/page-drivers/` | `useMaintenancePage.ts` |
|
||||
| Command Composable | `src/composables/commands/` | `useCrudCommands.ts` |
|
||||
| Page Driver Composable | `src/composables/page-drivers/` | `useSingleRecordMaintenancePage.ts` |
|
||||
| Command Composable | `src/composables/` | `useCrudCommands.ts` |
|
||||
| Form Composable | `src/composables/forms/` | `useForm.ts` |
|
||||
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
||||
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
||||
@@ -432,7 +412,7 @@ views/xxx.vue
|
||||
|
||||
## 七、對齊檢查清單(新增/重構時使用)
|
||||
|
||||
- [ ] 這個 view 超過 100 行了嗎?→ 考慮抽出 Page Component。
|
||||
- [ ] 這個 view 超過 200 行了嗎?→ 考慮抽出 Section 或 composable。
|
||||
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
|
||||
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
|
||||
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
|
||||
|
||||
+166
-198
@@ -2,17 +2,17 @@
|
||||
|
||||
## 目的
|
||||
|
||||
這份文件只描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案、或重構時有一致判斷基準。
|
||||
這份文件描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案或重構時有一致判斷基準。
|
||||
|
||||
本文件是現況快照;新增功能與重構的細節規範以 `docs/architecture-strategy.md`、`docs/llm-development-guide.md` 與各層 `src/**/GUIDE.md` 為準。
|
||||
|
||||
目前專案的主要責任鏈如下:
|
||||
|
||||
- `router` 決定 route 與 layout meta
|
||||
- `App.vue` 根據 route meta 組裝 app shell 與全域 UI
|
||||
- `views` 承接路由入口與頁面資料協調
|
||||
- `components` 承接 layout、page component、domain component 與較細的 UI 區塊
|
||||
- `composables` 承接可重用流程與 UI state
|
||||
- `stores` 承接跨頁狀態、快取與全域顯示狀態
|
||||
- `services` 承接 HTTP client、API 模組、token 與錯誤處理
|
||||
```txt
|
||||
router -> App.vue -> AppShell -> layout -> view -> page component -> section -> item
|
||||
↓
|
||||
page driver / command composable -> store -> service
|
||||
```
|
||||
|
||||
## 目前目錄的責任邊界
|
||||
|
||||
@@ -26,93 +26,116 @@
|
||||
|
||||
責任:
|
||||
|
||||
- 定義 route 與 route meta
|
||||
- 指定頁面使用哪種 layout
|
||||
- 串接導航守衛
|
||||
- 定義 route 與 route meta。
|
||||
- 指定頁面使用哪種 layout。
|
||||
- 串接導航守衛。
|
||||
|
||||
目前 `meta.layout` 已是 app shell 切換的正式入口:
|
||||
目前 `meta.layout` 是 app shell 切換的正式入口:
|
||||
|
||||
- `default` 走 [MainLayout.vue](../src/components/layouts/MainLayout.vue)
|
||||
- `none` 走 [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
|
||||
- `default` 走 [MainLayout.vue](../src/components/layouts/MainLayout.vue)。
|
||||
- `none` 走 [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)。
|
||||
|
||||
### `src/App.vue`
|
||||
### `src/App.vue` 與 `src/shell`
|
||||
|
||||
[App.vue](../src/App.vue) 目前不是單純掛載入口,而是實際的應用組裝層。
|
||||
[App.vue](../src/App.vue) 目前只掛載 [AppShell.vue](../src/shell/AppShell.vue),不再直接承擔全域 UI 組裝。
|
||||
|
||||
目前承擔的責任包含:
|
||||
`src/shell` 是 App Shell 層:
|
||||
|
||||
- 根據 `route.meta.layout` 切換 layout
|
||||
- 組裝 breadcrumb / favorites / menu 等 layout props
|
||||
- 放置全域搜尋結果 dialog
|
||||
- 放置全域訊息中心 dialog
|
||||
- 放置全域 snackbar
|
||||
- 串接 layout event 與路由跳轉
|
||||
- [AppShell.vue](../src/shell/AppShell.vue):layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
|
||||
- [AppTabs.vue](../src/shell/AppTabs.vue):default layout 下的 tabs 與 keep-alive router-view 容器。
|
||||
- [GlobalOverlays.vue](../src/shell/GlobalOverlays.vue):全域 snackbar、搜尋 dialog、訊息 dialog。
|
||||
|
||||
判斷原則:
|
||||
|
||||
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可留在 `App.vue`
|
||||
- 只屬於單一路由頁面的對話框或互動,不應堆到 `App.vue`
|
||||
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可放在 `src/shell`。
|
||||
- 只屬於單一路由頁面的對話框或互動,不應放進 `src/shell`。
|
||||
- shell 狀態協調優先放在 `src/composables/layout/useAppShell.ts`。
|
||||
|
||||
### `src/views`
|
||||
|
||||
`views` 目前整體方向是「路由入口 + 頁面資料協調 + 頁面事件協調」。
|
||||
`views` 是 route entry,方向是薄層:呼叫 page driver、掛載 page component、協調 route-level 事件。
|
||||
|
||||
目前較薄的 view:
|
||||
目前較典型的薄 view:
|
||||
|
||||
- [Home.vue](../src/views/Home.vue)
|
||||
- [Login.vue](../src/views/Login.vue)
|
||||
- [EditableGrid.vue](../src/views/maint/EditableGrid.vue)
|
||||
- [Forbidden.vue](../src/views/errors/Forbidden.vue)
|
||||
- [ServerError.vue](../src/views/errors/ServerError.vue)
|
||||
- [ServiceUnavailable.vue](../src/views/errors/ServiceUnavailable.vue)
|
||||
- [NetworkError.vue](../src/views/errors/NetworkError.vue)
|
||||
- [Maintenance.vue](../src/views/errors/Maintenance.vue)
|
||||
- [NotFound.vue](../src/views/errors/NotFound.vue)
|
||||
- [ErrorShell.vue](../src/views/errors/ErrorShell.vue)
|
||||
- [FncPage.vue](../src/views/FncPage.vue)
|
||||
- [Settings.vue](../src/views/Settings.vue)
|
||||
|
||||
目前仍偏厚的 view:
|
||||
|
||||
- [FncPage.vue](../src/views/FncPage.vue)
|
||||
- [SingleRecord.vue](../src/views/maint/SingleRecord.vue)
|
||||
- [EditableGrid.vue](../src/views/maint/EditableGrid.vue)
|
||||
- [MasterDetailA.vue](../src/views/maint/MasterDetailA.vue)
|
||||
- [MasterDetailB.vue](../src/views/maint/MasterDetailB.vue)
|
||||
- [MasterDetailC.vue](../src/views/maint/MasterDetailC.vue)
|
||||
|
||||
錯誤頁集中在 `src/views/errors`,通常使用 `meta.layout = 'none'`,並由 [ErrorShell.vue](../src/views/errors/ErrorShell.vue) 共用錯誤頁骨架。
|
||||
|
||||
[Login.vue](../src/views/Login.vue) 是 template core 例外:它仍負責登入頁組合、功能開關、小型提示 dialog 與登入流程協調。登入頁 UI 拆在 `components/login/*`,captcha 與 announcement 流程拆在頂層 login composable。
|
||||
|
||||
`views` 應遵守的原則:
|
||||
|
||||
- 可以持有 route、store、頁面資料組裝、頁面事件協調
|
||||
- 可以管理只屬於該頁的 dialog 顯示狀態
|
||||
- 不應長期承擔大量可抽出的模板片段
|
||||
- 不應把可重用流程直接留在頁面內重複複製
|
||||
- 可以持有 route、page driver 掛載、頁面資料組裝與頁面事件協調。
|
||||
- 可以管理只屬於該頁的小型 dialog 顯示狀態。
|
||||
- 不應長期承擔大型表格、表單、dialog 模板或可重用流程。
|
||||
- 不應直接處理底層 HTTP 細節。
|
||||
|
||||
### `src/components`
|
||||
|
||||
目前 `components` 已經分成幾種不同角色,不能再用單一規則描述。
|
||||
`components` 依角色分層,不再用單一規則描述。
|
||||
|
||||
#### 1. 頁面型元件
|
||||
#### 1. Root page/template components
|
||||
|
||||
目前以下元件實際上扮演 page component:
|
||||
目前仍放在 `src/components` 根目錄的頁面外殼:
|
||||
|
||||
- [PageLogin.vue](../src/components/PageLogin.vue)
|
||||
- [PageIndex.vue](../src/components/PageIndex.vue)
|
||||
- [PageMaint.vue](../src/components/PageMaint.vue)
|
||||
|
||||
這些檔案的責任是:
|
||||
這些是既有 template 頁面外殼或登入頁組裝元件。新增一般功能頁時,優先使用 `src/components/pages`。
|
||||
|
||||
- 接收 view 組好的資料與事件
|
||||
- 組裝某個完整頁面的主畫面
|
||||
- 再往下使用較小的子元件或 domain component
|
||||
#### 2. `components/pages`
|
||||
|
||||
命名規則:
|
||||
`components/pages` 是完整頁面主畫面組裝層:
|
||||
|
||||
- 只要是 page component,檔名以 `Page` 為前綴
|
||||
- page component 可以放在 `components` 根目錄
|
||||
- 不要把 page component 丟進 `base`
|
||||
- [PageHome.vue](../src/components/pages/PageHome.vue)
|
||||
- [PageSettings.vue](../src/components/pages/PageSettings.vue)
|
||||
- [PageFunction.vue](../src/components/pages/PageFunction.vue)
|
||||
- [PageMaintenance.vue](../src/components/pages/PageMaintenance.vue)
|
||||
- [PageEditableGridMaintenance.vue](../src/components/pages/PageEditableGridMaintenance.vue)
|
||||
- [PageMasterDetailAMaintenance.vue](../src/components/pages/PageMasterDetailAMaintenance.vue)
|
||||
- [PageMasterDetailBMaintenance.vue](../src/components/pages/PageMasterDetailBMaintenance.vue)
|
||||
- [PageMasterDetailCMaintenance.vue](../src/components/pages/PageMasterDetailCMaintenance.vue)
|
||||
|
||||
#### 2. `components/login`
|
||||
責任:
|
||||
|
||||
登入頁的較細 UI 區塊已集中到:
|
||||
- 接收 view/page driver 組好的資料與事件。
|
||||
- 組裝完整頁面的主要 section 順序。
|
||||
- 再往下使用 sections、items、feature/domain components。
|
||||
|
||||
#### 3. `components/sections`
|
||||
|
||||
`components/sections` 是頁面區塊容器:
|
||||
|
||||
- [SectionDataTable.vue](../src/components/sections/SectionDataTable.vue)
|
||||
- [SectionFormPanel.vue](../src/components/sections/SectionFormPanel.vue)
|
||||
- [SectionFormPage.vue](../src/components/sections/SectionFormPage.vue)
|
||||
- [SectionQueryPage.vue](../src/components/sections/SectionQueryPage.vue)
|
||||
|
||||
責任:
|
||||
|
||||
- 決定區塊布局與區塊互動。
|
||||
- 以 props 接收資料,以 emit 回報事件。
|
||||
- 不知道 route,不直接呼叫 API。
|
||||
|
||||
#### 4. `components/items`
|
||||
|
||||
`components/items` 是欄位群組或單筆資料呈現層:
|
||||
|
||||
- [ItemFormFieldGroup.vue](../src/components/items/ItemFormFieldGroup.vue)
|
||||
|
||||
item 不應知道自己被放在表格、grid、dialog 或頁面哪個位置。
|
||||
|
||||
#### 5. `components/login`
|
||||
|
||||
登入頁的較細 UI 區塊集中在:
|
||||
|
||||
- [CreateAccountLink.vue](../src/components/login/CreateAccountLink.vue)
|
||||
- [LoginAnnouncementBoard.vue](../src/components/login/LoginAnnouncementBoard.vue)
|
||||
@@ -123,56 +146,12 @@
|
||||
- [LoginToolBar.vue](../src/components/login/LoginToolBar.vue)
|
||||
- [LoginVerify.vue](../src/components/login/LoginVerify.vue)
|
||||
|
||||
這一層的定位是:
|
||||
這一層服務 `PageLogin`,不是全域 base library。
|
||||
|
||||
- 服務 `PageLogin`
|
||||
- 屬於 login 頁面家族
|
||||
- 不是全域 base library
|
||||
#### 6. `components/maint`
|
||||
|
||||
#### 3. `components/base`
|
||||
`components/maint` 是 maintenance demo / domain component 區域:
|
||||
|
||||
目前 `components/base` 只剩下:
|
||||
|
||||
- [DraggableDialog.vue](../src/components/base/DraggableDialog.vue)
|
||||
|
||||
目前判斷原則很直接:
|
||||
|
||||
- `base` 只放真正可跨頁重用、且不屬於特定 domain 的元件
|
||||
- 若元件只服務單一頁面家族或單一 domain,優先放回對應資料夾
|
||||
|
||||
#### 4. `components/layouts`
|
||||
|
||||
目前 layout 實作集中於:
|
||||
|
||||
- [MainLayout.vue](../src/components/layouts/MainLayout.vue)
|
||||
- [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
|
||||
- `src/components/layouts/main-layout/*`
|
||||
|
||||
其中 `main-layout/*` 是 `MainLayout` 底下拆出的骨架子元件:
|
||||
|
||||
- [AppBarBreadcrumbCol.vue](../src/components/layouts/main-layout/AppBarBreadcrumbCol.vue)
|
||||
- [AppBarFavoritesCol.vue](../src/components/layouts/main-layout/AppBarFavoritesCol.vue)
|
||||
- [AppBarTopCol.vue](../src/components/layouts/main-layout/AppBarTopCol.vue)
|
||||
- [DrawerDesktopMenu.vue](../src/components/layouts/main-layout/DrawerDesktopMenu.vue)
|
||||
- [DrawerMobileFavoritesPanel.vue](../src/components/layouts/main-layout/DrawerMobileFavoritesPanel.vue)
|
||||
- [DrawerMobileMenuPanel.vue](../src/components/layouts/main-layout/DrawerMobileMenuPanel.vue)
|
||||
|
||||
layout 應只承擔:
|
||||
|
||||
- app shell
|
||||
- drawer / app bar / favorites / breadcrumb 等框架 UI
|
||||
- 與 layout 視覺結構直接相關的互動
|
||||
|
||||
layout 不應承擔:
|
||||
|
||||
- 頁面專屬業務流程
|
||||
- 特定 domain 的資料規則
|
||||
|
||||
#### 5. `components/maint`
|
||||
|
||||
這個目錄目前是最接近 feature folder 的區域,放 maintenance 領域的 page component 與 domain component:
|
||||
|
||||
- [PageMaint.vue](../src/components/PageMaint.vue)
|
||||
- [CommonConfirmDialog.vue](../src/components/maint/CommonConfirmDialog.vue)
|
||||
- [EditableGrid.vue](../src/components/maint/EditableGrid.vue)
|
||||
- [MasterFileFormFields.vue](../src/components/maint/MasterFileFormFields.vue)
|
||||
@@ -180,46 +159,48 @@ layout 不應承擔:
|
||||
- [MntRecordNavToolbar.vue](../src/components/maint/MntRecordNavToolbar.vue)
|
||||
- `master-detail/*`
|
||||
|
||||
`master-detail/*` 目前屬於維護頁專用的較細組件群:
|
||||
若只是維護頁專用子元件,不要搬到 `base`。
|
||||
|
||||
- [CourseMobilePanel.vue](../src/components/maint/master-detail/CourseMobilePanel.vue)
|
||||
- [DetailCollapseGropus.vue](../src/components/maint/master-detail/DetailCollapseGropus.vue)
|
||||
- [DetailFullHeightPanel.vue](../src/components/maint/master-detail/DetailFullHeightPanel.vue)
|
||||
- [DetailNavigation.vue](../src/components/maint/master-detail/DetailNavigation.vue)
|
||||
- [DetailSidePanel.vue](../src/components/maint/master-detail/DetailSidePanel.vue)
|
||||
- [DetailSimpleList.vue](../src/components/maint/master-detail/DetailSimpleList.vue)
|
||||
#### 7. `components/layouts`
|
||||
|
||||
結論:
|
||||
layout 實作集中於:
|
||||
|
||||
- `components/maint` 主要扮演 maintenance domain component 層
|
||||
- `CommonConfirmDialog` 可以直接在 maintenance 頁或元件使用,不需要再包一層 CRUD dialog aggregator
|
||||
- 若只是維護頁專用子元件,不要搬到 `base`
|
||||
- [MainLayout.vue](../src/components/layouts/MainLayout.vue)
|
||||
- [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
|
||||
- `src/components/layouts/main-layout/*`
|
||||
|
||||
layout 只承擔 app shell、drawer、app bar、favorites、breadcrumb 等框架 UI,不承擔頁面專屬業務流程。
|
||||
|
||||
#### 8. `components/base`
|
||||
|
||||
`components/base` 放真正跨頁共用且不屬於特定 domain 的基礎元件:
|
||||
|
||||
- [DraggableDialog.vue](../src/components/base/DraggableDialog.vue)
|
||||
- [BaseFormTextField.vue](../src/components/base/BaseFormTextField.vue)
|
||||
- [BaseFormSelect.vue](../src/components/base/BaseFormSelect.vue)
|
||||
|
||||
只服務單一頁面家族或單一 domain 的元件不要放進 `base`。
|
||||
|
||||
### `src/composables`
|
||||
|
||||
目前已明確分成兩組:
|
||||
目前 composables 分成:
|
||||
|
||||
- `composables/layout/*`
|
||||
- `composables/maint/*`
|
||||
- `page-drivers/*`:頁面資料協調與 page model 組裝。
|
||||
- `commands/*`:命令式副作用流程,例如 create/edit/save/delete。
|
||||
- `layout/*`:AppShell / layout 狀態與事件協調。
|
||||
- `maint/*`:maintenance demo 的表單、CRUD、editable grid 狀態。
|
||||
- 頂層 login / utility composable:`useLoginCaptcha.ts`、`useLoginAnnouncements.ts`、`useApiCall.ts`。
|
||||
|
||||
代表性檔案:
|
||||
責任:
|
||||
|
||||
- [useAdminLayoutState.ts](../src/composables/layout/useAdminLayoutState.ts)
|
||||
- [useThemeToggle.ts](../src/composables/layout/useThemeToggle.ts)
|
||||
- [useMaintenanceCrudFlow.ts](../src/composables/maint/useMaintenanceCrudFlow.ts)
|
||||
- [useStudentMaintenanceForm.ts](../src/composables/maint/useStudentMaintenanceForm.ts)
|
||||
- [useEditableStudentGrid.ts](../src/composables/maint/useEditableStudentGrid.ts)
|
||||
- [useApiCall.ts](../src/composables/useApiCall.ts)
|
||||
|
||||
`composables` 的責任:
|
||||
|
||||
- 放可重用流程
|
||||
- 放可測試的 UI state
|
||||
- 放與模板結構耦合較低的狀態機
|
||||
- 放可重用流程。
|
||||
- 放可測試的 UI state。
|
||||
- 放與模板結構耦合較低的狀態機。
|
||||
- 不 import component 或 view。
|
||||
|
||||
### `src/stores`
|
||||
|
||||
目前 store 已經是正式分層的一部分,而不只是暫時狀態容器。
|
||||
目前 store 是跨頁共享狀態、快取與全域顯示狀態的正式分層。
|
||||
|
||||
代表性檔案:
|
||||
|
||||
@@ -230,25 +211,20 @@ layout 不應承擔:
|
||||
- [favorites.ts](../src/stores/favorites.ts)
|
||||
- [messages.ts](../src/stores/messages.ts)
|
||||
- [snackbar.ts](../src/stores/snackbar.ts)
|
||||
- [loginAnnouncements.ts](../src/stores/loginAnnouncements.ts)
|
||||
- [students.ts](../src/stores/students.ts)
|
||||
- [semesters.ts](../src/stores/semesters.ts)
|
||||
|
||||
責任:
|
||||
|
||||
- 承接跨頁共享狀態
|
||||
- 承接畫面快取與顯示狀態
|
||||
- 作為 view 與 services 之間的狀態收斂點
|
||||
- `app.ts` 目前是空的 Pinia scaffold,尚未承擔實際 app state
|
||||
- 承接跨頁共享狀態。
|
||||
- 承接畫面快取與全域顯示狀態。
|
||||
- 作為 view/page driver/composable 與 services 之間的狀態收斂點。
|
||||
|
||||
規則:
|
||||
|
||||
- store 檔案直接放在 `src/stores/*.ts`
|
||||
- 不要建立 `src/stores/stores/*` 這類重複巢狀目錄
|
||||
`app.ts` 目前是空的 Pinia scaffold,尚未承擔實際 app state。
|
||||
|
||||
### `src/services`
|
||||
|
||||
`services` 現在已經是一層明確的資料存取邊界,不應再被視為附屬工具資料夾。
|
||||
`services` 是 HTTP 與外部 API 邊界。
|
||||
|
||||
代表性檔案:
|
||||
|
||||
@@ -263,101 +239,93 @@ layout 不應承擔:
|
||||
|
||||
責任:
|
||||
|
||||
- 提供 HTTP client
|
||||
- 封裝 API 模組
|
||||
- 統一 token、session 與錯誤處理
|
||||
- 提供 `httpClient`。
|
||||
- 封裝 API 模組。
|
||||
- 統一 token、session 與錯誤處理。
|
||||
|
||||
規則:
|
||||
|
||||
- 元件不直接處理底層 HTTP 細節
|
||||
- 可共享的請求流程優先收斂到 store 或 composable,再由它們呼叫 service
|
||||
- 元件不直接處理底層 HTTP 細節。
|
||||
- service module 不持有 UI 狀態。
|
||||
- 可共享的請求流程優先收斂到 store、page driver 或 composable,再由它們呼叫 service。
|
||||
|
||||
## 目前已落地的分層模式
|
||||
|
||||
### 模式 1:`view -> page component -> page family components`
|
||||
### 模式 1:`view -> page driver -> page component`
|
||||
|
||||
已落地頁面:
|
||||
|
||||
- `Login`
|
||||
- `Home`
|
||||
|
||||
目前的穩定模式是:
|
||||
|
||||
- `view` 負責資料準備與事件協調
|
||||
- page component 負責頁面主畫面組裝
|
||||
- 較細的視覺區塊再拆到對應頁面家族資料夾,例如 `components/login/*`
|
||||
|
||||
### 模式 2:`view -> page component / domain components + maint composables`
|
||||
|
||||
已落地區域:
|
||||
|
||||
- `Settings`
|
||||
- `FncPage`
|
||||
- `views/maint/*`
|
||||
- `components/maint/*`
|
||||
- `composables/maint/*`
|
||||
|
||||
這一層目前是 maintenance 領域最清楚的結構:
|
||||
穩定模式:
|
||||
|
||||
- `views/maint/*` 承接 route 與頁面流程協調
|
||||
- [PageMaint.vue](../src/components/PageMaint.vue) 承接維護頁共用頁面骨架
|
||||
- `components/maint/*` 承接維護頁專用元件
|
||||
- `composables/maint/*` 承接 CRUD 流程、表單狀態與 editable grid 狀態
|
||||
- view 負責掛載 page driver 與 page component。
|
||||
- page driver 負責 page model、事件與頁面狀態協調。
|
||||
- page component 負責頁面主畫面組裝。
|
||||
|
||||
[EditableGrid.vue](../src/views/maint/EditableGrid.vue) 是目前最接近薄 view 的 maintenance 頁面。
|
||||
### 模式 2:`Login.vue -> PageLogin -> login components/composables`
|
||||
|
||||
### 模式 3:`router meta -> App.vue -> layout`
|
||||
登入頁是 template core,功能開關集中在 `Login.vue`:
|
||||
|
||||
- `withCaptcha`
|
||||
- `withAnnouncement`
|
||||
- `withForgotPassword`
|
||||
- `withRememberAccount`
|
||||
|
||||
資料流與 side effect 分別由 `useLoginCaptcha()`、`useLoginAnnouncements()`、`PageLogin` 與 `LoginForm` 承接。
|
||||
|
||||
### 模式 3:`router meta -> AppShell -> layout`
|
||||
|
||||
這一層已正式成立:
|
||||
|
||||
- route 決定 layout 類型
|
||||
- `App.vue` 決定套用哪個 shell
|
||||
- layout 專注在骨架與共用框架 UI
|
||||
|
||||
這代表 layout 的責任邊界不應再回頭混入頁面內部流程。
|
||||
- route 決定 layout 類型。
|
||||
- `AppShell` 決定套用哪個 shell layout。
|
||||
- layout 專注在骨架與共用框架 UI。
|
||||
|
||||
## 命名規則
|
||||
|
||||
### 頁面與 page component
|
||||
|
||||
- 直接被 route 載入的檔案放 `views`
|
||||
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴
|
||||
- page component 不放進 `base`
|
||||
|
||||
目前例子:
|
||||
|
||||
- [PageLogin.vue](../src/components/PageLogin.vue)
|
||||
- [PageIndex.vue](../src/components/PageIndex.vue)
|
||||
- [PageMaint.vue](../src/components/PageMaint.vue)
|
||||
- 直接被 route 載入的檔案放 `views`。
|
||||
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴。
|
||||
- page component 優先放 `components/pages`;既有 template 外殼可保留在 `components` 根目錄。
|
||||
- page component 不放進 `base`。
|
||||
|
||||
### 資料夾命名
|
||||
|
||||
- 多字資料夾一律使用 `kebab-case`
|
||||
- 不新增 `snake_case` 或 `PascalCase` 資料夾
|
||||
- 多字資料夾一律使用 `kebab-case`。
|
||||
- 不新增 `snake_case` 或 `PascalCase` 資料夾。
|
||||
|
||||
目前例子:
|
||||
|
||||
- `main-layout`
|
||||
- `master-detail`
|
||||
- `page-drivers`
|
||||
|
||||
### domain component 命名
|
||||
### component 命名
|
||||
|
||||
- 與特定領域強綁定的元件,優先用領域意圖命名
|
||||
- 不要為了抽象而保留含糊的舊前綴
|
||||
- 若元件只在 maint 領域使用,就留在 `components/maint`
|
||||
- Page component:`PageXxx.vue`
|
||||
- Section component:`SectionXxx.vue`
|
||||
- Item component:`ItemXxx.vue`
|
||||
- Base component:不使用 `Page` / `Section` / `Item` 前綴,直接以功能命名。
|
||||
|
||||
## 新增或修改檔案時的判斷準則
|
||||
|
||||
1. 這個檔案是否直接被 route 載入?
|
||||
- 是:優先放 `views`
|
||||
- 是:優先放 `views`。
|
||||
2. 這個檔案是否負責某個完整頁面的主畫面組裝?
|
||||
- 是:用 `Page` 前綴,放 page component 層,不要塞進 `base`
|
||||
- 是:用 `Page` 前綴,優先放 `components/pages`,不要塞進 `base`。
|
||||
3. 這段重複的是模板還是流程?
|
||||
- 模板:抽元件
|
||||
- 流程:抽 composable 或 store
|
||||
- 模板:抽元件。
|
||||
- 流程:抽 composable、page driver、command 或 store。
|
||||
4. 這個狀態是否跨頁共享,或需要快取 / 全域顯示控制?
|
||||
- 是:優先考慮 store
|
||||
- 是:優先考慮 store。
|
||||
5. 這個邏輯是否在處理 API、token、session、錯誤正規化?
|
||||
- 是:放 `services`
|
||||
6. 這個元件是否只屬於單一 domain?
|
||||
- 是:優先放到該 domain 目錄,例如 `components/maint`
|
||||
- 是:放 `services`。
|
||||
6. 這個元件是否只屬於單一 domain 或單一頁面家族?
|
||||
- 是:優先放到該 domain / feature 目錄,例如 `components/maint` 或 `components/login`。
|
||||
7. 這個抽象是否真的降低重複與理解成本?
|
||||
- 否:不要抽
|
||||
- 否:不要抽。
|
||||
|
||||
@@ -8,13 +8,12 @@
|
||||
|
||||
## 建議閱讀順序
|
||||
|
||||
1. `README.md`
|
||||
1. `src/GUIDE.md`
|
||||
2. `docs/architecture-strategy.md`
|
||||
3. `src/GUIDE.md`
|
||||
4. 依變更範圍閱讀對應的 `src/**/GUIDE.md`
|
||||
5. `docs/add-page-example.md`(需要新增頁面時)
|
||||
3. 依 `maintenanceContract.pageKind` 閱讀對應的 demo 與 `src/**/GUIDE.md`(查 `docs/architecture-strategy.md` 的分層說明)
|
||||
4. `docs/add-page-example.md`(需要新增頁面時)
|
||||
|
||||
`docs/frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md` 與 `src/**/GUIDE.md` 為準。
|
||||
`frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md` 與 `src/**/GUIDE.md` 為準。
|
||||
|
||||
## GUIDE 索引
|
||||
|
||||
@@ -41,11 +40,10 @@
|
||||
一般功能需求優先修改:
|
||||
|
||||
- `src/views/*`
|
||||
- `src/components/pages/*`
|
||||
- `src/components/sections/*`
|
||||
- `src/components/items/*`
|
||||
- `src/composables/page-drivers/*`
|
||||
- `src/composables/commands/*`
|
||||
- `src/composables/useCrudCommands.ts`
|
||||
- `src/stores/*`
|
||||
- `src/services/modules/*`
|
||||
- `src/router/routes.ts`
|
||||
@@ -73,7 +71,7 @@
|
||||
- 是否碰到 template core。
|
||||
- 是否已有同類型範例可沿用。
|
||||
- 是否需要新增 route。
|
||||
- 是否應拆成 page / section / item。
|
||||
- 是否應拆成 section / item。
|
||||
- 是否應新增 page driver 或 command composable。
|
||||
- 是否需要 store,或只需要頁面內 state。
|
||||
- 是否應定義新的 model 型別(`src/models/`)。
|
||||
@@ -92,6 +90,91 @@
|
||||
|
||||
判斷順序:先看有無「送出/存檔」→ 再看有無「查詢」→ 其餘視為一般列表頁。
|
||||
|
||||
## `.spec.json` 對照指南
|
||||
|
||||
當 LLM 依照 `GEN-FE-PROMPT` 讀取 `.ht/spec/{page}.spec.json` 後,依 `maintenanceContract.pageKind` 決定對應的 demo 與 composable 界面,再將 `.spec.json` 的 evidence 欄位對應到 composable 的 reactive state、computed 與 API calls。
|
||||
|
||||
### query(查詢頁)→ `SectionQueryPage`
|
||||
|
||||
參考:`src/views/demos/SectionQueryPageDemo.vue`、`src/composables/page-drivers/useSectionsDemoPage.ts`
|
||||
|
||||
架構:
|
||||
```
|
||||
View(自含 page model + UI) → SectionQueryPage
|
||||
↓
|
||||
composable (page driver)
|
||||
```
|
||||
|
||||
**composable 必須回傳:**
|
||||
|
||||
| 名稱 | 型別 | 對應 `.spec.json` 來源 |
|
||||
|------|------|------------------------|
|
||||
| `queryFilters` | `Ref<{ 每個欄位 }>` | `pageContract.forms[0].fields` — 每個 field 建一個 key,型別依 `field.type`(text→string, select→string \| null,選項取自 `field.options`) |
|
||||
| `pageModel` | `ComputedRef<{ title, ... }>` | `title` 來自 `pageContract.title`;`backLabel` 固定為 `'返回'` |
|
||||
| `handleQuerySearch()` | 函式 | 觸發 `apiContract.endpoints` 中 `usage=search` 的 API call;呼叫時機對應 `bddContract.scenarios` 中 `type=query` 的 When |
|
||||
| `handleQueryBack()` | 函式 | 對應 `pageContract.actions` 中 `actionType=back` |
|
||||
| 表格資料 | 在 `pageModel` 中 | `tables[].headers` 對應表格欄;`sampleRows` 對應欄位格式 |
|
||||
|
||||
**page component props:**
|
||||
- `v-model:query-filters` — 雙向綁定 `queryFilters`
|
||||
- `:page` — 傳入 `pageModel`
|
||||
|
||||
**page component emits:**
|
||||
- `@search` → 呼叫 `handleQuerySearch`
|
||||
- `@back` → 呼叫 `handleQueryBack`
|
||||
|
||||
### application(申請/表單頁)→ `SectionFormPage`
|
||||
|
||||
參考:`src/views/demos/SectionFormPageDemo.vue`
|
||||
|
||||
架構:
|
||||
```
|
||||
View(自含 page model + UI) → SectionFormPage
|
||||
↓
|
||||
composable (page driver)
|
||||
```
|
||||
|
||||
**composable 必須回傳:**
|
||||
|
||||
| 名稱 | 型別 | 對應 `.spec.json` 來源 |
|
||||
|------|------|------------------------|
|
||||
| `demoForm`(應改名為 `formState`) | `Ref<{ 每個欄位 }>` | `pageContract.forms[0].fields` — text/select 建 key;不可編輯的 `readonly` 欄位不放進 formState,改從 `pageModel` 單向顯示 |
|
||||
| `pageModel` | `ComputedRef` | `title` 來自 `pageContract.title` |
|
||||
| `handleFormSubmit()` | 函式 | 觸發 `apiContract.endpoints` 中 `usage=create` 的 POST endpoint;呼叫前驗證 `apiCatalog.fieldRules`;呼叫時機對應 `bddContract.scenarios` 中 `type=application-submit` 的 When/Then |
|
||||
| `resetDemoForm()`(改為 `resetForm`) | 函式 | 對應 `pageContract.actions` 中 `actionType=reset` |
|
||||
| `handleFormBack()` | 函式 | 對應 `actionType=back` |
|
||||
|
||||
**提交 payload 規則:**
|
||||
- `apiCatalog.fieldRules` 中的 `field` 與 `rule` 決定必填、長度、格式驗證
|
||||
- 型別轉換依 `field.type`:number 欄位不可包成 string 送出
|
||||
|
||||
### maintenance(維護/CRUD 頁)→ `maint/*`
|
||||
|
||||
參考:`src/views/maint/README.md` — 依資料結構選擇最接近的範本(EditableGrid / SingleRecord / MasterDetail A/B/C)
|
||||
|
||||
**composable 必須回傳:**
|
||||
|
||||
| 名稱 | 對應 `.spec.json` 來源 |
|
||||
|------|------------------------|
|
||||
| `search filters` | `pageContract.forms[0].fields` |
|
||||
| `table data / headers` | `pageContract.tables[].headers` + search API response |
|
||||
| `row action handlers` | `maintenanceContract.rowActions` — 每個 action 對應一個 handler;`enabledWhen` 決定啟用條件(如 `aprvYn === 'Z'` 時才能修改) |
|
||||
| `create/update/delete calls` | `apiContract.endpoints` 中對應的 POST/PUT/DELETE |
|
||||
|
||||
**row action 狀態規則:**
|
||||
- `enabledWhen` 直接轉為 template 中的 `:disabled` 或 `v-if` 條件
|
||||
- `maintenanceContract.businessRules` 中的額外限制一併套用
|
||||
|
||||
### 通用規則
|
||||
|
||||
**entity 命名:** 所有 composable、component、store 的名稱以 `maintenanceContract.dataModel.primaryEntity` 為 entity 名,例如 primaryEntity=`FacilityApply` → `useFacilityApplyPage.ts` → `PageFacilityApply.vue`。
|
||||
|
||||
**API 串接:** 在 `src/services/modules/` 新增對應 entity 的 API module,method 名稱對齊 `apiContract.endpoints[].usage`(search/create/update/delete/print),path 對齊 `endpoint.path`。
|
||||
|
||||
**錯誤處理:** 檢查 `apiContract.errorHandling.format` — 若為 `ProblemDetailsWithValidationErrors`,須處理 `errors` 物件中的逐欄錯誤訊息;若為 `ProblemDetails`,只顯示 `detail`。
|
||||
|
||||
**語系文案:** 欄位 label 與按鈕文字取自 `pageContract.forms[].fields[].label` 和 `pageContract.actions[].label`,放入 `src/language/` 對應語系 key。
|
||||
|
||||
## 完成前驗證
|
||||
|
||||
- Vue / TypeScript 結構有變更:`pnpm -s type-check`
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
## 一、Apple App Store 專案的核心架構特徵
|
||||
|
||||
### 1.1 單一業務邏輯門面(Jet Facade)
|
||||
|
||||
```
|
||||
browser.ts → bootstrap → Jet.load → runtime + objectGraph
|
||||
↓
|
||||
UI 層僅透過 jet.dispatch(intent) / jet.perform(action) 溝通
|
||||
```
|
||||
|
||||
- **Jet** 封裝所有業務邏輯:路由、資料取得、動作分發、metrics。
|
||||
- UI 層**不直接**呼叫 API、不直接操作 storage、不直接操作 history。
|
||||
- 所有外部依賴(fetch、storage、locale、user)統一注入 `Dependencies`,再組裝成 `ObjectGraph`。
|
||||
|
||||
### 1.2 Intent / Action 分離(查詢與命令)
|
||||
|
||||
| 類型 | 職責 | 回傳值 | 例子 |
|
||||
|------|------|--------|------|
|
||||
| **Intent** | 取得頁面資料(Query) | `Promise<Page>` | `RouteUrlIntent` → 回傳 `ProductPage` |
|
||||
| **Action** | 執行副作用(Command) | `'performed' \| 'unsupported'` | `FlowAction` → 導航到新頁面 |
|
||||
|
||||
- `FlowAction` 是主要導航機制:內含 `destination Intent` + `pageUrl` + `presentationContext`。
|
||||
- Action handler 註冊採用**型別註冊制**:`jet.onAction('flowAction', handler)`。
|
||||
|
||||
### 1.3 Page Model 驅動 UI(資料驅動)
|
||||
|
||||
```ts
|
||||
// App.svelte 的介面極簡
|
||||
export let page: Promise<Page> | Page
|
||||
```
|
||||
|
||||
- 整個應用由單一 `page` prop 驅動。
|
||||
- `PageResolver` 處理 `Promise<Page>` 的 loading / error 狀態。
|
||||
- `Page.svelte` 用 type guard 分發到對應的 page component:`isProductPage(page)` → `<ProductPage>`。
|
||||
- **Page 是 union type**,不是 route-based 的硬編碼映射。
|
||||
|
||||
### 1.4 Shelf / Item 分層(容器與內容分離)
|
||||
|
||||
```
|
||||
Page (TodayPage / ProductPage / ...)
|
||||
└── Shelf[] (水平捲軸 / 網格)
|
||||
└── ShelfItemLayout (佈局抽象:HorizontalShelf or Grid)
|
||||
└── Item (BrickItem / LargeLockupItem / ...)
|
||||
```
|
||||
|
||||
- **Shelf** = 容器邏輯:決定是水平捲軸還是網格、rowsPerColumn、邊框。
|
||||
- **ShelfItemLayout** = 佈局中介:根據 `isHorizontal` 選擇 `HorizontalShelf` 或 `Grid`。
|
||||
- **Item** = 純粹內容渲染:只關心單一資料單位的呈現,不知道自己是水平還是網格。
|
||||
- **FallbackShelf** = 優雅的降級策略:遇到未實作的 shelf 類型顯示 placeholder,不 crash。
|
||||
|
||||
### 1.5 Svelte Context 作為跨層依賴注入
|
||||
|
||||
```ts
|
||||
// bootstrap.ts
|
||||
context.set('jet', jet)
|
||||
context.set('i18n', i18nStore)
|
||||
|
||||
// 深層元件
|
||||
const jet = getJet() // 從 Svelte Context 取得
|
||||
const i18n = getI18n() // 從 Svelte Context 取得
|
||||
```
|
||||
|
||||
- 避免 props 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。
|
||||
|
||||
---
|
||||
|
||||
+14
-17
@@ -1,19 +1,19 @@
|
||||
# Src Guide
|
||||
|
||||
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view、page component 與 page driver 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
|
||||
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view 與 composable 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
|
||||
|
||||
## 資料流
|
||||
|
||||
```txt
|
||||
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
|
||||
↓
|
||||
page driver / command composable -> store -> service
|
||||
router -> AppShell -> layout -> view -> Section -> Item
|
||||
↓
|
||||
composable -> store -> service
|
||||
```
|
||||
|
||||
## 主要目錄
|
||||
|
||||
- `views/`:route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`。
|
||||
- `components/`:Vue UI 元件,依 pages / sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`。
|
||||
- `components/`:Vue UI 元件,依 sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`。
|
||||
- `composables/`:page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`。
|
||||
- `router/`:route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`。
|
||||
- `shell/`:AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`。
|
||||
@@ -57,15 +57,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
|
||||
- `views/FncPage.vue`
|
||||
- `views/Settings.vue`
|
||||
- `views/maint/*`
|
||||
- `components/PageIndex.vue`
|
||||
- `components/PageMaint.vue`
|
||||
- `components/maint/MaintShell.vue`
|
||||
- `components/maint/*`
|
||||
- `components/pages/*Maintenance.vue`
|
||||
- `components/sections/*`
|
||||
- `components/items/*`
|
||||
- `composables/page-drivers/*MaintenancePage.ts`
|
||||
- `composables/maint/*`
|
||||
- `composables/commands/*`
|
||||
- `composables/useCrudCommands.ts`
|
||||
- `stores/students.ts`
|
||||
- `stores/semesters.ts`
|
||||
- demo assets 與 demo language keys
|
||||
@@ -74,14 +72,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
|
||||
|
||||
## 新功能流程
|
||||
|
||||
1. 新增或修改 `views/*` route entry。
|
||||
2. 若有完整頁面 UI,新增 `components/pages/PageXxx.vue`。
|
||||
3. 若有頁面資料協調或 route param 轉換,新增 `composables/page-drivers/useXxxPage.ts`。
|
||||
4. 若畫面有獨立區塊,拆到 `components/sections/*`。
|
||||
5. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`。
|
||||
6. 跨頁共享狀態才新增或修改 `stores/*`。
|
||||
7. 外部 API 放在 `services/modules/*`。
|
||||
8. 在 `router/routes.ts` 新增 route。
|
||||
1. 新增或修改 `views/*` route entry,直接在 view 裡組裝 page model 與 UI。
|
||||
2. 若有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝。
|
||||
3. 若畫面有獨立區塊,拆到 `components/sections/*`。
|
||||
4. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`。
|
||||
5. 跨頁共享狀態才新增或修改 `stores/*`。
|
||||
6. 外部 API 放在 `services/modules/*`。
|
||||
7. 在 `router/routes.ts` 新增 route。
|
||||
|
||||
## 驗證
|
||||
|
||||
|
||||
+3
-3
@@ -84,7 +84,7 @@ Layout composables:
|
||||
|
||||
`src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。
|
||||
|
||||
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/components/PageLogin.vue` 與 `src/components/login/*`。
|
||||
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/views/Login.vue` 與 `src/components/login/*`。
|
||||
|
||||
`src/services/modules/auth.ts` 與 `src/services/modules/menu.ts` 是預設後端契約的 API adapter。接新後端時常需要調整,但不要把 UI 狀態放進 service module。
|
||||
|
||||
@@ -93,11 +93,11 @@ Layout composables:
|
||||
以下內容偏向示範資料與範例頁,建立新專案時可依需求替換或刪除:
|
||||
|
||||
- `src/views/Home.vue`
|
||||
- `src/components/PageIndex.vue`
|
||||
|
||||
- `src/views/maint/*`
|
||||
- `src/components/maint/*`
|
||||
- `src/composables/maint/*`
|
||||
- `src/components/PageMaint.vue`
|
||||
- `src/components/maint/MaintShell.vue`
|
||||
- `src/stores/students.ts`
|
||||
- `src/stores/semesters.ts`
|
||||
- `src/views/FncPage.vue`
|
||||
|
||||
+15
-28
@@ -1,37 +1,24 @@
|
||||
# Components Guide
|
||||
|
||||
`src/components` 放 Vue UI 元件,包含 layout、page component、feature/domain component 與少量跨頁共用元件。
|
||||
`components` 放 Vue UI 元件,依 sections / items / layouts / base 分層。頁面不再有獨立的 page component 層 — 頁面 UI 直接寫在 `views/*` 中。
|
||||
|
||||
## 分層
|
||||
## 子目錄
|
||||
|
||||
| 目錄 | 說明 | 指南 |
|
||||
|------|------|------|
|
||||
| `pages/` | 完整頁面組裝,檔名使用 `Page` 前綴 | — |
|
||||
| `sections/` | 頁面區塊容器,例如搜尋區、表格、dialog shell、panel | `sections/GUIDE.md` |
|
||||
| `items/` | 單筆資料、欄位群組或原子級呈現 | `items/GUIDE.md` |
|
||||
| `layouts/` | App shell layout | `layouts/GUIDE.md` |
|
||||
| `base/` | 真正跨頁共用且不屬於特定 domain 的基礎元件 | `base/GUIDE.md` |
|
||||
| `login/` | 登入頁專用 UI | — |
|
||||
| `maint/` | maintenance demo 舊有或領域型 UI 元件 | — |
|
||||
- `sections/`:獨立畫面區塊(搜尋面板、資料表格、表單面板),決定佈局,不關心單筆內容。詳見 `src/components/sections/GUIDE.md`。
|
||||
- `items/`:單一資料單位的純粹呈現,不管理狀態。詳見 `src/components/items/GUIDE.md`。
|
||||
- `layouts/`:App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`。
|
||||
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`。
|
||||
|
||||
`MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/maint/`。
|
||||
|
||||
## 規則
|
||||
|
||||
- 不要假設 `src/components` 會自動全域註冊元件;需要使用元件時,依照目前 Vue SFC 慣例明確 import。
|
||||
- 直接被 route 載入的檔案放在 `src/views`,不要放在 `src/components`。
|
||||
- 負責完整頁面主畫面組裝的元件使用 `Page` 前綴。
|
||||
- 只服務單一功能或 domain 的元件,放在對應資料夾,不要放進 `base`。
|
||||
- layout 元件只處理 app shell 與框架 UI,不放頁面專屬業務流程。
|
||||
- `pages` 可組合 sections/items,但不直接處理 API。
|
||||
- `sections` 決定布局與區塊互動,不知道 route。
|
||||
- `items` 不知道自己在表格、grid 或 dialog 中。
|
||||
- 元件不直接 import store 或 service。
|
||||
- 元件以 props 接收資料,以 emits 回報使用者意圖。
|
||||
- 可複用元件不含 domain 名稱(如 `student`、`course`)。
|
||||
|
||||
## 命名
|
||||
## 驗證
|
||||
|
||||
- Page component:`PageXxx.vue`
|
||||
- Section component:`SectionXxx.vue`
|
||||
- Item component:`ItemXxx.vue`
|
||||
- Layout component:依 shell/區塊命名,例如 `MainLayout.vue`
|
||||
|
||||
## 資料流
|
||||
|
||||
component 以 props 接收資料,以 emit 回報使用者事件。需要跨頁共享的狀態交給 `src/stores`;可重用流程或較複雜 UI state 放到 `src/composables`。
|
||||
- Vue / TypeScript 結構變更:`pnpm -s type-check`
|
||||
- 需要確認產物:`pnpm -s build`
|
||||
- route、layout 或主要畫面流程變更:啟動 dev server 並做瀏覽器檢查,除非使用者明確不需要。
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
<template>
|
||||
<v-container class="pa-0" fluid>
|
||||
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
|
||||
<v-sheet
|
||||
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
|
||||
color="surface"
|
||||
>
|
||||
<v-avatar color="primary" size="52" variant="tonal">
|
||||
<span class="text-h5">👋</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-1">
|
||||
使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組
|
||||
</div>
|
||||
</div>
|
||||
</v-sheet>
|
||||
|
||||
<section class="d-flex flex-column">
|
||||
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
|
||||
<!--
|
||||
使用 v-data-iterator 保留一致的列表輸出結構,
|
||||
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架。
|
||||
-->
|
||||
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
|
||||
<!--
|
||||
Vuetify 會把原始資料包進 wrapper,
|
||||
這裡統一解包可避免模板層散落型別判斷。
|
||||
-->
|
||||
<template #default="{ items }">
|
||||
<v-row density="compact">
|
||||
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
|
||||
<v-card
|
||||
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
|
||||
variant="outlined"
|
||||
@click="emit('news', resolveNewsItem(wrapped))"
|
||||
>
|
||||
<v-sheet class="news-badge">
|
||||
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
|
||||
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
|
||||
</v-sheet>
|
||||
<div class="flex-grow-1">
|
||||
<div class="d-flex flex-wrap align-center font-weight-bold">
|
||||
{{ resolveNewsItem(wrapped).title }}
|
||||
<v-chip
|
||||
v-if="resolveNewsItem(wrapped).isNew"
|
||||
class="ml-2"
|
||||
color="primary"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
>
|
||||
NEW
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="text-body-2 text-medium-emphasis mt-2">
|
||||
{{ resolveNewsItem(wrapped).desc }}
|
||||
</div>
|
||||
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon size="14" :icon="mdiFolderOutline" />
|
||||
<span>{{ resolveNewsItem(wrapped).dept }}</span>
|
||||
</div>
|
||||
<div class="d-flex align-center ga-1">
|
||||
<v-icon size="14" :icon="mdiEyeOutline" />
|
||||
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-iterator>
|
||||
</section>
|
||||
|
||||
<v-card
|
||||
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
|
||||
color="secondary"
|
||||
rounded="xl"
|
||||
variant="tonal"
|
||||
@click="emit('message-center')"
|
||||
>
|
||||
<div class="d-flex align-center ga-4">
|
||||
<v-avatar color="secondary" size="44" variant="flat">
|
||||
<span class="text-h6">✉️</span>
|
||||
</v-avatar>
|
||||
<div>
|
||||
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
|
||||
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-body-2 font-weight-medium">查看全部 →</div>
|
||||
</v-card>
|
||||
|
||||
<section class="d-flex flex-column pb-4">
|
||||
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
|
||||
<v-row class="mt-2" density="compact">
|
||||
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
|
||||
<v-card
|
||||
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
|
||||
variant="outlined"
|
||||
@click="emit('quick', item)"
|
||||
>
|
||||
<div class="text-h5">{{ item.icon }}</div>
|
||||
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
這個 dialog 只做消息內容呈現,
|
||||
開關狀態仍交給 view 管理,避免頁面元件自行持有流程狀態。
|
||||
-->
|
||||
<v-dialog
|
||||
:model-value="props.isNewsDialogOpen"
|
||||
max-width="640"
|
||||
@update:model-value="emit('update:isNewsDialogOpen', $event)"
|
||||
>
|
||||
<v-card v-if="props.selectedNews">
|
||||
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
|
||||
{{ props.selectedNews.title }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
|
||||
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
|
||||
{{ props.selectedNews.dept }} · {{ props.selectedNews.views }} 次瀏覽
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="pt-4">
|
||||
{{ props.selectedNews.desc }}
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
|
||||
關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
|
||||
|
||||
interface NewsItem {
|
||||
id: number
|
||||
date: string
|
||||
month: string
|
||||
title: string
|
||||
desc: string
|
||||
dept: string
|
||||
views: string
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
interface QuickItem {
|
||||
icon: string
|
||||
title: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
newsItems: NewsItem[]
|
||||
quickItems: QuickItem[]
|
||||
selectedNews: NewsItem | null
|
||||
isNewsDialogOpen: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
news: [item: NewsItem]
|
||||
'message-center': []
|
||||
quick: [item: QuickItem]
|
||||
'update:isNewsDialogOpen': [value: boolean]
|
||||
}>()
|
||||
|
||||
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
|
||||
function resolveNewsItem(wrapped: unknown): NewsItem {
|
||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||
return (wrapped as { raw: NewsItem }).raw
|
||||
}
|
||||
|
||||
return wrapped as NewsItem
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.news-item {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.news-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
|
||||
.news-badge {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgb(var(--v-theme-primary));
|
||||
color: rgb(var(--v-theme-on-primary));
|
||||
border-radius: 12px;
|
||||
padding: 10px 6px;
|
||||
min-height: 64px;
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.news-badge-date {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.news-badge-month {
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.quick-item {
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-item:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -1,548 +0,0 @@
|
||||
<template>
|
||||
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||
<!-- Side Layouts -->
|
||||
<v-row
|
||||
v-if="props.layout !== 'card'"
|
||||
class="fill-height"
|
||||
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
|
||||
no-gutters
|
||||
>
|
||||
<!-- Illustration Column -->
|
||||
<v-col
|
||||
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
|
||||
cols="12"
|
||||
lg="8"
|
||||
sm="6"
|
||||
>
|
||||
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<v-sheet
|
||||
v-if="props.withAnnouncement"
|
||||
class="board-wrapper pa-2 pa-lg-0"
|
||||
color="rgba(var(--v-theme-surface), 0.8)"
|
||||
elevation="0"
|
||||
max-width="680"
|
||||
rounded="lg"
|
||||
width="100%"
|
||||
>
|
||||
<LoginAnnouncementBoard
|
||||
:all-tab-label="props.announcementBoard.allTabLabel"
|
||||
:date-header="props.announcementBoard.dateHeader"
|
||||
:empty-text="props.announcementBoard.emptyText"
|
||||
:items="props.announcementBoard.items"
|
||||
:items-per-page="props.announcementBoard.itemsPerPage"
|
||||
:pagination-label="props.announcementBoard.paginationLabel"
|
||||
:school-header="props.announcementBoard.schoolHeader"
|
||||
:system-announcements="props.announcementBoard.systemAnnouncements"
|
||||
:tabs="props.announcementBoard.tabs"
|
||||
:title="props.announcementBoard.title"
|
||||
:title-header="props.announcementBoard.titleHeader"
|
||||
@select-announcement="handleSelectAnnouncement"
|
||||
/>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
|
||||
cols="12"
|
||||
lg="4"
|
||||
sm="6"
|
||||
>
|
||||
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
|
||||
<v-banner
|
||||
class="d-sm-none mb-2"
|
||||
density="comfortable"
|
||||
lines="one"
|
||||
:mobile="false"
|
||||
:stacked="false"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-slide-x-transition appear>
|
||||
<div class="mobile-banner-icon-wrap d-flex align-center">
|
||||
<v-icon
|
||||
class="mobile-banner-icon"
|
||||
color="primary"
|
||||
size="small"
|
||||
:icon="mdiBullhornVariantOutline"
|
||||
/>
|
||||
</div>
|
||||
</v-slide-x-transition>
|
||||
</template>
|
||||
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
||||
<template #actions>
|
||||
<v-btn
|
||||
class="text-none"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="mobileAnnouncementSheetVisible = true"
|
||||
>
|
||||
{{ props.mobileAnnouncement.viewAllText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-banner>
|
||||
</div>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div
|
||||
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
|
||||
>
|
||||
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-sm-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
:with-forgot-password="props.form.withForgotPassword"
|
||||
:with-remember-account="props.form.withRememberAccount"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
|
||||
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<!-- Card Layout (Centered) -->
|
||||
<v-row
|
||||
v-else
|
||||
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
|
||||
no-gutters
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg"
|
||||
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
||||
elevation="10"
|
||||
max-width="450"
|
||||
width="100%"
|
||||
>
|
||||
<LoginToolBar
|
||||
v-if="props.toolbar.show"
|
||||
:locale="props.toolbar.locale"
|
||||
:locales="props.toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||
<LoginBrand :title="props.branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-md-block"
|
||||
:welcome-description="props.header.welcomeDescription"
|
||||
:welcome-text="props.header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="props.form.accPlaceholder"
|
||||
:forgot-password-href="props.form.forgotPassword.href"
|
||||
:forgot-password-target="props.form.forgotPassword.target"
|
||||
:forgot-password-text="props.form.forgotPassword.text"
|
||||
:passw-placeholder="props.form.passwPlaceholder"
|
||||
:remember-me-label="props.form.rememberMeLabel"
|
||||
:remember-storage-key="props.form.rememberStorageKey"
|
||||
:submit-text="props.form.submitText"
|
||||
:with-forgot-password="props.form.withForgotPassword"
|
||||
:with-remember-account="props.form.withRememberAccount"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="handleLogin"
|
||||
>
|
||||
<template v-if="props.form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="props.form.captcha"
|
||||
:captcha-placeholder="props.form.captchaPlaceholder"
|
||||
:error-message="props.form.captchaErrorMessage"
|
||||
:loading="props.form.captchaLoading"
|
||||
:model-value="props.form.captchaValue"
|
||||
:refresh-title="props.form.refreshTitle"
|
||||
:verified="props.form.captchaVerified"
|
||||
:verify-text="props.form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-8 text-center text-caption text-grey-darken-2">
|
||||
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-row>
|
||||
|
||||
<v-bottom-sheet
|
||||
v-if="props.withAnnouncement"
|
||||
v-model="mobileAnnouncementSheetVisible"
|
||||
class="d-sm-none"
|
||||
>
|
||||
<v-card rounded="t-xl">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold">
|
||||
{{ props.mobileAnnouncement.listTitle }}
|
||||
</v-card-title>
|
||||
<v-list lines="two">
|
||||
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
|
||||
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
||||
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span
|
||||
>{{ item.createdAt }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
||||
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
|
||||
{{ props.mobileAnnouncement.closeText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-bottom-sheet>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiBullhornVariantOutline } from '@mdi/js'
|
||||
import { computed, ref } from 'vue'
|
||||
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
|
||||
import LoginBrand from './login/LoginBrand.vue'
|
||||
import LoginForm from './login/LoginForm.vue'
|
||||
import LoginHeader from './login/LoginHeader.vue'
|
||||
import LoginToolBar from './login/LoginToolBar.vue'
|
||||
import LoginVerify from './login/LoginVerify.vue'
|
||||
|
||||
interface BrandingConfig {
|
||||
title?: string
|
||||
organization?: string
|
||||
}
|
||||
|
||||
interface IllustrationConfig {
|
||||
image?: string | null
|
||||
title?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface HeaderConfig {
|
||||
welcomeText?: string
|
||||
welcomeDescription?: string
|
||||
}
|
||||
|
||||
interface AnnouncementTabConfig {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
interface AnnouncementItemConfig {
|
||||
id: string | number
|
||||
date: string
|
||||
school: string
|
||||
title: string
|
||||
tab?: string
|
||||
}
|
||||
|
||||
interface AnnouncementBoardConfig {
|
||||
title?: string
|
||||
tabs?: AnnouncementTabConfig[]
|
||||
items?: AnnouncementItemConfig[]
|
||||
systemAnnouncements?: {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}[]
|
||||
allTabLabel?: string
|
||||
itemsPerPage?: number
|
||||
dateHeader?: string
|
||||
schoolHeader?: string
|
||||
titleHeader?: string
|
||||
emptyText?: string
|
||||
paginationLabel?: string
|
||||
}
|
||||
|
||||
interface MobileAnnouncementConfig {
|
||||
items?: {
|
||||
id: string | number
|
||||
content: string
|
||||
title?: string
|
||||
createdAt?: string
|
||||
}[]
|
||||
show?: boolean
|
||||
viewAllText?: string
|
||||
listTitle?: string
|
||||
closeText?: string
|
||||
emptyText?: string
|
||||
}
|
||||
|
||||
interface ForgotPasswordConfig {
|
||||
text?: string
|
||||
href?: string
|
||||
target?: string
|
||||
}
|
||||
|
||||
interface FormConfig {
|
||||
accPlaceholder?: string
|
||||
passwPlaceholder?: string
|
||||
rememberMeLabel?: string
|
||||
submitText?: string
|
||||
rememberStorageKey?: string
|
||||
withForgotPassword?: boolean
|
||||
withRememberAccount?: boolean
|
||||
withCaptcha?: boolean
|
||||
captcha?: {
|
||||
imgUrl?: string
|
||||
id?: string
|
||||
tokenValue?: string
|
||||
}
|
||||
captchaValue?: string
|
||||
captchaLoading?: boolean
|
||||
captchaErrorMessage?: string
|
||||
captchaVerified?: boolean
|
||||
verifyText?: string
|
||||
captchaPlaceholder?: string
|
||||
refreshTitle?: string
|
||||
forgotPassword: ForgotPasswordConfig
|
||||
}
|
||||
|
||||
interface ToolBarConfig {
|
||||
show?: boolean
|
||||
locale?: string
|
||||
locales?: string[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
layout: 'side-left' | 'side-right' | 'card'
|
||||
withAnnouncement?: boolean
|
||||
branding: BrandingConfig
|
||||
illustration: IllustrationConfig
|
||||
announcementBoard: AnnouncementBoardConfig
|
||||
mobileAnnouncement: MobileAnnouncementConfig
|
||||
header: HeaderConfig
|
||||
form: FormConfig
|
||||
toolbar: ToolBarConfig
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
layout: 'side-left',
|
||||
withAnnouncement: true,
|
||||
branding: () => ({
|
||||
title: 'Skyteck Login',
|
||||
organization: 'school',
|
||||
}),
|
||||
illustration: () => ({
|
||||
image: null,
|
||||
title: 'Login',
|
||||
description: 'Login to your account',
|
||||
}),
|
||||
announcementBoard: () => ({
|
||||
title: '學校公告區',
|
||||
tabs: [
|
||||
{ label: '全部', value: '__all__' },
|
||||
{ label: '國中', value: 'junior' },
|
||||
{ label: '高中', value: 'senior' },
|
||||
],
|
||||
items: [
|
||||
{
|
||||
id: 'announcement-1',
|
||||
date: '2024-03-19',
|
||||
school: '市立實踐國中',
|
||||
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
|
||||
tab: 'junior',
|
||||
},
|
||||
],
|
||||
systemAnnouncements: [],
|
||||
allTabLabel: '全部',
|
||||
itemsPerPage: 5,
|
||||
dateHeader: '公告時間',
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
emptyText: '目前沒有公告資料',
|
||||
paginationLabel: '總筆數:',
|
||||
}),
|
||||
mobileAnnouncement: () => ({
|
||||
items: [],
|
||||
show: false,
|
||||
viewAllText: '查看全部',
|
||||
listTitle: '系統公告',
|
||||
closeText: '關閉',
|
||||
emptyText: '目前沒有公告',
|
||||
}),
|
||||
header: () => ({
|
||||
welcomeText: 'Welcome back 👋🏻',
|
||||
welcomeDescription: 'Please enter your account password to login',
|
||||
}),
|
||||
form: () => ({
|
||||
accPlaceholder: '請輸入帳號',
|
||||
passwPlaceholder: '請輸入密碼',
|
||||
rememberMeLabel: '記住帳號',
|
||||
submitText: '登入',
|
||||
rememberStorageKey: 'sklogin.remember.username',
|
||||
withForgotPassword: true,
|
||||
withRememberAccount: true,
|
||||
withCaptcha: true,
|
||||
captcha: undefined,
|
||||
captchaValue: '',
|
||||
captchaLoading: false,
|
||||
captchaErrorMessage: '',
|
||||
captchaVerified: false,
|
||||
verifyText: '驗證',
|
||||
captchaPlaceholder: '驗證碼',
|
||||
refreshTitle: '點擊刷新驗證碼',
|
||||
forgotPassword: {
|
||||
text: '忘記密碼?',
|
||||
href: '',
|
||||
target: undefined,
|
||||
},
|
||||
}),
|
||||
toolbar: () => ({
|
||||
show: true,
|
||||
locale: 'zh-TW',
|
||||
locales: ['zh-TW', 'en-US'],
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits([
|
||||
'submit',
|
||||
'change-locale',
|
||||
'forgot-password',
|
||||
'captcha-refresh',
|
||||
'captcha-change',
|
||||
'toggle-layout',
|
||||
'select-announcement',
|
||||
])
|
||||
|
||||
const mobileAnnouncementSheetVisible = ref(false)
|
||||
|
||||
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
|
||||
|
||||
const showMobileAnnouncementBanner = computed(() => {
|
||||
if (!props.withAnnouncement) return false
|
||||
if (props.mobileAnnouncement.show === false) return false
|
||||
return mobileAnnouncementItems.value.length > 0
|
||||
})
|
||||
|
||||
const mobileAnnouncementBannerText = computed(() => {
|
||||
return mobileAnnouncementItems.value[0]?.content ?? ''
|
||||
})
|
||||
|
||||
const layoutClass = computed(() => {
|
||||
return `layout-${props.layout}`
|
||||
})
|
||||
|
||||
function handleLogin(formData: Record<string, unknown>) {
|
||||
emit('submit', formData)
|
||||
}
|
||||
|
||||
function handleCaptchaRefresh() {
|
||||
emit('captcha-refresh')
|
||||
}
|
||||
|
||||
function handleCaptchaChange(value: string) {
|
||||
emit('captcha-change', value)
|
||||
}
|
||||
|
||||
function handleChangeLocale(nextLocale: string) {
|
||||
emit('change-locale', nextLocale)
|
||||
}
|
||||
|
||||
function handleToggleLayout() {
|
||||
emit('toggle-layout')
|
||||
}
|
||||
|
||||
function handleForgotPassword(e: MouseEvent) {
|
||||
emit('forgot-password', e)
|
||||
}
|
||||
|
||||
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
|
||||
emit('select-announcement', item)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.v-banner__prepend) {
|
||||
align-self: center;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
:deep(.v-banner-actions) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.mobile-banner-icon {
|
||||
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes mobile-banner-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-banner-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.illustration-panel {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--v-theme-background)) 0%,
|
||||
rgb(var(--v-theme-surface)) 100%
|
||||
);
|
||||
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
max-width: 450px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-header-height {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
/* Specific styles for side-right to flip border */
|
||||
.layout-side-right .illustration-panel {
|
||||
border-right: none;
|
||||
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
<v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
|
||||
<v-spacer />
|
||||
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
|
||||
>條件搜尋</v-btn
|
||||
>顯示條件搜尋</v-btn
|
||||
>
|
||||
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
|
||||
</v-card-title>
|
||||
@@ -19,37 +19,31 @@
|
||||
<v-card-text class="pb-0 pt-2">
|
||||
<v-row v-if="isSearchVisible" class="mb-2" density="compact">
|
||||
<v-col cols="12" md="3">
|
||||
<div class="text-body-1 text-medium-emphasis pl-2">學號</div>
|
||||
<v-text-field
|
||||
<BaseFormTextField
|
||||
v-model="search.studentId"
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details
|
||||
label="學號"
|
||||
:label-char-count="2"
|
||||
placeholder="例如:S2024001"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<div class="text-body-1 text-medium-emphasis pl-2">姓名</div>
|
||||
<v-text-field
|
||||
<BaseFormTextField
|
||||
v-model="search.name"
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details
|
||||
label="姓名"
|
||||
:label-char-count="2"
|
||||
placeholder="例如:王小明"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<div class="text-body-1 text-medium-emphasis pl-2">系所</div>
|
||||
<v-select
|
||||
<BaseFormSelect
|
||||
v-model="search.department"
|
||||
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
|
||||
clearable
|
||||
density="compact"
|
||||
hide-details
|
||||
label="系所"
|
||||
:label-char-count="2"
|
||||
:items="departments"
|
||||
variant="outlined"
|
||||
/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -374,6 +368,8 @@
|
||||
<script setup lang="ts">
|
||||
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
|
||||
|
||||
@@ -414,7 +410,9 @@ const {
|
||||
|
||||
const itemsPerPage = 10
|
||||
const currentPage = ref(1)
|
||||
const pageCount = computed(() => Math.max(1, Math.ceil(filteredStudents.value.length / itemsPerPage)))
|
||||
const pageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(filteredStudents.value.length / itemsPerPage))
|
||||
)
|
||||
const pageSummary = computed(() => {
|
||||
const total = filteredStudents.value.length
|
||||
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
||||
@@ -443,8 +441,7 @@ const singleDeleteMessage = computed(() => {
|
||||
})
|
||||
|
||||
const selectedDeleteMessage = computed(
|
||||
() =>
|
||||
`確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
|
||||
() => `確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
|
||||
)
|
||||
|
||||
watch(pageCount, (value) => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
:icon="mdAndUp ? false : mdiMagnify"
|
||||
:prepend-icon="mdAndUp ? mdiMagnify : undefined"
|
||||
size="small"
|
||||
:text="mdAndUp ? '搜尋條件' : false"
|
||||
:text="mdAndUp ? '顯示搜尋條件' : false"
|
||||
variant="text"
|
||||
@click="$emit('toggle-search')"
|
||||
>
|
||||
@@ -1,12 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
|
||||
defineProps<{
|
||||
page: MaintenancePageModel
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<EditableStudentGrid :title="page.title" />
|
||||
</template>
|
||||
@@ -1,13 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { FunctionPageModel } from '@/composables/page-drivers/useFunctionPage'
|
||||
|
||||
defineProps<{
|
||||
page: FunctionPageModel
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet height="100%" width="100%">
|
||||
{{ page.fncId }}
|
||||
</v-sheet>
|
||||
</template>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import PageIndex from '@/components/PageIndex.vue'
|
||||
import type { HomeNewsItem, HomePageModel, HomeQuickItem } from '@/composables/page-drivers/useHomePage'
|
||||
|
||||
defineProps<{
|
||||
page: HomePageModel
|
||||
selectedNews: HomeNewsItem | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
news: [item: HomeNewsItem]
|
||||
'message-center': []
|
||||
quick: [item: HomeQuickItem]
|
||||
}>()
|
||||
|
||||
const isNewsDialogOpen = defineModel<boolean>('newsDialogOpen', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageIndex
|
||||
v-model:is-news-dialog-open="isNewsDialogOpen"
|
||||
:news-items="page.newsItems"
|
||||
:quick-items="page.quickItems"
|
||||
:selected-news="selectedNews"
|
||||
@message-center="emit('message-center')"
|
||||
@news="emit('news', $event)"
|
||||
@quick="emit('quick', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import PageMaint from '@/components/PageMaint.vue'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
|
||||
defineProps<{
|
||||
page: MaintenancePageModel
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void
|
||||
(e: 'edit', record: unknown): void
|
||||
(e: 'view', record: unknown): void
|
||||
(e: 'delete', record: unknown): void
|
||||
(e: 'search', criteria: Record<string, unknown>): void
|
||||
}>()
|
||||
|
||||
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { default: false })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageMaint
|
||||
:title="page.title"
|
||||
:search-panel-open="searchPanelOpen"
|
||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||
@create="emit('create')"
|
||||
>
|
||||
<template #search-fields>
|
||||
<slot name="search-fields" />
|
||||
</template>
|
||||
<template #table>
|
||||
<slot name="table" />
|
||||
</template>
|
||||
</PageMaint>
|
||||
</template>
|
||||
@@ -1,478 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||
import DetailNavigation from '@/components/maint/master-detail/DetailNavigation.vue'
|
||||
import DetailSidePanel from '@/components/maint/master-detail/DetailSidePanel.vue'
|
||||
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
|
||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
||||
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
||||
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
||||
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
|
||||
import type {
|
||||
SaveSummaryItem,
|
||||
StudentFormState,
|
||||
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
import type { SemesterRecord } from '@/stores/semesters'
|
||||
import type { StudentRecord } from '@/stores/students'
|
||||
|
||||
interface FieldErrorItem {
|
||||
field: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface GradeOption {
|
||||
title: string
|
||||
value: number
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
activeMobilePanel: 'master' | 'detail'
|
||||
confirmCloseVisible: boolean
|
||||
confirmDeleteVisible: boolean
|
||||
confirmNavigateVisible: boolean
|
||||
confirmSaveVisible: boolean
|
||||
confirmSwitchVisible: boolean
|
||||
currentPage: number
|
||||
departments: string[]
|
||||
detailForm: SemesterRecord | null
|
||||
dialogSubtitle: string
|
||||
dialogTitle: string
|
||||
dialogVisible: boolean
|
||||
enrollYears: number[]
|
||||
errorSummary: FieldErrorItem[]
|
||||
fieldErrors: Record<keyof StudentFormState, string[]>
|
||||
gradeLabel: (grade: number) => string
|
||||
gradeOptions: GradeOption[]
|
||||
hasNextRecord: boolean
|
||||
hasPrevRecord: boolean
|
||||
headers: any[]
|
||||
isDetailEditing: boolean
|
||||
isDirty: boolean
|
||||
isEditMode: boolean
|
||||
isFormLocked: boolean
|
||||
isFormReadonly: boolean
|
||||
isLoading: boolean
|
||||
isMobile: boolean
|
||||
isSaving: boolean
|
||||
isViewMode: boolean
|
||||
items: StudentRecord[]
|
||||
itemsPerPage: number
|
||||
page: MaintenancePageModel
|
||||
pageCount: number
|
||||
pageSummary: string
|
||||
pendingDeleteLabel: string
|
||||
rowProps: (data: { item: StudentRecord }) => Record<string, string>
|
||||
saveSummary: SaveSummaryItem[]
|
||||
selectedSemester: SemesterRecord | null
|
||||
selectedSemesterId: number | null
|
||||
semesters: SemesterRecord[]
|
||||
statusColor: (status: string) => string
|
||||
statuses: string[]
|
||||
}>()
|
||||
|
||||
const form = defineModel<StudentFormState>('form', { required: true })
|
||||
const detailFormModel = defineModel<SemesterRecord | null>('detailForm', { required: true })
|
||||
const search = defineModel<{
|
||||
studentId: string
|
||||
name: string
|
||||
department: string
|
||||
grade: number | null
|
||||
status: string
|
||||
}>('search', { required: true })
|
||||
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'add-semester'): void
|
||||
(e: 'cancel-detail-edit'): void
|
||||
(e: 'clear-field-error', field: keyof StudentFormState): void
|
||||
(e: 'close'): void
|
||||
(e: 'close-detail-panel'): void
|
||||
(e: 'confirm-close'): void
|
||||
(e: 'confirm-delete'): void
|
||||
(e: 'confirm-navigate'): void
|
||||
(e: 'confirm-save'): void
|
||||
(e: 'confirm-switch'): void
|
||||
(e: 'create'): void
|
||||
(e: 'delete', record: StudentRecord): void
|
||||
(e: 'delete-current'): void
|
||||
(e: 'delete-semester', id: number): void
|
||||
(e: 'dialog-visible-change', value: boolean): void
|
||||
(e: 'edit', record: StudentRecord): void
|
||||
(e: 'first'): void
|
||||
(e: 'last'): void
|
||||
(e: 'next'): void
|
||||
(e: 'prev'): void
|
||||
(e: 'reset-search'): void
|
||||
(e: 'save'): void
|
||||
(e: 'save-detail-edit'): void
|
||||
(e: 'scroll-to-field', field: string): void
|
||||
(e: 'select-semester', id: number): void
|
||||
(e: 'start-detail-edit'): void
|
||||
(e: 'switch-to-edit'): void
|
||||
(e: 'switch-to-view'): void
|
||||
(e: 'update:confirmCloseVisible', value: boolean): void
|
||||
(e: 'update:confirmDeleteVisible', value: boolean): void
|
||||
(e: 'update:confirmNavigateVisible', value: boolean): void
|
||||
(e: 'update:confirmSaveVisible', value: boolean): void
|
||||
(e: 'update:confirmSwitchVisible', value: boolean): void
|
||||
(e: 'update:currentPage', page: number): void
|
||||
(e: 'view', record: StudentRecord): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageMaintenance
|
||||
v-model:search-panel-open="searchPanelOpen"
|
||||
:page="page"
|
||||
@create="emit('create')"
|
||||
>
|
||||
<template #search-fields>
|
||||
<SectionSearchPanel
|
||||
v-model="search"
|
||||
:departments="departments"
|
||||
:grade-options="gradeOptions"
|
||||
:statuses="statuses"
|
||||
@reset="emit('reset-search')"
|
||||
/>
|
||||
</template>
|
||||
<template #table>
|
||||
<SectionDataTable
|
||||
:current-page="currentPage"
|
||||
:grade-label="gradeLabel"
|
||||
:headers="headers"
|
||||
:items="items"
|
||||
:items-per-page="itemsPerPage"
|
||||
:page-count="pageCount"
|
||||
:page-summary="pageSummary"
|
||||
:row-props="rowProps"
|
||||
:status-color="statusColor"
|
||||
@delete="emit('delete', $event)"
|
||||
@edit="emit('edit', $event)"
|
||||
@update:current-page="emit('update:currentPage', $event)"
|
||||
@view="emit('view', $event)"
|
||||
/>
|
||||
</template>
|
||||
</PageMaintenance>
|
||||
|
||||
<teleport to="body">
|
||||
<v-overlay
|
||||
class="dialog-overlay"
|
||||
:close-on-content-click="false"
|
||||
:model-value="dialogVisible"
|
||||
scrim="rgba(0, 0, 0, 0.45)"
|
||||
scroll-strategy="block"
|
||||
@update:model-value="emit('dialog-visible-change', $event)"
|
||||
>
|
||||
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
||||
<div
|
||||
v-if="!isMobile || activeMobilePanel === 'detail'"
|
||||
class="detail-panel-wrapper"
|
||||
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
|
||||
>
|
||||
<DetailSidePanel
|
||||
v-model:detail-form="detailFormModel"
|
||||
:is-detail-editing="isDetailEditing"
|
||||
:is-mobile="isMobile"
|
||||
:is-view-mode="isViewMode"
|
||||
:selected-semester="selectedSemester"
|
||||
@cancel-edit="emit('cancel-detail-edit')"
|
||||
@close="emit('close-detail-panel')"
|
||||
@delete="emit('delete-semester', $event)"
|
||||
@save-edit="emit('save-detail-edit')"
|
||||
@start-edit="emit('start-detail-edit')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MntDialogCard
|
||||
v-if="!isMobile || activeMobilePanel === 'master'"
|
||||
:dialog-subtitle="dialogSubtitle"
|
||||
:dialog-title="dialogTitle"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
:width="isMobile ? '100%' : 760"
|
||||
>
|
||||
<template #toolbar>
|
||||
<MntRecordNavToolbar
|
||||
:has-next-record="hasNextRecord"
|
||||
:has-prev-record="hasPrevRecord"
|
||||
:is-edit-mode="isEditMode"
|
||||
:is-view-mode="isViewMode"
|
||||
:mobile="isMobile"
|
||||
@first="emit('first')"
|
||||
@last="emit('last')"
|
||||
@next="emit('next')"
|
||||
@prev="emit('prev')"
|
||||
@switch-to-edit="emit('switch-to-edit')"
|
||||
@switch-to-view="emit('switch-to-view')"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<v-alert
|
||||
v-if="errorSummary.length > 0 && !isLoading"
|
||||
class="mb-4"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
||||
<div class="d-flex flex-column ga-1">
|
||||
<v-btn
|
||||
v-for="error in errorSummary"
|
||||
:key="error.field"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="emit('scroll-to-field', error.field)"
|
||||
>
|
||||
{{ error.message }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<v-skeleton-loader
|
||||
v-if="isLoading"
|
||||
class="mt-4"
|
||||
type="subtitle,paragraph"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
<v-form
|
||||
v-else
|
||||
:class="{ 'form-readonly': isFormReadonly }"
|
||||
@submit.prevent="emit('save')"
|
||||
>
|
||||
<MasterFileFormFields
|
||||
:departments="departments"
|
||||
:enroll-years="enrollYears"
|
||||
:field-errors="fieldErrors"
|
||||
:form="form"
|
||||
:grade-options="gradeOptions"
|
||||
:is-form-locked="isFormLocked"
|
||||
:is-form-readonly="isFormReadonly"
|
||||
:statuses="statuses"
|
||||
@clear-field="emit('clear-field-error', $event)"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<DetailNavigation
|
||||
:is-mobile="isMobile"
|
||||
:is-view-mode="isViewMode"
|
||||
:selected-semester-id="selectedSemesterId"
|
||||
:semesters="semesters"
|
||||
@add="emit('add-semester')"
|
||||
@select="emit('select-semester', $event)"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template v-if="isMobile">
|
||||
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode"
|
||||
color="error"
|
||||
:disabled="isSaving"
|
||||
variant="tonal"
|
||||
@click="emit('delete-current')"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isViewMode"
|
||||
color="primary"
|
||||
:disabled="!isDirty || isLoading"
|
||||
:loading="isSaving"
|
||||
variant="flat"
|
||||
@click="emit('save')"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="isEditMode"
|
||||
color="error"
|
||||
:disabled="isSaving"
|
||||
variant="tonal"
|
||||
@click="emit('delete-current')"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!isViewMode"
|
||||
color="primary"
|
||||
:disabled="!isDirty || isLoading"
|
||||
:loading="isSaving"
|
||||
variant="flat"
|
||||
@click="emit('save')"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
|
||||
</template>
|
||||
</template>
|
||||
</MntDialogCard>
|
||||
</div>
|
||||
</v-overlay>
|
||||
</teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="confirmCloseVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="關閉不儲存"
|
||||
message="目前有尚未儲存的內容,確定要關閉嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="emit('confirm-close')"
|
||||
@update:model-value="emit('update:confirmCloseVisible', $event)"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="confirmSaveVisible"
|
||||
:confirm-loading="isSaving"
|
||||
confirm-text="確認儲存"
|
||||
max-width="520"
|
||||
title="確認儲存變更"
|
||||
@confirm="emit('confirm-save')"
|
||||
@update:model-value="emit('update:confirmSaveVisible', $event)"
|
||||
>
|
||||
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
|
||||
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
|
||||
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
|
||||
<div v-if="item.before !== null" class="text-body-2">
|
||||
<span class="text-medium-emphasis">原:</span>
|
||||
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
|
||||
{{ item.before }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
<span class="text-medium-emphasis">新:</span>
|
||||
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
|
||||
{{ item.after }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-body-2">目前沒有可儲存的變更。</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="confirmDeleteVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="確定刪除"
|
||||
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
|
||||
title="確認刪除"
|
||||
@confirm="emit('confirm-delete')"
|
||||
@update:model-value="emit('update:confirmDeleteVisible', $event)"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="confirmSwitchVisible"
|
||||
confirm-text="確定切換"
|
||||
max-width="480"
|
||||
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="emit('confirm-switch')"
|
||||
@update:model-value="emit('update:confirmSwitchVisible', $event)"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="confirmNavigateVisible"
|
||||
confirm-text="確定切換"
|
||||
max-width="480"
|
||||
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="emit('confirm-navigate')"
|
||||
@update:model-value="emit('update:confirmNavigateVisible', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay :deep(.v-overlay__content) {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-panel > .v-card {
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.detail-panel-wrapper {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-active {
|
||||
width: 600px;
|
||||
opacity: 1;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-title) {
|
||||
padding: 16px 20px 12px;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-actions) {
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.v-card-text) {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-mobile {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-mobile.is-active {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-readonly :deep(.v-field) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dialog-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel > .v-card {
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { SettingsPageModel } from '@/composables/page-drivers/useSettingsPage'
|
||||
|
||||
defineProps<{
|
||||
page: SettingsPageModel
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ page.title }}</div>
|
||||
</template>
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string
|
||||
backLabel?: string
|
||||
error?: string
|
||||
loading?: boolean
|
||||
message?: string
|
||||
@@ -10,7 +9,6 @@ interface Props {
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
backLabel: '返回',
|
||||
resetLabel: '清除',
|
||||
submitLabel: '存檔',
|
||||
})
|
||||
@@ -25,7 +23,7 @@ const emit = defineEmits<{
|
||||
<template>
|
||||
<v-form @submit.prevent="emit('submit')">
|
||||
<v-container fluid class="pt-2 px-1">
|
||||
<v-card>
|
||||
<v-card class="mb-2">
|
||||
<v-card-title class="bg-primary text-title-large text-center py-2">
|
||||
{{ title }}
|
||||
</v-card-title>
|
||||
@@ -50,7 +48,6 @@ const emit = defineEmits<{
|
||||
{{ submitLabel }}
|
||||
</v-btn>
|
||||
<v-btn type="button" variant="tonal" @click="emit('reset')">{{ resetLabel }}</v-btn>
|
||||
<v-btn type="button" variant="text" @click="emit('back')">{{ backLabel }}</v-btn>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
@@ -6,9 +6,7 @@ interface Props {
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
backLabel: '返回',
|
||||
})
|
||||
withDefaults(defineProps<Props>(), {})
|
||||
|
||||
const emit = defineEmits<{
|
||||
search: []
|
||||
@@ -18,7 +16,7 @@ const emit = defineEmits<{
|
||||
|
||||
<template>
|
||||
<v-container fluid class="pt-2 px-1">
|
||||
<v-card>
|
||||
<v-card class="mb-2">
|
||||
<v-card-title class="text-title-large bg-primary">{{ title }}</v-card-title>
|
||||
<v-card-text class="pa-4">
|
||||
<v-alert v-if="error" class="mb-4" type="error" variant="tonal">{{ error }}</v-alert>
|
||||
@@ -38,9 +36,5 @@ const emit = defineEmits<{
|
||||
</v-card>
|
||||
|
||||
<slot name="sections" />
|
||||
|
||||
<v-row class="pa-4">
|
||||
<v-btn variant="tonal" @click="emit('back')">{{ backLabel }}</v-btn>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<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>
|
||||
+18
-15
@@ -4,12 +4,13 @@
|
||||
|
||||
## 子目錄
|
||||
|
||||
- `page-drivers/`:頁面資料協調與 page model 組裝。
|
||||
- `commands/`:命令式副作用流程,例如 create/edit/save/delete。
|
||||
- `layout/`:AppShell / layout 狀態與事件協調。
|
||||
- `page-drivers/`:頁面資料協調與 page model 組裝(僅複雜頁面使用)。
|
||||
- `maint/`:maintenance demo 的表單、CRUD、editable grid 狀態。
|
||||
- `layout/`:AppShell / layout 狀態與事件協調。
|
||||
|
||||
頂層也可放通用 composable,例如 `useApiCall.ts`:封裝 loading / data / error / execute 模式、自動 snackbar 錯誤提示與取消請求過濾。適合為單一 API 呼叫提供輕量狀態管理,但不替代 page driver 或 command composable。
|
||||
頂層放通用 composable:
|
||||
- `useApiCall.ts`:封裝 loading / data / error / execute 模式。
|
||||
- `useCrudCommands.ts`:通用 CRUD 狀態機(新增 / 編輯 / 檢視 / 儲存 / highlight)。
|
||||
|
||||
## 新增規則
|
||||
|
||||
@@ -22,20 +23,22 @@
|
||||
|
||||
## Page Driver
|
||||
|
||||
Page driver 負責:
|
||||
Page driver 只應在「需要協調多個 composable / store / route」時才成立。若頁面邏輯只有:
|
||||
|
||||
- 組裝一個 `computed` page model(3-5 個欄位)
|
||||
- 沒有搜尋、沒有 dialog、沒有複雜事件
|
||||
|
||||
則**不要建立 page driver**,直接在 view 裡寫 `computed` 即可。
|
||||
|
||||
當需要 page driver 時,它負責:
|
||||
- route param/query 轉成頁面資料
|
||||
- 組裝 page model
|
||||
- 組裝 page component 需要的 props/events
|
||||
- 協調 store、command composable、表單 composable
|
||||
- 組裝 page component 需要的 props/events
|
||||
|
||||
View 只呼叫 page driver 並掛載 page component。
|
||||
View 以 destructure 方式取用 page driver 回傳值:
|
||||
```ts
|
||||
const { pageModel, search, handleSubmit } = useXxxPage()
|
||||
```
|
||||
模板中直接使用,不寫 `.value`:`:page="pageModel"`、`v-model="search"`。
|
||||
|
||||
## Commands
|
||||
|
||||
Command composable 負責副作用流程,不負責畫面布局:
|
||||
|
||||
- 新增 / 編輯 / 刪除 / 儲存
|
||||
- 儲存前確認
|
||||
- 成功後重新載入或 highlight
|
||||
- 與 store/service 的 mutation 流程
|
||||
|
||||
@@ -29,6 +29,18 @@ const fixedMenuItems: LayoutMenuItem[] = [
|
||||
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '範例頁面',
|
||||
navigable: false,
|
||||
subItems: [
|
||||
{
|
||||
title: 'SectionQueryPage',
|
||||
icon: mdiFileDocumentOutline,
|
||||
path: '/demos/sections/query-page',
|
||||
},
|
||||
{ title: 'SectionFormPage', icon: mdiFileDocumentOutline, path: '/demos/sections/form-page' },
|
||||
],
|
||||
},
|
||||
{ title: '登入頁', path: '/login' },
|
||||
]
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
import { useStudentStore } from '@/stores/students'
|
||||
|
||||
export function useEditableGridMaintenancePage() {
|
||||
const studentStore = useStudentStore()
|
||||
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '可編輯表格維護示範',
|
||||
records: studentStore.students,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
|
||||
return {
|
||||
pageModel,
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
export interface FunctionPageModel {
|
||||
fncId: string
|
||||
}
|
||||
|
||||
export function useFunctionPage() {
|
||||
const route = useRoute()
|
||||
|
||||
const pageModel = computed<FunctionPageModel>(() => ({
|
||||
fncId: String(route.params.fncId ?? ''),
|
||||
}))
|
||||
|
||||
return {
|
||||
pageModel,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
|
||||
export interface UseMaintenancePageOptions {
|
||||
title: string
|
||||
records: unknown[]
|
||||
itemsPerPage?: number
|
||||
}
|
||||
|
||||
export function useMaintenancePage(options: UseMaintenancePageOptions) {
|
||||
const search = ref<Record<string, unknown>>({})
|
||||
const searchPanelOpen = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const itemsPerPage = options.itemsPerPage ?? 10
|
||||
|
||||
const pageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(options.records.length / itemsPerPage))
|
||||
)
|
||||
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: options.title,
|
||||
records: options.records,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
|
||||
function load() {
|
||||
// 由呼叫方在 load 中觸發資料載入;未來可擴充為非同步
|
||||
}
|
||||
|
||||
function resetSearch() {
|
||||
search.value = {}
|
||||
}
|
||||
|
||||
return {
|
||||
pageModel,
|
||||
search,
|
||||
searchPanelOpen,
|
||||
currentPage,
|
||||
itemsPerPage,
|
||||
pageCount,
|
||||
load,
|
||||
resetSearch,
|
||||
}
|
||||
}
|
||||
@@ -383,8 +383,11 @@ export function useMasterDetailAMaintenancePage() {
|
||||
}
|
||||
|
||||
return {
|
||||
confirmSave,
|
||||
currentPage,
|
||||
departments,
|
||||
detailForm,
|
||||
flow,
|
||||
formState,
|
||||
gradeOptions,
|
||||
itemsPerPage,
|
||||
@@ -396,7 +399,9 @@ export function useMasterDetailAMaintenancePage() {
|
||||
pageCount,
|
||||
pageModel,
|
||||
pageSummary,
|
||||
requestSaveConfirmation,
|
||||
resetSearch,
|
||||
scrollToField,
|
||||
search,
|
||||
searchPanelOpen,
|
||||
snackbarVisible,
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
import { useStudentStore } from '@/stores/students'
|
||||
|
||||
export function useMasterDetailBMaintenancePage() {
|
||||
const studentStore = useStudentStore()
|
||||
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '主從資料維護示範B',
|
||||
records: studentStore.students,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
|
||||
return {
|
||||
pageModel,
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
import { useStudentStore } from '@/stores/students'
|
||||
|
||||
export function useMasterDetailCMaintenancePage() {
|
||||
const studentStore = useStudentStore()
|
||||
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '主從資料維護示範C',
|
||||
records: studentStore.students,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
|
||||
return {
|
||||
pageModel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { StudentRecord } from '@/models/student'
|
||||
import { useSnackbarStore } from '@/stores/snackbar'
|
||||
import type {
|
||||
SaveSummaryItem,
|
||||
StudentFormState,
|
||||
} from '@/composables/maint/useStudentMaintenanceForm'
|
||||
|
||||
export interface ReportSummary {
|
||||
id: number
|
||||
title: string
|
||||
owner: string
|
||||
status: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ReportFilters {
|
||||
keyword: string
|
||||
owner: string
|
||||
}
|
||||
|
||||
export interface DemoFormState {
|
||||
title: string
|
||||
owner: string
|
||||
category: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface MaintenanceSearchState {
|
||||
studentId: string
|
||||
name: string
|
||||
department: string
|
||||
grade: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface SectionsDemoPageModel {
|
||||
title: string
|
||||
ownerOptions: string[]
|
||||
categoryOptions: string[]
|
||||
queryMessage: string
|
||||
formMessage: string
|
||||
reports: ReportSummary[]
|
||||
departments: string[]
|
||||
gradeOptions: GradeOption[]
|
||||
enrollYears: number[]
|
||||
statuses: string[]
|
||||
maintenanceHeaders: Array<Record<string, unknown>>
|
||||
maintenanceItems: StudentRecord[]
|
||||
maintenanceItemsPerPage: number
|
||||
maintenancePageCount: number
|
||||
maintenancePageSummary: string
|
||||
formPanelProps: FormPanelProps
|
||||
}
|
||||
|
||||
interface GradeOption {
|
||||
title: string
|
||||
value: number
|
||||
}
|
||||
|
||||
type FieldErrors = Record<keyof StudentFormState, string[]>
|
||||
|
||||
interface FormPanelProps {
|
||||
confirmCloseVisible: boolean
|
||||
confirmDeleteVisible: boolean
|
||||
confirmNavigateVisible: boolean
|
||||
confirmSaveVisible: boolean
|
||||
confirmSwitchVisible: boolean
|
||||
departments: string[]
|
||||
dialogSubtitle: string
|
||||
dialogTitle: string
|
||||
dialogVisible: boolean
|
||||
enrollYears: number[]
|
||||
errorSummary: Array<{ field: string; message: string }>
|
||||
fieldErrors: FieldErrors
|
||||
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 reports: ReportSummary[] = [
|
||||
{ id: 1, title: '學生統計', owner: '教務處', status: '已發布', updatedAt: '2026-05-01' },
|
||||
{ id: 2, title: '課程統計', owner: '課務組', status: '草稿', updatedAt: '2026-05-08' },
|
||||
{ id: 3, title: '系統使用量', owner: '資訊中心', status: '已發布', updatedAt: '2026-05-15' },
|
||||
]
|
||||
|
||||
const ownerOptions = ['全部', '教務處', '課務組', '資訊中心']
|
||||
const categoryOptions = ['一般報表', '申請表單', '維護資料']
|
||||
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
|
||||
const gradeOptions: GradeOption[] = [
|
||||
{ title: '大一', value: 1 },
|
||||
{ title: '大二', value: 2 },
|
||||
{ title: '大三', value: 3 },
|
||||
{ title: '大四', value: 4 },
|
||||
]
|
||||
const enrollYears = [2026, 2025, 2024, 2023]
|
||||
const statuses = ['在學', '休學', '畢業']
|
||||
const maintenanceItemsPerPage = 5
|
||||
|
||||
const students: StudentRecord[] = [
|
||||
{
|
||||
id: 1,
|
||||
studentId: 'S2026001',
|
||||
name: '王小明',
|
||||
department: '資訊工程',
|
||||
grade: 1,
|
||||
enrollYear: 2026,
|
||||
credits: 18,
|
||||
advisor: '陳教授',
|
||||
email: 'ming@example.edu',
|
||||
phone: '0912000001',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
studentId: 'S2025007',
|
||||
name: '林雅婷',
|
||||
department: '企業管理',
|
||||
grade: 2,
|
||||
enrollYear: 2025,
|
||||
credits: 42,
|
||||
advisor: '李教授',
|
||||
email: 'yating@example.edu',
|
||||
phone: '0912000002',
|
||||
status: '在學',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
studentId: 'S2024012',
|
||||
name: '張志豪',
|
||||
department: '應用外語',
|
||||
grade: 3,
|
||||
enrollYear: 2024,
|
||||
credits: 86,
|
||||
advisor: '黃教授',
|
||||
email: 'zhihao@example.edu',
|
||||
phone: '0912000003',
|
||||
status: '休學',
|
||||
},
|
||||
]
|
||||
|
||||
const maintenanceHeaders = [
|
||||
{ title: '學號', key: 'studentId', sortable: true, width: 120 },
|
||||
{ title: '姓名', key: 'name', sortable: true, width: 100 },
|
||||
{ title: '系所', key: 'department', sortable: true, width: 140 },
|
||||
{ title: '年級', key: 'grade', sortable: true, width: 90 },
|
||||
{ title: 'Email', key: 'email', sortable: true, width: 200 },
|
||||
{ title: '狀態', key: 'status', sortable: true, width: 90 },
|
||||
{ title: '操作', key: 'actions', sortable: false, width: 220 },
|
||||
]
|
||||
|
||||
const defaultQueryFilters: ReportFilters = {
|
||||
keyword: '',
|
||||
owner: '全部',
|
||||
}
|
||||
|
||||
const defaultDemoForm: DemoFormState = {
|
||||
title: '',
|
||||
owner: '教務處',
|
||||
category: '一般報表',
|
||||
description: '',
|
||||
}
|
||||
|
||||
const defaultMaintenanceSearch: MaintenanceSearchState = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: '',
|
||||
grade: null,
|
||||
status: '',
|
||||
}
|
||||
|
||||
const defaultFormPanelForm: StudentFormState = {
|
||||
studentId: '',
|
||||
name: '',
|
||||
department: departments[0] ?? '',
|
||||
grade: gradeOptions[0]?.value ?? 1,
|
||||
enrollYear: enrollYears[0] ?? 2026,
|
||||
credits: 0,
|
||||
advisor: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
status: statuses[0] ?? '',
|
||||
}
|
||||
|
||||
function createEmptyFieldErrors(): FieldErrors {
|
||||
return {
|
||||
studentId: [],
|
||||
name: [],
|
||||
department: [],
|
||||
grade: [],
|
||||
enrollYear: [],
|
||||
credits: [],
|
||||
advisor: [],
|
||||
email: [],
|
||||
phone: [],
|
||||
status: [],
|
||||
}
|
||||
}
|
||||
|
||||
export function useSectionsDemoPage() {
|
||||
const snackbar = useSnackbarStore()
|
||||
const queryFilters = ref<ReportFilters>({ ...defaultQueryFilters })
|
||||
const demoForm = ref<DemoFormState>({ ...defaultDemoForm })
|
||||
const maintenanceSearch = ref<MaintenanceSearchState>({ ...defaultMaintenanceSearch })
|
||||
const maintenanceCurrentPage = ref(1)
|
||||
const formPanelVisible = ref(false)
|
||||
const formPanelForm = ref<StudentFormState>({ ...defaultFormPanelForm })
|
||||
const fieldErrors = ref<FieldErrors>(createEmptyFieldErrors())
|
||||
const queryMessage = ref('')
|
||||
const formMessage = ref('')
|
||||
|
||||
const filteredReports = computed(() => {
|
||||
const keyword = queryFilters.value.keyword.trim().toLowerCase()
|
||||
const owner = queryFilters.value.owner
|
||||
return reports.filter((item) => {
|
||||
const keywordMatched =
|
||||
!keyword ||
|
||||
item.title.toLowerCase().includes(keyword) ||
|
||||
item.owner.toLowerCase().includes(keyword)
|
||||
const ownerMatched = owner === '全部' || item.owner === owner
|
||||
return keywordMatched && ownerMatched
|
||||
})
|
||||
})
|
||||
|
||||
const maintenanceItems = computed(() => {
|
||||
const keywordId = maintenanceSearch.value.studentId.trim().toLowerCase()
|
||||
const keywordName = maintenanceSearch.value.name.trim().toLowerCase()
|
||||
return students.filter((item) => {
|
||||
const idMatched = !keywordId || item.studentId.toLowerCase().includes(keywordId)
|
||||
const nameMatched = !keywordName || item.name.toLowerCase().includes(keywordName)
|
||||
const departmentMatched =
|
||||
!maintenanceSearch.value.department || item.department === maintenanceSearch.value.department
|
||||
const gradeMatched =
|
||||
maintenanceSearch.value.grade == null || item.grade === maintenanceSearch.value.grade
|
||||
const statusMatched = !maintenanceSearch.value.status || item.status === maintenanceSearch.value.status
|
||||
return idMatched && nameMatched && departmentMatched && gradeMatched && statusMatched
|
||||
})
|
||||
})
|
||||
|
||||
const maintenancePageCount = computed(() =>
|
||||
Math.max(1, Math.ceil(maintenanceItems.value.length / maintenanceItemsPerPage))
|
||||
)
|
||||
|
||||
const maintenancePageSummary = computed(() => {
|
||||
const total = maintenanceItems.value.length
|
||||
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
||||
const start = (maintenanceCurrentPage.value - 1) * maintenanceItemsPerPage + 1
|
||||
const end = Math.min(maintenanceCurrentPage.value * maintenanceItemsPerPage, total)
|
||||
return `第 ${start}-${end} 筆 / 共 ${total} 筆`
|
||||
})
|
||||
|
||||
const isFormPanelDirty = computed(
|
||||
() => JSON.stringify(formPanelForm.value) !== JSON.stringify(defaultFormPanelForm)
|
||||
)
|
||||
|
||||
const formPanelProps = computed<FormPanelProps>(() => ({
|
||||
confirmCloseVisible: false,
|
||||
confirmDeleteVisible: false,
|
||||
confirmNavigateVisible: false,
|
||||
confirmSaveVisible: false,
|
||||
confirmSwitchVisible: false,
|
||||
departments,
|
||||
dialogSubtitle: formPanelForm.value.studentId || '尚未輸入學號',
|
||||
dialogTitle: 'SectionFormPanel 範例',
|
||||
dialogVisible: formPanelVisible.value,
|
||||
enrollYears,
|
||||
errorSummary: [],
|
||||
fieldErrors: fieldErrors.value,
|
||||
gradeOptions,
|
||||
hasNextRecord: false,
|
||||
hasPrevRecord: false,
|
||||
isDirty: isFormPanelDirty.value,
|
||||
isEditMode: false,
|
||||
isFormLocked: false,
|
||||
isFormReadonly: false,
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
isViewMode: false,
|
||||
pendingDeleteLabel: formPanelForm.value.name || '目前資料',
|
||||
saveSummary: [],
|
||||
statuses,
|
||||
}))
|
||||
|
||||
const pageModel = computed<SectionsDemoPageModel>(() => ({
|
||||
title: '新增頁面與 Section 範例',
|
||||
ownerOptions,
|
||||
categoryOptions,
|
||||
queryMessage: queryMessage.value,
|
||||
formMessage: formMessage.value,
|
||||
reports: filteredReports.value,
|
||||
departments,
|
||||
gradeOptions,
|
||||
enrollYears,
|
||||
statuses,
|
||||
maintenanceHeaders,
|
||||
maintenanceItems: maintenanceItems.value,
|
||||
maintenanceItemsPerPage,
|
||||
maintenancePageCount: maintenancePageCount.value,
|
||||
maintenancePageSummary: maintenancePageSummary.value,
|
||||
formPanelProps: formPanelProps.value,
|
||||
}))
|
||||
|
||||
watch(maintenancePageCount, (value) => {
|
||||
if (maintenanceCurrentPage.value > value) maintenanceCurrentPage.value = value
|
||||
})
|
||||
|
||||
function handleQuerySearch() {
|
||||
queryMessage.value = `查詢完成,共 ${filteredReports.value.length} 筆`
|
||||
}
|
||||
|
||||
function handleQueryBack() {
|
||||
snackbar.show({ message: '查詢頁返回事件', color: 'info' })
|
||||
}
|
||||
|
||||
function handleFormSubmit() {
|
||||
formMessage.value = demoForm.value.title.trim()
|
||||
? `已送出:${demoForm.value.title.trim()}`
|
||||
: '請輸入標題後再送出'
|
||||
}
|
||||
|
||||
function resetDemoForm() {
|
||||
demoForm.value = { ...defaultDemoForm }
|
||||
formMessage.value = ''
|
||||
}
|
||||
|
||||
function handleFormBack() {
|
||||
snackbar.show({ message: '表單頁返回事件', color: 'info' })
|
||||
}
|
||||
|
||||
function resetMaintenanceSearch() {
|
||||
maintenanceSearch.value = { ...defaultMaintenanceSearch }
|
||||
maintenanceCurrentPage.value = 1
|
||||
}
|
||||
|
||||
function handleMaintenanceAction(action: string, record: StudentRecord) {
|
||||
snackbar.show({ message: `${action}:${record.studentId} ${record.name}`, color: 'info' })
|
||||
}
|
||||
|
||||
function openFormPanel() {
|
||||
formPanelVisible.value = true
|
||||
}
|
||||
|
||||
function closeFormPanel() {
|
||||
formPanelVisible.value = false
|
||||
}
|
||||
|
||||
function handleFormPanelVisibleChange(value: boolean) {
|
||||
formPanelVisible.value = value
|
||||
}
|
||||
|
||||
function handleFormPanelSave() {
|
||||
formPanelVisible.value = false
|
||||
snackbar.show({ message: 'SectionFormPanel 儲存事件', color: 'success' })
|
||||
}
|
||||
|
||||
function clearFormPanelFieldError(field: keyof StudentFormState | string) {
|
||||
const key = field as keyof StudentFormState
|
||||
if (!fieldErrors.value[key]?.length) return
|
||||
fieldErrors.value[key] = []
|
||||
}
|
||||
|
||||
function gradeLabel(grade: number) {
|
||||
return gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
if (status === '在學') return 'success'
|
||||
if (status === '休學') return 'warning'
|
||||
if (status === '畢業') return 'secondary'
|
||||
return 'default'
|
||||
}
|
||||
|
||||
function rowProps() {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
demoForm,
|
||||
formPanelForm,
|
||||
maintenanceCurrentPage,
|
||||
maintenanceSearch,
|
||||
pageModel,
|
||||
queryFilters,
|
||||
clearFormPanelFieldError,
|
||||
closeFormPanel,
|
||||
gradeLabel,
|
||||
handleFormBack,
|
||||
handleFormPanelSave,
|
||||
handleFormPanelVisibleChange,
|
||||
handleFormSubmit,
|
||||
handleMaintenanceAction,
|
||||
handleQueryBack,
|
||||
handleQuerySearch,
|
||||
openFormPanel,
|
||||
resetDemoForm,
|
||||
resetMaintenanceSearch,
|
||||
rowProps,
|
||||
statusColor,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
export interface SettingsPageModel {
|
||||
title: string
|
||||
}
|
||||
|
||||
export function useSettingsPage() {
|
||||
const pageModel = computed<SettingsPageModel>(() => ({
|
||||
title: '設定頁面',
|
||||
}))
|
||||
|
||||
return { pageModel }
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useDisplay } from 'vuetify'
|
||||
import { useCrudCommands } from '@/composables/commands/useCrudCommands'
|
||||
import { useCrudCommands } from '@/composables/useCrudCommands'
|
||||
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||
import {
|
||||
type StudentFormState,
|
||||
|
||||
@@ -143,6 +143,8 @@ export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
|
||||
schoolHeader: '公告學校',
|
||||
titleHeader: '公告標題',
|
||||
paginationLabel: '總筆數:',
|
||||
allTabLabel: '全部',
|
||||
emptyText: '目前沒有公告資料',
|
||||
}))
|
||||
|
||||
const selectedAnnouncement = computed(() => {
|
||||
|
||||
+1
-1
@@ -23,4 +23,4 @@
|
||||
- 各頁面的 specific model 擴展 `BasePageModel`(例如 `MaintenancePageModel` 加 `type`、`records`)。
|
||||
- `PageModel` union 供 page component props 型別使用。
|
||||
|
||||
新增頁面類型時,先擴充 `PageModel` union 再新增對應的 page driver。
|
||||
新增頁面類型時,先擴充 `PageModel` union。若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),再建立對應的 page driver;簡單頁面直接在 view 用 `computed` 組裝 page model 即可。
|
||||
|
||||
+18
-2
@@ -5,7 +5,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: () => import('@/views/Home.vue'),
|
||||
meta: { layout: 'default', requiresAuth: true },
|
||||
meta: { layout: 'default', requiresAuth: false },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
@@ -17,7 +17,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { layout: 'none', guestOnly: true },
|
||||
meta: { layout: 'none', guestOnly: false },
|
||||
},
|
||||
{
|
||||
path: '/single-record-maintenance',
|
||||
@@ -49,6 +49,22 @@ export const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/maint/EditableGrid.vue'),
|
||||
meta: { layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/demos/sections',
|
||||
redirect: '/demos/sections/query-page',
|
||||
},
|
||||
{
|
||||
path: '/demos/sections/query-page',
|
||||
name: 'demo-section-query-page',
|
||||
component: () => import('@/views/demos/SectionQueryPageDemo.vue'),
|
||||
meta: { title: 'SectionQueryPage 示範', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/demos/sections/form-page',
|
||||
name: 'demo-section-form-page',
|
||||
component: () => import('@/views/demos/SectionFormPageDemo.vue'),
|
||||
meta: { title: 'SectionFormPage 示範', layout: 'default' },
|
||||
},
|
||||
{
|
||||
path: '/:fncId([0-9A-Z]{5,6})',
|
||||
name: 'fnc-page',
|
||||
|
||||
@@ -32,3 +32,4 @@ service module 不需要自行 catch 並處理錯誤,交由 interceptors/hooks
|
||||
- JSON payload 用 `json`,FormData 用 `body`。
|
||||
- 取消請求使用原生 `AbortController` 與 `signal`。
|
||||
- token 注入與 401 force logout 集中在 hooks,不在單一 API module 重寫。
|
||||
- 重複請求取消策略(key 命名、何時 abort、何時清理)由 store/composable 決定,service module 不應持有 controller map。
|
||||
|
||||
@@ -84,3 +84,9 @@ token 由 `tokenService` 作為單一來源:
|
||||
## 請求取消
|
||||
|
||||
需要取消請求時,由 store 或 composable 建立 `AbortController`,service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
|
||||
|
||||
建議做法:
|
||||
|
||||
- 在 store/composable 以 key 管理同類請求(例如 `auth/login`、`menu/get-menu`)。
|
||||
- 發新請求前先取消同 key 舊請求,避免競態與多餘流量。
|
||||
- 請求結束後於 `finally` 清理該 key;離開流程(如 `clear`、`logout`)時清理全部 key。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CaptchaResponse } from '@/types/api'
|
||||
import type { CaptchaResponse, LoginRequestBody } from '@/types/api'
|
||||
import { httpClient } from '../client'
|
||||
|
||||
export interface RequestOptions {
|
||||
@@ -10,11 +10,19 @@ export const authApi = {
|
||||
getCaptcha: async () => ({
|
||||
data: await httpClient.get('Auth/get-captcha').json<CaptchaResponse>(),
|
||||
}),
|
||||
login: async (payload: FormData, options?: RequestOptions) => ({
|
||||
loginWithFormData: async (payload: FormData, options?: RequestOptions) => ({
|
||||
data: await httpClient
|
||||
.post('Auth/login', {
|
||||
body: payload,
|
||||
signal: options?.signal,
|
||||
signal: options?.signal,
|
||||
})
|
||||
.json<unknown>(),
|
||||
}),
|
||||
loginWithJson: async (payload: LoginRequestBody, options?: RequestOptions) => ({
|
||||
data: await httpClient
|
||||
.post('Auth/login', {
|
||||
json: payload,
|
||||
signal: options?.signal,
|
||||
})
|
||||
.json<unknown>(),
|
||||
}),
|
||||
|
||||
@@ -21,3 +21,10 @@
|
||||
## 資料流
|
||||
|
||||
store 可以呼叫 service module。component 不應繞過 store/composable 直接處理 token、session 或 HTTP hooks。
|
||||
|
||||
## 請求取消慣例
|
||||
|
||||
- 需要避免重複提交或快速切換造成的舊請求殘留時,在 store 層管理 `AbortController`。
|
||||
- 同一類請求使用固定 key(例如 `auth/login`、`menu/get-menu`),新請求前先取消舊請求。
|
||||
- service module 只接收 `signal`,不管理 controller lifecycle。
|
||||
- store 在 `finally` 清理該 key,在 `clear/logout` 清理全部 key。
|
||||
|
||||
+55
-20
@@ -1,10 +1,51 @@
|
||||
import type { LoginPayload, User } from '@/types/api'
|
||||
import type { LoginPayload, LoginRequestBody, LoginRequestFormat, User } from '@/types/api'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { authApi } from '@/services/modules/auth'
|
||||
import { tokenService } from '@/services/token'
|
||||
import { useMenuStore } from '@/stores/menu'
|
||||
import { createRequestControllerManager } from '@/stores/request-controller'
|
||||
|
||||
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
|
||||
|
||||
interface LoginOptions {
|
||||
requestFormat?: LoginRequestFormat
|
||||
}
|
||||
|
||||
function createLoginRequestBody(payload: LoginPayload): LoginRequestBody {
|
||||
return {
|
||||
UserID: payload.UserID,
|
||||
Password: payload.Password,
|
||||
...(payload.captcha
|
||||
? {
|
||||
DNTCaptchaInputText: payload.captcha.DNTCaptchaInputText,
|
||||
DNTCaptchaText: payload.captcha.DNTCaptchaText,
|
||||
DNTCaptchaToken: payload.captcha.DNTCaptchaToken,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
|
||||
function createLoginFormData(payload: LoginRequestBody) {
|
||||
const formData = new FormData()
|
||||
formData.append('UserID', payload.UserID)
|
||||
formData.append('Password', payload.Password)
|
||||
|
||||
if (payload.DNTCaptchaInputText) {
|
||||
formData.append('DNTCaptchaInputText', payload.DNTCaptchaInputText)
|
||||
}
|
||||
|
||||
if (payload.DNTCaptchaText) {
|
||||
formData.append('DNTCaptchaText', payload.DNTCaptchaText)
|
||||
}
|
||||
|
||||
if (payload.DNTCaptchaToken) {
|
||||
formData.append('DNTCaptchaToken', payload.DNTCaptchaToken)
|
||||
}
|
||||
|
||||
return formData
|
||||
}
|
||||
|
||||
// - 只在 store 管理登入狀態:user/token/loading/error
|
||||
// - Component 不直接呼叫 API,避免狀態散落
|
||||
@@ -16,32 +57,24 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const token = tokenService.token
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
// 只針對 login 取消重複請求,避免競態與重複提交
|
||||
const loginController = ref<AbortController | null>(null)
|
||||
const requestControllerManager = createRequestControllerManager()
|
||||
|
||||
const isAuthenticated = computed(() => !!token.value)
|
||||
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
||||
|
||||
const login = async (payload: LoginPayload) => {
|
||||
loginController.value?.abort()
|
||||
loginController.value = new AbortController()
|
||||
const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
|
||||
const signal = requestControllerManager.replace('auth/login')
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const formData = new FormData()
|
||||
formData.append('UserID', payload.UserID)
|
||||
formData.append('Password', payload.Password)
|
||||
|
||||
if (payload.captcha) {
|
||||
formData.append('DNTCaptchaInputText', payload.captcha.DNTCaptchaInputText)
|
||||
formData.append('DNTCaptchaText', payload.captcha.DNTCaptchaText)
|
||||
formData.append('DNTCaptchaToken', payload.captcha.DNTCaptchaToken)
|
||||
}
|
||||
|
||||
const { data } = await authApi.login(formData, {
|
||||
signal: loginController.value.signal,
|
||||
})
|
||||
const requestBody = createLoginRequestBody(payload)
|
||||
const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
|
||||
const requestOptions = { signal }
|
||||
const { data } =
|
||||
requestFormat === 'json'
|
||||
? await authApi.loginWithJson(requestBody, requestOptions)
|
||||
: await authApi.loginWithFormData(createLoginFormData(requestBody), requestOptions)
|
||||
|
||||
const parseUser = (val: unknown): User | undefined => {
|
||||
if (!val || typeof val !== 'object') return
|
||||
@@ -81,6 +114,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
user.value = result.user ?? null
|
||||
// 使用者token寫入
|
||||
tokenService.setToken(result.accessToken)
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
@@ -90,11 +124,12 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
loginController.value = null
|
||||
requestControllerManager.clear('auth/login')
|
||||
}
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
requestControllerManager.clearAll()
|
||||
user.value = null
|
||||
tokenService.clearToken()
|
||||
useMenuStore().clear()
|
||||
|
||||
+9
-2
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { normalizeError } from '@/services/error'
|
||||
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
||||
import { createRequestControllerManager } from '@/stores/request-controller'
|
||||
|
||||
export interface LayoutMenuItem {
|
||||
title: string
|
||||
@@ -17,6 +18,7 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
const isRail = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const requestControllerManager = createRequestControllerManager()
|
||||
|
||||
const menuStorageKey = 'sk_playground_menu'
|
||||
const favoriteStorageKey = 'sk_playground_favorite'
|
||||
@@ -180,6 +182,7 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
requestControllerManager.clearAll()
|
||||
menu.value = []
|
||||
favorite.value = []
|
||||
isRail.value = false
|
||||
@@ -190,9 +193,10 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
}
|
||||
|
||||
const getMenu = async (id: string) => {
|
||||
const signal = requestControllerManager.replace('menu/get-menu')
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getMenu({ userID: id })
|
||||
const res = await menuApi.getMenu({ userID: id }, { signal })
|
||||
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
@@ -202,13 +206,15 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
requestControllerManager.clear('menu/get-menu')
|
||||
}
|
||||
}
|
||||
|
||||
const getFavorite = async (id: string) => {
|
||||
const signal = requestControllerManager.replace('menu/get-favorite')
|
||||
try {
|
||||
loading.value = true
|
||||
const res = await menuApi.getFavorite({ userID: id })
|
||||
const res = await menuApi.getFavorite({ userID: id }, { signal })
|
||||
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||
} catch (error_) {
|
||||
const normalizedError = normalizeError(error_)
|
||||
@@ -218,6 +224,7 @@ export const useMenuStore = defineStore('menu', () => {
|
||||
throw normalizedError
|
||||
} finally {
|
||||
loading.value = false
|
||||
requestControllerManager.clear('menu/get-favorite')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export interface RequestControllerManager {
|
||||
replace: (key: string) => AbortSignal
|
||||
clear: (key: string) => void
|
||||
clearAll: () => void
|
||||
}
|
||||
|
||||
export function createRequestControllerManager(): RequestControllerManager {
|
||||
const controllers = new Map<string, AbortController>()
|
||||
|
||||
const replace = (key: string): AbortSignal => {
|
||||
controllers.get(key)?.abort()
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
return controller.signal
|
||||
}
|
||||
|
||||
const clear = (key: string) => {
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
controllers.forEach((controller) => {
|
||||
controller.abort()
|
||||
})
|
||||
controllers.clear()
|
||||
}
|
||||
|
||||
return {
|
||||
replace,
|
||||
clear,
|
||||
clearAll,
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,13 @@ export interface LoginPayload {
|
||||
Password: string
|
||||
captcha?: LoginCaptchaPayload
|
||||
}
|
||||
|
||||
export interface LoginRequestBody {
|
||||
UserID: string
|
||||
Password: string
|
||||
DNTCaptchaInputText?: string
|
||||
DNTCaptchaText?: string
|
||||
DNTCaptchaToken?: string
|
||||
}
|
||||
|
||||
export type LoginRequestFormat = 'formData' | 'json'
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import PageFunction from '@/components/pages/PageFunction.vue'
|
||||
import { useFunctionPage } from '@/composables/page-drivers/useFunctionPage'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { pageModel } = useFunctionPage()
|
||||
const route = useRoute()
|
||||
const pageModel = computed(() => ({
|
||||
fncId: String(route.params.fncId ?? ''),
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageFunction :page="pageModel" />
|
||||
<v-sheet height="100%" width="100%">
|
||||
{{ pageModel.fncId }}
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
+37
-8
@@ -1,32 +1,61 @@
|
||||
# Views Guide
|
||||
|
||||
`views` 是 route entry。View 應維持薄層,負責掛載 page driver 與 page component,不承載大段 UI、dialog、表單欄位或 store mutation 細節。
|
||||
`views` 是 route entry。View 自含 page model 組裝與頁面 UI,若邏輯複雜才抽到 page driver composable。
|
||||
|
||||
## 規則
|
||||
|
||||
- 使用 `<script setup lang="ts">`。
|
||||
- 直接 route component 放在 `src/views` 或 `src/views/<feature>`。
|
||||
- 一般 view 目標 < 80 行。
|
||||
- route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。
|
||||
- 不直接 import 或包住 `MainLayout.vue`。
|
||||
- 不直接定義大型 `<v-dialog>`、`<v-overlay>`、大型表格或大型表單。
|
||||
- 複雜 UI 拆到 `components/sections/*` 或 `components/items/*`。
|
||||
|
||||
## 建議形狀
|
||||
|
||||
簡單頁面:直接在 view 組裝 page model 與 template。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import PageReports from '@/components/pages/PageReports.vue'
|
||||
import { useReportsPage } from '@/composables/page-drivers/useReportsPage'
|
||||
|
||||
const page = useReportsPage()
|
||||
import { computed } from 'vue'
|
||||
const pageModel = computed(() => ({ title: '我的頁面' }))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageReports :page="page.pageModel.value" />
|
||||
<v-card>{{ pageModel.title }}</v-card>
|
||||
</template>
|
||||
```
|
||||
|
||||
複雜頁面:透過 page driver composable 協調多個資料來源。
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import { useXxxPage } from '@/composables/page-drivers/useXxxPage'
|
||||
const { pageModel, search, handleSave, ... } = useXxxPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MaintShell :title="pageModel.title" @create="handleCreate">
|
||||
<template #table>...</template>
|
||||
</MaintShell>
|
||||
</template>
|
||||
```
|
||||
|
||||
以 destructure 方式取用 composable 回傳值,模板不寫 `.value`。
|
||||
|
||||
## Login.vue 開關
|
||||
|
||||
`Login.vue` 是登入頁的完整入口,登入頁功能開關集中在 view 內宣告,透過 composable 往下傳遞,不在子元件各自決定是否啟用。
|
||||
|
||||
- `withCaptcha`:控制驗證碼 UI、captcha API 載入/刷新,以及登入 payload 是否帶 captcha 資料。關閉時不應發出 captcha API,也不應檢查或送出 captcha 欄位。
|
||||
- `withAnnouncement`:控制公告 UI、公告 mock data/composable 資料流與公告詳情互動。關閉時公告板、手機公告列與公告對話框資料來源都應停用。
|
||||
- `withForgotPassword`:控制忘記密碼連結與事件。關閉時 UI 不顯示,也不應觸發忘記密碼事件。
|
||||
- `withRememberAccount`:控制記住帳號 UI 與 localStorage 讀寫。關閉時不顯示 checkbox、不讀寫記住帳號 storage,送出資料固定視為未記住帳號。
|
||||
|
||||
新增登入頁選配功能時,優先維持同樣模式:view 宣告開關、composable 負責資料流與 side effect、form component 只依 props 呈現 UI 與發出事件。
|
||||
|
||||
## 子目錄
|
||||
|
||||
- `views/demos` 是一般頁面與 section 使用方式的 demo route entry,仍需維持薄 view。
|
||||
- `views/maint` 是 maintenance demo route entry。詳見 `src/views/maint/GUIDE.md`。
|
||||
- `views/errors` 是錯誤頁入口,通常使用 `meta.layout = 'none'`。每個錯誤頁(`Forbidden.vue`、`ServerError.vue`、`NotFound.vue` 等)只傳入 props 給共用的 `ErrorShell.vue`,不再各自重複佈局邏輯。`ErrorShell.vue` 提供標題、圖示、顏色、描述、後端訊息、操作按鈕(返回上頁 / 回首頁 / 前往登入)等 slots。
|
||||
|
||||
+124
-10
@@ -1,17 +1,131 @@
|
||||
<script setup lang="ts">
|
||||
import PageHome from '@/components/pages/PageHome.vue'
|
||||
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
|
||||
import { useHomePage } from '@/composables/page-drivers/useHomePage'
|
||||
|
||||
const page = useHomePage()
|
||||
const { handleMessageCenter, handleNews, handleQuick, isNewsDialogOpen, pageModel, selectedNews } =
|
||||
useHomePage()
|
||||
|
||||
interface NewsItem {
|
||||
id: number
|
||||
date: string
|
||||
month: string
|
||||
title: string
|
||||
desc: string
|
||||
dept: string
|
||||
views: string
|
||||
isNew: boolean
|
||||
}
|
||||
|
||||
function resolveNewsItem(wrapped: unknown): NewsItem {
|
||||
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
|
||||
return (wrapped as { raw: NewsItem }).raw
|
||||
}
|
||||
return wrapped as NewsItem
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageHome
|
||||
v-model:news-dialog-open="page.isNewsDialogOpen.value"
|
||||
:page="page.pageModel.value"
|
||||
:selected-news="page.selectedNews.value"
|
||||
@message-center="page.handleMessageCenter"
|
||||
@news="page.handleNews"
|
||||
@quick="page.handleQuick"
|
||||
/>
|
||||
<v-sheet>
|
||||
<v-container fluid class="pa-0 px-2">
|
||||
<v-card variant="flat">
|
||||
<v-card-title> 歡迎使用校務資訊系統 </v-card-title>
|
||||
<v-card-text class="text-grey">
|
||||
使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card variant="flat" border="thin primary" class="pa-4">
|
||||
<v-card-title class="mb-4"> 最新消息 </v-card-title>
|
||||
<v-data-iterator item-key="id" :items="pageModel.newsItems" :items-per-page="-1">
|
||||
<template #default="{ items }">
|
||||
<v-row density="compact">
|
||||
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
|
||||
<v-card @click="handleNews(resolveNewsItem(wrapped))">
|
||||
<div class="d-flex flex-no-wrap">
|
||||
<v-avatar rounded="0" size="64" class="flex-column bg-primary">
|
||||
<div>
|
||||
{{ resolveNewsItem(wrapped).date }}
|
||||
</div>
|
||||
<div>
|
||||
{{ resolveNewsItem(wrapped).month }}
|
||||
</div>
|
||||
</v-avatar>
|
||||
<div class="flex-fill">
|
||||
<v-card-title>
|
||||
{{ resolveNewsItem(wrapped).title }}
|
||||
<v-chip
|
||||
v-if="resolveNewsItem(wrapped).isNew"
|
||||
class="ml-2"
|
||||
color="yellow"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
>
|
||||
NEW
|
||||
</v-chip>
|
||||
</v-card-title>
|
||||
<v-card-text class="pa-4">
|
||||
{{ resolveNewsItem(wrapped).desc }}
|
||||
</v-card-text>
|
||||
<v-card-text class="pt-0">
|
||||
<v-row align="center">
|
||||
<v-icon size="14" :icon="mdiFolderOutline" />
|
||||
<v-col cols="1"> {{ resolveNewsItem(wrapped).dept }}</v-col>
|
||||
<v-icon size="14" :icon="mdiEyeOutline" />
|
||||
<v-col>{{ resolveNewsItem(wrapped).views }} 次瀏覽</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
</v-data-iterator>
|
||||
</v-card>
|
||||
|
||||
<v-card class="pa-4 mt-4" @click="handleMessageCenter">
|
||||
<v-card-title class="mb-4"> 訊息中心 </v-card-title>
|
||||
<v-card-text class="text-body-large text-secondary">
|
||||
有 {{ Math.floor(Math.random() * 10) }} 筆未讀
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-card variant="flat" border="thin primary" class="pa-4 mt-4">
|
||||
<v-row density="compact" align="center">
|
||||
<v-card-title> 快速存取 </v-card-title>
|
||||
<v-col v-for="item in pageModel.quickItems" :key="item.title">
|
||||
<v-card
|
||||
class="d-flex flex-column align-center ga-1 text-center py-3 px-2"
|
||||
color="primary-variant"
|
||||
variant="tonal"
|
||||
@click="handleQuick(item)"
|
||||
>
|
||||
<div class="text-h5">{{ item.icon }}</div>
|
||||
<div class="text-body-medium font-weight-medium">{{ item.title }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-container>
|
||||
|
||||
<v-dialog v-model="isNewsDialogOpen" max-width="640">
|
||||
<v-card v-if="selectedNews">
|
||||
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
|
||||
{{ selectedNews.title }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
|
||||
{{ selectedNews.month }} {{ selectedNews.date }} · {{ selectedNews.dept }} ·
|
||||
{{ selectedNews.views }} 次瀏覽
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="pt-4">
|
||||
{{ selectedNews.desc }}
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="isNewsDialogOpen = false"> 關閉 </v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</v-sheet>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
+342
-78
@@ -1,63 +1,15 @@
|
||||
<template>
|
||||
<page-login
|
||||
:announcement-board="announcementBoard"
|
||||
:branding="branding"
|
||||
:form="form"
|
||||
:header="header"
|
||||
:illustration="illustration"
|
||||
:layout="formPositionLayout"
|
||||
:mobile-announcement="mobileAnnouncement"
|
||||
:toolbar="toolbar"
|
||||
:with-announcement="withAnnouncement"
|
||||
@captcha-change="handleCaptchaChange"
|
||||
@captcha-refresh="handleCaptchaRefresh"
|
||||
@change-locale="handleChangeLocale"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@select-announcement="handleSelectAnnouncement"
|
||||
@submit="onLogin"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
|
||||
<v-dialog v-model="dialogVisible" width="360">
|
||||
<v-card>
|
||||
<v-card-title>{{ dialogTitle }}</v-card-title>
|
||||
<v-card-text>{{ dialogMessage }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
|
||||
{{ t('common.ok') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="announcementDialogVisible" max-width="720">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">
|
||||
{{ selectedAnnouncement?.title }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="pt-2">
|
||||
{{ selectedAnnouncement?.date }} ・ {{ selectedAnnouncement?.school }}
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="text-body-1">
|
||||
{{ selectedAnnouncementDetail }}
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
|
||||
關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { mdiBullhornVariantOutline } from '@mdi/js'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import HyakkaouAcademyImage from '@/assets/logo.png'
|
||||
import PageLogin from '@/components/PageLogin.vue'
|
||||
import LoginAnnouncementBoard from '@/components/login/LoginAnnouncementBoard.vue'
|
||||
import LoginBrand from '@/components/login/LoginBrand.vue'
|
||||
import LoginForm from '@/components/login/LoginForm.vue'
|
||||
import LoginHeader from '@/components/login/LoginHeader.vue'
|
||||
import LoginToolBar from '@/components/login/LoginToolBar.vue'
|
||||
import LoginVerify from '@/components/login/LoginVerify.vue'
|
||||
import {
|
||||
type LoginAnnouncementListItem,
|
||||
useLoginAnnouncements,
|
||||
@@ -69,7 +21,6 @@ import { useSnackbarStore } from '@/stores/snackbar'
|
||||
|
||||
type LayoutType = 'side-left' | 'side-right' | 'card'
|
||||
|
||||
// i18n
|
||||
const { t, locale } = useI18n()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -77,21 +28,14 @@ const authStore = useAuthStore()
|
||||
const menuStore = useMenuStore()
|
||||
const snackbarStore = useSnackbarStore()
|
||||
|
||||
// 語系選項
|
||||
const locales = ['zh-TW', 'en-US']
|
||||
|
||||
// 插圖圖片來源
|
||||
const illustrationImage = ref(HyakkaouAcademyImage)
|
||||
|
||||
// 功能開關與版型
|
||||
const formPositionLayout = ref<LayoutType>('side-left')
|
||||
// 是否啟用公告
|
||||
const withAnnouncement = ref(true)
|
||||
const withForgotPassword = ref(true)
|
||||
const withRememberAccount = ref(true)
|
||||
|
||||
// 功能開關:是否啟用驗證碼
|
||||
const withCaptcha = ref(true)
|
||||
|
||||
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
|
||||
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
|
||||
const {
|
||||
@@ -101,7 +45,6 @@ const {
|
||||
selectedAnnouncementDetail,
|
||||
} = loginAnnouncements
|
||||
|
||||
// 文字內容(i18n)
|
||||
const title = computed(() => t('pages.login.title'))
|
||||
const organization = computed(() => t('pages.login.organization'))
|
||||
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
|
||||
@@ -117,19 +60,14 @@ const verifyText = computed(() => t('pages.login.verifyText'))
|
||||
const captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
|
||||
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
|
||||
|
||||
// 連結與儲存設定
|
||||
// 忘記密碼連結(由 form.forgotPassword 設定)
|
||||
const forgotPasswordHref = ref('/forgot-password')
|
||||
const forgotPasswordTarget = ref<string | undefined>(undefined)
|
||||
// 記住帳號的 localStorage key
|
||||
const rememberStorageKey = ref('login.remember.username')
|
||||
// 驗證與對話框狀態
|
||||
const dialogVisible = ref(false)
|
||||
const dialogTitle = ref('')
|
||||
const dialogMessage = ref('')
|
||||
const announcementDialogVisible = ref(false)
|
||||
|
||||
// 內容組合(傳入 PageLogin)
|
||||
const branding = computed(() => ({
|
||||
title: title.value,
|
||||
organization: organization.value,
|
||||
@@ -146,7 +84,6 @@ const header = computed(() => ({
|
||||
welcomeDescription: welcomeDescription.value,
|
||||
}))
|
||||
|
||||
// 表單區塊設定(含功能開關)
|
||||
const form = computed(() => ({
|
||||
accPlaceholder: accPlaceholder.value,
|
||||
passwPlaceholder: passwPlaceholder.value,
|
||||
@@ -158,7 +95,6 @@ const form = computed(() => ({
|
||||
rememberStorageKey: rememberStorageKey.value,
|
||||
withForgotPassword: withForgotPassword.value,
|
||||
withRememberAccount: withRememberAccount.value,
|
||||
// 功能開關:是否顯示驗證碼
|
||||
withCaptcha: withCaptcha.value,
|
||||
captcha: loginCaptcha.formCaptcha.value,
|
||||
captchaValue: loginCaptcha.captchaValue.value,
|
||||
@@ -172,18 +108,26 @@ const form = computed(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
// 右上工具列設定(含顯示開關)
|
||||
const toolbar = computed(() => ({
|
||||
// 功能開關:是否顯示語系切換工具列
|
||||
show: true,
|
||||
locale: locale.value,
|
||||
locales,
|
||||
}))
|
||||
|
||||
// 事件處理
|
||||
const mobileAnnouncementSheetVisible = ref(false)
|
||||
const mobileAnnouncementItems = computed(() => mobileAnnouncement.value.items ?? [])
|
||||
const showMobileAnnouncementBanner = computed(() => {
|
||||
if (!withAnnouncement.value) return false
|
||||
if (mobileAnnouncement.value.show === false) return false
|
||||
return mobileAnnouncementItems.value.length > 0
|
||||
})
|
||||
const mobileAnnouncementBannerText = computed(() => {
|
||||
return mobileAnnouncementItems.value[0]?.content ?? ''
|
||||
})
|
||||
const layoutClass = computed(() => `layout-${formPositionLayout.value}`)
|
||||
|
||||
function handleForgotPassword(e: MouseEvent) {
|
||||
if (!withForgotPassword.value) return
|
||||
|
||||
console.log('Forgot Password Click:', e)
|
||||
}
|
||||
|
||||
@@ -239,8 +183,6 @@ async function onLogin(data: Record<string, unknown>) {
|
||||
|
||||
menuStore.getMenu(authStore.user?.id ?? '')
|
||||
|
||||
// menuStore.getFavorite(authStore.user?.id ?? '')
|
||||
|
||||
snackbarStore.show({
|
||||
message: t('pages.login.alert.loginSuccess'),
|
||||
color: 'success',
|
||||
@@ -264,3 +206,325 @@ onMounted(() => {
|
||||
void loginCaptcha.loadCaptcha().catch(() => undefined)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
|
||||
<v-row
|
||||
v-if="formPositionLayout !== 'card'"
|
||||
class="fill-height"
|
||||
:class="{ 'flex-row-reverse': formPositionLayout === 'side-right' }"
|
||||
no-gutters
|
||||
>
|
||||
<v-col
|
||||
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
|
||||
cols="12"
|
||||
lg="8"
|
||||
sm="6"
|
||||
>
|
||||
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
|
||||
<LoginBrand :title="branding.title" />
|
||||
</div>
|
||||
<v-sheet
|
||||
v-if="withAnnouncement"
|
||||
class="board-wrapper pa-2 pa-lg-0"
|
||||
color="rgba(var(--v-theme-surface), 0.8)"
|
||||
elevation="0"
|
||||
max-width="680"
|
||||
rounded="lg"
|
||||
width="100%"
|
||||
>
|
||||
<LoginAnnouncementBoard
|
||||
:all-tab-label="announcementBoard.allTabLabel"
|
||||
:date-header="announcementBoard.dateHeader"
|
||||
:empty-text="announcementBoard.emptyText"
|
||||
:items="announcementBoard.items"
|
||||
:items-per-page="announcementBoard.itemsPerPage"
|
||||
:pagination-label="announcementBoard.paginationLabel"
|
||||
:school-header="announcementBoard.schoolHeader"
|
||||
:system-announcements="announcementBoard.systemAnnouncements"
|
||||
:tabs="announcementBoard.tabs"
|
||||
:title="announcementBoard.title"
|
||||
:title-header="announcementBoard.titleHeader"
|
||||
@select-announcement="handleSelectAnnouncement"
|
||||
/>
|
||||
</v-sheet>
|
||||
</v-col>
|
||||
|
||||
<v-col
|
||||
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
|
||||
cols="12"
|
||||
lg="4"
|
||||
sm="6"
|
||||
>
|
||||
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
|
||||
<v-banner
|
||||
class="d-sm-none mb-2"
|
||||
density="comfortable"
|
||||
lines="one"
|
||||
:mobile="false"
|
||||
:stacked="false"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-slide-x-transition appear>
|
||||
<div class="mobile-banner-icon-wrap d-flex align-center">
|
||||
<v-icon
|
||||
class="mobile-banner-icon"
|
||||
color="primary"
|
||||
size="small"
|
||||
:icon="mdiBullhornVariantOutline"
|
||||
/>
|
||||
</div>
|
||||
</v-slide-x-transition>
|
||||
</template>
|
||||
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
|
||||
<template #actions>
|
||||
<v-btn
|
||||
class="text-none"
|
||||
color="primary"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="mobileAnnouncementSheetVisible = true"
|
||||
>
|
||||
{{ mobileAnnouncement.viewAllText }}
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-banner>
|
||||
</div>
|
||||
<LoginToolBar
|
||||
v-if="toolbar.show"
|
||||
:locale="toolbar.locale"
|
||||
:locales="toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div
|
||||
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
|
||||
>
|
||||
<div class="login-header-height d-flex d-sm-none justify-center align-start">
|
||||
<LoginBrand :title="branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-sm-block"
|
||||
:welcome-description="header.welcomeDescription"
|
||||
:welcome-text="header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="form.accPlaceholder"
|
||||
:forgot-password-href="form.forgotPassword.href"
|
||||
:forgot-password-target="form.forgotPassword.target"
|
||||
:forgot-password-text="form.forgotPassword.text"
|
||||
:passw-placeholder="form.passwPlaceholder"
|
||||
:remember-me-label="form.rememberMeLabel"
|
||||
:remember-storage-key="form.rememberStorageKey"
|
||||
:submit-text="form.submitText"
|
||||
:with-forgot-password="form.withForgotPassword"
|
||||
:with-remember-account="form.withRememberAccount"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="onLogin"
|
||||
>
|
||||
<template v-if="form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="form.captcha"
|
||||
:captcha-placeholder="form.captchaPlaceholder"
|
||||
:error-message="form.captchaErrorMessage"
|
||||
:loading="form.captchaLoading"
|
||||
:model-value="form.captchaValue"
|
||||
:refresh-title="form.refreshTitle"
|
||||
:verified="form.captchaVerified"
|
||||
:verify-text="form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
|
||||
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
|
||||
</div>
|
||||
</div>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row
|
||||
v-else
|
||||
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
|
||||
no-gutters
|
||||
>
|
||||
<v-card
|
||||
class="rounded-lg"
|
||||
:class="toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
|
||||
elevation="10"
|
||||
max-width="450"
|
||||
width="100%"
|
||||
>
|
||||
<LoginToolBar
|
||||
v-if="toolbar.show"
|
||||
:locale="toolbar.locale"
|
||||
:locales="toolbar.locales"
|
||||
@change-locale="handleChangeLocale"
|
||||
@toggle-layout="handleToggleLayout"
|
||||
/>
|
||||
<div class="d-flex justify-center mb-6 mb-md-4">
|
||||
<LoginBrand :title="branding.title" />
|
||||
</div>
|
||||
<LoginHeader
|
||||
class="d-none d-md-block"
|
||||
:welcome-description="header.welcomeDescription"
|
||||
:welcome-text="header.welcomeText"
|
||||
/>
|
||||
<LoginForm
|
||||
:acc-placeholder="form.accPlaceholder"
|
||||
:forgot-password-href="form.forgotPassword.href"
|
||||
:forgot-password-target="form.forgotPassword.target"
|
||||
:forgot-password-text="form.forgotPassword.text"
|
||||
:passw-placeholder="form.passwPlaceholder"
|
||||
:remember-me-label="form.rememberMeLabel"
|
||||
:remember-storage-key="form.rememberStorageKey"
|
||||
:submit-text="form.submitText"
|
||||
:with-forgot-password="form.withForgotPassword"
|
||||
:with-remember-account="form.withRememberAccount"
|
||||
@forgot-password="handleForgotPassword"
|
||||
@submit="onLogin"
|
||||
>
|
||||
<template v-if="form.withCaptcha" #verify>
|
||||
<LoginVerify
|
||||
:captcha="form.captcha"
|
||||
:captcha-placeholder="form.captchaPlaceholder"
|
||||
:error-message="form.captchaErrorMessage"
|
||||
:loading="form.captchaLoading"
|
||||
:model-value="form.captchaValue"
|
||||
:refresh-title="form.refreshTitle"
|
||||
:verified="form.captchaVerified"
|
||||
:verify-text="form.verifyText"
|
||||
@refresh="handleCaptchaRefresh"
|
||||
@update:model-value="handleCaptchaChange"
|
||||
/>
|
||||
</template>
|
||||
</LoginForm>
|
||||
<div class="mt-8 text-center text-caption text-grey-darken-2">
|
||||
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-row>
|
||||
|
||||
<v-bottom-sheet
|
||||
v-if="withAnnouncement"
|
||||
v-model="mobileAnnouncementSheetVisible"
|
||||
class="d-sm-none"
|
||||
>
|
||||
<v-card rounded="t-xl">
|
||||
<v-card-title class="text-subtitle-1 font-weight-bold">
|
||||
{{ mobileAnnouncement.listTitle }}
|
||||
</v-card-title>
|
||||
<v-list lines="two">
|
||||
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
|
||||
<v-list-item-title>{{ item.content }}</v-list-item-title>
|
||||
<v-list-item-subtitle v-if="item.title || item.createdAt">
|
||||
{{ item.title }}<span v-if="item.title && item.createdAt"> ・ </span
|
||||
>{{ item.createdAt }}
|
||||
</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
<v-list-item v-if="mobileAnnouncementItems.length === 0">
|
||||
<v-list-item-title>{{ mobileAnnouncement.emptyText }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
<v-card-actions class="justify-end">
|
||||
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
|
||||
{{ mobileAnnouncement.closeText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-bottom-sheet>
|
||||
</v-sheet>
|
||||
|
||||
<v-dialog v-model="dialogVisible" width="360">
|
||||
<v-card>
|
||||
<v-card-title>{{ dialogTitle }}</v-card-title>
|
||||
<v-card-text>{{ dialogMessage }}</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
|
||||
{{ t('common.ok') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="announcementDialogVisible" max-width="720">
|
||||
<v-card>
|
||||
<v-card-title class="text-h6">
|
||||
{{ selectedAnnouncement?.title }}
|
||||
</v-card-title>
|
||||
<v-card-subtitle class="pt-2">
|
||||
{{ selectedAnnouncement?.date }} ・ {{ selectedAnnouncement?.school }}
|
||||
</v-card-subtitle>
|
||||
<v-card-text class="text-body-1">
|
||||
{{ selectedAnnouncementDetail }}
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer />
|
||||
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
|
||||
關閉
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.v-banner__prepend) {
|
||||
align-self: center;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
:deep(.v-banner-actions) {
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.mobile-banner-icon {
|
||||
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
@keyframes mobile-banner-breathe {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.9;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.mobile-banner-icon {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.illustration-panel {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--v-theme-background)) 0%,
|
||||
rgb(var(--v-theme-surface)) 100%
|
||||
);
|
||||
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
max-width: 450px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.login-header-height {
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.layout-side-right .illustration-panel {
|
||||
border-right: none;
|
||||
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import PageSettings from '@/components/pages/PageSettings.vue'
|
||||
import { useSettingsPage } from '@/composables/page-drivers/useSettingsPage'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { pageModel } = useSettingsPage()
|
||||
const pageModel = computed(() => ({
|
||||
title: '設定頁面',
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageSettings :page="pageModel" />
|
||||
<div>{{ pageModel.title }}</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||
import SectionFormPage from '@/components/sections/SectionFormPage.vue'
|
||||
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
|
||||
|
||||
const { demoForm, handleFormBack, handleFormSubmit, pageModel, resetDemoForm } =
|
||||
useSectionsDemoPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionFormPage
|
||||
reset-label="清除"
|
||||
submit-label="送出"
|
||||
:message="pageModel.formMessage"
|
||||
title="SectionFormPage 報表申請"
|
||||
@back="handleFormBack"
|
||||
@reset="resetDemoForm"
|
||||
@submit="handleFormSubmit"
|
||||
>
|
||||
<template #fields>
|
||||
<v-row density="compact">
|
||||
<v-col cols="12" md="6">
|
||||
<BaseFormTextField v-model="demoForm.title" label="標題" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<BaseFormSelect v-model="demoForm.owner" label="單位" :items="pageModel.ownerOptions" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="3">
|
||||
<BaseFormSelect
|
||||
v-model="demoForm.category"
|
||||
label="類型"
|
||||
:items="pageModel.categoryOptions"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12">
|
||||
<BaseFormTextField v-model="demoForm.description" label="說明" />
|
||||
</v-col>
|
||||
</v-row>
|
||||
</template>
|
||||
|
||||
<template #sections>
|
||||
<v-card class="mb-2">
|
||||
<v-card-title class="text-title-medium font-weight-bold">明細</v-card-title>
|
||||
<v-card-text>
|
||||
<v-table density="compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>欄位</th>
|
||||
<th>值</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>單位</td>
|
||||
<td>{{ demoForm.owner }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>類型</td>
|
||||
<td>{{ demoForm.category }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<template #notices>
|
||||
<v-list class="bg-yellow-lighten-5" density="compact">
|
||||
<v-list-item>送出前確認標題與單位。</v-list-item>
|
||||
<v-list-item>表單狀態由 page driver 管理。</v-list-item>
|
||||
</v-list>
|
||||
</template>
|
||||
</SectionFormPage>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||
import SectionQueryPage from '@/components/sections/SectionQueryPage.vue'
|
||||
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
|
||||
|
||||
const { handleQueryBack, handleQuerySearch, pageModel, queryFilters } = useSectionsDemoPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SectionQueryPage title="查詢頁DEMO" @back="handleQueryBack" @search="handleQuerySearch">
|
||||
<template #filters>
|
||||
<v-col cols="12" md="4">
|
||||
<BaseFormTextField v-model="queryFilters.keyword" label="關鍵字" />
|
||||
</v-col>
|
||||
<v-col cols="12" md="4">
|
||||
<BaseFormSelect v-model="queryFilters.owner" label="單位" :items="pageModel.ownerOptions" />
|
||||
</v-col>
|
||||
</template>
|
||||
|
||||
<template #results>
|
||||
<v-alert v-if="pageModel.queryMessage" class="mb-3" type="success" variant="tonal">
|
||||
{{ pageModel.queryMessage }}
|
||||
</v-alert>
|
||||
<v-table density="compact">
|
||||
<thead class="bg-primary">
|
||||
<tr>
|
||||
<th>名稱</th>
|
||||
<th>單位</th>
|
||||
<th>狀態</th>
|
||||
<th>更新日</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="pageModel.reports.length === 0">
|
||||
<td class="text-center" colspan="4">尚無查詢結果</td>
|
||||
</tr>
|
||||
<tr v-for="row in pageModel.reports" :key="row.id">
|
||||
<td>{{ row.title }}</td>
|
||||
<td>{{ row.owner }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ row.updatedAt }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</v-table>
|
||||
</template>
|
||||
</SectionQueryPage>
|
||||
</template>
|
||||
@@ -1,10 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue'
|
||||
import { useEditableGridMaintenancePage } from '@/composables/page-drivers/useEditableGridMaintenancePage'
|
||||
import { computed } from 'vue'
|
||||
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
|
||||
import type { MaintenancePageModel } from '@/models/page'
|
||||
import { useStudentStore } from '@/stores/students'
|
||||
|
||||
const page = useEditableGridMaintenancePage()
|
||||
const studentStore = useStudentStore()
|
||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||
type: 'maintenance',
|
||||
title: '可編輯表格維護示範',
|
||||
records: studentStore.students,
|
||||
loading: false,
|
||||
error: null,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageEditableGridMaintenance :page="page.pageModel.value" />
|
||||
<EditableStudentGrid :title="pageModel.title" />
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Maintenance Views Guide
|
||||
|
||||
`views/maint` 是維護頁 demo。所有檔案都應是薄 route entry,實際 UI 與流程分別放在 `components/pages`、`components/sections`、`components/items` 與 `composables/page-drivers`。
|
||||
`views/maint` 是維護頁 demo。所有檔案都是自含的 route entry,UI 與流程直接在 view 中組合 `components/maint/MaintShell`、`components/sections`、`components/items` 與 composable。
|
||||
|
||||
## 目前範本
|
||||
|
||||
- `SingleRecord.vue`:單主檔 CRUD + dialog。
|
||||
- `SingleRecord.vue`:單主檔 CRUD + dialog(使用 page driver composable)。
|
||||
- `EditableGrid.vue`:可編輯表格。
|
||||
- `MasterDetailA.vue`:主檔 + 側邊明細 panel。
|
||||
- `MasterDetailA.vue`:主檔 + 側邊明細 panel(使用 page driver composable)。
|
||||
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
|
||||
- `MasterDetailC.vue`:主檔 + 簡化明細清單。
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
複製維護頁時同步調整:
|
||||
|
||||
- `router/routes.ts` 的 `path`、`name`、`component`、`meta.layout`
|
||||
- page driver 名稱與 import
|
||||
- page component 名稱與 import
|
||||
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
|
||||
- store、service、model、語系、menu/favorites/breadcrumb 相關資料
|
||||
|
||||
|
||||
@@ -1,34 +1,421 @@
|
||||
<script setup lang="ts">
|
||||
import PageMasterDetailAMaintenance from '@/components/pages/PageMasterDetailAMaintenance.vue'
|
||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||
import DetailNavigation from '@/components/maint/master-detail/DetailNavigation.vue'
|
||||
import DetailSidePanel from '@/components/maint/master-detail/DetailSidePanel.vue'
|
||||
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
|
||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
||||
import { useMasterDetailAMaintenancePage } from '@/composables/page-drivers/useMasterDetailAMaintenancePage'
|
||||
|
||||
const page = useMasterDetailAMaintenancePage()
|
||||
const {
|
||||
confirmSave, currentPage, departments, detailForm, flow, formState,
|
||||
gradeOptions, itemsPerPage, masterDetailEvents, masterDetailProps,
|
||||
openAddDialog, openEditDialog, openViewDialog, pageCount, pageModel,
|
||||
pageSummary, requestSaveConfirmation, resetSearch, scrollToField,
|
||||
search, searchPanelOpen, snackbarVisible, statuses, students, tableHeaders,
|
||||
} = useMasterDetailAMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageMasterDetailAMaintenance
|
||||
v-model:search="page.search.value"
|
||||
v-model:search-panel-open="page.searchPanelOpen.value"
|
||||
v-bind="page.masterDetailProps.value"
|
||||
:current-page="page.currentPage.value"
|
||||
:grade-label="page.formState.gradeLabel"
|
||||
:headers="page.tableHeaders.value"
|
||||
:items="page.students.value"
|
||||
:items-per-page="page.itemsPerPage"
|
||||
:page="page.pageModel.value"
|
||||
:page-count="page.pageCount.value"
|
||||
:page-summary="page.pageSummary.value"
|
||||
:row-props="page.formState.rowProps"
|
||||
:status-color="page.formState.statusColor"
|
||||
@create="page.openAddDialog"
|
||||
@edit="page.openEditDialog"
|
||||
@reset-search="page.resetSearch"
|
||||
@update:current-page="page.currentPage.value = $event"
|
||||
@view="page.openViewDialog"
|
||||
v-on="page.masterDetailEvents"
|
||||
<MaintShell
|
||||
:search-panel-open="searchPanelOpen"
|
||||
:title="pageModel.title"
|
||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||
@create="openAddDialog"
|
||||
>
|
||||
<template #search-fields>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormTextField
|
||||
id="search-student-id"
|
||||
v-model="search.studentId"
|
||||
label="學號"
|
||||
:label-char-count="2"
|
||||
name="searchStudentId"
|
||||
placeholder="例如:S2024001"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormTextField
|
||||
id="search-name"
|
||||
v-model="search.name"
|
||||
label="姓名"
|
||||
:label-char-count="2"
|
||||
name="searchName"
|
||||
placeholder="例如:王小明"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormSelect
|
||||
id="search-department"
|
||||
v-model="search.department"
|
||||
label="系所"
|
||||
:label-char-count="2"
|
||||
:items="departments"
|
||||
name="searchDepartment"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormSelect
|
||||
id="search-grade"
|
||||
v-model="search.grade"
|
||||
label="年級"
|
||||
:label-char-count="2"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
name="searchGrade"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormSelect
|
||||
id="search-status"
|
||||
v-model="search.status"
|
||||
label="狀態"
|
||||
:label-char-count="2"
|
||||
:items="statuses"
|
||||
name="searchStatus"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||
<v-btn variant="text" @click="resetSearch">清除</v-btn>
|
||||
<v-btn color="primary" disabled variant="tonal">查詢</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
<template #table>
|
||||
<SectionDataTable
|
||||
:current-page="currentPage"
|
||||
:grade-label="formState.gradeLabel"
|
||||
:headers="tableHeaders"
|
||||
:items="students"
|
||||
:items-per-page="itemsPerPage"
|
||||
:page-count="pageCount"
|
||||
:page-summary="pageSummary"
|
||||
:row-props="formState.rowProps"
|
||||
:status-color="formState.statusColor"
|
||||
@delete="flow.requestDeleteConfirmation($event)"
|
||||
@edit="openEditDialog($event)"
|
||||
@update:current-page="currentPage = $event"
|
||||
@view="openViewDialog($event)"
|
||||
/>
|
||||
</template>
|
||||
</MaintShell>
|
||||
|
||||
<teleport to="body">
|
||||
<v-overlay
|
||||
class="dialog-overlay"
|
||||
:close-on-content-click="false"
|
||||
:model-value="masterDetailProps.dialogVisible"
|
||||
scrim="rgba(0, 0, 0, 0.45)"
|
||||
scroll-strategy="block"
|
||||
@update:model-value="flow.handleDialogVisibility($event)"
|
||||
>
|
||||
<div class="dialog-panel" :class="{ 'is-mobile': masterDetailProps.isMobile }">
|
||||
<div
|
||||
v-if="!masterDetailProps.isMobile || masterDetailProps.activeMobilePanel === 'detail'"
|
||||
class="detail-panel-wrapper"
|
||||
:class="{ 'is-active': !!masterDetailProps.selectedSemesterId, 'is-mobile': masterDetailProps.isMobile }"
|
||||
>
|
||||
<DetailSidePanel
|
||||
v-model:detail-form="detailForm"
|
||||
:is-detail-editing="masterDetailProps.isDetailEditing"
|
||||
:is-mobile="masterDetailProps.isMobile"
|
||||
:is-view-mode="masterDetailProps.isViewMode"
|
||||
:selected-semester="masterDetailProps.selectedSemester"
|
||||
v-on="masterDetailEvents"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<MntDialogCard
|
||||
v-if="!masterDetailProps.isMobile || masterDetailProps.activeMobilePanel === 'master'"
|
||||
:dialog-subtitle="masterDetailProps.dialogSubtitle"
|
||||
:dialog-title="masterDetailProps.dialogTitle"
|
||||
:is-edit-mode="masterDetailProps.isEditMode"
|
||||
:is-view-mode="masterDetailProps.isViewMode"
|
||||
:width="masterDetailProps.isMobile ? '100%' : 760"
|
||||
>
|
||||
<template #toolbar>
|
||||
<MntRecordNavToolbar
|
||||
:has-next-record="masterDetailProps.hasNextRecord"
|
||||
:has-prev-record="masterDetailProps.hasPrevRecord"
|
||||
:is-edit-mode="masterDetailProps.isEditMode"
|
||||
:is-view-mode="masterDetailProps.isViewMode"
|
||||
:mobile="masterDetailProps.isMobile"
|
||||
v-on="masterDetailEvents"
|
||||
/>
|
||||
</template>
|
||||
<template #content>
|
||||
<v-alert
|
||||
v-if="masterDetailProps.errorSummary.length > 0 && !masterDetailProps.isLoading"
|
||||
class="mb-4"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
>
|
||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
||||
<div class="d-flex flex-column ga-1">
|
||||
<v-btn
|
||||
v-for="error in masterDetailProps.errorSummary"
|
||||
:key="error.field"
|
||||
color="error"
|
||||
size="small"
|
||||
variant="text"
|
||||
@click="scrollToField(error.field)"
|
||||
>
|
||||
{{ error.message }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<v-skeleton-loader
|
||||
v-if="masterDetailProps.isLoading"
|
||||
class="mt-4"
|
||||
type="subtitle,paragraph"
|
||||
width="100%"
|
||||
/>
|
||||
|
||||
<v-form
|
||||
v-else
|
||||
:class="{ 'form-readonly': masterDetailProps.isFormReadonly }"
|
||||
@submit.prevent="requestSaveConfirmation()"
|
||||
>
|
||||
<MasterFileFormFields
|
||||
:departments="masterDetailProps.departments"
|
||||
:enroll-years="masterDetailProps.enrollYears"
|
||||
:field-errors="masterDetailProps.fieldErrors"
|
||||
:form="formState.form.value"
|
||||
:grade-options="masterDetailProps.gradeOptions"
|
||||
:is-form-locked="masterDetailProps.isFormLocked"
|
||||
:is-form-readonly="masterDetailProps.isFormReadonly"
|
||||
:statuses="masterDetailProps.statuses"
|
||||
v-on="masterDetailEvents"
|
||||
/>
|
||||
|
||||
<v-divider />
|
||||
|
||||
<DetailNavigation
|
||||
:is-mobile="masterDetailProps.isMobile"
|
||||
:is-view-mode="masterDetailProps.isViewMode"
|
||||
:selected-semester-id="masterDetailProps.selectedSemesterId"
|
||||
:semesters="masterDetailProps.semesters"
|
||||
v-on="masterDetailEvents"
|
||||
/>
|
||||
</v-form>
|
||||
</template>
|
||||
<template #actions>
|
||||
<template v-if="masterDetailProps.isMobile">
|
||||
<v-btn :disabled="masterDetailProps.isSaving" variant="text" @click="flow.requestCloseDialog()">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="masterDetailProps.isEditMode"
|
||||
color="error"
|
||||
:disabled="masterDetailProps.isSaving"
|
||||
variant="tonal"
|
||||
@click="flow.requestDeleteCurrent()"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!masterDetailProps.isViewMode"
|
||||
color="primary"
|
||||
:disabled="!masterDetailProps.isDirty || masterDetailProps.isLoading"
|
||||
:loading="masterDetailProps.isSaving"
|
||||
variant="flat"
|
||||
@click="requestSaveConfirmation()"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="flow.requestCloseDialog()">關閉</v-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-spacer />
|
||||
<v-btn :disabled="masterDetailProps.isSaving" variant="text" @click="flow.requestCloseDialog()">取消</v-btn>
|
||||
<v-btn
|
||||
v-if="masterDetailProps.isEditMode"
|
||||
color="error"
|
||||
:disabled="masterDetailProps.isSaving"
|
||||
variant="tonal"
|
||||
@click="flow.requestDeleteCurrent()"
|
||||
>
|
||||
刪除
|
||||
</v-btn>
|
||||
<v-btn
|
||||
v-if="!masterDetailProps.isViewMode"
|
||||
color="primary"
|
||||
:disabled="!masterDetailProps.isDirty || masterDetailProps.isLoading"
|
||||
:loading="masterDetailProps.isSaving"
|
||||
variant="flat"
|
||||
@click="requestSaveConfirmation()"
|
||||
>
|
||||
儲存
|
||||
</v-btn>
|
||||
<v-btn v-else color="primary" variant="flat" @click="flow.requestCloseDialog()">關閉</v-btn>
|
||||
</template>
|
||||
</template>
|
||||
</MntDialogCard>
|
||||
</div>
|
||||
</v-overlay>
|
||||
</teleport>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="masterDetailProps.confirmCloseVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="關閉不儲存"
|
||||
message="目前有尚未儲存的內容,確定要關閉嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="flow.confirmClose()"
|
||||
@update:model-value="flow.confirmCloseVisible.value = $event"
|
||||
/>
|
||||
|
||||
<v-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
|
||||
<ConfirmDialog
|
||||
:model-value="masterDetailProps.confirmSaveVisible"
|
||||
:confirm-loading="masterDetailProps.isSaving"
|
||||
confirm-text="確認儲存"
|
||||
max-width="520"
|
||||
title="確認儲存變更"
|
||||
@confirm="confirmSave()"
|
||||
@update:model-value="flow.confirmSaveVisible.value = $event"
|
||||
>
|
||||
<div v-if="masterDetailProps.saveSummary.length > 0" class="d-flex flex-column ga-2">
|
||||
<div v-for="item in masterDetailProps.saveSummary" :key="item.label" class="d-flex flex-column">
|
||||
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
|
||||
<div v-if="item.before !== null" class="text-body-2">
|
||||
<span class="text-medium-emphasis">原:</span>
|
||||
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
|
||||
{{ item.before }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-body-2">
|
||||
<span class="text-medium-emphasis">新:</span>
|
||||
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
|
||||
{{ item.after }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-body-2">目前沒有可儲存的變更。</div>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="masterDetailProps.confirmDeleteVisible"
|
||||
confirm-color="error"
|
||||
confirm-text="確定刪除"
|
||||
:message="`確定要刪除 ${masterDetailProps.pendingDeleteLabel} 嗎?此操作無法復原。`"
|
||||
title="確認刪除"
|
||||
@confirm="flow.confirmDelete()"
|
||||
@update:model-value="flow.confirmDeleteVisible.value = $event"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="masterDetailProps.confirmSwitchVisible"
|
||||
confirm-text="確定切換"
|
||||
max-width="480"
|
||||
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="flow.confirmSwitch()"
|
||||
@update:model-value="flow.confirmSwitchVisible.value = $event"
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
:model-value="masterDetailProps.confirmNavigateVisible"
|
||||
confirm-text="確定切換"
|
||||
max-width="480"
|
||||
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
|
||||
title="未儲存變更"
|
||||
@confirm="flow.confirmNavigate()"
|
||||
@update:model-value="flow.confirmNavigateVisible.value = $event"
|
||||
/>
|
||||
|
||||
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
|
||||
儲存成功
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dialog-overlay :deep(.v-overlay__content) {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
}
|
||||
|
||||
.dialog-panel {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.dialog-panel > .v-card {
|
||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.detail-panel-wrapper {
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-active {
|
||||
width: 600px;
|
||||
opacity: 1;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-title) {
|
||||
padding: 16px 20px 12px;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-actions) {
|
||||
gap: 8px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dialog-panel.is-mobile :deep(.v-card-text) {
|
||||
padding-bottom: 88px;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-mobile {
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-panel-wrapper.is-mobile.is-active {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-readonly :deep(.v-field) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dialog-panel {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dialog-panel > .v-card {
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,108 @@
|
||||
<script setup lang="ts">
|
||||
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
||||
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
|
||||
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
|
||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||
|
||||
const page = useSingleRecordMaintenancePage()
|
||||
const {
|
||||
commands, currentPage, departments, flow, formPanelEvents, formPanelProps,
|
||||
formState, gradeOptions, itemsPerPage, pageCount, pageModel, pageSummary,
|
||||
resetSearch, search, searchPanelOpen, snackbarVisible,
|
||||
statuses, students, tableHeaders,
|
||||
} = useSingleRecordMaintenancePage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageMaintenance
|
||||
v-model:search-panel-open="page.searchPanelOpen.value"
|
||||
:page="page.pageModel.value"
|
||||
@create="page.commands.openAddDialog"
|
||||
<MaintShell
|
||||
:title="pageModel.title"
|
||||
:search-panel-open="searchPanelOpen"
|
||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||
@create="commands.openAddDialog"
|
||||
>
|
||||
<template #search-fields>
|
||||
<SectionSearchPanel
|
||||
v-model="page.search.value"
|
||||
:departments="page.departments"
|
||||
:grade-options="page.gradeOptions"
|
||||
:statuses="page.statuses"
|
||||
@reset="page.resetSearch"
|
||||
/>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormTextField
|
||||
id="search-student-id"
|
||||
v-model="search.studentId"
|
||||
label="學號"
|
||||
:label-char-count="2"
|
||||
name="searchStudentId"
|
||||
placeholder="例如:S2024001"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormTextField
|
||||
id="search-name"
|
||||
v-model="search.name"
|
||||
label="姓名"
|
||||
:label-char-count="2"
|
||||
name="searchName"
|
||||
placeholder="例如:王小明"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormSelect
|
||||
id="search-department"
|
||||
v-model="search.department"
|
||||
label="系所"
|
||||
:label-char-count="2"
|
||||
:items="departments"
|
||||
name="searchDepartment"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormSelect
|
||||
id="search-grade"
|
||||
v-model="search.grade"
|
||||
label="年級"
|
||||
:label-char-count="2"
|
||||
item-title="title"
|
||||
item-value="value"
|
||||
:items="gradeOptions"
|
||||
name="searchGrade"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col cols="12" md="2">
|
||||
<BaseFormSelect
|
||||
id="search-status"
|
||||
v-model="search.status"
|
||||
label="狀態"
|
||||
:label-char-count="2"
|
||||
:items="statuses"
|
||||
name="searchStatus"
|
||||
/>
|
||||
</v-col>
|
||||
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
|
||||
<v-btn variant="text" @click="resetSearch">清除</v-btn>
|
||||
<v-btn color="primary" disabled variant="tonal">查詢</v-btn>
|
||||
</v-col>
|
||||
</template>
|
||||
<template #table>
|
||||
<SectionDataTable
|
||||
v-model:current-page="page.currentPage.value"
|
||||
:grade-label="page.formState.gradeLabel"
|
||||
:headers="page.tableHeaders.value"
|
||||
:items="page.students.value"
|
||||
:items-per-page="page.itemsPerPage"
|
||||
:page-count="page.pageCount.value"
|
||||
:page-summary="page.pageSummary.value"
|
||||
:row-props="page.formState.rowProps"
|
||||
:status-color="page.formState.statusColor"
|
||||
@delete="page.flow.requestDeleteConfirmation"
|
||||
@edit="page.commands.openEditDialog"
|
||||
@view="page.commands.openViewDialog"
|
||||
v-model:current-page="currentPage"
|
||||
:grade-label="formState.gradeLabel"
|
||||
:headers="tableHeaders"
|
||||
:items="students"
|
||||
:items-per-page="itemsPerPage"
|
||||
:page-count="pageCount"
|
||||
:page-summary="pageSummary"
|
||||
:row-props="formState.rowProps"
|
||||
:status-color="formState.statusColor"
|
||||
@delete="flow.requestDeleteConfirmation"
|
||||
@edit="commands.openEditDialog"
|
||||
@view="commands.openViewDialog"
|
||||
/>
|
||||
</template>
|
||||
</PageMaintenance>
|
||||
</MaintShell>
|
||||
|
||||
<SectionFormPanel
|
||||
v-bind="page.formPanelProps.value"
|
||||
v-on="page.formPanelEvents"
|
||||
v-bind="formPanelProps"
|
||||
v-on="formPanelEvents"
|
||||
/>
|
||||
|
||||
<v-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
|
||||
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
|
||||
儲存成功
|
||||
</v-snackbar>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user