Compare commits
8 Commits
799b16578d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| aa49d78a84 | |||
| afbdea6b13 | |||
| 915f3b7f2f | |||
| f61432ad8a | |||
| 7b0cfe4448 | |||
| 7b99087cbb | |||
| ad00f5c195 | |||
| b8664b5c3e |
@@ -4,11 +4,11 @@ registry=https://registry.npmjs.org/
|
|||||||
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
|
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
|
||||||
# save-exact=true
|
# save-exact=true
|
||||||
|
|
||||||
# 安全防禦:禁止安裝發布未滿 7 天的套件 (預防供應鏈攻擊)
|
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
|
||||||
# npm v11.10+
|
# npm v11.10+
|
||||||
min-release-age=7
|
min-release-age=4
|
||||||
# pnpm
|
# pnpm
|
||||||
minimum-release-age=10080
|
minimum-release-age=5760
|
||||||
|
|
||||||
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
|
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
|
||||||
# engine-strict=true
|
# engine-strict=true
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
- `src/services/modules/<domain>.ts` — service modules
|
- `src/services/modules/<domain>.ts` — service modules
|
||||||
- Examples of correct vs. incorrect naming:
|
- Examples of correct vs. incorrect naming:
|
||||||
- ❌ `PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
|
- ❌ `PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
|
||||||
- ❌ `useStudentMaintenancePage.ts` → ✅ `useMaintenancePage.ts`
|
- ❌ `useStudentMaintenancePage.ts` → ✅ `useSingleRecordMaintenancePage.ts`
|
||||||
- ❌ `ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
|
- ❌ `ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
|
||||||
- ❌ `useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
|
- ❌ `useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
|
||||||
- ✅ `models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
|
- ✅ `models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
|
||||||
|
|||||||
+25
-93
@@ -7,17 +7,20 @@
|
|||||||
目前新增一般頁面的預設資料流:
|
目前新增一般頁面的預設資料流:
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
router -> view -> page driver -> page component -> sections/items
|
router -> view -> sections/items
|
||||||
↓
|
↓
|
||||||
store/composable -> service
|
composable -> store -> service
|
||||||
```
|
```
|
||||||
|
|
||||||
## 1. 新增 page driver
|
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
|
||||||
|
|
||||||
頁面資料、事件與暫時 UI state 優先放在 page driver,view 只負責掛載。
|
## 1. 新增 view(含 page model)
|
||||||
|
|
||||||
```ts
|
簡單頁面的 page model 直接在 view 裡用 `computed` 組裝,不需要額外建立 page driver。
|
||||||
// src/composables/page-drivers/useReportsPage.ts
|
|
||||||
|
```vue
|
||||||
|
<!-- src/views/reports/Reports.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useSnackbarStore } from '@/stores/snackbar'
|
import { useSnackbarStore } from '@/stores/snackbar'
|
||||||
|
|
||||||
@@ -27,104 +30,33 @@ export interface ReportSummary {
|
|||||||
owner: string
|
owner: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportsPageModel {
|
|
||||||
title: string
|
|
||||||
rows: ReportSummary[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialRows: ReportSummary[] = [
|
const initialRows: ReportSummary[] = [
|
||||||
{ id: 1, title: '學生統計', owner: '教務處' },
|
{ id: 1, title: '學生統計', owner: '教務處' },
|
||||||
{ id: 2, title: '課程統計', owner: '課務組' },
|
{ id: 2, title: '課程統計', owner: '課務組' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useReportsPage() {
|
const snackbar = useSnackbarStore()
|
||||||
const snackbar = useSnackbarStore()
|
const rows = ref<ReportSummary[]>(initialRows)
|
||||||
const rows = ref<ReportSummary[]>(initialRows)
|
const pageModel = computed(() => ({
|
||||||
|
title: '報表清單',
|
||||||
|
rows: rows.value,
|
||||||
|
}))
|
||||||
|
|
||||||
const pageModel = computed<ReportsPageModel>(() => ({
|
function openReport(row: ReportSummary) {
|
||||||
title: '報表清單',
|
snackbar.show({ message: `開啟:${row.title}`, color: 'info' })
|
||||||
rows: rows.value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
function openReport(row: ReportSummary) {
|
|
||||||
snackbar.show({ message: `開啟:${row.title}`, color: 'info' })
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageModel,
|
|
||||||
openReport,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
若資料來自 API,page driver 可呼叫 store 或 composable;底層 HTTP 細節仍放在 `services/modules/*`。
|
|
||||||
|
|
||||||
## 2. 新增 page component
|
|
||||||
|
|
||||||
完整頁面主畫面放在 `src/components/pages`,檔名使用 `Page` 前綴。component 以 props 接收資料,以 emit 回報使用者事件,不直接處理 route 或底層 HTTP。
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- src/components/pages/PageReports.vue -->
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReportSummary, ReportsPageModel } from '@/composables/page-drivers/useReportsPage'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: ReportsPageModel
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
open: [row: ReportSummary]
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-card flat>
|
<PageReports :page="pageModel" @open="openReport" />
|
||||||
<v-card-title class="text-h6">{{ page.title }}</v-card-title>
|
|
||||||
<v-table>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>名稱</th>
|
|
||||||
<th>負責單位</th>
|
|
||||||
<th class="text-right">操作</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-for="row in page.rows" :key="row.id">
|
|
||||||
<td>{{ row.title }}</td>
|
|
||||||
<td>{{ row.owner }}</td>
|
|
||||||
<td class="text-right">
|
|
||||||
<v-btn color="primary" size="small" variant="text" @click="emit('open', row)">
|
|
||||||
開啟
|
|
||||||
</v-btn>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`。
|
||||||
|
|
||||||
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`。
|
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`。
|
||||||
|
|
||||||
## 3. 新增 route view
|
## 2. 加入 route
|
||||||
|
|
||||||
view 維持薄層,只呼叫 page driver 並掛載 page component。
|
|
||||||
|
|
||||||
```vue
|
|
||||||
<!-- src/views/reports/Reports.vue -->
|
|
||||||
<script setup lang="ts">
|
|
||||||
import PageReports from '@/components/pages/PageReports.vue'
|
|
||||||
import { useReportsPage } from '@/composables/page-drivers/useReportsPage'
|
|
||||||
|
|
||||||
const page = useReportsPage()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<PageReports :page="page.pageModel.value" @open="page.openReport" />
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. 加入 route
|
|
||||||
|
|
||||||
route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
|
route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
|
||||||
|
|
||||||
@@ -147,7 +79,7 @@ route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMa
|
|||||||
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
|
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
|
||||||
- 若只是新增 route,通常不需要修改 `MainLayout.vue` 或 `src/shell/*`。
|
- 若只是新增 route,通常不需要修改 `MainLayout.vue` 或 `src/shell/*`。
|
||||||
|
|
||||||
## 5. 需要 API 時新增 service module
|
## 3. 需要 API 時新增 service module
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/services/modules/reports.ts
|
// src/services/modules/reports.ts
|
||||||
@@ -170,7 +102,7 @@ service 只封裝 HTTP 細節,不持有 UI 狀態。
|
|||||||
|
|
||||||
`httpClient` 的 `baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`。
|
`httpClient` 的 `baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`。
|
||||||
|
|
||||||
## 6. 需要共享狀態時新增 store
|
## 4. 需要共享狀態時新增 store
|
||||||
|
|
||||||
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
|
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
|
||||||
|
|
||||||
@@ -202,7 +134,7 @@ export const useReportsStore = defineStore('reports', () => {
|
|||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
## 7. 驗證
|
## 5. 驗證
|
||||||
|
|
||||||
至少執行:
|
至少執行:
|
||||||
|
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
## 二、我們專案的現況診斷
|
|
||||||
|
|
||||||
本文件是 `docs/architecture-strategy.md` 第二章的現況快照。分層細節以 `docs/architecture-strategy.md` 與 `src/**/GUIDE.md` 為準。
|
|
||||||
|
|
||||||
### 2.1 App Shell 已拆分
|
|
||||||
|
|
||||||
`App.vue` 目前只掛載 `src/shell/AppShell.vue`,不再承擔 layout props、tabs、搜尋 dialog、訊息 dialog 或 snackbar 的具體組裝。
|
|
||||||
|
|
||||||
目前責任分布:
|
|
||||||
|
|
||||||
| 職責 | 目前位置 |
|
|
||||||
|------|----------|
|
|
||||||
| Layout 切換 | `src/shell/AppShell.vue` |
|
|
||||||
| Tabs / keep-alive router-view | `src/shell/AppTabs.vue` |
|
|
||||||
| Breadcrumb / favorites / menu wiring | `src/composables/layout/useAppShell.ts` + `AppShell.vue` |
|
|
||||||
| Search Dialog / Message Dialog / Snackbar | `src/shell/GlobalOverlays.vue` |
|
|
||||||
| Logout / force logout | `src/composables/layout/useAppShell.ts` |
|
|
||||||
| HTTP Toast | `src/services/http-toast.ts` + `GlobalOverlays.vue` |
|
|
||||||
|
|
||||||
### 2.2 Views 已大幅變薄
|
|
||||||
|
|
||||||
維護頁與一般頁面目前多數已轉為 route-level wiring:
|
|
||||||
|
|
||||||
- `Home.vue`:呼叫 `useHomePage()`,掛載 `PageHome`。
|
|
||||||
- `Settings.vue`:呼叫 `useSettingsPage()`,掛載 `PageSettings`。
|
|
||||||
- `FncPage.vue`:呼叫 `useFunctionPage()`,掛載 `PageFunction`。
|
|
||||||
- `views/maint/*`:呼叫對應 page driver,掛載 `components/pages/*Maintenance.vue`。
|
|
||||||
|
|
||||||
`SingleRecord.vue` 已不再直接管理 store mutation、大型 dialog 模板、表格分頁與 CRUD 細節;這些流程已移到 page driver、section component、item component 與 command composable。
|
|
||||||
|
|
||||||
`Login.vue` 是 template core 例外,仍負責登入頁組合、功能開關、小型提示 dialog 與登入流程協調。登入頁的 captcha、announcement、忘記密碼與記住帳號流程已透過 composable / props / emits 拆分,後續調整應維持該模式。
|
|
||||||
|
|
||||||
### 2.3 Page Driver / Command / Page Component 已落地
|
|
||||||
|
|
||||||
目前已存在的主要分層:
|
|
||||||
|
|
||||||
```txt
|
|
||||||
view -> page driver -> page component -> section/item
|
|
||||||
↓
|
|
||||||
command/store/service
|
|
||||||
```
|
|
||||||
|
|
||||||
- `src/composables/page-drivers/*`:組裝 page model、route/query 轉換與頁面事件。
|
|
||||||
- `src/composables/commands/useCrudCommands.ts`:承接維護頁 CRUD 命令流程。
|
|
||||||
- `src/components/pages/*`:完整頁面的主畫面組裝。
|
|
||||||
- `src/components/sections/*`:搜尋區、表格區、表單 dialog/panel、表單/查詢頁外殼。
|
|
||||||
- `src/components/items/*`:欄位群組或單筆資料呈現。
|
|
||||||
|
|
||||||
### 2.4 Dialog 與區塊拆分狀態
|
|
||||||
|
|
||||||
維護頁的大型 dialog 與表單欄位已從 view 抽出:
|
|
||||||
|
|
||||||
- `SectionFormPanel.vue`:維護頁表單 overlay/dialog shell。
|
|
||||||
- `MntDialogCard.vue`、`MntRecordNavToolbar.vue`:維護頁 dialog 內部骨架。
|
|
||||||
- `ItemFormFieldGroup.vue`:表單欄位群組。
|
|
||||||
|
|
||||||
新增頁面時,若只是小型提示 dialog 且只屬於單一路由,可先留在 page driver / page component。若 dialog 包含大型表單、確認流程或可重用骨架,優先抽到 section 或 feature component。
|
|
||||||
|
|
||||||
### 2.5 仍需注意的邊界
|
|
||||||
|
|
||||||
- `src/models/page.ts` 目前主要服務 maintenance page model;部分頁面仍在各自 page driver 內定義局部 page model 型別。
|
|
||||||
- `components/maint/*` 與 maintenance page components 屬於 demo / maintenance 領域,不應直接升格為全域 base 元件。
|
|
||||||
- `src/components/base` 目前只放跨頁共用基礎元件,例如 `DraggableDialog`、`BaseFormTextField`、`BaseFormSelect`。
|
|
||||||
- `src/stores/app.ts` 仍是 Pinia scaffold,尚未承擔實際 app state。
|
|
||||||
- 一般功能需求不應修改 `App.vue`、`src/shell/*`、layout、router guard 或 HTTP core,除非需求明確牽涉這些 template core。
|
|
||||||
@@ -24,17 +24,11 @@ Read only when needed: [analyse now](./analyse-now.md)
|
|||||||
│ App Shell │
|
│ App Shell │
|
||||||
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
|
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
│ page model (reactive)
|
│ reactive / props
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Page Driver │
|
│ View │
|
||||||
│ (views/*.vue — 極薄,只負責:組裝 page model / 事件轉發) │
|
│ (views/*.vue — 自含 page model、頁面 UI 與 section 組合) │
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
|
||||||
│ props / emits
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
|
||||||
│ Page Component │
|
|
||||||
│ (PageXxx.vue — 組裝完整頁面,決定 Section 順序與 override) │
|
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────┬──────────────────────────────────┘
|
||||||
│ section data
|
│ section data
|
||||||
▼
|
▼
|
||||||
@@ -58,7 +52,7 @@ Read only when needed: [analyse now](./analyse-now.md)
|
|||||||
### 3.2 Page Model 作為主要資料單位
|
### 3.2 Page Model 作為主要資料單位
|
||||||
|
|
||||||
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
|
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
|
||||||
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 usePageDriver() 取得 page model,傳給 Page component」。
|
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 composable 取得 page model,組裝 section 元件」。
|
||||||
- Page model 可以來自:
|
- Page model 可以來自:
|
||||||
- store(已有快取)
|
- store(已有快取)
|
||||||
- service(直接 API)
|
- service(直接 API)
|
||||||
@@ -67,23 +61,15 @@ Read only when needed: [analyse now](./analyse-now.md)
|
|||||||
範例:
|
範例:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// src/composables/usePageDriver.ts
|
// views/maint/Example.vue — 簡單頁面直接在 view 組裝 page model
|
||||||
export function useMaintenancePage() {
|
const studentStore = useStudentStore()
|
||||||
const studentStore = useStudentStore()
|
const pageModel = computed<MaintenancePageModel>(() => ({
|
||||||
const { records, loading, error, load } = useCrudDriver({
|
type: 'maintenance',
|
||||||
store: studentStore,
|
title: '單筆資料維護',
|
||||||
loadAction: () => studentStore.fetchStudents(),
|
records: studentStore.students,
|
||||||
})
|
loading: false,
|
||||||
|
error: null,
|
||||||
const pageModel = computed(() => ({
|
}))
|
||||||
title: '單筆資料維護',
|
|
||||||
records: records.value,
|
|
||||||
loading: loading.value,
|
|
||||||
error: error.value,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { pageModel, load }
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.3 查詢(Query)與命令(Command)分離
|
### 3.3 查詢(Query)與命令(Command)分離
|
||||||
@@ -138,17 +124,13 @@ src/
|
|||||||
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
|
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
|
||||||
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
|
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
|
||||||
│
|
│
|
||||||
├── views/ ← 維持:Page Driver(極薄)
|
├── views/ ← 維持:自含頁面,邏輯與 UI 同檔
|
||||||
│ └── maint/
|
│ └── maint/
|
||||||
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + 掛載 PageMaintDriver
|
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + MaintShell 外殼
|
||||||
│
|
│
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── pages/ ← 新增:Page Component 層
|
|
||||||
│ │ ├── PageMaintenance.vue
|
|
||||||
│ │ └── PageReport.vue
|
|
||||||
│ │
|
|
||||||
│ ├── sections/ ← 新增:Section / Shelf 層
|
│ ├── sections/ ← 新增:Section / Shelf 層
|
||||||
│ │ ├── SectionSearchPanel.vue
|
│ │ ├
|
||||||
│ │ ├── SectionDataTable.vue
|
│ │ ├── SectionDataTable.vue
|
||||||
│ │ └── SectionFormPanel.vue
|
│ │ └── SectionFormPanel.vue
|
||||||
│ │
|
│ │
|
||||||
@@ -164,8 +146,8 @@ src/
|
|||||||
│ └── DraggableDialog.vue
|
│ └── DraggableDialog.vue
|
||||||
│
|
│
|
||||||
├── composables/
|
├── composables/
|
||||||
│ ├── page-drivers/ ← 新增:頁面資料協調
|
│ ├── page-drivers/ ← 新增:頁面資料協調(僅複雜頁面需要)
|
||||||
│ │ └── useMaintenancePage.ts
|
│ │ └── useSingleRecordMaintenancePage.ts
|
||||||
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
|
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
|
||||||
│ │ └── useCrudCommands.ts
|
│ │ └── useCrudCommands.ts
|
||||||
│ ├── forms/ ← 維持/重組:表單狀態機
|
│ ├── forms/ ← 維持/重組:表單狀態機
|
||||||
@@ -201,14 +183,10 @@ src/
|
|||||||
```vue
|
```vue
|
||||||
<!-- views/maint/SingleRecord.vue(優化後) -->
|
<!-- views/maint/SingleRecord.vue(優化後) -->
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useMaintenancePage } from '@/composables/page-drivers/useMaintenancePage'
|
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||||
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||||
|
|
||||||
const { pageModel, load } = useMaintenancePage({
|
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
|
||||||
title: '單筆資料維護',
|
|
||||||
records: [],
|
|
||||||
})
|
|
||||||
load()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -216,31 +194,25 @@ load()
|
|||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Layer 3: Page Component(`src/components/pages/`)
|
#### Layer 3: View(`src/views/`)
|
||||||
|
|
||||||
- **職責**:組裝完整頁面的 section 順序、處理 page-level slot override、分發頁面級事件。
|
- **職責**:自含頁面的完整入口 — 組裝 page model、協調 composable、撰寫頁面 template。
|
||||||
- **命名**:一律 `Page` 前綴。
|
- **禁止**:頁面 UI 不再拆到另一個 page component 層。
|
||||||
- **對齊**:App Store 的 `ProductPage.svelte`、`TodayPage.svelte`、`DefaultPage.svelte`。
|
- **對齊**:標準 Vue SPA 慣例。
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<!-- components/pages/PageMaintenance.vue -->
|
<!-- views/maint/SingleRecord.vue -->
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<PageMaintShell :title="page.title">
|
import MaintShell from '@/components/maint/MaintShell.vue'
|
||||||
<template #search>
|
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
||||||
<SectionSearchPanel :fields="page.searchFields" @search="emit('search', $event)" />
|
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<SectionDataTable :records="page.records" @edit="emit('edit', $event)" />
|
|
||||||
</template>
|
|
||||||
</PageMaintShell>
|
|
||||||
|
|
||||||
<!-- 頁面級 dialog 外掛,內容再拆到 SectionFormPanel -->
|
const { pageModel, searchPanelOpen, commands, search, students, ... } = useSingleRecordMaintenancePage()
|
||||||
<SectionFormPanel
|
</script>
|
||||||
v-model="formVisible"
|
|
||||||
:mode="formMode"
|
<template>
|
||||||
:record="activeRecord"
|
<MaintShell :title="pageModel.title" @create="commands.openAddDialog">
|
||||||
@save="emit('save', $event)"
|
<template #table>...</template>
|
||||||
/>
|
</MaintShell>
|
||||||
</template>
|
</template>
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -355,10 +327,11 @@ views/xxx.vue
|
|||||||
- `src/shell/GlobalOverlays.vue`:snackbar、搜尋 dialog、訊息 dialog。
|
- `src/shell/GlobalOverlays.vue`:snackbar、搜尋 dialog、訊息 dialog。
|
||||||
3. [x] 新增 `src/components/pages/`:建立第一個 `PageMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
|
3. [x] 新增 `src/components/pages/`:建立第一個 `PageMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
|
||||||
- 定義 `MaintenancePageModel` props 與 `create/edit/view/delete/search` emits。
|
- 定義 `MaintenancePageModel` props 與 `create/edit/view/delete/search` emits。
|
||||||
- 使用 `PageMaint.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
|
- 使用 `MaintShell.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
|
||||||
4. [x] 新增 `src/composables/page-drivers/`:建立 `useMaintenancePage.ts`。
|
4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例。
|
||||||
- 透過 options 傳入 title 與 records,協調搜尋條件、分頁與 `pageModel`。
|
- 協調搜尋條件、分頁與 `pageModel`。
|
||||||
- 提供 `load()` 與 `resetSearch()` 供 Page Driver 呼叫。
|
- 提供 `load()` 與 `resetSearch()` 供 Page Driver 呼叫。
|
||||||
|
- 後續已刪除純包裝型 driver(如 `useMaintenancePage`)。僅當頁面需要協調多個 composable 時才建立 page driver。
|
||||||
|
|
||||||
### Phase 2:遷移最厚的 view(SingleRecord.vue) ✅ 已完成
|
### Phase 2:遷移最厚的 view(SingleRecord.vue) ✅ 已完成
|
||||||
|
|
||||||
@@ -374,7 +347,7 @@ views/xxx.vue
|
|||||||
- View 中不再直接定義 `<teleport>`、`<v-overlay>` 或多個確認 dialog。
|
- View 中不再直接定義 `<teleport>`、`<v-overlay>` 或多個確認 dialog。
|
||||||
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`。
|
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`。
|
||||||
- 只呈現欄位與欄位錯誤,透過 `v-model` 與 `clear-field-error` 與上層互動。
|
- 只呈現欄位與欄位錯誤,透過 `v-model` 與 `clear-field-error` 與上層互動。
|
||||||
6. [x] 將 CRUD command 流程抽出到 `src/composables/commands/useCrudCommands.ts`。
|
6. [x] 將 CRUD command 流程抽出到 `src/composables/useCrudCommands.ts`。
|
||||||
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
|
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
|
||||||
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
|
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
|
||||||
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
|
||||||
@@ -382,6 +355,8 @@ views/xxx.vue
|
|||||||
|
|
||||||
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
|
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
|
||||||
|
|
||||||
|
> 後續簡化時,B/C/EditableGrid 的薄 page driver 已 inline 回 view,只保留有真實複雜邏輯的 driver。
|
||||||
|
|
||||||
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
|
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
|
||||||
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
|
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
|
||||||
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`。
|
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`。
|
||||||
@@ -399,6 +374,8 @@ views/xxx.vue
|
|||||||
|
|
||||||
### Phase 4:非 maintenance 頁面統一 ✅ 已完成
|
### Phase 4:非 maintenance 頁面統一 ✅ 已完成
|
||||||
|
|
||||||
|
> 後續簡化時,Settings/FncPage 的薄 page driver 已 inline 回 view,型別移至 page component 自身。
|
||||||
|
|
||||||
1. [x] `Home.vue`、`Settings.vue`、`FncPage.vue` 套用 Page Driver + 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/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`。
|
- `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 掛載。
|
2. [x] `App.vue` 最終只保留 shell 掛載。
|
||||||
- `src/App.vue` 縮減為 7 行,只掛載 `AppShell`。
|
- `src/App.vue` 縮減為 7 行,只掛載 `AppShell`。
|
||||||
- `src/shell/AppShell.vue` 承接 layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
|
- `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` |
|
| App Shell | `src/shell/` | `AppShell.vue`、`GlobalOverlays.vue` |
|
||||||
| Page Driver | `src/views/` | `SingleRecord.vue`(route view,不改名) |
|
| View(自含頁面) | `src/views/` | `SingleRecord.vue` |
|
||||||
| Page Component | `src/components/pages/` | `PageMaintenance.vue` |
|
|
||||||
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue`、`SectionSearchPanel.vue` |
|
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue`、`SectionSearchPanel.vue` |
|
||||||
| Item / Atom | `src/components/items/` | `ItemDataRow.vue`、`ItemFormField.vue` |
|
| Item / Atom | `src/components/items/` | `ItemDataRow.vue`、`ItemFormField.vue` |
|
||||||
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
|
||||||
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
|
||||||
| Page Driver Composable | `src/composables/page-drivers/` | `useMaintenancePage.ts` |
|
| Page Driver Composable | `src/composables/page-drivers/` | `useSingleRecordMaintenancePage.ts` |
|
||||||
| Command Composable | `src/composables/commands/` | `useCrudCommands.ts` |
|
| Command Composable | `src/composables/` | `useCrudCommands.ts` |
|
||||||
| Form Composable | `src/composables/forms/` | `useForm.ts` |
|
| Form Composable | `src/composables/forms/` | `useForm.ts` |
|
||||||
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
| Domain Store | `src/stores/` | `students.ts`(維持) |
|
||||||
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
| Service Module | `src/services/modules/` | `students.ts`(維持) |
|
||||||
@@ -432,7 +412,7 @@ views/xxx.vue
|
|||||||
|
|
||||||
## 七、對齊檢查清單(新增/重構時使用)
|
## 七、對齊檢查清單(新增/重構時使用)
|
||||||
|
|
||||||
- [ ] 這個 view 超過 100 行了嗎?→ 考慮抽出 Page Component。
|
- [ ] 這個 view 超過 200 行了嗎?→ 考慮抽出 Section 或 composable。
|
||||||
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
|
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
|
||||||
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
|
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
|
||||||
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
|
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ router -> App.vue -> AppShell -> layout -> view -> page component -> section ->
|
|||||||
|
|
||||||
`components/sections` 是頁面區塊容器:
|
`components/sections` 是頁面區塊容器:
|
||||||
|
|
||||||
- [SectionSearchPanel.vue](../src/components/sections/SectionSearchPanel.vue)
|
|
||||||
- [SectionDataTable.vue](../src/components/sections/SectionDataTable.vue)
|
- [SectionDataTable.vue](../src/components/sections/SectionDataTable.vue)
|
||||||
- [SectionFormPanel.vue](../src/components/sections/SectionFormPanel.vue)
|
- [SectionFormPanel.vue](../src/components/sections/SectionFormPanel.vue)
|
||||||
- [SectionFormPage.vue](../src/components/sections/SectionFormPage.vue)
|
- [SectionFormPage.vue](../src/components/sections/SectionFormPage.vue)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
1. `src/GUIDE.md`
|
1. `src/GUIDE.md`
|
||||||
2. `docs/architecture-strategy.md`
|
2. `docs/architecture-strategy.md`
|
||||||
3. 依 `maintenanceContract.pageKind` 閱讀對應的 `src/**/GUIDE.md`(查 `src/GUIDE.md` 中的「依 pageKind 選擇起點」表格)
|
3. 依 `maintenanceContract.pageKind` 閱讀對應的 demo 與 `src/**/GUIDE.md`(查 `docs/architecture-strategy.md` 的分層說明)
|
||||||
4. `docs/add-page-example.md`(需要新增頁面時)
|
4. `docs/add-page-example.md`(需要新增頁面時)
|
||||||
|
|
||||||
`frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md` 與 `src/**/GUIDE.md` 為準。
|
`frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md` 與 `src/**/GUIDE.md` 為準。
|
||||||
@@ -40,11 +40,10 @@
|
|||||||
一般功能需求優先修改:
|
一般功能需求優先修改:
|
||||||
|
|
||||||
- `src/views/*`
|
- `src/views/*`
|
||||||
- `src/components/pages/*`
|
|
||||||
- `src/components/sections/*`
|
- `src/components/sections/*`
|
||||||
- `src/components/items/*`
|
- `src/components/items/*`
|
||||||
- `src/composables/page-drivers/*`
|
- `src/composables/page-drivers/*`
|
||||||
- `src/composables/commands/*`
|
- `src/composables/useCrudCommands.ts`
|
||||||
- `src/stores/*`
|
- `src/stores/*`
|
||||||
- `src/services/modules/*`
|
- `src/services/modules/*`
|
||||||
- `src/router/routes.ts`
|
- `src/router/routes.ts`
|
||||||
@@ -72,7 +71,7 @@
|
|||||||
- 是否碰到 template core。
|
- 是否碰到 template core。
|
||||||
- 是否已有同類型範例可沿用。
|
- 是否已有同類型範例可沿用。
|
||||||
- 是否需要新增 route。
|
- 是否需要新增 route。
|
||||||
- 是否應拆成 page / section / item。
|
- 是否應拆成 section / item。
|
||||||
- 是否應新增 page driver 或 command composable。
|
- 是否應新增 page driver 或 command composable。
|
||||||
- 是否需要 store,或只需要頁面內 state。
|
- 是否需要 store,或只需要頁面內 state。
|
||||||
- 是否應定義新的 model 型別(`src/models/`)。
|
- 是否應定義新的 model 型別(`src/models/`)。
|
||||||
@@ -97,11 +96,13 @@
|
|||||||
|
|
||||||
### query(查詢頁)→ `SectionQueryPage`
|
### query(查詢頁)→ `SectionQueryPage`
|
||||||
|
|
||||||
參考:`src/views/demos/SectionQueryPageDemo.vue`、`src/components/pages/PageSectionQueryPageDemo.vue`、`src/composables/page-drivers/useSectionsDemoPage.ts`
|
參考:`src/views/demos/SectionQueryPageDemo.vue`、`src/composables/page-drivers/useSectionsDemoPage.ts`
|
||||||
|
|
||||||
架構:
|
架構:
|
||||||
```
|
```
|
||||||
View (薄層) → composable (page driver) → PageSectionQueryPageDemo → SectionQueryPage
|
View(自含 page model + UI) → SectionQueryPage
|
||||||
|
↓
|
||||||
|
composable (page driver)
|
||||||
```
|
```
|
||||||
|
|
||||||
**composable 必須回傳:**
|
**composable 必須回傳:**
|
||||||
@@ -124,11 +125,13 @@ View (薄層) → composable (page driver) → PageSectionQueryPageDemo → Sect
|
|||||||
|
|
||||||
### application(申請/表單頁)→ `SectionFormPage`
|
### application(申請/表單頁)→ `SectionFormPage`
|
||||||
|
|
||||||
參考:`src/views/demos/SectionFormPageDemo.vue`、`src/components/pages/PageSectionFormPageDemo.vue`
|
參考:`src/views/demos/SectionFormPageDemo.vue`
|
||||||
|
|
||||||
架構:
|
架構:
|
||||||
```
|
```
|
||||||
View (薄層) → composable (page driver) → PageSectionFormPageDemo → SectionFormPage
|
View(自含 page model + UI) → SectionFormPage
|
||||||
|
↓
|
||||||
|
composable (page driver)
|
||||||
```
|
```
|
||||||
|
|
||||||
**composable 必須回傳:**
|
**composable 必須回傳:**
|
||||||
@@ -145,11 +148,6 @@ View (薄層) → composable (page driver) → PageSectionFormPageDemo → Secti
|
|||||||
- `apiCatalog.fieldRules` 中的 `field` 與 `rule` 決定必填、長度、格式驗證
|
- `apiCatalog.fieldRules` 中的 `field` 與 `rule` 決定必填、長度、格式驗證
|
||||||
- 型別轉換依 `field.type`:number 欄位不可包成 string 送出
|
- 型別轉換依 `field.type`:number 欄位不可包成 string 送出
|
||||||
|
|
||||||
**page component emits:**
|
|
||||||
- `@submit` → 呼叫 `handleFormSubmit`
|
|
||||||
- `@reset` → 呼叫 `resetForm`
|
|
||||||
- `@back` → 呼叫 `handleFormBack`
|
|
||||||
|
|
||||||
### maintenance(維護/CRUD 頁)→ `maint/*`
|
### maintenance(維護/CRUD 頁)→ `maint/*`
|
||||||
|
|
||||||
參考:`src/views/maint/README.md` — 依資料結構選擇最接近的範本(EditableGrid / SingleRecord / MasterDetail A/B/C)
|
參考:`src/views/maint/README.md` — 依資料結構選擇最接近的範本(EditableGrid / SingleRecord / MasterDetail A/B/C)
|
||||||
|
|||||||
@@ -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 Guide
|
||||||
|
|
||||||
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view、page component 與 page driver 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
|
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view 與 composable 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
|
||||||
|
|
||||||
## 資料流
|
## 資料流
|
||||||
|
|
||||||
```txt
|
```txt
|
||||||
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
|
router -> AppShell -> layout -> view -> Section -> Item
|
||||||
↓
|
↓
|
||||||
page driver / command composable -> store -> service
|
composable -> store -> service
|
||||||
```
|
```
|
||||||
|
|
||||||
## 主要目錄
|
## 主要目錄
|
||||||
|
|
||||||
- `views/`:route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`。
|
- `views/`:route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`。
|
||||||
- `components/`:Vue UI 元件,依 pages / sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`。
|
- `components/`:Vue UI 元件,依 sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`。
|
||||||
- `composables/`:page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`。
|
- `composables/`:page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`。
|
||||||
- `router/`:route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`。
|
- `router/`:route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`。
|
||||||
- `shell/`:AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`。
|
- `shell/`:AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`。
|
||||||
@@ -57,15 +57,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
|
|||||||
- `views/FncPage.vue`
|
- `views/FncPage.vue`
|
||||||
- `views/Settings.vue`
|
- `views/Settings.vue`
|
||||||
- `views/maint/*`
|
- `views/maint/*`
|
||||||
- `components/PageIndex.vue`
|
- `components/maint/MaintShell.vue`
|
||||||
- `components/PageMaint.vue`
|
|
||||||
- `components/maint/*`
|
- `components/maint/*`
|
||||||
- `components/pages/*Maintenance.vue`
|
|
||||||
- `components/sections/*`
|
- `components/sections/*`
|
||||||
- `components/items/*`
|
- `components/items/*`
|
||||||
- `composables/page-drivers/*MaintenancePage.ts`
|
- `composables/page-drivers/*MaintenancePage.ts`
|
||||||
- `composables/maint/*`
|
- `composables/maint/*`
|
||||||
- `composables/commands/*`
|
- `composables/useCrudCommands.ts`
|
||||||
- `stores/students.ts`
|
- `stores/students.ts`
|
||||||
- `stores/semesters.ts`
|
- `stores/semesters.ts`
|
||||||
- demo assets 與 demo language keys
|
- demo assets 與 demo language keys
|
||||||
@@ -74,14 +72,13 @@ router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -
|
|||||||
|
|
||||||
## 新功能流程
|
## 新功能流程
|
||||||
|
|
||||||
1. 新增或修改 `views/*` route entry。
|
1. 新增或修改 `views/*` route entry,直接在 view 裡組裝 page model 與 UI。
|
||||||
2. 若有完整頁面 UI,新增 `components/pages/PageXxx.vue`。
|
2. 若有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝。
|
||||||
3. 若有頁面資料協調或 route param 轉換,新增 `composables/page-drivers/useXxxPage.ts`。
|
3. 若畫面有獨立區塊,拆到 `components/sections/*`。
|
||||||
4. 若畫面有獨立區塊,拆到 `components/sections/*`。
|
4. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`。
|
||||||
5. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`。
|
5. 跨頁共享狀態才新增或修改 `stores/*`。
|
||||||
6. 跨頁共享狀態才新增或修改 `stores/*`。
|
6. 外部 API 放在 `services/modules/*`。
|
||||||
7. 外部 API 放在 `services/modules/*`。
|
7. 在 `router/routes.ts` 新增 route。
|
||||||
8. 在 `router/routes.ts` 新增 route。
|
|
||||||
|
|
||||||
## 驗證
|
## 驗證
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -84,7 +84,7 @@ Layout composables:
|
|||||||
|
|
||||||
`src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。
|
`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。
|
`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/views/Home.vue`
|
||||||
- `src/components/PageIndex.vue`
|
|
||||||
- `src/views/maint/*`
|
- `src/views/maint/*`
|
||||||
- `src/components/maint/*`
|
- `src/components/maint/*`
|
||||||
- `src/composables/maint/*`
|
- `src/composables/maint/*`
|
||||||
- `src/components/PageMaint.vue`
|
- `src/components/maint/MaintShell.vue`
|
||||||
- `src/stores/students.ts`
|
- `src/stores/students.ts`
|
||||||
- `src/stores/semesters.ts`
|
- `src/stores/semesters.ts`
|
||||||
- `src/views/FncPage.vue`
|
- `src/views/FncPage.vue`
|
||||||
|
|||||||
+12
-46
@@ -1,55 +1,21 @@
|
|||||||
# Src Guide
|
# Components Guide
|
||||||
|
|
||||||
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view、page component 與 page driver 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
|
`components` 放 Vue UI 元件,依 sections / items / layouts / base 分層。頁面不再有獨立的 page component 層 — 頁面 UI 直接寫在 `views/*` 中。
|
||||||
|
|
||||||
Template Core 與 Demo/Example 的完整清單見 `src/README.md`。
|
## 子目錄
|
||||||
|
|
||||||
## 資料流
|
- `sections/`:獨立畫面區塊(搜尋面板、資料表格、表單面板),決定佈局,不關心單筆內容。詳見 `src/components/sections/GUIDE.md`。
|
||||||
|
- `items/`:單一資料單位的純粹呈現,不管理狀態。詳見 `src/components/items/GUIDE.md`。
|
||||||
|
- `layouts/`:App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`。
|
||||||
|
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`。
|
||||||
|
|
||||||
```txt
|
`MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/maint/`。
|
||||||
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
|
|
||||||
↓
|
|
||||||
page driver / command composable -> store -> service
|
|
||||||
```
|
|
||||||
|
|
||||||
## 主要目錄
|
## 規則
|
||||||
|
|
||||||
- `views/`:route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`。
|
- 元件不直接 import store 或 service。
|
||||||
- `components/`:Vue UI 元件,依 pages / sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`。
|
- 元件以 props 接收資料,以 emits 回報使用者意圖。
|
||||||
- `composables/`:page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`。
|
- 可複用元件不含 domain 名稱(如 `student`、`course`)。
|
||||||
- `router/`:route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`。
|
|
||||||
- `shell/`:AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`。
|
|
||||||
- `stores/`:跨頁共享狀態與快取。詳見 `src/stores/GUIDE.md`。
|
|
||||||
- `services/`:HTTP client、API module、token/session、錯誤處理。詳見 `src/services/GUIDE.md`。
|
|
||||||
- `language/`:Vue I18n 文案。詳見 `src/language/GUIDE.md`。
|
|
||||||
|
|
||||||
## 依 pageKind 選擇起點
|
|
||||||
|
|
||||||
`.spec.json` 中 `maintenanceContract.pageKind` 決定使用哪一種 demo 架構。完整欄位對照見 `docs/llm-development-guide.md` 的「`.spec.json` 對照指南」。
|
|
||||||
|
|
||||||
| pageKind | 參考 Demo | 讀取 GUIDE |
|
|
||||||
| ------------- | ------------------------------------------ | -------------------------------------------------------- |
|
|
||||||
| `query` | `src/views/demos/SectionQueryPageDemo.vue` | `src/components/sections/GUIDE.md`(SectionQueryPage) |
|
|
||||||
| `application` | `src/views/demos/SectionFormPageDemo.vue` | `src/components/sections/GUIDE.md`(SectionFormPage) |
|
|
||||||
| `maintenance` | `src/views/maint/*` | `src/views/maint/README.md` + `src/views/maint/GUIDE.md` |
|
|
||||||
| `auth` | `src/views/Login.vue` | `src/views/GUIDE.md` |
|
|
||||||
| `print` | query 或 application demo | 同 query / application |
|
|
||||||
| `chooser` | 不適用 demo | 轉為 route group 或 tab |
|
|
||||||
|
|
||||||
## 新功能流程
|
|
||||||
|
|
||||||
1. 依 `pageKind` 選擇最接近的 demo。
|
|
||||||
2. 在 `src/views/<domain>/` 新增 route view(薄層,只掛 page driver)。
|
|
||||||
3. 在 `src/composables/page-drivers/` 新增 page driver composable。
|
|
||||||
4. 在 `src/components/pages/` 新增 page component。
|
|
||||||
5. 若畫面有獨立區塊,拆到 `src/components/sections/*`。
|
|
||||||
6. 若區塊內有欄位群組,拆到 `src/components/items/*`。
|
|
||||||
7. 在 `src/services/modules/` 新增 API module。
|
|
||||||
8. 在 `src/models/` 定義 page model 與 domain model 型別。
|
|
||||||
9. 在 `src/router/routes.ts` 新增 route。
|
|
||||||
10. 在 `src/language/` 新增語系文案。
|
|
||||||
|
|
||||||
跨頁共享狀態才新增或修改 `src/stores/*`。
|
|
||||||
|
|
||||||
## 驗證
|
## 驗證
|
||||||
|
|
||||||
|
|||||||
@@ -1,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-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
|
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
|
||||||
>條件搜尋</v-btn
|
>顯示條件搜尋</v-btn
|
||||||
>
|
>
|
||||||
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
|
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
@@ -19,37 +19,31 @@
|
|||||||
<v-card-text class="pb-0 pt-2">
|
<v-card-text class="pb-0 pt-2">
|
||||||
<v-row v-if="isSearchVisible" class="mb-2" density="compact">
|
<v-row v-if="isSearchVisible" class="mb-2" density="compact">
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<div class="text-body-1 text-medium-emphasis pl-2">學號</div>
|
<BaseFormTextField
|
||||||
<v-text-field
|
|
||||||
v-model="search.studentId"
|
v-model="search.studentId"
|
||||||
clearable
|
clearable
|
||||||
density="compact"
|
label="學號"
|
||||||
hide-details
|
:label-char-count="2"
|
||||||
placeholder="例如:S2024001"
|
placeholder="例如:S2024001"
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<div class="text-body-1 text-medium-emphasis pl-2">姓名</div>
|
<BaseFormTextField
|
||||||
<v-text-field
|
|
||||||
v-model="search.name"
|
v-model="search.name"
|
||||||
clearable
|
clearable
|
||||||
density="compact"
|
label="姓名"
|
||||||
hide-details
|
:label-char-count="2"
|
||||||
placeholder="例如:王小明"
|
placeholder="例如:王小明"
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
<v-col cols="12" md="3">
|
<v-col cols="12" md="3">
|
||||||
<div class="text-body-1 text-medium-emphasis pl-2">系所</div>
|
<BaseFormSelect
|
||||||
<v-select
|
|
||||||
v-model="search.department"
|
v-model="search.department"
|
||||||
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
|
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
|
||||||
clearable
|
clearable
|
||||||
density="compact"
|
label="系所"
|
||||||
hide-details
|
:label-char-count="2"
|
||||||
:items="departments"
|
:items="departments"
|
||||||
variant="outlined"
|
|
||||||
/>
|
/>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -374,6 +368,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
|
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
|
||||||
import { computed, ref, watch } from 'vue'
|
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 ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
||||||
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
|
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
|
||||||
|
|
||||||
@@ -414,7 +410,9 @@ const {
|
|||||||
|
|
||||||
const itemsPerPage = 10
|
const itemsPerPage = 10
|
||||||
const currentPage = ref(1)
|
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 pageSummary = computed(() => {
|
||||||
const total = filteredStudents.value.length
|
const total = filteredStudents.value.length
|
||||||
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
if (total === 0) return '第 0-0 筆 / 共 0 筆'
|
||||||
@@ -443,8 +441,7 @@ const singleDeleteMessage = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const selectedDeleteMessage = computed(
|
const selectedDeleteMessage = computed(
|
||||||
() =>
|
() => `確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
|
||||||
`確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(pageCount, (value) => {
|
watch(pageCount, (value) => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
:icon="mdAndUp ? false : mdiMagnify"
|
:icon="mdAndUp ? false : mdiMagnify"
|
||||||
:prepend-icon="mdAndUp ? mdiMagnify : undefined"
|
:prepend-icon="mdAndUp ? mdiMagnify : undefined"
|
||||||
size="small"
|
size="small"
|
||||||
:text="mdAndUp ? '搜尋條件' : false"
|
:text="mdAndUp ? '顯示搜尋條件' : false"
|
||||||
variant="text"
|
variant="text"
|
||||||
@click="$emit('toggle-search')"
|
@click="$emit('toggle-search')"
|
||||||
>
|
>
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: MaintenancePageModel
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Page component 接收 page model,再把頁面標題轉交給既有 editable grid feature component。 -->
|
|
||||||
<EditableStudentGrid :title="page.title" />
|
|
||||||
</template>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { FunctionPageModel } from '@/composables/page-drivers/useFunctionPage'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: FunctionPageModel
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Page component 只呈現 page driver 解析後的功能代碼,不直接讀 route params。 -->
|
|
||||||
<v-sheet height="100%" width="100%">
|
|
||||||
{{ page.fncId }}
|
|
||||||
</v-sheet>
|
|
||||||
</template>
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PageIndex from '@/components/PageIndex.vue'
|
|
||||||
import type { HomeNewsItem, HomePageModel, HomeQuickItem } from '@/composables/page-drivers/useHomePage'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: HomePageModel
|
|
||||||
selectedNews: HomeNewsItem | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 首頁互動都往上 emit,讓 page driver 統一處理 dialog、訊息中心與 snackbar。
|
|
||||||
const emit = defineEmits<{
|
|
||||||
news: [item: HomeNewsItem]
|
|
||||||
'message-center': []
|
|
||||||
quick: [item: HomeQuickItem]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 新聞 dialog 開關是 PageIndex 的雙向 UI 狀態,由 view/page driver 持有。
|
|
||||||
const isNewsDialogOpen = defineModel<boolean>('newsDialogOpen', { default: false })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- PageHome 作為 page component,把 page model 拆給既有 PageIndex 外殼與事件。 -->
|
|
||||||
<PageIndex
|
|
||||||
v-model:is-news-dialog-open="isNewsDialogOpen"
|
|
||||||
:news-items="page.newsItems"
|
|
||||||
:quick-items="page.quickItems"
|
|
||||||
:selected-news="selectedNews"
|
|
||||||
@message-center="emit('message-center')"
|
|
||||||
@news="emit('news', $event)"
|
|
||||||
@quick="emit('quick', $event)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import PageMaint from '@/components/PageMaint.vue'
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: MaintenancePageModel
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// PageMaintenance 只轉發維護頁使用者意圖,CRUD 副作用交給 page driver / command composable。
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'create'): void
|
|
||||||
(e: 'edit', record: unknown): void
|
|
||||||
(e: 'view', record: unknown): void
|
|
||||||
(e: 'delete', record: unknown): void
|
|
||||||
(e: 'search', criteria: Record<string, unknown>): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 搜尋面板開關是頁面 UI 狀態,用 v-model 交回 view/page driver。
|
|
||||||
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { default: false })
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- PageMaint 提供維護頁外殼;搜尋欄位與表格內容由 slot 交給各頁組合。 -->
|
|
||||||
<PageMaint
|
|
||||||
:title="page.title"
|
|
||||||
:search-panel-open="searchPanelOpen"
|
|
||||||
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
|
||||||
@create="emit('create')"
|
|
||||||
>
|
|
||||||
<template #search-fields>
|
|
||||||
<slot name="search-fields" />
|
|
||||||
</template>
|
|
||||||
<template #table>
|
|
||||||
<slot name="table" />
|
|
||||||
</template>
|
|
||||||
</PageMaint>
|
|
||||||
</template>
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
|
|
||||||
import DetailNavigation from '@/components/maint/master-detail/DetailNavigation.vue'
|
|
||||||
import DetailSidePanel from '@/components/maint/master-detail/DetailSidePanel.vue'
|
|
||||||
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
|
|
||||||
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
|
|
||||||
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
|
|
||||||
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
|
|
||||||
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
|
||||||
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
|
|
||||||
import type {
|
|
||||||
SaveSummaryItem,
|
|
||||||
StudentFormState,
|
|
||||||
} from '@/composables/maint/useStudentMaintenanceForm'
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
import type { SemesterRecord } from '@/stores/semesters'
|
|
||||||
import type { StudentRecord } from '@/stores/students'
|
|
||||||
|
|
||||||
interface FieldErrorItem {
|
|
||||||
field: string
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GradeOption {
|
|
||||||
title: string
|
|
||||||
value: number
|
|
||||||
}
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
activeMobilePanel: 'master' | 'detail'
|
|
||||||
confirmCloseVisible: boolean
|
|
||||||
confirmDeleteVisible: boolean
|
|
||||||
confirmNavigateVisible: boolean
|
|
||||||
confirmSaveVisible: boolean
|
|
||||||
confirmSwitchVisible: boolean
|
|
||||||
currentPage: number
|
|
||||||
departments: string[]
|
|
||||||
detailForm: SemesterRecord | null
|
|
||||||
dialogSubtitle: string
|
|
||||||
dialogTitle: string
|
|
||||||
dialogVisible: boolean
|
|
||||||
enrollYears: number[]
|
|
||||||
errorSummary: FieldErrorItem[]
|
|
||||||
fieldErrors: Record<keyof StudentFormState, string[]>
|
|
||||||
gradeLabel: (grade: number) => string
|
|
||||||
gradeOptions: GradeOption[]
|
|
||||||
hasNextRecord: boolean
|
|
||||||
hasPrevRecord: boolean
|
|
||||||
headers: any[]
|
|
||||||
isDetailEditing: boolean
|
|
||||||
isDirty: boolean
|
|
||||||
isEditMode: boolean
|
|
||||||
isFormLocked: boolean
|
|
||||||
isFormReadonly: boolean
|
|
||||||
isLoading: boolean
|
|
||||||
isMobile: boolean
|
|
||||||
isSaving: boolean
|
|
||||||
isViewMode: boolean
|
|
||||||
items: StudentRecord[]
|
|
||||||
itemsPerPage: number
|
|
||||||
page: MaintenancePageModel
|
|
||||||
pageCount: number
|
|
||||||
pageSummary: string
|
|
||||||
pendingDeleteLabel: string
|
|
||||||
rowProps: (data: { item: StudentRecord }) => Record<string, string>
|
|
||||||
saveSummary: SaveSummaryItem[]
|
|
||||||
selectedSemester: SemesterRecord | null
|
|
||||||
selectedSemesterId: number | null
|
|
||||||
semesters: SemesterRecord[]
|
|
||||||
statusColor: (status: string) => string
|
|
||||||
statuses: string[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const form = defineModel<StudentFormState>('form', { required: true })
|
|
||||||
const detailFormModel = defineModel<SemesterRecord | null>('detailForm', { required: true })
|
|
||||||
// 主檔表單、子檔表單與搜尋條件都由 page driver 持有,Page component 只透過 v-model 回寫。
|
|
||||||
const search = defineModel<{
|
|
||||||
studentId: string
|
|
||||||
name: string
|
|
||||||
department: string
|
|
||||||
grade: number | null
|
|
||||||
status: string
|
|
||||||
}>('search', { required: true })
|
|
||||||
const searchPanelOpen = defineModel<boolean>('searchPanelOpen', { required: true })
|
|
||||||
|
|
||||||
// 主從維護頁的 CRUD 與導覽意圖都往上 emit,讓 page driver / command composable 統一處理。
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'add-semester'): void
|
|
||||||
(e: 'cancel-detail-edit'): void
|
|
||||||
(e: 'clear-field-error', field: keyof StudentFormState): void
|
|
||||||
(e: 'close'): void
|
|
||||||
(e: 'close-detail-panel'): void
|
|
||||||
(e: 'confirm-close'): void
|
|
||||||
(e: 'confirm-delete'): void
|
|
||||||
(e: 'confirm-navigate'): void
|
|
||||||
(e: 'confirm-save'): void
|
|
||||||
(e: 'confirm-switch'): void
|
|
||||||
(e: 'create'): void
|
|
||||||
(e: 'delete', record: StudentRecord): void
|
|
||||||
(e: 'delete-current'): void
|
|
||||||
(e: 'delete-semester', id: number): void
|
|
||||||
(e: 'dialog-visible-change', value: boolean): void
|
|
||||||
(e: 'edit', record: StudentRecord): void
|
|
||||||
(e: 'first'): void
|
|
||||||
(e: 'last'): void
|
|
||||||
(e: 'next'): void
|
|
||||||
(e: 'prev'): void
|
|
||||||
(e: 'reset-search'): void
|
|
||||||
(e: 'save'): void
|
|
||||||
(e: 'save-detail-edit'): void
|
|
||||||
(e: 'scroll-to-field', field: string): void
|
|
||||||
(e: 'select-semester', id: number): void
|
|
||||||
(e: 'start-detail-edit'): void
|
|
||||||
(e: 'switch-to-edit'): void
|
|
||||||
(e: 'switch-to-view'): void
|
|
||||||
(e: 'update:confirmCloseVisible', value: boolean): void
|
|
||||||
(e: 'update:confirmDeleteVisible', value: boolean): void
|
|
||||||
(e: 'update:confirmNavigateVisible', value: boolean): void
|
|
||||||
(e: 'update:confirmSaveVisible', value: boolean): void
|
|
||||||
(e: 'update:confirmSwitchVisible', value: boolean): void
|
|
||||||
(e: 'update:currentPage', page: number): void
|
|
||||||
(e: 'view', record: StudentRecord): void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- PageMaintenance 提供維護頁外殼;主從頁在 slots 中組合搜尋、表格與子檔內容。 -->
|
|
||||||
<PageMaintenance
|
|
||||||
v-model:search-panel-open="searchPanelOpen"
|
|
||||||
:page="page"
|
|
||||||
@create="emit('create')"
|
|
||||||
>
|
|
||||||
<!-- 搜尋欄位沿用 SectionSearchPanel,搜尋條件透過 v-model 回到 page driver。 -->
|
|
||||||
<template #search-fields>
|
|
||||||
<SectionSearchPanel
|
|
||||||
v-model="search"
|
|
||||||
:departments="departments"
|
|
||||||
:grade-options="gradeOptions"
|
|
||||||
:statuses="statuses"
|
|
||||||
@reset="emit('reset-search')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<!-- 主檔表格沿用 SectionDataTable,列操作只 emit 使用者意圖。 -->
|
|
||||||
<template #table>
|
|
||||||
<SectionDataTable
|
|
||||||
:current-page="currentPage"
|
|
||||||
:grade-label="gradeLabel"
|
|
||||||
:headers="headers"
|
|
||||||
:items="items"
|
|
||||||
:items-per-page="itemsPerPage"
|
|
||||||
:page-count="pageCount"
|
|
||||||
:page-summary="pageSummary"
|
|
||||||
:row-props="rowProps"
|
|
||||||
:status-color="statusColor"
|
|
||||||
@delete="emit('delete', $event)"
|
|
||||||
@edit="emit('edit', $event)"
|
|
||||||
@update:current-page="emit('update:currentPage', $event)"
|
|
||||||
@view="emit('view', $event)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</PageMaintenance>
|
|
||||||
|
|
||||||
<teleport to="body">
|
|
||||||
<v-overlay
|
|
||||||
class="dialog-overlay"
|
|
||||||
:close-on-content-click="false"
|
|
||||||
:model-value="dialogVisible"
|
|
||||||
scrim="rgba(0, 0, 0, 0.45)"
|
|
||||||
scroll-strategy="block"
|
|
||||||
@update:model-value="emit('dialog-visible-change', $event)"
|
|
||||||
>
|
|
||||||
<div class="dialog-panel" :class="{ 'is-mobile': isMobile }">
|
|
||||||
<div
|
|
||||||
v-if="!isMobile || activeMobilePanel === 'detail'"
|
|
||||||
class="detail-panel-wrapper"
|
|
||||||
:class="{ 'is-active': !!selectedSemesterId, 'is-mobile': isMobile }"
|
|
||||||
>
|
|
||||||
<DetailSidePanel
|
|
||||||
v-model:detail-form="detailFormModel"
|
|
||||||
:is-detail-editing="isDetailEditing"
|
|
||||||
:is-mobile="isMobile"
|
|
||||||
:is-view-mode="isViewMode"
|
|
||||||
:selected-semester="selectedSemester"
|
|
||||||
@cancel-edit="emit('cancel-detail-edit')"
|
|
||||||
@close="emit('close-detail-panel')"
|
|
||||||
@delete="emit('delete-semester', $event)"
|
|
||||||
@save-edit="emit('save-detail-edit')"
|
|
||||||
@start-edit="emit('start-detail-edit')"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MntDialogCard
|
|
||||||
v-if="!isMobile || activeMobilePanel === 'master'"
|
|
||||||
:dialog-subtitle="dialogSubtitle"
|
|
||||||
:dialog-title="dialogTitle"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:is-view-mode="isViewMode"
|
|
||||||
:width="isMobile ? '100%' : 760"
|
|
||||||
>
|
|
||||||
<template #toolbar>
|
|
||||||
<MntRecordNavToolbar
|
|
||||||
:has-next-record="hasNextRecord"
|
|
||||||
:has-prev-record="hasPrevRecord"
|
|
||||||
:is-edit-mode="isEditMode"
|
|
||||||
:is-view-mode="isViewMode"
|
|
||||||
:mobile="isMobile"
|
|
||||||
@first="emit('first')"
|
|
||||||
@last="emit('last')"
|
|
||||||
@next="emit('next')"
|
|
||||||
@prev="emit('prev')"
|
|
||||||
@switch-to-edit="emit('switch-to-edit')"
|
|
||||||
@switch-to-view="emit('switch-to-view')"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #content>
|
|
||||||
<v-alert
|
|
||||||
v-if="errorSummary.length > 0 && !isLoading"
|
|
||||||
class="mb-4"
|
|
||||||
type="error"
|
|
||||||
variant="tonal"
|
|
||||||
>
|
|
||||||
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
|
|
||||||
<div class="d-flex flex-column ga-1">
|
|
||||||
<v-btn
|
|
||||||
v-for="error in errorSummary"
|
|
||||||
:key="error.field"
|
|
||||||
color="error"
|
|
||||||
size="small"
|
|
||||||
variant="text"
|
|
||||||
@click="emit('scroll-to-field', error.field)"
|
|
||||||
>
|
|
||||||
{{ error.message }}
|
|
||||||
</v-btn>
|
|
||||||
</div>
|
|
||||||
</v-alert>
|
|
||||||
|
|
||||||
<v-skeleton-loader
|
|
||||||
v-if="isLoading"
|
|
||||||
class="mt-4"
|
|
||||||
type="subtitle,paragraph"
|
|
||||||
width="100%"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-form
|
|
||||||
v-else
|
|
||||||
:class="{ 'form-readonly': isFormReadonly }"
|
|
||||||
@submit.prevent="emit('save')"
|
|
||||||
>
|
|
||||||
<MasterFileFormFields
|
|
||||||
:departments="departments"
|
|
||||||
:enroll-years="enrollYears"
|
|
||||||
:field-errors="fieldErrors"
|
|
||||||
:form="form"
|
|
||||||
:grade-options="gradeOptions"
|
|
||||||
:is-form-locked="isFormLocked"
|
|
||||||
:is-form-readonly="isFormReadonly"
|
|
||||||
:statuses="statuses"
|
|
||||||
@clear-field="emit('clear-field-error', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-divider />
|
|
||||||
|
|
||||||
<DetailNavigation
|
|
||||||
:is-mobile="isMobile"
|
|
||||||
:is-view-mode="isViewMode"
|
|
||||||
:selected-semester-id="selectedSemesterId"
|
|
||||||
:semesters="semesters"
|
|
||||||
@add="emit('add-semester')"
|
|
||||||
@select="emit('select-semester', $event)"
|
|
||||||
/>
|
|
||||||
</v-form>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<template v-if="isMobile">
|
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="isEditMode"
|
|
||||||
color="error"
|
|
||||||
:disabled="isSaving"
|
|
||||||
variant="tonal"
|
|
||||||
@click="emit('delete-current')"
|
|
||||||
>
|
|
||||||
刪除
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="!isViewMode"
|
|
||||||
color="primary"
|
|
||||||
:disabled="!isDirty || isLoading"
|
|
||||||
:loading="isSaving"
|
|
||||||
variant="flat"
|
|
||||||
@click="emit('save')"
|
|
||||||
>
|
|
||||||
儲存
|
|
||||||
</v-btn>
|
|
||||||
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
|
|
||||||
</template>
|
|
||||||
<template v-else>
|
|
||||||
<v-spacer />
|
|
||||||
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="isEditMode"
|
|
||||||
color="error"
|
|
||||||
:disabled="isSaving"
|
|
||||||
variant="tonal"
|
|
||||||
@click="emit('delete-current')"
|
|
||||||
>
|
|
||||||
刪除
|
|
||||||
</v-btn>
|
|
||||||
<v-btn
|
|
||||||
v-if="!isViewMode"
|
|
||||||
color="primary"
|
|
||||||
:disabled="!isDirty || isLoading"
|
|
||||||
:loading="isSaving"
|
|
||||||
variant="flat"
|
|
||||||
@click="emit('save')"
|
|
||||||
>
|
|
||||||
儲存
|
|
||||||
</v-btn>
|
|
||||||
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</MntDialogCard>
|
|
||||||
</div>
|
|
||||||
</v-overlay>
|
|
||||||
</teleport>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
:model-value="confirmCloseVisible"
|
|
||||||
confirm-color="error"
|
|
||||||
confirm-text="關閉不儲存"
|
|
||||||
message="目前有尚未儲存的內容,確定要關閉嗎?"
|
|
||||||
title="未儲存變更"
|
|
||||||
@confirm="emit('confirm-close')"
|
|
||||||
@update:model-value="emit('update:confirmCloseVisible', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
:model-value="confirmSaveVisible"
|
|
||||||
:confirm-loading="isSaving"
|
|
||||||
confirm-text="確認儲存"
|
|
||||||
max-width="520"
|
|
||||||
title="確認儲存變更"
|
|
||||||
@confirm="emit('confirm-save')"
|
|
||||||
@update:model-value="emit('update:confirmSaveVisible', $event)"
|
|
||||||
>
|
|
||||||
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
|
|
||||||
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
|
|
||||||
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
|
|
||||||
<div v-if="item.before !== null" class="text-body-2">
|
|
||||||
<span class="text-medium-emphasis">原:</span>
|
|
||||||
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
|
|
||||||
{{ item.before }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="text-body-2">
|
|
||||||
<span class="text-medium-emphasis">新:</span>
|
|
||||||
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
|
|
||||||
{{ item.after }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-else class="text-body-2">目前沒有可儲存的變更。</div>
|
|
||||||
</ConfirmDialog>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
:model-value="confirmDeleteVisible"
|
|
||||||
confirm-color="error"
|
|
||||||
confirm-text="確定刪除"
|
|
||||||
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
|
|
||||||
title="確認刪除"
|
|
||||||
@confirm="emit('confirm-delete')"
|
|
||||||
@update:model-value="emit('update:confirmDeleteVisible', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
:model-value="confirmSwitchVisible"
|
|
||||||
confirm-text="確定切換"
|
|
||||||
max-width="480"
|
|
||||||
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
|
|
||||||
title="未儲存變更"
|
|
||||||
@confirm="emit('confirm-switch')"
|
|
||||||
@update:model-value="emit('update:confirmSwitchVisible', $event)"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ConfirmDialog
|
|
||||||
:model-value="confirmNavigateVisible"
|
|
||||||
confirm-text="確定切換"
|
|
||||||
max-width="480"
|
|
||||||
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
|
|
||||||
title="未儲存變更"
|
|
||||||
@confirm="emit('confirm-navigate')"
|
|
||||||
@update:model-value="emit('update:confirmNavigateVisible', $event)"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.dialog-overlay :deep(.v-overlay__content) {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-end;
|
|
||||||
width: 100vw;
|
|
||||||
max-width: 100vw;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel {
|
|
||||||
width: auto;
|
|
||||||
max-width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
background: transparent;
|
|
||||||
padding: 0;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel > .v-card {
|
|
||||||
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel-wrapper {
|
|
||||||
width: 0;
|
|
||||||
opacity: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel-wrapper.is-active {
|
|
||||||
width: 600px;
|
|
||||||
opacity: 1;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel.is-mobile {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel.is-mobile :deep(.dialog-title) {
|
|
||||||
padding: 16px 20px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
|
|
||||||
padding: 8px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel.is-mobile :deep(.dialog-actions) {
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
|
|
||||||
flex: 1 1 0;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel.is-mobile :deep(.v-card-text) {
|
|
||||||
padding-bottom: 88px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel-wrapper.is-mobile {
|
|
||||||
width: 100%;
|
|
||||||
opacity: 1;
|
|
||||||
overflow: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.detail-panel-wrapper.is-mobile.is-active {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-readonly :deep(.v-field) {
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
|
||||||
.dialog-panel {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dialog-panel > .v-card {
|
|
||||||
width: 100%;
|
|
||||||
box-shadow: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,89 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
|
||||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
|
||||||
import SectionFormPage from '@/components/sections/SectionFormPage.vue'
|
|
||||||
import type {
|
|
||||||
DemoFormState,
|
|
||||||
SectionsDemoPageModel,
|
|
||||||
} from '@/composables/page-drivers/useSectionsDemoPage'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: SectionsDemoPageModel
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// 表單內容由 page driver 持有,Page component 只透過 v-model 呈現與回寫。
|
|
||||||
const demoForm = defineModel<DemoFormState>('demoForm', { required: true })
|
|
||||||
|
|
||||||
// 送出、清除、返回都往上 emit,讓 page driver 統一處理訊息與副作用。
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'back'): void
|
|
||||||
(e: 'reset'): void
|
|
||||||
(e: 'submit'): void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SectionFormPage
|
|
||||||
back-label="回到列表"
|
|
||||||
reset-label="清除"
|
|
||||||
submit-label="送出"
|
|
||||||
:message="page.formMessage"
|
|
||||||
title="SectionFormPage 報表申請"
|
|
||||||
@back="emit('back')"
|
|
||||||
@reset="emit('reset')"
|
|
||||||
@submit="emit('submit')"
|
|
||||||
>
|
|
||||||
<!-- SectionFormPage 決定表單頁外殼;fields slot 放實際欄位組合。 -->
|
|
||||||
<template #fields>
|
|
||||||
<v-row density="compact">
|
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<BaseFormTextField v-model="demoForm.title" label="標題" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="3">
|
|
||||||
<BaseFormSelect v-model="demoForm.owner" label="單位" :items="page.ownerOptions" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="3">
|
|
||||||
<BaseFormSelect v-model="demoForm.category" label="類型" :items="page.categoryOptions" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12">
|
|
||||||
<BaseFormTextField v-model="demoForm.description" label="說明" />
|
|
||||||
</v-col>
|
|
||||||
</v-row>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- sections slot 放表單主欄位以外的子區段,例如明細表格或補充資訊。 -->
|
|
||||||
<template #sections>
|
|
||||||
<v-card class="mb-2">
|
|
||||||
<v-card-title class="text-title-medium font-weight-bold">明細</v-card-title>
|
|
||||||
<v-card-text>
|
|
||||||
<v-table density="compact">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>欄位</th>
|
|
||||||
<th>值</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td>單位</td>
|
|
||||||
<td>{{ demoForm.owner }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>類型</td>
|
|
||||||
<td>{{ demoForm.category }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
</v-card-text>
|
|
||||||
</v-card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- notices slot 放配合事項,讓外殼固定、內容由頁面決定。 -->
|
|
||||||
<template #notices>
|
|
||||||
<v-list class="bg-yellow-lighten-5" density="compact">
|
|
||||||
<v-list-item>送出前確認標題與單位。</v-list-item>
|
|
||||||
<v-list-item>表單狀態由 page driver 管理。</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
</template>
|
|
||||||
</SectionFormPage>
|
|
||||||
</template>
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
|
||||||
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
|
||||||
import SectionQueryPage from '@/components/sections/SectionQueryPage.vue'
|
|
||||||
import type {
|
|
||||||
ReportFilters,
|
|
||||||
SectionsDemoPageModel,
|
|
||||||
} from '@/composables/page-drivers/useSectionsDemoPage'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: SectionsDemoPageModel
|
|
||||||
}>()
|
|
||||||
|
|
||||||
// Page component 只接收 page driver 組好的 page model;查詢條件用 v-model 回寫給 view/page driver。
|
|
||||||
const queryFilters = defineModel<ReportFilters>('queryFilters', { required: true })
|
|
||||||
|
|
||||||
// 使用者意圖往上 emit,由 page driver 決定查詢、返回等副作用。
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'back'): void
|
|
||||||
(e: 'search'): void
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<SectionQueryPage
|
|
||||||
back-label="回到列表"
|
|
||||||
title="查詢頁DEMO"
|
|
||||||
@back="emit('back')"
|
|
||||||
@search="emit('search')"
|
|
||||||
>
|
|
||||||
<!-- SectionQueryPage 決定查詢頁外殼;欄位內容由 filters slot 交給頁面自行組合。 -->
|
|
||||||
<template #filters>
|
|
||||||
<v-col cols="12" md="4">
|
|
||||||
<BaseFormTextField v-model="queryFilters.keyword" label="關鍵字" />
|
|
||||||
</v-col>
|
|
||||||
<v-col cols="12" md="4">
|
|
||||||
<BaseFormSelect v-model="queryFilters.owner" label="單位" :items="page.ownerOptions" />
|
|
||||||
</v-col>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- results slot 放查詢結果;資料仍由 page model 提供,這裡只負責呈現。 -->
|
|
||||||
<template #results>
|
|
||||||
<v-alert v-if="page.queryMessage" class="mb-3" type="success" variant="tonal">
|
|
||||||
{{ page.queryMessage }}
|
|
||||||
</v-alert>
|
|
||||||
<v-table density="compact">
|
|
||||||
<thead class="bg-primary">
|
|
||||||
<tr>
|
|
||||||
<th>名稱</th>
|
|
||||||
<th>單位</th>
|
|
||||||
<th>狀態</th>
|
|
||||||
<th>更新日</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr v-if="page.reports.length === 0">
|
|
||||||
<td class="text-center" colspan="4">尚無查詢結果</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-for="row in page.reports" :key="row.id">
|
|
||||||
<td>{{ row.title }}</td>
|
|
||||||
<td>{{ row.owner }}</td>
|
|
||||||
<td>{{ row.status }}</td>
|
|
||||||
<td>{{ row.updatedAt }}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</v-table>
|
|
||||||
</template>
|
|
||||||
</SectionQueryPage>
|
|
||||||
</template>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { SettingsPageModel } from '@/composables/page-drivers/useSettingsPage'
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
page: SettingsPageModel
|
|
||||||
}>()
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<!-- Page component 只呈現 page driver 組好的設定頁 model。 -->
|
|
||||||
<div>{{ page.title }}</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
backLabel?: string
|
|
||||||
error?: string
|
error?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
message?: string
|
message?: string
|
||||||
@@ -10,7 +9,6 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {
|
||||||
backLabel: '返回',
|
|
||||||
resetLabel: '清除',
|
resetLabel: '清除',
|
||||||
submitLabel: '存檔',
|
submitLabel: '存檔',
|
||||||
})
|
})
|
||||||
@@ -50,7 +48,6 @@ const emit = defineEmits<{
|
|||||||
{{ submitLabel }}
|
{{ submitLabel }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn type="button" variant="tonal" @click="emit('reset')">{{ resetLabel }}</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-row>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-container>
|
</v-container>
|
||||||
|
|||||||
@@ -6,9 +6,7 @@ interface Props {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
withDefaults(defineProps<Props>(), {})
|
||||||
backLabel: '返回',
|
|
||||||
})
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
search: []
|
search: []
|
||||||
@@ -38,9 +36,5 @@ const emit = defineEmits<{
|
|||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<slot name="sections" />
|
<slot name="sections" />
|
||||||
|
|
||||||
<v-row class="pa-4">
|
|
||||||
<v-btn variant="tonal" @click="emit('back')">{{ backLabel }}</v-btn>
|
|
||||||
</v-row>
|
|
||||||
</v-container>
|
</v-container>
|
||||||
</template>
|
</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 組裝。
|
- `page-drivers/`:頁面資料協調與 page model 組裝(僅複雜頁面使用)。
|
||||||
- `commands/`:命令式副作用流程,例如 create/edit/save/delete。
|
|
||||||
- `layout/`:AppShell / layout 狀態與事件協調。
|
|
||||||
- `maint/`:maintenance demo 的表單、CRUD、editable grid 狀態。
|
- `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 負責:
|
Page driver 只應在「需要協調多個 composable / store / route」時才成立。若頁面邏輯只有:
|
||||||
|
|
||||||
|
- 組裝一個 `computed` page model(3-5 個欄位)
|
||||||
|
- 沒有搜尋、沒有 dialog、沒有複雜事件
|
||||||
|
|
||||||
|
則**不要建立 page driver**,直接在 view 裡寫 `computed` 即可。
|
||||||
|
|
||||||
|
當需要 page driver 時,它負責:
|
||||||
- route param/query 轉成頁面資料
|
- route param/query 轉成頁面資料
|
||||||
- 組裝 page model
|
|
||||||
- 組裝 page component 需要的 props/events
|
|
||||||
- 協調 store、command composable、表單 composable
|
- 協調 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 流程
|
- 與 store/service 的 mutation 流程
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { computed } from 'vue'
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
import { useStudentStore } from '@/stores/students'
|
|
||||||
|
|
||||||
export function useEditableGridMaintenancePage() {
|
|
||||||
const studentStore = useStudentStore()
|
|
||||||
|
|
||||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
|
||||||
type: 'maintenance',
|
|
||||||
title: '可編輯表格維護示範',
|
|
||||||
records: studentStore.students,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageModel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { computed } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
export interface FunctionPageModel {
|
|
||||||
fncId: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFunctionPage() {
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const pageModel = computed<FunctionPageModel>(() => ({
|
|
||||||
fncId: String(route.params.fncId ?? ''),
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageModel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
import { computed, ref } from 'vue'
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
|
|
||||||
export interface UseMaintenancePageOptions {
|
|
||||||
title: string
|
|
||||||
records: unknown[]
|
|
||||||
itemsPerPage?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMaintenancePage(options: UseMaintenancePageOptions) {
|
|
||||||
const search = ref<Record<string, unknown>>({})
|
|
||||||
const searchPanelOpen = ref(false)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const itemsPerPage = options.itemsPerPage ?? 10
|
|
||||||
|
|
||||||
const pageCount = computed(() =>
|
|
||||||
Math.max(1, Math.ceil(options.records.length / itemsPerPage))
|
|
||||||
)
|
|
||||||
|
|
||||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
|
||||||
type: 'maintenance',
|
|
||||||
title: options.title,
|
|
||||||
records: options.records,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
// 由呼叫方在 load 中觸發資料載入;未來可擴充為非同步
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetSearch() {
|
|
||||||
search.value = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageModel,
|
|
||||||
search,
|
|
||||||
searchPanelOpen,
|
|
||||||
currentPage,
|
|
||||||
itemsPerPage,
|
|
||||||
pageCount,
|
|
||||||
load,
|
|
||||||
resetSearch,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -383,8 +383,11 @@ export function useMasterDetailAMaintenancePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
confirmSave,
|
||||||
currentPage,
|
currentPage,
|
||||||
departments,
|
departments,
|
||||||
|
detailForm,
|
||||||
|
flow,
|
||||||
formState,
|
formState,
|
||||||
gradeOptions,
|
gradeOptions,
|
||||||
itemsPerPage,
|
itemsPerPage,
|
||||||
@@ -396,7 +399,9 @@ export function useMasterDetailAMaintenancePage() {
|
|||||||
pageCount,
|
pageCount,
|
||||||
pageModel,
|
pageModel,
|
||||||
pageSummary,
|
pageSummary,
|
||||||
|
requestSaveConfirmation,
|
||||||
resetSearch,
|
resetSearch,
|
||||||
|
scrollToField,
|
||||||
search,
|
search,
|
||||||
searchPanelOpen,
|
searchPanelOpen,
|
||||||
snackbarVisible,
|
snackbarVisible,
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
import { useStudentStore } from '@/stores/students'
|
|
||||||
|
|
||||||
export function useMasterDetailBMaintenancePage() {
|
|
||||||
const studentStore = useStudentStore()
|
|
||||||
|
|
||||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
|
||||||
type: 'maintenance',
|
|
||||||
title: '主從資料維護示範B',
|
|
||||||
records: studentStore.students,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageModel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import type { MaintenancePageModel } from '@/models/page'
|
|
||||||
import { useStudentStore } from '@/stores/students'
|
|
||||||
|
|
||||||
export function useMasterDetailCMaintenancePage() {
|
|
||||||
const studentStore = useStudentStore()
|
|
||||||
|
|
||||||
const pageModel = computed<MaintenancePageModel>(() => ({
|
|
||||||
type: 'maintenance',
|
|
||||||
title: '主從資料維護示範C',
|
|
||||||
records: studentStore.students,
|
|
||||||
loading: false,
|
|
||||||
error: null,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
pageModel,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
export interface SettingsPageModel {
|
|
||||||
title: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useSettingsPage() {
|
|
||||||
const pageModel = computed<SettingsPageModel>(() => ({
|
|
||||||
title: '設定頁面',
|
|
||||||
}))
|
|
||||||
|
|
||||||
return { pageModel }
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { useDisplay } from 'vuetify'
|
import { useDisplay } from 'vuetify'
|
||||||
import { useCrudCommands } from '@/composables/commands/useCrudCommands'
|
import { useCrudCommands } from '@/composables/useCrudCommands'
|
||||||
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
|
||||||
import {
|
import {
|
||||||
type StudentFormState,
|
type StudentFormState,
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
|
|||||||
schoolHeader: '公告學校',
|
schoolHeader: '公告學校',
|
||||||
titleHeader: '公告標題',
|
titleHeader: '公告標題',
|
||||||
paginationLabel: '總筆數:',
|
paginationLabel: '總筆數:',
|
||||||
|
allTabLabel: '全部',
|
||||||
|
emptyText: '目前沒有公告資料',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const selectedAnnouncement = computed(() => {
|
const selectedAnnouncement = computed(() => {
|
||||||
|
|||||||
+1
-1
@@ -23,4 +23,4 @@
|
|||||||
- 各頁面的 specific model 擴展 `BasePageModel`(例如 `MaintenancePageModel` 加 `type`、`records`)。
|
- 各頁面的 specific model 擴展 `BasePageModel`(例如 `MaintenancePageModel` 加 `type`、`records`)。
|
||||||
- `PageModel` union 供 page component props 型別使用。
|
- `PageModel` union 供 page component props 型別使用。
|
||||||
|
|
||||||
新增頁面類型時,先擴充 `PageModel` union 再新增對應的 page driver。
|
新增頁面類型時,先擴充 `PageModel` union。若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),再建立對應的 page driver;簡單頁面直接在 view 用 `computed` 組裝 page model 即可。
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
path: '/',
|
path: '/',
|
||||||
name: 'home',
|
name: 'home',
|
||||||
component: () => import('@/views/Home.vue'),
|
component: () => import('@/views/Home.vue'),
|
||||||
meta: { layout: 'default', requiresAuth: true },
|
meta: { layout: 'default', requiresAuth: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
@@ -17,7 +17,7 @@ export const routes: RouteRecordRaw[] = [
|
|||||||
path: '/login',
|
path: '/login',
|
||||||
name: 'login',
|
name: 'login',
|
||||||
component: () => import('@/views/Login.vue'),
|
component: () => import('@/views/Login.vue'),
|
||||||
meta: { layout: 'none', guestOnly: true },
|
meta: { layout: 'none', guestOnly: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/single-record-maintenance',
|
path: '/single-record-maintenance',
|
||||||
|
|||||||
@@ -32,3 +32,4 @@ service module 不需要自行 catch 並處理錯誤,交由 interceptors/hooks
|
|||||||
- JSON payload 用 `json`,FormData 用 `body`。
|
- JSON payload 用 `json`,FormData 用 `body`。
|
||||||
- 取消請求使用原生 `AbortController` 與 `signal`。
|
- 取消請求使用原生 `AbortController` 與 `signal`。
|
||||||
- token 注入與 401 force logout 集中在 hooks,不在單一 API module 重寫。
|
- 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 建立 `AbortController`,service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
|
||||||
|
|
||||||
|
建議做法:
|
||||||
|
|
||||||
|
- 在 store/composable 以 key 管理同類請求(例如 `auth/login`、`menu/get-menu`)。
|
||||||
|
- 發新請求前先取消同 key 舊請求,避免競態與多餘流量。
|
||||||
|
- 請求結束後於 `finally` 清理該 key;離開流程(如 `clear`、`logout`)時清理全部 key。
|
||||||
|
|||||||
@@ -21,3 +21,10 @@
|
|||||||
## 資料流
|
## 資料流
|
||||||
|
|
||||||
store 可以呼叫 service module。component 不應繞過 store/composable 直接處理 token、session 或 HTTP hooks。
|
store 可以呼叫 service module。component 不應繞過 store/composable 直接處理 token、session 或 HTTP hooks。
|
||||||
|
|
||||||
|
## 請求取消慣例
|
||||||
|
|
||||||
|
- 需要避免重複提交或快速切換造成的舊請求殘留時,在 store 層管理 `AbortController`。
|
||||||
|
- 同一類請求使用固定 key(例如 `auth/login`、`menu/get-menu`),新請求前先取消舊請求。
|
||||||
|
- service module 只接收 `signal`,不管理 controller lifecycle。
|
||||||
|
- store 在 `finally` 清理該 key,在 `clear/logout` 清理全部 key。
|
||||||
|
|||||||
+6
-6
@@ -5,6 +5,7 @@ import { normalizeError } from '@/services/error'
|
|||||||
import { authApi } from '@/services/modules/auth'
|
import { authApi } from '@/services/modules/auth'
|
||||||
import { tokenService } from '@/services/token'
|
import { tokenService } from '@/services/token'
|
||||||
import { useMenuStore } from '@/stores/menu'
|
import { useMenuStore } from '@/stores/menu'
|
||||||
|
import { createRequestControllerManager } from '@/stores/request-controller'
|
||||||
|
|
||||||
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
|
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
|
||||||
|
|
||||||
@@ -56,22 +57,20 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
const token = tokenService.token
|
const token = tokenService.token
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
// 只針對 login 取消重複請求,避免競態與重複提交
|
const requestControllerManager = createRequestControllerManager()
|
||||||
const loginController = ref<AbortController | null>(null)
|
|
||||||
|
|
||||||
const isAuthenticated = computed(() => !!token.value)
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
|
||||||
|
|
||||||
const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
|
const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
|
||||||
loginController.value?.abort()
|
const signal = requestControllerManager.replace('auth/login')
|
||||||
loginController.value = new AbortController()
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestBody = createLoginRequestBody(payload)
|
const requestBody = createLoginRequestBody(payload)
|
||||||
const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
|
const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
|
||||||
const requestOptions = { signal: loginController.value.signal }
|
const requestOptions = { signal }
|
||||||
const { data } =
|
const { data } =
|
||||||
requestFormat === 'json'
|
requestFormat === 'json'
|
||||||
? await authApi.loginWithJson(requestBody, requestOptions)
|
? await authApi.loginWithJson(requestBody, requestOptions)
|
||||||
@@ -125,11 +124,12 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
throw normalizedError
|
throw normalizedError
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
loginController.value = null
|
requestControllerManager.clear('auth/login')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
requestControllerManager.clearAll()
|
||||||
user.value = null
|
user.value = null
|
||||||
tokenService.clearToken()
|
tokenService.clearToken()
|
||||||
useMenuStore().clear()
|
useMenuStore().clear()
|
||||||
|
|||||||
+9
-2
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { normalizeError } from '@/services/error'
|
import { normalizeError } from '@/services/error'
|
||||||
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
import { menuApi, type MenuNode } from '@/services/modules/menu'
|
||||||
|
import { createRequestControllerManager } from '@/stores/request-controller'
|
||||||
|
|
||||||
export interface LayoutMenuItem {
|
export interface LayoutMenuItem {
|
||||||
title: string
|
title: string
|
||||||
@@ -17,6 +18,7 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
const isRail = ref(false)
|
const isRail = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const requestControllerManager = createRequestControllerManager()
|
||||||
|
|
||||||
const menuStorageKey = 'sk_playground_menu'
|
const menuStorageKey = 'sk_playground_menu'
|
||||||
const favoriteStorageKey = 'sk_playground_favorite'
|
const favoriteStorageKey = 'sk_playground_favorite'
|
||||||
@@ -180,6 +182,7 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
|
requestControllerManager.clearAll()
|
||||||
menu.value = []
|
menu.value = []
|
||||||
favorite.value = []
|
favorite.value = []
|
||||||
isRail.value = false
|
isRail.value = false
|
||||||
@@ -190,9 +193,10 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getMenu = async (id: string) => {
|
const getMenu = async (id: string) => {
|
||||||
|
const signal = requestControllerManager.replace('menu/get-menu')
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
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[]) : []
|
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
const normalizedError = normalizeError(error_)
|
const normalizedError = normalizeError(error_)
|
||||||
@@ -202,13 +206,15 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
throw normalizedError
|
throw normalizedError
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
|
requestControllerManager.clear('menu/get-menu')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const getFavorite = async (id: string) => {
|
const getFavorite = async (id: string) => {
|
||||||
|
const signal = requestControllerManager.replace('menu/get-favorite')
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
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[]) : []
|
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
|
||||||
} catch (error_) {
|
} catch (error_) {
|
||||||
const normalizedError = normalizeError(error_)
|
const normalizedError = normalizeError(error_)
|
||||||
@@ -218,6 +224,7 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
throw normalizedError
|
throw normalizedError
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageFunction from '@/components/pages/PageFunction.vue'
|
import { computed } from 'vue'
|
||||||
import { useFunctionPage } from '@/composables/page-drivers/useFunctionPage'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
const { pageModel } = useFunctionPage()
|
const route = useRoute()
|
||||||
|
const pageModel = computed(() => ({
|
||||||
|
fncId: String(route.params.fncId ?? ''),
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageFunction :page="pageModel" />
|
<v-sheet height="100%" width="100%">
|
||||||
|
{{ pageModel.fncId }}
|
||||||
|
</v-sheet>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+27
-10
@@ -1,41 +1,58 @@
|
|||||||
# Views Guide
|
# Views Guide
|
||||||
|
|
||||||
`views` 是 route entry。View 應維持薄層,負責掛載 page driver 與 page component,不承載大段 UI、dialog、表單欄位或 store mutation 細節。
|
`views` 是 route entry。View 自含 page model 組裝與頁面 UI,若邏輯複雜才抽到 page driver composable。
|
||||||
|
|
||||||
## 規則
|
## 規則
|
||||||
|
|
||||||
- 使用 `<script setup lang="ts">`。
|
- 使用 `<script setup lang="ts">`。
|
||||||
- 直接 route component 放在 `src/views` 或 `src/views/<feature>`。
|
- 直接 route component 放在 `src/views` 或 `src/views/<feature>`。
|
||||||
- 一般 view 目標 < 80 行。
|
|
||||||
- route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。
|
- route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。
|
||||||
- 不直接 import 或包住 `MainLayout.vue`。
|
- 不直接 import 或包住 `MainLayout.vue`。
|
||||||
- 不直接定義大型 `<v-dialog>`、`<v-overlay>`、大型表格或大型表單。
|
- 複雜 UI 拆到 `components/sections/*` 或 `components/items/*`。
|
||||||
|
|
||||||
## 建議形狀
|
## 建議形狀
|
||||||
|
|
||||||
|
簡單頁面:直接在 view 組裝 page model 與 template。
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageReports from '@/components/pages/PageReports.vue'
|
import { computed } from 'vue'
|
||||||
import { useReportsPage } from '@/composables/page-drivers/useReportsPage'
|
const pageModel = computed(() => ({ title: '我的頁面' }))
|
||||||
|
|
||||||
const page = useReportsPage()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageReports :page="page.pageModel.value" />
|
<v-card>{{ pageModel.title }}</v-card>
|
||||||
</template>
|
</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 開關
|
||||||
|
|
||||||
`Login.vue` 是登入頁的組合層,登入頁功能開關集中在 view 內宣告,再透過 `PageLogin` / composable 往下傳遞,不在子元件各自決定是否啟用。
|
`Login.vue` 是登入頁的完整入口,登入頁功能開關集中在 view 內宣告,透過 composable 往下傳遞,不在子元件各自決定是否啟用。
|
||||||
|
|
||||||
- `withCaptcha`:控制驗證碼 UI、captcha API 載入/刷新,以及登入 payload 是否帶 captcha 資料。關閉時不應發出 captcha API,也不應檢查或送出 captcha 欄位。
|
- `withCaptcha`:控制驗證碼 UI、captcha API 載入/刷新,以及登入 payload 是否帶 captcha 資料。關閉時不應發出 captcha API,也不應檢查或送出 captcha 欄位。
|
||||||
- `withAnnouncement`:控制公告 UI、公告 mock data/composable 資料流與公告詳情互動。關閉時公告板、手機公告列與公告對話框資料來源都應停用。
|
- `withAnnouncement`:控制公告 UI、公告 mock data/composable 資料流與公告詳情互動。關閉時公告板、手機公告列與公告對話框資料來源都應停用。
|
||||||
- `withForgotPassword`:控制忘記密碼連結與事件。關閉時 UI 不顯示,也不應觸發忘記密碼事件。
|
- `withForgotPassword`:控制忘記密碼連結與事件。關閉時 UI 不顯示,也不應觸發忘記密碼事件。
|
||||||
- `withRememberAccount`:控制記住帳號 UI 與 localStorage 讀寫。關閉時不顯示 checkbox、不讀寫記住帳號 storage,送出資料固定視為未記住帳號。
|
- `withRememberAccount`:控制記住帳號 UI 與 localStorage 讀寫。關閉時不顯示 checkbox、不讀寫記住帳號 storage,送出資料固定視為未記住帳號。
|
||||||
|
|
||||||
新增登入頁選配功能時,優先維持同樣模式:view 宣告開關、composable 負責資料流與 side effect、page/form component 只依 props 呈現 UI 與發出事件。
|
新增登入頁選配功能時,優先維持同樣模式:view 宣告開關、composable 負責資料流與 side effect、form component 只依 props 呈現 UI 與發出事件。
|
||||||
|
|
||||||
## 子目錄
|
## 子目錄
|
||||||
|
|
||||||
|
|||||||
+124
-10
@@ -1,17 +1,131 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageHome from '@/components/pages/PageHome.vue'
|
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
|
||||||
import { useHomePage } from '@/composables/page-drivers/useHomePage'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageHome
|
<v-sheet>
|
||||||
v-model:news-dialog-open="page.isNewsDialogOpen.value"
|
<v-container fluid class="pa-0 px-2">
|
||||||
:page="page.pageModel.value"
|
<v-card variant="flat">
|
||||||
:selected-news="page.selectedNews.value"
|
<v-card-title> 歡迎使用校務資訊系統 </v-card-title>
|
||||||
@message-center="page.handleMessageCenter"
|
<v-card-text class="text-grey">
|
||||||
@news="page.handleNews"
|
使用頂部搜尋框快速找到功能,或從左側選單瀏覽所有系統模組
|
||||||
@quick="page.handleQuick"
|
</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>
|
</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">
|
<script setup lang="ts">
|
||||||
|
import { mdiBullhornVariantOutline } from '@mdi/js'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import HyakkaouAcademyImage from '@/assets/logo.png'
|
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 {
|
import {
|
||||||
type LoginAnnouncementListItem,
|
type LoginAnnouncementListItem,
|
||||||
useLoginAnnouncements,
|
useLoginAnnouncements,
|
||||||
@@ -69,7 +21,6 @@ import { useSnackbarStore } from '@/stores/snackbar'
|
|||||||
|
|
||||||
type LayoutType = 'side-left' | 'side-right' | 'card'
|
type LayoutType = 'side-left' | 'side-right' | 'card'
|
||||||
|
|
||||||
// i18n
|
|
||||||
const { t, locale } = useI18n()
|
const { t, locale } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -77,21 +28,14 @@ const authStore = useAuthStore()
|
|||||||
const menuStore = useMenuStore()
|
const menuStore = useMenuStore()
|
||||||
const snackbarStore = useSnackbarStore()
|
const snackbarStore = useSnackbarStore()
|
||||||
|
|
||||||
// 語系選項
|
|
||||||
const locales = ['zh-TW', 'en-US']
|
const locales = ['zh-TW', 'en-US']
|
||||||
|
|
||||||
// 插圖圖片來源
|
|
||||||
const illustrationImage = ref(HyakkaouAcademyImage)
|
const illustrationImage = ref(HyakkaouAcademyImage)
|
||||||
|
|
||||||
// 功能開關與版型
|
|
||||||
const formPositionLayout = ref<LayoutType>('side-left')
|
const formPositionLayout = ref<LayoutType>('side-left')
|
||||||
// 是否啟用公告
|
|
||||||
const withAnnouncement = ref(true)
|
const withAnnouncement = ref(true)
|
||||||
const withForgotPassword = ref(true)
|
const withForgotPassword = ref(true)
|
||||||
const withRememberAccount = ref(true)
|
const withRememberAccount = ref(true)
|
||||||
|
|
||||||
// 功能開關:是否啟用驗證碼
|
|
||||||
const withCaptcha = ref(true)
|
const withCaptcha = ref(true)
|
||||||
|
|
||||||
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
|
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
|
||||||
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
|
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
|
||||||
const {
|
const {
|
||||||
@@ -101,7 +45,6 @@ const {
|
|||||||
selectedAnnouncementDetail,
|
selectedAnnouncementDetail,
|
||||||
} = loginAnnouncements
|
} = loginAnnouncements
|
||||||
|
|
||||||
// 文字內容(i18n)
|
|
||||||
const title = computed(() => t('pages.login.title'))
|
const title = computed(() => t('pages.login.title'))
|
||||||
const organization = computed(() => t('pages.login.organization'))
|
const organization = computed(() => t('pages.login.organization'))
|
||||||
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
|
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 captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
|
||||||
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
|
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
|
||||||
|
|
||||||
// 連結與儲存設定
|
|
||||||
// 忘記密碼連結(由 form.forgotPassword 設定)
|
|
||||||
const forgotPasswordHref = ref('/forgot-password')
|
const forgotPasswordHref = ref('/forgot-password')
|
||||||
const forgotPasswordTarget = ref<string | undefined>(undefined)
|
const forgotPasswordTarget = ref<string | undefined>(undefined)
|
||||||
// 記住帳號的 localStorage key
|
|
||||||
const rememberStorageKey = ref('login.remember.username')
|
const rememberStorageKey = ref('login.remember.username')
|
||||||
// 驗證與對話框狀態
|
|
||||||
const dialogVisible = ref(false)
|
const dialogVisible = ref(false)
|
||||||
const dialogTitle = ref('')
|
const dialogTitle = ref('')
|
||||||
const dialogMessage = ref('')
|
const dialogMessage = ref('')
|
||||||
const announcementDialogVisible = ref(false)
|
const announcementDialogVisible = ref(false)
|
||||||
|
|
||||||
// 內容組合(傳入 PageLogin)
|
|
||||||
const branding = computed(() => ({
|
const branding = computed(() => ({
|
||||||
title: title.value,
|
title: title.value,
|
||||||
organization: organization.value,
|
organization: organization.value,
|
||||||
@@ -146,7 +84,6 @@ const header = computed(() => ({
|
|||||||
welcomeDescription: welcomeDescription.value,
|
welcomeDescription: welcomeDescription.value,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 表單區塊設定(含功能開關)
|
|
||||||
const form = computed(() => ({
|
const form = computed(() => ({
|
||||||
accPlaceholder: accPlaceholder.value,
|
accPlaceholder: accPlaceholder.value,
|
||||||
passwPlaceholder: passwPlaceholder.value,
|
passwPlaceholder: passwPlaceholder.value,
|
||||||
@@ -158,7 +95,6 @@ const form = computed(() => ({
|
|||||||
rememberStorageKey: rememberStorageKey.value,
|
rememberStorageKey: rememberStorageKey.value,
|
||||||
withForgotPassword: withForgotPassword.value,
|
withForgotPassword: withForgotPassword.value,
|
||||||
withRememberAccount: withRememberAccount.value,
|
withRememberAccount: withRememberAccount.value,
|
||||||
// 功能開關:是否顯示驗證碼
|
|
||||||
withCaptcha: withCaptcha.value,
|
withCaptcha: withCaptcha.value,
|
||||||
captcha: loginCaptcha.formCaptcha.value,
|
captcha: loginCaptcha.formCaptcha.value,
|
||||||
captchaValue: loginCaptcha.captchaValue.value,
|
captchaValue: loginCaptcha.captchaValue.value,
|
||||||
@@ -172,18 +108,26 @@ const form = computed(() => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// 右上工具列設定(含顯示開關)
|
|
||||||
const toolbar = computed(() => ({
|
const toolbar = computed(() => ({
|
||||||
// 功能開關:是否顯示語系切換工具列
|
|
||||||
show: true,
|
show: true,
|
||||||
locale: locale.value,
|
locale: locale.value,
|
||||||
locales,
|
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) {
|
function handleForgotPassword(e: MouseEvent) {
|
||||||
if (!withForgotPassword.value) return
|
if (!withForgotPassword.value) return
|
||||||
|
|
||||||
console.log('Forgot Password Click:', e)
|
console.log('Forgot Password Click:', e)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +183,6 @@ async function onLogin(data: Record<string, unknown>) {
|
|||||||
|
|
||||||
menuStore.getMenu(authStore.user?.id ?? '')
|
menuStore.getMenu(authStore.user?.id ?? '')
|
||||||
|
|
||||||
// menuStore.getFavorite(authStore.user?.id ?? '')
|
|
||||||
|
|
||||||
snackbarStore.show({
|
snackbarStore.show({
|
||||||
message: t('pages.login.alert.loginSuccess'),
|
message: t('pages.login.alert.loginSuccess'),
|
||||||
color: 'success',
|
color: 'success',
|
||||||
@@ -264,3 +206,325 @@ onMounted(() => {
|
|||||||
void loginCaptcha.loadCaptcha().catch(() => undefined)
|
void loginCaptcha.loadCaptcha().catch(() => undefined)
|
||||||
})
|
})
|
||||||
</script>
|
</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">
|
<script setup lang="ts">
|
||||||
import PageSettings from '@/components/pages/PageSettings.vue'
|
import { computed } from 'vue'
|
||||||
import { useSettingsPage } from '@/composables/page-drivers/useSettingsPage'
|
|
||||||
|
|
||||||
const { pageModel } = useSettingsPage()
|
const pageModel = computed(() => ({
|
||||||
|
title: '設定頁面',
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageSettings :page="pageModel" />
|
<div>{{ pageModel.title }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,17 +1,75 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageSectionFormPageDemo from '@/components/pages/PageSectionFormPageDemo.vue'
|
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||||
|
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||||
|
import SectionFormPage from '@/components/sections/SectionFormPage.vue'
|
||||||
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
|
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
|
||||||
|
|
||||||
// Demo view 維持薄層,只掛 page driver,並把 page model / actions 傳給 page component。
|
const { demoForm, handleFormBack, handleFormSubmit, pageModel, resetDemoForm } =
|
||||||
const page = useSectionsDemoPage()
|
useSectionsDemoPage()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageSectionFormPageDemo
|
<SectionFormPage
|
||||||
v-model:demo-form="page.demoForm.value"
|
reset-label="清除"
|
||||||
:page="page.pageModel.value"
|
submit-label="送出"
|
||||||
@back="page.handleFormBack"
|
:message="pageModel.formMessage"
|
||||||
@reset="page.resetDemoForm"
|
title="SectionFormPage 報表申請"
|
||||||
@submit="page.handleFormSubmit"
|
@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>
|
</template>
|
||||||
|
|||||||
@@ -1,16 +1,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageSectionQueryPageDemo from '@/components/pages/PageSectionQueryPageDemo.vue'
|
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
|
||||||
|
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
|
||||||
|
import SectionQueryPage from '@/components/sections/SectionQueryPage.vue'
|
||||||
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
|
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
|
||||||
|
|
||||||
// Demo view 維持薄層,只掛 page driver,並把 page model / actions 傳給 page component。
|
const { handleQueryBack, handleQuerySearch, pageModel, queryFilters } = useSectionsDemoPage()
|
||||||
const page = useSectionsDemoPage()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageSectionQueryPageDemo
|
<SectionQueryPage title="查詢頁DEMO" @back="handleQueryBack" @search="handleQuerySearch">
|
||||||
v-model:query-filters="page.queryFilters.value"
|
<template #filters>
|
||||||
:page="page.pageModel.value"
|
<v-col cols="12" md="4">
|
||||||
@back="page.handleQueryBack"
|
<BaseFormTextField v-model="queryFilters.keyword" label="關鍵字" />
|
||||||
@search="page.handleQuerySearch"
|
</v-col>
|
||||||
/>
|
<v-col cols="12" md="4">
|
||||||
|
<BaseFormSelect v-model="queryFilters.owner" label="單位" :items="pageModel.ownerOptions" />
|
||||||
|
</v-col>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #results>
|
||||||
|
<v-alert v-if="pageModel.queryMessage" class="mb-3" type="success" variant="tonal">
|
||||||
|
{{ pageModel.queryMessage }}
|
||||||
|
</v-alert>
|
||||||
|
<v-table density="compact">
|
||||||
|
<thead class="bg-primary">
|
||||||
|
<tr>
|
||||||
|
<th>名稱</th>
|
||||||
|
<th>單位</th>
|
||||||
|
<th>狀態</th>
|
||||||
|
<th>更新日</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="pageModel.reports.length === 0">
|
||||||
|
<td class="text-center" colspan="4">尚無查詢結果</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="row in pageModel.reports" :key="row.id">
|
||||||
|
<td>{{ row.title }}</td>
|
||||||
|
<td>{{ row.owner }}</td>
|
||||||
|
<td>{{ row.status }}</td>
|
||||||
|
<td>{{ row.updatedAt }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</template>
|
||||||
|
</SectionQueryPage>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue'
|
import { computed } from 'vue'
|
||||||
import { useEditableGridMaintenancePage } from '@/composables/page-drivers/useEditableGridMaintenancePage'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageEditableGridMaintenance :page="page.pageModel.value" />
|
<EditableStudentGrid :title="pageModel.title" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# Maintenance Views Guide
|
# Maintenance Views Guide
|
||||||
|
|
||||||
`views/maint` 是維護頁 demo。所有檔案都應是薄 route entry,實際 UI 與流程分別放在 `components/pages`、`components/sections`、`components/items` 與 `composables/page-drivers`。
|
`views/maint` 是維護頁 demo。所有檔案都是自含的 route entry,UI 與流程直接在 view 中組合 `components/maint/MaintShell`、`components/sections`、`components/items` 與 composable。
|
||||||
|
|
||||||
## 目前範本
|
## 目前範本
|
||||||
|
|
||||||
- `SingleRecord.vue`:單主檔 CRUD + dialog。
|
- `SingleRecord.vue`:單主檔 CRUD + dialog(使用 page driver composable)。
|
||||||
- `EditableGrid.vue`:可編輯表格。
|
- `EditableGrid.vue`:可編輯表格。
|
||||||
- `MasterDetailA.vue`:主檔 + 側邊明細 panel。
|
- `MasterDetailA.vue`:主檔 + 側邊明細 panel(使用 page driver composable)。
|
||||||
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
|
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
|
||||||
- `MasterDetailC.vue`:主檔 + 簡化明細清單。
|
- `MasterDetailC.vue`:主檔 + 簡化明細清單。
|
||||||
|
|
||||||
@@ -15,8 +15,6 @@
|
|||||||
複製維護頁時同步調整:
|
複製維護頁時同步調整:
|
||||||
|
|
||||||
- `router/routes.ts` 的 `path`、`name`、`component`、`meta.layout`
|
- `router/routes.ts` 的 `path`、`name`、`component`、`meta.layout`
|
||||||
- page driver 名稱與 import
|
|
||||||
- page component 名稱與 import
|
|
||||||
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
|
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
|
||||||
- store、service、model、語系、menu/favorites/breadcrumb 相關資料
|
- store、service、model、語系、menu/favorites/breadcrumb 相關資料
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,421 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageMasterDetailAMaintenance
|
<MaintShell
|
||||||
v-model:search="page.search.value"
|
:search-panel-open="searchPanelOpen"
|
||||||
v-model:search-panel-open="page.searchPanelOpen.value"
|
:title="pageModel.title"
|
||||||
v-bind="page.masterDetailProps.value"
|
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||||
:current-page="page.currentPage.value"
|
@create="openAddDialog"
|
||||||
:grade-label="page.formState.gradeLabel"
|
>
|
||||||
:headers="page.tableHeaders.value"
|
<template #search-fields>
|
||||||
:items="page.students.value"
|
<v-col cols="12" md="2">
|
||||||
:items-per-page="page.itemsPerPage"
|
<BaseFormTextField
|
||||||
:page="page.pageModel.value"
|
id="search-student-id"
|
||||||
:page-count="page.pageCount.value"
|
v-model="search.studentId"
|
||||||
:page-summary="page.pageSummary.value"
|
label="學號"
|
||||||
:row-props="page.formState.rowProps"
|
:label-char-count="2"
|
||||||
:status-color="page.formState.statusColor"
|
name="searchStudentId"
|
||||||
@create="page.openAddDialog"
|
placeholder="例如:S2024001"
|
||||||
@edit="page.openEditDialog"
|
/>
|
||||||
@reset-search="page.resetSearch"
|
</v-col>
|
||||||
@update:current-page="page.currentPage.value = $event"
|
<v-col cols="12" md="2">
|
||||||
@view="page.openViewDialog"
|
<BaseFormTextField
|
||||||
v-on="page.masterDetailEvents"
|
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>
|
</v-snackbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-overlay :deep(.v-overlay__content) {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100vw;
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel > .v-card {
|
||||||
|
box-shadow: -4px 0 16px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel-wrapper {
|
||||||
|
width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel-wrapper.is-active {
|
||||||
|
width: 600px;
|
||||||
|
opacity: 1;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.is-mobile {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.is-mobile :deep(.dialog-title) {
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.is-mobile :deep(.dialog-toolbar) {
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.is-mobile :deep(.dialog-actions) {
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.is-mobile :deep(.dialog-actions .v-btn) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel.is-mobile :deep(.v-card-text) {
|
||||||
|
padding-bottom: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel-wrapper.is-mobile {
|
||||||
|
width: 100%;
|
||||||
|
opacity: 1;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-panel-wrapper.is-mobile.is-active {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-readonly :deep(.v-field) {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.dialog-panel {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-panel > .v-card {
|
||||||
|
width: 100%;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,52 +1,108 @@
|
|||||||
<script setup lang="ts">
|
<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 SectionDataTable from '@/components/sections/SectionDataTable.vue'
|
||||||
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
|
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
|
||||||
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
|
|
||||||
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageMaintenance
|
<MaintShell
|
||||||
v-model:search-panel-open="page.searchPanelOpen.value"
|
:title="pageModel.title"
|
||||||
:page="page.pageModel.value"
|
:search-panel-open="searchPanelOpen"
|
||||||
@create="page.commands.openAddDialog"
|
@toggle-search="searchPanelOpen = !searchPanelOpen"
|
||||||
|
@create="commands.openAddDialog"
|
||||||
>
|
>
|
||||||
<template #search-fields>
|
<template #search-fields>
|
||||||
<SectionSearchPanel
|
<v-col cols="12" md="2">
|
||||||
v-model="page.search.value"
|
<BaseFormTextField
|
||||||
:departments="page.departments"
|
id="search-student-id"
|
||||||
:grade-options="page.gradeOptions"
|
v-model="search.studentId"
|
||||||
:statuses="page.statuses"
|
label="學號"
|
||||||
@reset="page.resetSearch"
|
: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>
|
||||||
<template #table>
|
<template #table>
|
||||||
<SectionDataTable
|
<SectionDataTable
|
||||||
v-model:current-page="page.currentPage.value"
|
v-model:current-page="currentPage"
|
||||||
:grade-label="page.formState.gradeLabel"
|
:grade-label="formState.gradeLabel"
|
||||||
:headers="page.tableHeaders.value"
|
:headers="tableHeaders"
|
||||||
:items="page.students.value"
|
:items="students"
|
||||||
:items-per-page="page.itemsPerPage"
|
:items-per-page="itemsPerPage"
|
||||||
:page-count="page.pageCount.value"
|
:page-count="pageCount"
|
||||||
:page-summary="page.pageSummary.value"
|
:page-summary="pageSummary"
|
||||||
:row-props="page.formState.rowProps"
|
:row-props="formState.rowProps"
|
||||||
:status-color="page.formState.statusColor"
|
:status-color="formState.statusColor"
|
||||||
@delete="page.flow.requestDeleteConfirmation"
|
@delete="flow.requestDeleteConfirmation"
|
||||||
@edit="page.commands.openEditDialog"
|
@edit="commands.openEditDialog"
|
||||||
@view="page.commands.openViewDialog"
|
@view="commands.openViewDialog"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</PageMaintenance>
|
</MaintShell>
|
||||||
|
|
||||||
<SectionFormPanel
|
<SectionFormPanel
|
||||||
v-bind="page.formPanelProps.value"
|
v-bind="formPanelProps"
|
||||||
v-on="page.formPanelEvents"
|
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>
|
</v-snackbar>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user