refactor(login): compose page from focused login components

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

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

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

Update page creation docs to reflect the simplified flow where views render
sections/items directly and composables coordinate store/service access when
needed.
This commit is contained in:
skytek_xinliang
2026-05-27 13:43:43 +08:00
parent 7b99087cbb
commit 7b0cfe4448
18 changed files with 614 additions and 1007 deletions
+7 -53
View File
@@ -7,9 +7,9 @@
目前新增一般頁面的預設資料流: 目前新增一般頁面的預設資料流:
```txt ```txt
router -> view -> (page driver) -> page component -> sections/items router -> view -> sections/items
store/composable -> service composable -> store -> service
``` ```
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。 Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
@@ -22,7 +22,6 @@ Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page mod
<!-- src/views/reports/Reports.vue --> <!-- src/views/reports/Reports.vue -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import PageReports from '@/components/pages/PageReports.vue'
import { useSnackbarStore } from '@/stores/snackbar' import { useSnackbarStore } from '@/stores/snackbar'
export interface ReportSummary { export interface ReportSummary {
@@ -55,54 +54,9 @@ function openReport(row: ReportSummary) {
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md` 若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`
## 2. 新增 page component
完整頁面主畫面放在 `src/components/pages`,檔名使用 `Page` 前綴。component 以 props 接收資料,以 emit 回報使用者事件,不直接處理 route 或底層 HTTP。
```vue
<!-- src/components/pages/PageReports.vue -->
<script setup lang="ts">
import type { ReportSummary } from '@/views/reports/Reports.vue'
defineProps<{
page: { title: string; rows: ReportSummary[] }
}>()
const emit = defineEmits<{
open: [row: ReportSummary]
}>()
</script>
<template>
<v-card flat>
<v-card-title class="text-h6">{{ page.title }}</v-card-title>
<v-table>
<thead>
<tr>
<th>名稱</th>
<th>負責單位</th>
<th class="text-right">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="row in page.rows" :key="row.id">
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
<td class="text-right">
<v-btn color="primary" size="small" variant="text" @click="emit('open', row)">
開啟
</v-btn>
</td>
</tr>
</tbody>
</v-table>
</v-card>
</template>
```
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue` 若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`
## 3. 加入 route ## 2. 加入 route
route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。 route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
@@ -125,7 +79,7 @@ route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMa
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。 - 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
- 若只是新增 route,通常不需要修改 `MainLayout.vue``src/shell/*` - 若只是新增 route,通常不需要修改 `MainLayout.vue``src/shell/*`
## 4. 需要 API 時新增 service module ## 3. 需要 API 時新增 service module
```ts ```ts
// src/services/modules/reports.ts // src/services/modules/reports.ts
@@ -148,7 +102,7 @@ service 只封裝 HTTP 細節,不持有 UI 狀態。
`httpClient``baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET` `httpClient``baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`
## 5. 需要共享狀態時新增 store ## 4. 需要共享狀態時新增 store
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。 只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
@@ -180,7 +134,7 @@ export const useReportsStore = defineStore('reports', () => {
}) })
``` ```
## 6. 驗證 ## 5. 驗證
至少執行: 至少執行:
+31 -44
View File
@@ -24,17 +24,11 @@ Read only when needed: [analyse now](./analyse-now.md)
│ App Shell │ │ App 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
@@ -130,15 +124,11 @@ 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 │ │ ├── SectionSearchPanel.vue
│ │ ├── SectionDataTable.vue │ │ ├── SectionDataTable.vue
@@ -193,10 +183,10 @@ src/
```vue ```vue
<!-- views/maint/SingleRecord.vue優化後 --> <!-- views/maint/SingleRecord.vue優化後 -->
<script setup lang="ts"> <script setup lang="ts">
import PageMaintenance from '@/components/pages/PageMaintenance.vue' import MaintShell from '@/components/MaintShell.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage' import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const { pageModel, commands, formPanelProps, formPanelEvents } = useSingleRecordMaintenancePage() const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
</script> </script>
<template> <template>
@@ -204,31 +194,25 @@ const { pageModel, commands, formPanelProps, formPanelEvents } = useSingleRecord
</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/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>
``` ```
@@ -343,7 +327,7 @@ 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/`:建立第一個 page driver 範例。 4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例。
- 協調搜尋條件、分頁與 `pageModel` - 協調搜尋條件、分頁與 `pageModel`
- 提供 `load()``resetSearch()` 供 Page Driver 呼叫。 - 提供 `load()``resetSearch()` 供 Page Driver 呼叫。
@@ -399,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 組合。
--- ---
@@ -408,8 +396,7 @@ 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`(維持) |
@@ -425,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。
+10 -12
View File
@@ -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,7 +40,6 @@
一般功能需求優先修改: 一般功能需求優先修改:
- `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/*`
@@ -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 -2
View File
@@ -57,8 +57,7 @@ router -> AppShell -> layout -> view -> Section -> Item
- `views/FncPage.vue` - `views/FncPage.vue`
- `views/Settings.vue` - `views/Settings.vue`
- `views/maint/*` - `views/maint/*`
- `components/PageIndex.vue` - `components/MaintShell.vue`
- `components/PageMaint.vue`
- `components/maint/*` - `components/maint/*`
- `components/sections/*` - `components/sections/*`
- `components/items/*` - `components/items/*`
+3 -3
View File
@@ -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/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`
+1 -1
View File
@@ -9,7 +9,7 @@
- `layouts/`App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md` - `layouts/`App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md` - `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`
`PageMaint.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/` 頂層。 `MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/` 頂層。
## 規則 ## 規則
-234
View File
@@ -1,234 +0,0 @@
<template>
<v-container class="pa-0" fluid>
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
<v-sheet
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
color="surface"
>
<v-avatar color="primary" size="52" variant="tonal">
<span class="text-h5">👋</span>
</v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<!--
使用 v-data-iterator 保留一致的列表輸出結構
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架
-->
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
<!--
Vuetify 會把原始資料包進 wrapper
這裡統一解包可避免模板層散落型別判斷
-->
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="emit('news', resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="emit('message-center')"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="emit('quick', item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<!--
這個 dialog 只做消息內容呈現
開關狀態仍交給 view 管理避免頁面元件自行持有流程狀態
-->
<v-dialog
:model-value="props.isNewsDialogOpen"
max-width="640"
@update:model-value="emit('update:isNewsDialogOpen', $event)"
>
<v-card v-if="props.selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ props.selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
{{ props.selectedNews.dept }} · {{ props.selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ props.selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
interface NewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
interface QuickItem {
icon: string
title: string
}
const props = defineProps<{
newsItems: NewsItem[]
quickItems: QuickItem[]
selectedNews: NewsItem | null
isNewsDialogOpen: boolean
}>()
const emit = defineEmits<{
news: [item: NewsItem]
'message-center': []
quick: [item: QuickItem]
'update:isNewsDialogOpen': [value: boolean]
}>()
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
function resolveNewsItem(wrapped: unknown): NewsItem {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return (wrapped as { raw: NewsItem }).raw
}
return wrapped as NewsItem
}
</script>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
-548
View File
@@ -1,548 +0,0 @@
<template>
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
<!-- Side Layouts -->
<v-row
v-if="props.layout !== 'card'"
class="fill-height"
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
no-gutters
>
<!-- Illustration Column -->
<v-col
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
cols="12"
lg="8"
sm="6"
>
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="props.branding.title" />
</div>
<v-sheet
v-if="props.withAnnouncement"
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
max-width="680"
rounded="lg"
width="100%"
>
<LoginAnnouncementBoard
:all-tab-label="props.announcementBoard.allTabLabel"
:date-header="props.announcementBoard.dateHeader"
:empty-text="props.announcementBoard.emptyText"
:items="props.announcementBoard.items"
:items-per-page="props.announcementBoard.itemsPerPage"
:pagination-label="props.announcementBoard.paginationLabel"
:school-header="props.announcementBoard.schoolHeader"
:system-announcements="props.announcementBoard.systemAnnouncements"
:tabs="props.announcementBoard.tabs"
:title="props.announcementBoard.title"
:title-header="props.announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement"
/>
</v-sheet>
</v-col>
<v-col
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
cols="12"
lg="4"
sm="6"
>
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
<v-banner
class="d-sm-none mb-2"
density="comfortable"
lines="one"
:mobile="false"
:stacked="false"
>
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon
class="mobile-banner-icon"
color="primary"
size="small"
:icon="mdiBullhornVariantOutline"
/>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none"
color="primary"
size="small"
variant="text"
@click="mobileAnnouncementSheetVisible = true"
>
{{ props.mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
>
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
:with-forgot-password="props.form.withForgotPassword"
:with-remember-account="props.form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</div>
</v-col>
</v-row>
<!-- Card Layout (Centered) -->
<v-row
v-else
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
no-gutters
>
<v-card
class="rounded-lg"
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
elevation="10"
max-width="450"
width="100%"
>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-md-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
:with-forgot-password="props.form.withForgotPassword"
:with-remember-account="props.form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet
v-if="props.withAnnouncement"
v-model="mobileAnnouncementSheetVisible"
class="d-sm-none"
>
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ props.mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span
>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ props.mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
</template>
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, ref } from 'vue'
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
import LoginBrand from './login/LoginBrand.vue'
import LoginForm from './login/LoginForm.vue'
import LoginHeader from './login/LoginHeader.vue'
import LoginToolBar from './login/LoginToolBar.vue'
import LoginVerify from './login/LoginVerify.vue'
interface BrandingConfig {
title?: string
organization?: string
}
interface IllustrationConfig {
image?: string | null
title?: string
description?: string
}
interface HeaderConfig {
welcomeText?: string
welcomeDescription?: string
}
interface AnnouncementTabConfig {
label: string
value: string
}
interface AnnouncementItemConfig {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface AnnouncementBoardConfig {
title?: string
tabs?: AnnouncementTabConfig[]
items?: AnnouncementItemConfig[]
systemAnnouncements?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
interface MobileAnnouncementConfig {
items?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
show?: boolean
viewAllText?: string
listTitle?: string
closeText?: string
emptyText?: string
}
interface ForgotPasswordConfig {
text?: string
href?: string
target?: string
}
interface FormConfig {
accPlaceholder?: string
passwPlaceholder?: string
rememberMeLabel?: string
submitText?: string
rememberStorageKey?: string
withForgotPassword?: boolean
withRememberAccount?: boolean
withCaptcha?: boolean
captcha?: {
imgUrl?: string
id?: string
tokenValue?: string
}
captchaValue?: string
captchaLoading?: boolean
captchaErrorMessage?: string
captchaVerified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
forgotPassword: ForgotPasswordConfig
}
interface ToolBarConfig {
show?: boolean
locale?: string
locales?: string[]
}
interface Props {
layout: 'side-left' | 'side-right' | 'card'
withAnnouncement?: boolean
branding: BrandingConfig
illustration: IllustrationConfig
announcementBoard: AnnouncementBoardConfig
mobileAnnouncement: MobileAnnouncementConfig
header: HeaderConfig
form: FormConfig
toolbar: ToolBarConfig
}
const props = withDefaults(defineProps<Props>(), {
layout: 'side-left',
withAnnouncement: true,
branding: () => ({
title: 'Skyteck Login',
organization: 'school',
}),
illustration: () => ({
image: null,
title: 'Login',
description: 'Login to your account',
}),
announcementBoard: () => ({
title: '學校公告區',
tabs: [
{ label: '全部', value: '__all__' },
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: [
{
id: 'announcement-1',
date: '2024-03-19',
school: '市立實踐國中',
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
tab: 'junior',
},
],
systemAnnouncements: [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
}),
mobileAnnouncement: () => ({
items: [],
show: false,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}),
header: () => ({
welcomeText: 'Welcome back 👋🏻',
welcomeDescription: 'Please enter your account password to login',
}),
form: () => ({
accPlaceholder: '請輸入帳號',
passwPlaceholder: '請輸入密碼',
rememberMeLabel: '記住帳號',
submitText: '登入',
rememberStorageKey: 'sklogin.remember.username',
withForgotPassword: true,
withRememberAccount: true,
withCaptcha: true,
captcha: undefined,
captchaValue: '',
captchaLoading: false,
captchaErrorMessage: '',
captchaVerified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
forgotPassword: {
text: '忘記密碼?',
href: '',
target: undefined,
},
}),
toolbar: () => ({
show: true,
locale: 'zh-TW',
locales: ['zh-TW', 'en-US'],
}),
})
const emit = defineEmits([
'submit',
'change-locale',
'forgot-password',
'captcha-refresh',
'captcha-change',
'toggle-layout',
'select-announcement',
])
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (!props.withAnnouncement) return false
if (props.mobileAnnouncement.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => {
return `layout-${props.layout}`
})
function handleLogin(formData: Record<string, unknown>) {
emit('submit', formData)
}
function handleCaptchaRefresh() {
emit('captcha-refresh')
}
function handleCaptchaChange(value: string) {
emit('captcha-change', value)
}
function handleChangeLocale(nextLocale: string) {
emit('change-locale', nextLocale)
}
function handleToggleLayout() {
emit('toggle-layout')
}
function handleForgotPassword(e: MouseEvent) {
emit('forgot-password', e)
}
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
emit('select-announcement', item)
}
</script>
<style scoped>
:deep(.v-banner__prepend) {
align-self: center;
margin-inline-end: 16px;
}
:deep(.v-banner-actions) {
align-self: center;
}
.mobile-banner-icon {
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
transform-origin: center;
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-banner-icon {
animation: none;
}
}
.illustration-panel {
background: linear-gradient(
135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%
);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
/* Specific styles for side-right to flip border */
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
+2
View File
@@ -143,6 +143,8 @@ export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
schoolHeader: '公告學校', schoolHeader: '公告學校',
titleHeader: '公告標題', titleHeader: '公告標題',
paginationLabel: '總筆數:', paginationLabel: '總筆數:',
allTabLabel: '全部',
emptyText: '目前沒有公告資料',
})) }))
const selectedAnnouncement = computed(() => { const selectedAnnouncement = computed(() => {
+5 -5
View File
@@ -29,15 +29,15 @@ const pageModel = computed(() => ({ title: '我的頁面' }))
```vue ```vue
<script setup lang="ts"> <script setup lang="ts">
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/MaintShell.vue'
import { useXxxPage } from '@/composables/page-drivers/useXxxPage' import { useXxxPage } from '@/composables/page-drivers/useXxxPage'
const { pageModel, search, handleSave, ... } = useXxxPage() const { pageModel, search, handleSave, ... } = useXxxPage()
</script> </script>
<template> <template>
<PageMaint :title="pageModel.title" @create="handleCreate"> <MaintShell :title="pageModel.title" @create="handleCreate">
<template #table>...</template> <template #table>...</template>
</PageMaint> </MaintShell>
</template> </template>
``` ```
@@ -45,14 +45,14 @@ const { pageModel, search, handleSave, ... } = useXxxPage()
## 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 與發出事件。
## 子目錄 ## 子目錄
+195 -10
View File
@@ -1,18 +1,203 @@
<script setup lang="ts"> <script setup lang="ts">
import PageIndex from '@/components/PageIndex.vue' import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
import { useHomePage } from '@/composables/page-drivers/useHomePage' import { useHomePage } from '@/composables/page-drivers/useHomePage'
const { handleMessageCenter, handleNews, handleQuick, isNewsDialogOpen, pageModel, selectedNews } = 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>
<PageIndex <v-container class="pa-0" fluid>
v-model:is-news-dialog-open="isNewsDialogOpen" <div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
:news-items="pageModel.newsItems" <v-sheet
:quick-items="pageModel.quickItems" class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
:selected-news="selectedNews" color="surface"
@message-center="handleMessageCenter" >
@news="handleNews($event)" <v-avatar color="primary" size="52" variant="tonal">
@quick="handleQuick($event)" <span class="text-h5">👋</span>
/> </v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<v-data-iterator class="mt-2" item-key="id" :items="pageModel.newsItems" :items-per-page="-1">
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="handleNews(resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="handleMessageCenter"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in pageModel.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="handleQuick(item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<v-dialog
v-model="isNewsDialogOpen"
max-width="640"
>
<v-card v-if="selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ selectedNews.month }} {{ selectedNews.date }} ·
{{ selectedNews.dept }} · {{ selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="isNewsDialogOpen = false">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template> </template>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
+342 -78
View File
@@ -1,63 +1,15 @@
<template>
<page-login
:announcement-board="announcementBoard"
:branding="branding"
:form="form"
:header="header"
:illustration="illustration"
:layout="formPositionLayout"
:mobile-announcement="mobileAnnouncement"
:toolbar="toolbar"
:with-announcement="withAnnouncement"
@captcha-change="handleCaptchaChange"
@captcha-refresh="handleCaptchaRefresh"
@change-locale="handleChangeLocale"
@forgot-password="handleForgotPassword"
@select-announcement="handleSelectAnnouncement"
@submit="onLogin"
@toggle-layout="handleToggleLayout"
/>
<v-dialog v-model="dialogVisible" width="360">
<v-card>
<v-card-title>{{ dialogTitle }}</v-card-title>
<v-card-text>{{ dialogMessage }}</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="dialogVisible = false">
{{ t('common.ok') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="announcementDialogVisible" max-width="720">
<v-card>
<v-card-title class="text-h6">
{{ selectedAnnouncement?.title }}
</v-card-title>
<v-card-subtitle class="pt-2">
{{ selectedAnnouncement?.date }} {{ selectedAnnouncement?.school }}
</v-card-subtitle>
<v-card-text class="text-body-1">
{{ selectedAnnouncementDetail }}
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="flat" @click="announcementDialogVisible = false">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts"> <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 -1
View File
@@ -1,6 +1,6 @@
# Maintenance Views Guide # Maintenance Views Guide
`views/maint` 是維護頁 demo。所有檔案都是自含的 route entry,UI 與流程直接在 view 中組合 `PageMaint``components/sections``components/items` 與 composable。 `views/maint` 是維護頁 demo。所有檔案都是自含的 route entry,UI 與流程直接在 view 中組合 `MaintShell``components/sections``components/items` 與 composable。
## 目前範本 ## 目前範本
+3 -3
View File
@@ -5,7 +5,7 @@ import DetailSidePanel from '@/components/maint/master-detail/DetailSidePanel.vu
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue' import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue' import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue' import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/MaintShell.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue' import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue' import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
import { useMasterDetailAMaintenancePage } from '@/composables/page-drivers/useMasterDetailAMaintenancePage' import { useMasterDetailAMaintenancePage } from '@/composables/page-drivers/useMasterDetailAMaintenancePage'
@@ -20,7 +20,7 @@ const {
</script> </script>
<template> <template>
<PageMaint <MaintShell
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
:title="pageModel.title" :title="pageModel.title"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
@@ -52,7 +52,7 @@ const {
@view="openViewDialog($event)" @view="openViewDialog($event)"
/> />
</template> </template>
</PageMaint> </MaintShell>
<teleport to="body"> <teleport to="body">
<v-overlay <v-overlay
+5 -5
View File
@@ -1,12 +1,12 @@
<template> <template>
<!-- Page component 組合 PageMaint 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script --> <!-- Page component 組合 MaintShell 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<page-maint <maint-shell
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
:title="pageModel.title" :title="pageModel.title"
@create="openAddDialog" @create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
> >
<!-- 搜尋欄位放在 PageMaint search-fields slot讓外殼固定欄位由頁面決定 --> <!-- 搜尋欄位放在 MaintShell search-fields slot讓外殼固定欄位由頁面決定 -->
<template #search-fields> <template #search-fields>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div> <div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
@@ -173,7 +173,7 @@
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
</page-maint> </maint-shell>
<!-- 主從式維護視窗 --> <!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 --> <!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
@@ -511,7 +511,7 @@ import DetailFullHeightPanel from '@/components/maint/master-detail/DetailFullHe
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue' import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue' import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue' import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/MaintShell.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow' import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm' import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters' import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters'
+5 -5
View File
@@ -1,12 +1,12 @@
<template> <template>
<!-- Page component 組合 PageMaint 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script --> <!-- Page component 組合 MaintShell 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<page-maint <maint-shell
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
:title="pageModel.title" :title="pageModel.title"
@create="openAddDialog" @create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
> >
<!-- 搜尋欄位放在 PageMaint search-fields slot讓外殼固定欄位由頁面決定 --> <!-- 搜尋欄位放在 MaintShell search-fields slot讓外殼固定欄位由頁面決定 -->
<template #search-fields> <template #search-fields>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div> <div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
@@ -173,7 +173,7 @@
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
</page-maint> </maint-shell>
<!-- 主從式維護視窗 --> <!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 --> <!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
@@ -498,7 +498,7 @@ import DetailSimpleList from '@/components/maint/master-detail/DetailSimpleList.
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue' import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue' import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue' import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/MaintShell.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow' import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm' import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters' import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters'
+3 -3
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/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 SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
@@ -14,7 +14,7 @@ const {
</script> </script>
<template> <template>
<PageMaint <MaintShell
:title="pageModel.title" :title="pageModel.title"
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
@@ -45,7 +45,7 @@ const {
@view="commands.openViewDialog" @view="commands.openViewDialog"
/> />
</template> </template>
</PageMaint> </MaintShell>
<SectionFormPanel <SectionFormPanel
v-bind="formPanelProps" v-bind="formPanelProps"