Compare commits

...

17 Commits

Author SHA1 Message Date
skytek_xinliang aa49d78a84 refactor: 搜尋欄位 2026-06-10 11:44:08 +08:00
skytek_xinliang afbdea6b13 refactor: home 2026-06-09 16:12:49 +08:00
skytek_xinliang 915f3b7f2f fix: abort機制 2026-06-01 14:50:47 +08:00
skytek_xinliang f61432ad8a fixing adn docing 2026-06-01 14:44:39 +08:00
skytek_xinliang 7b0cfe4448 refactor(login): compose page from focused login components
Split the login page into smaller reusable components for branding,
toolbar, header, form, announcements, and mobile layout behavior. This
keeps the view responsible for orchestration while moving UI sections into
focused components.

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

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

Update page creation docs to reflect the simplified flow where views render
sections/items directly and composables coordinate store/service access when
needed.
2026-05-27 13:43:43 +08:00
skytek_xinliang 7b99087cbb docs: simplify page architecture and component guidance
Update the src documentation to emphasize building pages from route views,
composables, sections, and items instead of a dedicated pages layer.

Clarify the recommended data flow and new feature workflow so template users
start from views and only introduce page-driver composables when coordination
logic becomes complex.docs: simplify page architecture and component guidance

Update the src documentation to emphasize building pages from route views,
composables, sections, and items instead of a dedicated pages layer.

Clarify the recommended data flow and new feature workflow so template users
start from views and only introduce page-driver composables when coordination
logic becomes complex.
2026-05-27 11:50:40 +08:00
skytek_xinliang ad00f5c195 docs: clarify optional page drivers in page guide
Update documentation to show that simple pages can define page models
directly in views without creating a page driver. Adjust examples,
section numbering, and naming guidance to better distinguish simple view
state from reusable page-driver patterns.docs: clarify optional page drivers in page guide

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

Move simple page models into page components and build trivial computed
models directly in views to avoid unnecessary page drivers. Update views
to destructure page driver returns and rely on template ref unwrapping,
and document the guidance for when page drivers should be introduced.
2026-05-27 11:10:34 +08:00
skytek_xinliang 799b16578d docs: expand LLM guide with spec-to-page mapping
Update the LLM development guide to prioritize src/GUIDE.md and add
detailed `.spec.json` mapping rules for query, application, and
maintenance pages. Clarify how page contracts, API contracts, actions,
validation, naming, error handling, and i18n should drive generated
composables and page components.docs: expand LLM guide with spec-to-page mapping

Update the LLM development guide to prioritize src/GUIDE.md and add
detailed `.spec.json` mapping rules for query, application, and
maintenance pages. Clarify how page contracts, API contracts, actions,
validation, naming, error handling, and i18n should drive generated
composables and page components.
2026-05-26 17:09:41 +08:00
skytek_xinliang b5be5b4448 feat(auth): support JSON and form-data login requests
Split the auth login API into format-specific methods and add request
format selection in the auth store. Build a shared login request body so
captcha fields can be sent consistently as either JSON or FormData.feat(auth): support JSON and form-data login requests

Split the auth login API into format-specific methods and add request
format selection in the auth store. Build a shared login request body so
captcha fields can be sent consistently as either JSON or FormData.
2026-05-25 13:55:47 +08:00
skytek_xinliang ec62fcee51 feat(sections): add SectionFormPage demo
Add a dedicated SectionFormPage demo component and view to showcase form fields, detail sections, notices, and page-driver-managed state.

Remove the obsolete SectionSearchPanel demo route and menu entry, and add spacing to SectionFormPage cards for improved layout.feat(sections): add SectionFormPage demo

Add a dedicated SectionFormPage demo component and view to showcase form fields, detail sections, notices, and page-driver-managed state.

Remove the obsolete SectionSearchPanel demo route and menu entry, and add spacing to SectionFormPage cards for improved layout.
2026-05-22 15:41:52 +08:00
skytek_xinliang cad44db4c7 docs(pages): clarify page driver component boundaries
Add inline comments to page components documenting how page models,
v-model state, and emitted user intents flow through the page driver.

This clarifies that page components remain presentation-focused while
routing, dialog state, CRUD side effects, and command handling stay in
the page driver or related composables.docs(pages): clarify page driver component boundaries

Add inline comments to page components documenting how page models,
v-model state, and emitted user intents flow through the page driver.

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