Merge branch 'refactor'

This commit is contained in:
skytek_xinliang
2026-05-21 16:12:06 +08:00
73 changed files with 6666 additions and 6147 deletions
+3
View File
@@ -1,3 +1,6 @@
# Vite dev proxy 目標後端 URL。
VITE_PROXY_TARGET=http://192.168.89.54:9002
# Vite API base URL。 # Vite API base URL。
# 使用 Vite dev proxy 時,建議維持相對路徑。 # 使用 Vite dev proxy 時,建議維持相對路徑。
VITE_API_BASE_URL=/service/api VITE_API_BASE_URL=/service/api
+7 -9
View File
@@ -36,13 +36,11 @@ output/playwright/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
# Added by code-review-graph
.code-review-graph/
.codex/config.toml .codex
.agents/ .agents
.claude/ .claude
.ruler/ .ruler
.playwright/ .playwright
.mcp.json opencode.json
.opencode.json .antigravitycli
+24 -2
View File
@@ -4,12 +4,34 @@
- Follow the existing code style and patterns. - Follow the existing code style and patterns.
- Use pnpm for running project commands. - Use pnpm for running project commands.
- Keep code in TypeScript unless migration is required. - Keep code in TypeScript unless migration is required.
- When refactoring or creating new components, review `docs/frontend-layering.md` first and follow its layering and responsibility guidelines. - Before modifying or adding files in a `src/` subdirectory, read the corresponding `src/**/GUIDE.md` to understand the layer's constraints and conventions. Use `docs/llm-development-guide.md` as the index to find which GUIDE applies.
- When a change affects LLM editing boundaries, page creation flow, layout usage, login-page boundaries, or frontend layering rules, update `docs/llm-development-guide.md` in the same change. - When the change introduces a new pattern, directory, or convention that affects layer boundaries, create or update the relevant `src/**/GUIDE.md` and ensure `docs/llm-development-guide.md` indexes it.
- When refactoring or creating new components, review `docs/architecture-strategy.md` first and follow its layering and responsibility guidelines.
- For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause. - For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause.
- Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits. - Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits.
- When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient. - When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient.
## Naming Generalization Rule
- This project is a **template** intended to be reused across different data domains (student, course, teacher, etc.).
- **Reusable abstractions** (Page Components, Sections, Items, generic composables, base components) **must not contain domain-specific names** (e.g., `Student`, `Course`) in their file names, type names, or export names.
- Domain-specific names are **only allowed** in:
- `src/models/<domain>.ts` — domain models
- `src/stores/<domain>.ts` — domain stores
- `src/services/modules/<domain>.ts` — service modules
- Examples of correct vs. incorrect naming:
-`PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
-`useStudentMaintenancePage.ts` → ✅ `useMaintenancePage.ts`
-`ItemStudentRow.vue` → ✅ `ItemDataRow.vue`
-`useStudentCrudCommands.ts` → ✅ `useCrudCommands.ts`
-`models/student.ts`, `stores/students.ts` — domain layer, specific names are correct
## GUIDE.md 寫作規則
- `src/**/GUIDE.md` 只保留該層/目錄的**約束、慣例與索引**,不要塞入詳細 API 文件。
- 當新增 pattern、目錄或慣例影響層邊界時,建立或更新對應的 `src/**/GUIDE.md`,並確保 `docs/llm-development-guide.md` 將其列入索引。
- 元件的 Props/Slots/Emits 詳細說明放在各子目錄的 `GUIDE.md`(如 `src/components/base/GUIDE.md``src/components/sections/GUIDE.md`),不要放在上層 `src/components/GUIDE.md`
- **新增 page/section 元件時,必須一併描述「視覺特徵」**:說明畫面上出現哪些元素(如標題卡片、按鈕類型、表格位置),讓 LLM 能從截圖或設計稿判斷該用哪個元件。視覺特徵寫在對應子目錄 `GUIDE.md` 的「視覺特徵」小節。
## Stack ## Stack
- Framework: Vue 3 + Vite - Framework: Vue 3 + Vite
- UI Library: Vuetify - UI Library: Vuetify
-9
View File
@@ -67,15 +67,6 @@ pnpm dev
cp .env.example .env cp .env.example .env
``` ```
常用設定:
```env
VITE_API_BASE_URL=/service/api
VITE_SKIP_LOGIN=false
VITE_DEV_DEFAULT_USER_ID=
VITE_DEV_DEFAULT_PASSWORD=
```
`client.ts` 會優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。 `client.ts` 會優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
實際 `.env``.env.*.local` 不應提交。production API URL 應由使用專案自己的部署環境提供。 實際 `.env``.env.*.local` 不應提交。production API URL 應由使用專案自己的部署環境提供。
+47
View File
@@ -0,0 +1,47 @@
## 二、我們專案的現況診斷
### 2.1 App.vue 過度臃腫(~590 行)
| 職責 | 行數 | 應屬層級 |
|------|------|----------|
| Layout 切換 | ~20 | App Shell |
| Tabs 管理 | ~80 | Page Driver |
| Breadcrumb 組裝 | ~40 | Layout |
| Favorites 管理 | ~60 | Store |
| Search Dialog | ~80 | App Shell / Widget |
| Message Dialog | ~60 | App Shell / Widget |
| Snackbar | ~10 | Global Overlay |
| Logout / Force logout | ~30 | Auth Flow |
| HTTP Toast | ~20 | Service Layer |
- **問題**App.vue 同時承擔 App Shell、Page Driver、Global Widget、Auth Flow 四種責任。
- **對比**App Store 的 `App.svelte` 只有 161 行,只負責 `Navigation + PageResolver + Footer`
### 2.2 Views 過厚(SingleRecord.vue ~830 行)
- 混雜:表格呈現、搜尋表單、dialog 模板、表單狀態、CRUD 流程、驗證邏輯、分頁、snackbar。
- **對比**App Store 的 `ProductPage.svelte` 只有 77 行,只負責「把 page 轉成 DefaultPageRequirements + 一個 slot override」。
### 2.3 缺乏統一的頁面資料門面
```
現況:
view → store → service(直接鏈式呼叫)
view 自己管理 loading / error / dialog visible
App Store
UI → jet.dispatch(intent) → runtime → controller → page model
UI 只接收 page model,不管理載入狀態
```
### 2.4 Dialog 狀態與模板內嵌於 View
- `SingleRecord.vue` 內含 5 個 `ConfirmDialog` 實例 + 1 個大 form overlay。
- 任何 dialog 更動都需要修改 view 檔案。
### 2.5 沒有容器/內容分離的 Section 層
- 表格、表單、搜尋區塊都是直接寫在 view 或 page component 中。
- 缺乏類似 `ShelfItemLayout` 的通用佈局抽象:「這一區是水平捲軸還是網格」應該由容器決定,裡面的內容元件不應該知道。
---
+86 -164
View File
@@ -1,139 +1,19 @@
# 資料流與元件分層優化策略 # 資料流與元件分層優化策略
> 分析標的:`~/code/apps.apple.com-main`Apple App Store Web — Svelte + Jet 架構)
> 適用目標:`/home/carl/git/skt-vuetify-templates`Vue 3 + Vuetify + Pinia
> 原則:不強制遷移既有程式碼,但所有新增功能與重構以本文件為最高準則。
---
## 一、Apple App Store 專案的核心架構特徵 ## 一、Apple App Store 專案的核心架構特徵
### 1.1 單一業務邏輯門面Jet Facade 1 單一業務邏輯門面
2 Intent / Action 分離(查詢與命令)
3 Page Model 驅動 UI(資料驅動)
4 Shelf / Item 分層(容器與內容分離)
5 Svelte Context 作為跨層依賴注入
6 命令式外殼 + 聲明式 UI
``` Read only when needed: [what apple do](./what-apple-do.md)
browser.ts → bootstrap → Jet.load → runtime + objectGraph
UI 層僅透過 jet.dispatch(intent) / jet.perform(action) 溝通
```
- **Jet** 封裝所有業務邏輯:路由、資料取得、動作分發、metrics。
- UI 層**不直接**呼叫 API、不直接操作 storage、不直接操作 history。
- 所有外部依賴(fetch、storage、locale、user)統一注入 `Dependencies`,再組裝成 `ObjectGraph`
### 1.2 Intent / Action 分離(查詢與命令)
| 類型 | 職責 | 回傳值 | 例子 |
|------|------|--------|------|
| **Intent** | 取得頁面資料(Query | `Promise<Page>` | `RouteUrlIntent` → 回傳 `ProductPage` |
| **Action** | 執行副作用(Command | `'performed' \| 'unsupported'` | `FlowAction` → 導航到新頁面 |
- `FlowAction` 是主要導航機制:內含 `destination Intent` + `pageUrl` + `presentationContext`
- Action handler 註冊採用**型別註冊制**:`jet.onAction('flowAction', handler)`
### 1.3 Page Model 驅動 UI(資料驅動)
```ts
// App.svelte 的介面極簡
export let page: Promise<Page> | Page
```
- 整個應用由單一 `page` prop 驅動。
- `PageResolver` 處理 `Promise<Page>` 的 loading / error 狀態。
- `Page.svelte` 用 type guard 分發到對應的 page component`isProductPage(page)``<ProductPage>`
- **Page 是 union type**,不是 route-based 的硬編碼映射。
### 1.4 Shelf / Item 分層(容器與內容分離)
```
Page (TodayPage / ProductPage / ...)
└── Shelf[] (水平捲軸 / 網格)
└── ShelfItemLayout (佈局抽象:HorizontalShelf or Grid)
└── Item (BrickItem / LargeLockupItem / ...)
```
- **Shelf** = 容器邏輯:決定是水平捲軸還是網格、rowsPerColumn、邊框。
- **ShelfItemLayout** = 佈局中介:根據 `isHorizontal` 選擇 `HorizontalShelf``Grid`
- **Item** = 純粹內容渲染:只關心單一資料單位的呈現,不知道自己是水平還是網格。
- **FallbackShelf** = 優雅的降級策略:遇到未實作的 shelf 類型顯示 placeholder,不 crash。
### 1.5 Svelte Context 作為跨層依賴注入
```ts
// bootstrap.ts
context.set('jet', jet)
context.set('i18n', i18nStore)
// 深層元件
const jet = getJet() // 從 Svelte Context 取得
const i18n = getI18n() // 從 Svelte Context 取得
```
- 避免 props 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。
---
## 二、我們專案的現況診斷 ## 二、我們專案的現況診斷
### 2.1 App.vue 過度臃腫(~590 行) Read only when needed: [analyse now](./analyse-now.md)
| 職責 | 行數 | 應屬層級 |
|------|------|----------|
| Layout 切換 | ~20 | App Shell |
| Tabs 管理 | ~80 | Page Driver |
| Breadcrumb 組裝 | ~40 | Layout |
| Favorites 管理 | ~60 | Store |
| Search Dialog | ~80 | App Shell / Widget |
| Message Dialog | ~60 | App Shell / Widget |
| Snackbar | ~10 | Global Overlay |
| Logout / Force logout | ~30 | Auth Flow |
| HTTP Toast | ~20 | Service Layer |
- **問題**App.vue 同時承擔 App Shell、Page Driver、Global Widget、Auth Flow 四種責任。
- **對比**App Store 的 `App.svelte` 只有 161 行,只負責 `Navigation + PageResolver + Footer`
### 2.2 Views 過厚(SingleRecord.vue ~830 行)
- 混雜:表格呈現、搜尋表單、dialog 模板、表單狀態、CRUD 流程、驗證邏輯、分頁、snackbar。
- **對比**App Store 的 `ProductPage.svelte` 只有 77 行,只負責「把 page 轉成 DefaultPageRequirements + 一個 slot override」。
### 2.3 缺乏統一的頁面資料門面
```
現況:
view → store → service(直接鏈式呼叫)
view 自己管理 loading / error / dialog visible
App Store
UI → jet.dispatch(intent) → runtime → controller → page model
UI 只接收 page model,不管理載入狀態
```
### 2.4 Dialog 狀態與模板內嵌於 View
- `SingleRecord.vue` 內含 5 個 `ConfirmDialog` 實例 + 1 個大 form overlay。
- 任何 dialog 更動都需要修改 view 檔案。
### 2.5 沒有容器/內容分離的 Section 層
- 表格、表單、搜尋區塊都是直接寫在 view 或 page component 中。
- 缺乏類似 `ShelfItemLayout` 的通用佈局抽象:「這一區是水平捲軸還是網格」應該由容器決定,裡面的內容元件不應該知道。
---
## 三、優化後的資料流策略 ## 三、優化後的資料流策略
@@ -188,7 +68,7 @@ App Store
```ts ```ts
// src/composables/usePageDriver.ts // src/composables/usePageDriver.ts
export function useStudentMaintenancePage() { export function useMaintenancePage() {
const studentStore = useStudentStore() const studentStore = useStudentStore()
const { records, loading, error, load } = useCrudDriver({ const { records, loading, error, load } = useCrudDriver({
store: studentStore, store: studentStore,
@@ -264,7 +144,7 @@ src/
├── components/ ├── components/
│ ├── pages/ ← 新增:Page Component 層 │ ├── pages/ ← 新增:Page Component 層
│ │ ├── PageStudentMaintenance.vue │ │ ├── PageMaintenance.vue
│ │ └── PageReport.vue │ │ └── PageReport.vue
│ │ │ │
│ ├── sections/ ← 新增:Section / Shelf 層 │ ├── sections/ ← 新增:Section / Shelf 層
@@ -273,7 +153,7 @@ src/
│ │ └── SectionFormPanel.vue │ │ └── SectionFormPanel.vue
│ │ │ │
│ ├── items/ ← 新增:Item / Atom 層(領域獨立) │ ├── items/ ← 新增:Item / Atom 層(領域獨立)
│ │ ├── ItemStudentRow.vue │ │ ├── ItemDataRow.vue
│ │ └── ItemFormField.vue │ │ └── ItemFormField.vue
│ │ │ │
│ ├── layouts/ ← 維持:App Shell Layout │ ├── layouts/ ← 維持:App Shell Layout
@@ -285,11 +165,11 @@ src/
├── composables/ ├── composables/
│ ├── page-drivers/ ← 新增:頁面資料協調 │ ├── page-drivers/ ← 新增:頁面資料協調
│ │ └── useStudentMaintenancePage.ts │ │ └── useMaintenancePage.ts
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action) │ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
│ │ └── useStudentCrudCommands.ts │ │ └── useCrudCommands.ts
│ ├── forms/ ← 維持/重組:表單狀態機 │ ├── forms/ ← 維持/重組:表單狀態機
│ │ └── useStudentForm.ts │ │ └── useForm.ts
│ └── layout/ ← 維持 │ └── layout/ ← 維持
├── models/ ← 新增:領域模型與 Page Union ├── models/ ← 新增:領域模型與 Page Union
@@ -321,15 +201,18 @@ src/
```vue ```vue
<!-- views/maint/SingleRecord.vue優化後 --> <!-- views/maint/SingleRecord.vue優化後 -->
<script setup lang="ts"> <script setup lang="ts">
import { useStudentMaintenancePage } from '@/composables/page-drivers/useStudentMaintenancePage' import { useMaintenancePage } from '@/composables/page-drivers/useMaintenancePage'
import PageStudentMaintenance from '@/components/pages/PageStudentMaintenance.vue' import PageMaintenance from '@/components/pages/PageMaintenance.vue'
const { pageModel, load } = useStudentMaintenancePage() const { pageModel, load } = useMaintenancePage({
title: '單筆資料維護',
records: [],
})
load() load()
</script> </script>
<template> <template>
<PageStudentMaintenance :page="pageModel" /> <PageMaintenance :page="pageModel" />
</template> </template>
``` ```
@@ -340,7 +223,7 @@ load()
- **對齊**App Store 的 `ProductPage.svelte``TodayPage.svelte``DefaultPage.svelte` - **對齊**App Store 的 `ProductPage.svelte``TodayPage.svelte``DefaultPage.svelte`
```vue ```vue
<!-- components/pages/PageStudentMaintenance.vue --> <!-- components/pages/PageMaintenance.vue -->
<template> <template>
<PageMaintShell :title="page.title"> <PageMaintShell :title="page.title">
<template #search> <template #search>
@@ -392,7 +275,7 @@ load()
- **對齊**App Store 的 `BrickItem.svelte``LargeLockupItem.svelte` - **對齊**App Store 的 `BrickItem.svelte``LargeLockupItem.svelte`
```vue ```vue
<!-- ItemStudentRow.vue --> <!-- ItemDataRow.vue -->
<template> <template>
<div class="d-flex ga-2"> <div class="d-flex ga-2">
<v-chip size="small" :color="statusColor(data.status)"> <v-chip size="small" :color="statusColor(data.status)">
@@ -407,7 +290,7 @@ load()
| 場景 | 容器(Section | 內容(Item | | 場景 | 容器(Section | 內容(Item |
|------|-----------------|--------------| |------|-----------------|--------------|
| 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemStudentRow`(決定單列呈現) | | 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemDataRow`(決定單列呈現) |
| 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) | | 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) |
| 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD | `ItemProductCard`(卡片內容) | | 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD | `ItemProductCard`(卡片內容) |
| 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions | `ItemFormFieldGroup`(欄位群組) | | 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions | `ItemFormFieldGroup`(欄位群組) |
@@ -461,30 +344,69 @@ views/xxx.vue
## 五、重構優先順序與遷移路徑 ## 五、重構優先順序與遷移路徑
### Phase 1:建立基礎設施(不動既有 view) ### Phase 1:建立基礎設施(不動既有 view) ✅ 已完成
1. 新增 `src/models/`:定義領域模型與 Page union type。 1. [x] 新增 `src/models/`:定義領域模型與 Page union type。
2. 新增 `src/shell/` `App.vue` 抽出 `GlobalOverlays.vue``AppTabs.vue` - `src/models/student.ts`抽出 `StudentRecord``stores/students.ts` 改為 re-export 以保持向後相容
3. 新增 `src/components/pages/`:建立第一個 `PageStudentMaintenance.vue`(可從 `PageMaint.vue` 擴展) - `src/models/page.ts`:定義 `BasePageModel``MaintenancePageModel``PageModel` union
4. 新增 `src/composables/page-drivers/`:建立 `useStudentMaintenancePage.ts` 2. [x] 新增 `src/shell/`:從 `App.vue` 抽出 `GlobalOverlays.vue``AppTabs.vue`
- `src/shell/AppShell.vue`layout 切換與全域 overlay 掛載點。
- `src/shell/AppTabs.vue`:頁籤管理與 router-view 容器。
- `src/shell/GlobalOverlays.vue`snackbar、搜尋 dialog、訊息 dialog。
3. [x] 新增 `src/components/pages/`:建立第一個 `PageMaintenance.vue`(可從 `PageMaint.vue` 擴展)。
- 定義 `MaintenancePageModel` props 與 `create/edit/view/delete/search` emits。
- 使用 `PageMaint.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
4. [x] 新增 `src/composables/page-drivers/`:建立 `useMaintenancePage.ts`
- 透過 options 傳入 title 與 records,協調搜尋條件、分頁與 `pageModel`
- 提供 `load()``resetSearch()` 供 Page Driver 呼叫。
### Phase 2:遷移最厚的 viewSingleRecord.vue ### Phase 2:遷移最厚的 viewSingleRecord.vue ✅ 已完成
1.`SingleRecord.vue` 的模板抽出到 `PageStudentMaintenance.vue` 1. [x]`SingleRecord.vue` 縮減為 route-level Page Driver
2. 將 dialog 抽出到 `SectionFormPanel.vue` - 目前只負責呼叫 `useSingleRecordMaintenancePage()`,並組裝 `PageMaintenance``SectionSearchPanel``SectionDataTable``SectionFormPanel`
3. 將表單欄位抽出到 `ItemFormFieldGroup.vue``ItemFormField.vue[]` - 行數已由 921 行縮減至 52 行,達成 < 80 行目標
4. 將 CRUD 流程抽出到 `composables/commands/useStudentCrudCommands.ts` 2. [x] 將搜尋區塊抽出到 `src/components/sections/SectionSearchPanel.vue`
5. `SingleRecord.vue` 目標縮減到 < 80 行 - 負責搜尋欄位佈局與 reset 事件,不直接操作 store
3. [x] 將表格與分頁抽出到 `src/components/sections/SectionDataTable.vue`
- 負責 headers、資料列 slot、操作按鈕與分頁 footer,透過 emit 回傳 view/edit/delete/page 事件。
4. [x] 將 dialog 抽出到 `src/components/sections/SectionFormPanel.vue`
- 包含側邊 overlay、`MntDialogCard`、record navigation toolbar 與確認 dialog。
- View 中不再直接定義 `<teleport>``<v-overlay>` 或多個確認 dialog。
5. [x] 將表單欄位抽出到 `src/components/items/ItemFormFieldGroup.vue`
- 只呈現欄位與欄位錯誤,透過 `v-model``clear-field-error` 與上層互動。
6. [x] 將 CRUD command 流程抽出到 `src/composables/commands/useCrudCommands.ts`
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
- `SingleRecord.vue` 不再直接操作 `studentStore`
### Phase 3:推廣到所有 maintenance 頁面 ### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
1. `EditableGrid.vue``MasterDetailA/B/C.vue` 依同樣模式重構。 1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
2. 建立通用的 `useCrudPageDriver()``useCrudCommands()`,減少重複 - `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`
- 新增 `src/components/pages/PageEditableGridMaintenance.vue`,保留既有 `src/components/maint/EditableGrid.vue` 作為主要內容元件。
2. [x] `MasterDetailA.vue` 依 Page Driver + Page Component 模式重構。
- `src/views/maint/MasterDetailA.vue` 縮減為 34 行。
- 新增 `src/composables/page-drivers/useMasterDetailAMaintenancePage.ts`
- 新增 `src/components/pages/PageMasterDetailAMaintenance.vue` 承接原本主從維護 UI。
3. [x] `MasterDetailB.vue``MasterDetailC.vue` 依 Page Driver + Page Component 模式重構。
- `src/views/maint/MasterDetailB.vue``src/views/maint/MasterDetailC.vue` 均縮減為 10 行。
- 新增 `src/composables/page-drivers/useMasterDetailBMaintenancePage.ts``src/composables/page-drivers/useMasterDetailCMaintenancePage.ts`
- 新增 `src/components/pages/PageMasterDetailBMaintenance.vue``src/components/pages/PageMasterDetailCMaintenance.vue`
4. [x] 通用方向已落地為「每頁 page driver + page component」與既有 `useCrudCommands()`
- Phase 3 未再抽出跨 A/B/C 的大型共用 driver,避免在 demo 變體尚未收斂前建立過早抽象。
### Phase 4:非 maintenance 頁面統一 ### Phase 4:非 maintenance 頁面統一 ✅ 已完成
1. `Home.vue``Settings.vue``FncPage.vue` 套用 Page Driver + Page Component 模式。 1. [x] `Home.vue``Settings.vue``FncPage.vue` 套用 Page Driver + Page Component 模式。
2. `App.vue` 最終只保留 layout 切換與 `GlobalOverlays` 掛載 - `src/views/Home.vue` 縮減為 17 行,新增 `src/components/pages/PageHome.vue``src/composables/page-drivers/useHomePage.ts`
- `src/views/Settings.vue` 縮減為 10 行,新增 `src/components/pages/PageSettings.vue``src/composables/page-drivers/useSettingsPage.ts`
- `src/views/FncPage.vue` 縮減為 10 行,新增 `src/components/pages/PageFunction.vue``src/composables/page-drivers/useFunctionPage.ts`
2. [x] `App.vue` 最終只保留 shell 掛載。
- `src/App.vue` 縮減為 7 行,只掛載 `AppShell`
- `src/shell/AppShell.vue` 承接 layout 切換、layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
- `src/composables/layout/useAppShell.ts` 承接 menu 合併、favorite、breadcrumb、layout action、手動/強制登出流程。
--- ---
@@ -494,14 +416,14 @@ views/xxx.vue
|------|------|---------------| |------|------|---------------|
| App Shell | `src/shell/` | `AppShell.vue``GlobalOverlays.vue` | | App Shell | `src/shell/` | `AppShell.vue``GlobalOverlays.vue` |
| Page Driver | `src/views/` | `SingleRecord.vue`route view,不改名) | | Page Driver | `src/views/` | `SingleRecord.vue`route view,不改名) |
| Page Component | `src/components/pages/` | `PageStudentMaintenance.vue` | | Page Component | `src/components/pages/` | `PageMaintenance.vue` |
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue``SectionSearchPanel.vue` | | Section / Shelf | `src/components/sections/` | `SectionDataTable.vue``SectionSearchPanel.vue` |
| Item / Atom | `src/components/items/` | `ItemStudentRow.vue``ItemFormField.vue` | | Item / Atom | `src/components/items/` | `ItemDataRow.vue``ItemFormField.vue` |
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) | | Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) | | Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
| Page Driver Composable | `src/composables/page-drivers/` | `useStudentMaintenancePage.ts` | | Page Driver Composable | `src/composables/page-drivers/` | `useMaintenancePage.ts` |
| Command Composable | `src/composables/commands/` | `useStudentCrudCommands.ts` | | Command Composable | `src/composables/commands/` | `useCrudCommands.ts` |
| Form Composable | `src/composables/forms/` | `useStudentForm.ts` | | Form Composable | `src/composables/forms/` | `useForm.ts` |
| Domain Store | `src/stores/` | `students.ts`(維持) | | Domain Store | `src/stores/` | `students.ts`(維持) |
| Service Module | `src/services/modules/` | `students.ts`(維持) | | Service Module | `src/services/modules/` | `students.ts`(維持) |
| Domain Model | `src/models/` | `student.ts``page.ts` | | Domain Model | `src/models/` | `student.ts``page.ts` |
@@ -521,4 +443,4 @@ views/xxx.vue
--- ---
*本文件取代 `docs/frontend-layering.md` 與 `src/components/README.md` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。* *本文件取代 `docs/frontend-layering.md` 與 `src/components/GUIDE.md` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。*
+74 -276
View File
@@ -2,303 +2,101 @@
## 文件目的 ## 文件目的
本專案是給其他 Vue/Vuetify 專案使用的 template。LLM 協助修改時,主要工作應集中`src` 底下新增或修改頁面、元件、store、service 與 composable,並讓一般頁面自然被 `MainLayout` 包住 本專案是給其他 Vue/Vuetify 專案使用的 template。LLM 協助修改時,預設應`src` 底下依分層規則新增或修改頁面、元件、store、service 與 composable。
本文件描述的是後續 LLM 依照 template 修改專案時的預設操作規則,不是目前對話 session 的永久限制。若使用者明確要求修改 template shell 或登入頁入口,可依需求處理 本文件只保留全域操作順序與導覽。各層細節規範放在 `src/**/GUIDE.md`,避免重複維護
建議閱讀順序 ## 建議閱讀順序
1. `README.md` 1. `README.md`
2. `src/README.md` 2. `docs/architecture-strategy.md`
3. `docs/add-page-example.md` 3. `src/GUIDE.md`
4. `docs/frontend-layering.md` 4. 依變更範圍閱讀對應的 `src/**/GUIDE.md`
5. `docs/add-page-example.md`(需要新增頁面時)
## 預設不可修改的檔案 `docs/frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md``src/**/GUIDE.md` 為準。
以下檔案視為 template 核心邊界。LLM 依本文件處理一般功能需求時,不應修改: ## GUIDE 索引
- `src/components/layouts/MainLayout.vue` | 範圍 | 指南 |
- `src/views/Login.vue` |------|------|
| `src` 總覽、資料流、template core、demo 邊界 | `src/GUIDE.md` |
| route view 與薄 view 規則 | `src/views/GUIDE.md` |
| maintenance demo view | `src/views/maint/GUIDE.md` |
| Vue component 分層 | `src/components/GUIDE.md` |
| base 元件 | `src/components/base/GUIDE.md` |
| section 元件 | `src/components/sections/GUIDE.md` |
| layout 邊界 | `src/components/layouts/GUIDE.md` |
| page driver、command、layout composable | `src/composables/GUIDE.md` |
| route 與 guard | `src/router/GUIDE.md` |
| AppShell、tabs、global overlays | `src/shell/GUIDE.md` |
| Pinia store | `src/stores/GUIDE.md` |
| HTTP service / ky / API module | `src/services/GUIDE.md` |
| domain model 與 page model 型別 | `src/models/GUIDE.md` |
| 跨模組共用 API 型別 | `src/types/GUIDE.md` |
| i18n 文案 | `src/language/GUIDE.md` |
如果一般功能需求看起來需要修改上述檔案,先停下來回報原因,並改從可修改檔案尋找解法。只有在使用者明確指定要調整 layout shell 或登入頁入口時,才可以修改。 ## 預設修改策略
## 優先修改的位置 一般功能需求優先修改:
一般功能需求應優先落在:
- `src/views/*` - `src/views/*`
- `src/views/<feature>/*` - `src/components/pages/*`
- `src/components/<feature>/*` - `src/components/sections/*`
- `src/composables/<feature>/*` - `src/components/items/*`
- `src/composables/page-drivers/*`
- `src/composables/commands/*`
- `src/stores/*` - `src/stores/*`
- `src/services/*` - `src/services/modules/*`
- `src/router/routes.ts` - `src/router/routes.ts`
- `src/language/*.json` - `src/language/*.json`
新增頁面時,通常只需要新增 view、必要的 feature components,並在 `src/router/routes.ts` 加入 route 除非使用者明確要求,避免先修改 template core。template core 清單與 demo/example 邊界見 `src/GUIDE.md`
## 依循 `src/` 既有慣例 ## 常用判斷
開始實作前,先檢查需求最接近的既有檔案,並沿用目前 `src/` 的責任分工、命名方式、資料流與 import 寫法。不要另起一套資料夾結構、狀態管理方式或 API 呼叫方式 - 新 route:讀 `src/router/GUIDE.md`
- 一般頁面:讀 `src/views/GUIDE.md``src/components/GUIDE.md``src/composables/GUIDE.md`
目前主要資料流是: - 維護頁:讀 `src/views/maint/GUIDE.md`
- 查詢/列表頁(篩選 + 表格):讀 `src/components/sections/GUIDE.md``SectionQueryPage`)。
```txt - 申請/填寫頁(送出按鈕):讀 `src/components/sections/GUIDE.md``SectionFormPage`)。
router -> App.vue -> layout -> view -> component -> composable/store -> service - layout / AppShell / tabs / global overlay:讀 `src/shell/GUIDE.md``src/components/layouts/GUIDE.md`
``` - API 串接:讀 `src/services/GUIDE.md`
- 跨頁共享狀態:讀 `src/stores/GUIDE.md`
判斷原則: - 定義 page model 或 domain model 型別:讀 `src/models/GUIDE.md`
- 共用 API 型別定義:讀 `src/types/GUIDE.md`
- `router` 決定 route、layout meta、auth meta 與錯誤頁入口 - 錯誤頁:讀 `src/views/GUIDE.md`ErrorShell 模式)與 `src/router/GUIDE.md`(錯誤頁路由慣例)。
- `App.vue` 組裝 layout props、全域 UI 與 layout event - 語系文案:讀 `src/language/GUIDE.md`
- `views` 承接路由入口、頁面資料協調與頁面事件協調
- `components` 承接畫面呈現、props/emits 與可拆分 UI 區塊
- `composables` 承接可重用流程、頁面狀態機或較複雜的 UI state
- `stores` 承接跨頁共享狀態、快取與全域顯示狀態
- `services` 承接 HTTP client、API 模組、token、session 與錯誤處理
新增功能時,優先找相同類型的現有範例再修改:
- 新 route:參考 `src/router/routes.ts`
- 一般被主 layout 包住的頁面:參考 `src/views/Home.vue``src/views/maint/EditableGrid.vue`
- 登入相關 UI:參考 `src/components/PageLogin.vue``src/components/login/*`
- 維護頁:參考 `src/views/maint/*``src/components/maint/*``src/composables/maint/*`
- 維護頁範本選擇:參考 `src/views/maint/README.md`
- API 呼叫:參考 `src/services/modules/*` 與使用它們的 store/composable
- 全域提示:參考 `src/stores/snackbar.ts``src/composables/useApiCall.ts`
## Template Core 與 Demo 邊界
一般功能需求應避免先修改 template core。這些檔案支撐 app shell、route/layout、登入、全域狀態與 API 基礎設施:
- `src/main.ts`
- `src/App.vue`
- `src/router/index.ts`
- `src/router/guards.ts`
- `src/components/layouts/*`
- `src/views/Login.vue`
- `src/plugins/*`
- `src/styles/*`
- `src/services/client.ts`
- `src/services/interceptors.ts`
- `src/services/token.ts`
- `src/services/session.ts`
- `src/services/error.ts`
- `src/services/http-error.ts`
- `src/services/http-toast.ts`
- `src/stores/auth.ts`
- `src/stores/menu.ts`
- `src/stores/breadcrumbs.ts`
- `src/stores/favorites.ts`
- `src/stores/messages.ts`
- `src/stores/snackbar.ts`
- `src/stores/app.ts`
- `src/composables/layout/*`
以下內容偏向 demo/example,建立正式專案時可依需求替換或刪除:
- `src/views/Home.vue`
- `src/components/PageIndex.vue`
- `src/views/maint/*`
- `src/components/maint/*`
- `src/composables/maint/*`
- `src/components/PageMaint.vue`
- `src/stores/students.ts`
- `src/stores/semesters.ts`
- `src/views/FncPage.vue`
- `src/views/Settings.vue`
- `src/assets/logo.png`
- `src/assets/logo.svg`
- `src/assets/robot-svgrepo-com.svg`
`maint` 是可參考的 demo feature,不是所有新專案都必須保留的核心功能。
移除 demo/example 時,同步清理 `src/router/routes.ts`、相關 menu/favorites/breadcrumb 流程、語系文案、assets 與 import。
## 新增一般頁面的流程
1.`src/views``src/views/<feature>` 新增頁面檔案。
2. 若頁面超過單一簡單畫面,將主要 UI 拆到 `src/components/<feature>`
3. 若有可重用或狀態較多的流程,放到 `src/composables/<feature>`
4. 若資料需要跨頁共享,才新增或修改 `src/stores`
5.`src/router/routes.ts` 新增 route。
6. 一般頁面的 route 必須使用 `meta: { layout: 'default' }`
範例:
```ts
{
path: '/reports',
name: 'reports',
component: () => import('@/views/reports/Reports.vue'),
meta: { layout: 'default' },
}
```
使用 `layout: 'default'` 的頁面會由 `App.vue` 放進 `MainLayout` 的預設 slot,不需要也不可以直接 import 或包裝 `MainLayout.vue`
完整範例見 `docs/add-page-example.md`
## 何時使用 `layout: 'none'`
只有下列頁面應使用 `meta: { layout: 'none' }`
- 登入頁
- 錯誤頁
- 維護中頁
- 明確要求不要被主框架包住的獨立頁
不要為了一般功能頁使用 `layout: 'none'`
## Layout 邊界
`MainLayout` 只負責 app shell
- drawer
- app bar
- breadcrumb
- favorites
- toolbar actions
- 主內容 slot
頁面專屬業務流程、資料規則、表單、列表、對話框、查詢條件與 CRUD 行為都不應放進 layout。
如果頁面需要在主內容區呈現特定畫面,請修改該 route 對應的 view 或 feature component。
如果頁面需要影響 breadcrumb、favorites、menu 或 toolbar,優先使用現有 store、route meta 或 `App.vue` 已經提供的 layout props/event 流程,不要修改 `MainLayout.vue`
## Login 邊界
`src/views/Login.vue` 是登入頁入口。一般功能需求預設不修改這個檔案。
若需求是調整登入頁內部區塊,優先檢查並修改:
- `src/components/PageLogin.vue`
- `src/components/login/*`
- `src/stores/auth.ts`
- `src/stores/loginAnnouncements.ts`
- `src/services/modules/auth.ts`
若需求明確要求改變登入頁 route、guard 或登入後導向,優先檢查:
- `src/router/routes.ts`
- `src/router/guards.ts`
- `src/stores/auth.ts`
## View 與 Component 分工
`views` 是路由入口,負責:
- 接 route params/query
- 組合頁面資料
- 串接 store、service、composable
- 管理頁面專屬事件
- 組裝頁面主 component
`components/<feature>` 負責:
- 呈現頁面區塊
- 接 props
- emit 使用者事件
- 拆分表單、列表、工具列、dialog 等 UI
不要把完整功能長期塞在單一 view。當畫面有多個區塊、表單、列表、dialog 或重複 UI 時,應拆到 feature components。
## Router 安排規則
route 集中放在 `src/router/routes.ts`。不要在 view 或 component 裡臨時建立 route 設定。
一般 route 應包含:
- `path`
- `name`
- `component`
- `meta.layout`
需要登入才可進入的頁面使用既有 `requiresAuth` meta。訪客專用頁面使用既有 `guestOnly` meta。導航守衛流程放在 `src/router/guards.ts`,不要散落在頁面 component。
## Composable 使用規則
只有在邏輯有下列特性時才新增 composable
- 會被多個元件使用
- 狀態流程明顯
- 有副作用或非簡單 UI 邏輯
- 從 component 抽出後可讓 component 責任更清楚
目前既有 composable 的使用定位:
- `src/composables/useApiCall.ts`:包裝可重用 API 呼叫狀態與錯誤提示流程
- `src/composables/layout/useAdminLayoutState.ts`:提供 layout shell 所需的狀態組裝
- `src/composables/layout/useThemeToggle.ts`:提供主題切換流程
- `src/composables/maint/useMaintenanceCrudFlow.ts`:提供維護頁 CRUD 流程狀態
- `src/composables/maint/useStudentMaintenanceForm.ts`:提供學生維護表單狀態
- `src/composables/maint/useEditableStudentGrid.ts`:提供 editable grid 狀態
新增 composable 時,用 `useXxx.ts` 命名。若只服務單一 feature,放在 `src/composables/<feature>`;若確定跨 feature 使用,才放在 `src/composables` 根目錄。
## Store 與 Service 資料流規則
只有在資料或狀態跨頁共享時才使用 store。單一頁面的暫時 UI 狀態應留在 view、feature component 或 composable。
store 檔案直接放在 `src/stores/*.ts`。不要建立 `src/stores/stores/*` 或其他重複巢狀 store 目錄。
service 放在 `src/services`,負責外部 API 與 HTTP 細節。component 不應直接處理底層 HTTP client、token、hooks 或錯誤正規化。
資料流優先順序:
1. component 透過 props/emits 與 view 或 page component 溝通。
2. view 或 composable 協調頁面流程。
3. 跨頁共享狀態由 store 管理。
4. store 或 composable 呼叫 service。
5. service 回傳資料,不持有 UI 狀態。
不要讓 service import component、view 或 store。不要讓 component 直接繞過既有 store/composable 去操作 token、session 或 HTTP hooks。
## 環境變數規則
template 提供 `.env.example` 作為範本。不要提交實際 `.env``.env.*.local`
常用變數:
- `VITE_API_BASE_URL`API base URL,預設使用 `/service/api` 搭配 Vite proxy。
- `VITE_SKIP_LOGIN`:登入示範開關,只有專案明確支援時才使用。
- `VITE_DEV_DEFAULT_USER_ID``VITE_DEV_DEFAULT_PASSWORD`:本機開發示範帳號,放在本機 env,不要寫進程式。
## Vuetify 使用規則
優先使用 Vuetify 原生元件、props、slots 與事件。當需求直接牽涉 Vuetify 元件行為、DOM、可及性輸出或 slot 結構時,先查官方 Vuetify API 文件,再修改實作。
icon 使用 `@mdi/js`
```vue
<script setup lang="ts">
import { mdiAccount } from '@mdi/js'
</script>
<template>
<v-icon :icon="mdiAccount" />
</template>
```
## 修改前檢查 ## 修改前檢查
開始改檔前先確認: - 是否碰到 template core。
- 是否已有同類型範例可沿用。
- 是否需要新增 route。
- 是否應拆成 page / section / item。
- 是否應新增 page driver 或 command composable。
- 是否需要 store,或只需要頁面內 state。
- 是否應定義新的 model 型別(`src/models/`)。
- 是否需要更新語系、menu、breadcrumb、favorites。
- 是否會碰到禁止修改檔案 ## 從視覺特徵選擇 section 元件
- 是否只需要新增或修改 view
- 是否需要新增 route 當收到 prototype 截圖或設計稿時,依畫面特徵選擇 section 外殼:
- 是否需要拆 feature component
- 是否已閱讀 `docs/frontend-layering.md` | 特徵 | 選擇 |
- 是否已參考 `src/` 裡相同類型的既有範例 |------|------|
- 是否符合 Vue 3、Composition API、`<script setup lang="ts">` 的既有寫法 | 有「送出/存檔」按鈕,且畫面為填寫表單(欄位 + 配合事項 + 動作按鈕) | `SectionFormPage` |
| 有「查詢」按鈕,且畫面為篩選條件 + 結果表格/列表 | `SectionQueryPage` |
| 純粹表格列表(無送出/查詢按鈕,只有 CRUD 操作) | 不用 section 外殼,直接組合 `v-data-table` |
| 混合結構(有查詢也有表單填寫) | 評估是否拆成兩頁;若必須同頁,不用通用外殼 |
判斷順序:先看有無「送出/存檔」→ 再看有無「查詢」→ 其餘視為一般列表頁。
## 完成前驗證 ## 完成前驗證
修改完成後,至少執行與變更範圍相符的檢查: - Vue / TypeScript 結構有變更:`pnpm -s type-check`
- 需要確認產物可建置:`pnpm -s build`
- TypeScript 或 Vue 結構有變更:`pnpm type-check` - Markdown 或大量搬移:`git diff --check`
- 需要確認產物可建置:`pnpm build` - route、layout 或主要畫面流程有變更:啟動 dev server 並用瀏覽器確認,除非使用者明確不需要。
- route、layout 或主要畫面流程有變更:啟動 dev server 並用瀏覽器確認頁面被正確包在主 layout 中
如果無法執行驗證,回報原因,不要宣稱已驗證。 如果無法執行驗證,回報原因,不要宣稱已驗證。
+81
View File
@@ -0,0 +1,81 @@
## 一、Apple App Store 專案的核心架構特徵
### 1.1 單一業務邏輯門面(Jet Facade)
```
browser.ts → bootstrap → Jet.load → runtime + objectGraph
UI 層僅透過 jet.dispatch(intent) / jet.perform(action) 溝通
```
- **Jet** 封裝所有業務邏輯:路由、資料取得、動作分發、metrics。
- UI 層**不直接**呼叫 API、不直接操作 storage、不直接操作 history。
- 所有外部依賴(fetch、storage、locale、user)統一注入 `Dependencies`,再組裝成 `ObjectGraph`
### 1.2 Intent / Action 分離(查詢與命令)
| 類型 | 職責 | 回傳值 | 例子 |
|------|------|--------|------|
| **Intent** | 取得頁面資料(Query | `Promise<Page>` | `RouteUrlIntent` → 回傳 `ProductPage` |
| **Action** | 執行副作用(Command | `'performed' \| 'unsupported'` | `FlowAction` → 導航到新頁面 |
- `FlowAction` 是主要導航機制:內含 `destination Intent` + `pageUrl` + `presentationContext`
- Action handler 註冊採用**型別註冊制**:`jet.onAction('flowAction', handler)`
### 1.3 Page Model 驅動 UI(資料驅動)
```ts
// App.svelte 的介面極簡
export let page: Promise<Page> | Page
```
- 整個應用由單一 `page` prop 驅動。
- `PageResolver` 處理 `Promise<Page>` 的 loading / error 狀態。
- `Page.svelte` 用 type guard 分發到對應的 page component`isProductPage(page)``<ProductPage>`
- **Page 是 union type**,不是 route-based 的硬編碼映射。
### 1.4 Shelf / Item 分層(容器與內容分離)
```
Page (TodayPage / ProductPage / ...)
└── Shelf[] (水平捲軸 / 網格)
└── ShelfItemLayout (佈局抽象:HorizontalShelf or Grid)
└── Item (BrickItem / LargeLockupItem / ...)
```
- **Shelf** = 容器邏輯:決定是水平捲軸還是網格、rowsPerColumn、邊框。
- **ShelfItemLayout** = 佈局中介:根據 `isHorizontal` 選擇 `HorizontalShelf``Grid`
- **Item** = 純粹內容渲染:只關心單一資料單位的呈現,不知道自己是水平還是網格。
- **FallbackShelf** = 優雅的降級策略:遇到未實作的 shelf 類型顯示 placeholder,不 crash。
### 1.5 Svelte Context 作為跨層依賴注入
```ts
// bootstrap.ts
context.set('jet', jet)
context.set('i18n', i18nStore)
// 深層元件
const jet = getJet() // 從 Svelte Context 取得
const i18n = getI18n() // 從 Svelte Context 取得
```
- 避免 props 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。
---
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"vuetify-mcp": {
"type": "remote",
"url": "https://mcp.vuetifyjs.com/mcp",
"enabled": true
}
}
}
+6 -588
View File
@@ -1,589 +1,7 @@
<template> <script setup lang="ts">
<!-- 根據路由設定 meta.layout 動態切換佈局 --> import AppShell from '@/shell/AppShell.vue'
<component
:is="activeLayout"
v-bind="layoutProps"
v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
v-model:is-rail="menuStore.isRail"
@action="handleLayoutAction"
@logout="handleLogout"
@remove-favorite="handleRemoveFavorite"
@search="handleSearch"
@select="handleSelect"
>
<template #breadcrumb-actions>
<v-btn
color="secondary"
:disabled="isFavoriteActionDisabled"
size="small"
variant="outlined"
@click="toggleFavorite"
>
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
{{ favoriteActionLabel }}
</v-btn>
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
<v-icon class="mr-1" size="14" :icon="mdiHome" />
返回首頁
</v-btn>
</template>
<!-- 如果是預設佈局顯示分頁標籤 -->
<template v-if="showTabs">
<div class="d-flex flex-column h-100">
<v-tabs
v-model="activeTab"
bg-color="background"
color="primary"
density="compact"
show-arrows
style="flex-shrink: 0"
>
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
{{ tab.title }}
<v-btn
aria-label="關閉頁籤"
class="pl-2"
color="grey"
density="compact"
:disabled="tabs.length <= 1"
icon
size="x-small"
variant="text"
@click.prevent.stop="closeTab(tab.path)"
>
<v-icon :icon="mdiClose" />
</v-btn>
</v-tab>
</v-tabs>
<div class="flex-grow-1 overflow-auto" style="min-height: 0" tabindex="0">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</div>
</template>
<!-- 其他佈局直接顯示內容 -->
<router-view v-else />
</component>
<v-dialog v-model="searchDialog" max-width="640">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
<v-card-subtitle v-if="searchKeyword" class="pt-4"
>關鍵字{{ searchKeyword }}</v-card-subtitle
>
<v-card-text class="pt-2">
<v-alert
v-if="searchResults.length === 0"
class="mt-2"
density="compact"
type="info"
variant="tonal"
>
查無結果
</v-alert>
<v-list v-else density="compact">
<v-list-item
v-for="item in searchResults"
:key="item.path"
class="mb-2"
@click="handleSearchSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.parents?.length">
{{ item.parents.join(' / ') }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="searchDialog = false">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!--
訊息中心 Dialog
放在 App.vue 的原因是要被首頁卡片頂部工具列訊息按鈕共同觸發
並且避免在 layout/template 層放入業務 UI維持模板的純展示特性
-->
<v-dialog v-model="messageStore.isOpen" max-width="720">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis"
>僅示意資料不含延伸功能</v-card-subtitle
>
<v-card-text class="pa-4">
<!--
使用 v-data-iterator 進行資料展示
這樣若未來要加排序或分頁不需改動結構
-->
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
<template #default="{ items }">
<v-list density="compact">
<v-list-item
v-for="wrapped in items"
:key="resolveMessageItem(wrapped).id"
border="sm"
class="pa-2 mb-1"
>
<template #prepend>
<v-avatar color="primary" size="28" variant="tonal">
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
</v-avatar>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
{{ resolveMessageItem(wrapped).title }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-medium-emphasis">
{{ resolveMessageItem(wrapped).meta }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
</v-data-iterator>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="messageStore.close()">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.visible"
:color="snackbar.color"
:location="snackbar.location"
:timeout="snackbar.timeout"
:variant="snackbar.variant"
>
{{ snackbar.message }}
</v-snackbar>
</template>
<script setup>
import {
mdiBellOutline,
mdiCalendarOutline,
mdiClose,
mdiCloseCircle,
mdiCog,
mdiFileDocumentOutline,
mdiFileTreeOutline,
mdiHome,
mdiHomeCityOutline,
mdiPlusCircle,
mdiSchoolOutline,
mdiTableEdit,
} from '@mdi/js'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MainLayout from '@/components/layouts/MainLayout.vue'
import PlainLayout from '@/components/layouts/PlainLayout.vue'
import { HTTP_TOAST_EVENT } from './services/http-toast'
import { SESSION_FORCE_LOGOUT_EVENT } from './services/session'
import { useAuthStore } from './stores/auth'
import { useBreadcrumbStore } from './stores/breadcrumbs'
import { useFavoritesStore } from './stores/favorites'
import { useMenuStore } from './stores/menu'
import { useMessageStore } from './stores/messages'
import { useSnackbarStore } from './stores/snackbar'
const route = useRoute()
const router = useRouter()
const snackbar = useSnackbarStore()
const authStore = useAuthStore()
const menuStore = useMenuStore()
const breadcrumbStore = useBreadcrumbStore()
const favoritesStore = useFavoritesStore()
// 訊息中心:集中控制 dialog 顯示狀態
const messageStore = useMessageStore()
// 固定選單(合併到 API 回傳的選單)
const _fixedMenuItems = [
{
title: '資料維護',
navigable: false,
subItems: [
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
],
},
{ title: '登入頁', path: '/login' },
]
// 範例選單(用於 tab 顯示名稱的保底資料)
const _menuItemsExample = [
{ title: '首頁', icon: mdiHome, path: '/' },
{
title: '設定',
icon: mdiCog,
path: '/settings',
navigable: false,
},
..._fixedMenuItems,
]
/**
* 佈局對映表
*/
const layoutMap = {
default: MainLayout,
none: PlainLayout,
}
// 取得當前應使用的組件
const activeLayout = computed(() => {
return layoutMap[route.meta.layout] || MainLayout
})
function buildMergedMenuItems(items) {
const flatPaths = new Set()
const collectPaths = (list) => {
for (const item of list || []) {
if (item?.path) flatPaths.add(item.path)
if (item?.subItems?.length) collectPaths(item.subItems)
}
}
collectPaths(items)
const mergeFixedItems = (list) => {
return (list || []).map((item) => {
if (!item?.subItems?.length) return item
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
return { ...item, subItems }
})
}
const fixedItems = mergeFixedItems(_fixedMenuItems).filter((item) => {
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
return item.subItems.length > 0
})
return [...(items || []), ...fixedItems]
}
// 根據不同 Layout 傳遞不同的 Props
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
const mergedFavoriteItems = computed(() => {
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
const seen = new Set()
return combined.filter((item) => {
const key = item.path ?? item.title
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
})
const layoutProps = computed(() => {
const layout = route.meta.layout
if (layout === 'default') {
return {
systemTitle: '測試環境',
favoriteItems: mergedFavoriteItems.value,
menuItems: mergedMenuItems.value,
breadcrumbItems: breadcrumbStore.breadcrumbItems,
}
}
return {}
})
function handleSelect(item) {
console.log('Selected:', item)
if (item.path) {
router.push(item.path)
}
}
const searchDialog = ref(false)
const searchKeyword = ref('')
const searchResults = ref([])
function buildSearchResults(items, keyword, parents = []) {
const results = []
for (const item of items || []) {
const currentParents = item?.title ? [...parents, item.title] : parents
if (item?.subItems?.length) {
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
}
if (item?.path && item?.title) {
const hit = item.title.toLowerCase().includes(keyword)
if (hit) {
results.push({
title: item.title,
path: item.path,
icon: item.icon,
parents: parents,
})
}
}
}
return results
}
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
function handleSearch(value) {
const keyword = String(value ?? '').trim()
searchKeyword.value = keyword
if (!keyword) {
// 空字串時不顯示結果彈窗
searchResults.value = []
searchDialog.value = false
return
}
const lowered = keyword.toLowerCase()
// 依合併後的 menuItems 進行比對
searchResults.value = buildSearchResults(mergedMenuItems.value, lowered)
// 開啟彈窗顯示搜尋結果
searchDialog.value = true
}
// 點擊搜尋結果後導頁(行為等同選單點擊)
function handleSearchSelect(item) {
searchDialog.value = false
handleSelect(item)
}
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
const messageItems = [
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: mdiBellOutline },
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: mdiSchoolOutline },
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: mdiHomeCityOutline },
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: mdiCalendarOutline },
]
// v-data-iterator 會包裝 items,這裡取回原始資料物件
function resolveMessageItem(wrapped) {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return wrapped.raw
}
return wrapped
}
// 由 layout 的 action 事件統一進入此處處理
// 目前只處理訊息中心,其他 action 可在此擴充
function handleLayoutAction(type) {
if (type === 'messages') {
messageStore.open()
return
}
}
function performLogout({ message, color }) {
authStore.logout()
tabs.value = []
activeTab.value = null
snackbar.show({ message, color })
router.replace({ name: 'login' })
}
function handleLogout() {
performLogout({ message: '登出成功', color: 'success' })
}
function handleForceLogout(event) {
const message = event?.detail?.message || '請重新登入'
performLogout({ message, color: 'warning' })
}
function handleHttpToast(event) {
const detail = event?.detail
const message = detail?.message
if (!message) return
const level = detail?.level
const color = level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'
snackbar.show({ message, color, timeout: 3000, location: 'top right', variant: 'flat' })
}
onMounted(() => {
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
onBeforeUnmount(() => {
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
// --- Tabs Logic ---
const tabs = ref([])
const activeTab = ref(null)
const showTabs = computed(() => {
return route.meta.layout === 'default'
})
// 遞迴尋找標題
function findTitle(path) {
const recursiveFind = (items) => {
for (const item of items) {
if (item.path === path) return item.title
if (item.subItems?.length) {
const found = recursiveFind(item.subItems)
if (found) return found
}
}
return null
}
// 1. 搜尋 Store 中的選單
let title = recursiveFind(menuStore.menuItems)
if (title) return title
// 2. 搜尋最愛選單
title = recursiveFind(menuStore.favoriteItems)
if (title) return title
// 3. 搜尋靜態範例選單
title = recursiveFind(_menuItemsExample)
if (title) return title
// 4. 特殊路徑處理
if (path === '/') return '首頁'
return path
}
function findMenuItem(path) {
const recursiveFind = (items) => {
for (const item of items) {
if (item.path === path) return item
if (item.subItems?.length) {
const found = recursiveFind(item.subItems)
if (found) return found
}
}
return null
}
return recursiveFind(mergedMenuItems.value)
}
const currentFavoriteInfo = computed(() => {
const path = route.path
const menuItem = findMenuItem(path)
const title =
menuItem?.title ||
(typeof route.meta?.title === 'string' ? route.meta.title : null) ||
findTitle(path)
return {
title,
path,
icon: menuItem?.icon,
}
})
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
const isFavoriteActionDisabled = computed(
() => !currentFavoriteInfo.value?.path || route.path === '/'
)
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
const favoriteActionIcon = computed(() =>
isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle
)
function toggleFavoriteItem(item) {
if (!item?.path || item.path === '/') return
favoritesStore.toggle({
title: item.title || findTitle(item.path),
path: item.path,
icon: item.icon,
})
}
function toggleFavorite() {
toggleFavoriteItem(currentFavoriteInfo.value)
}
function handleRemoveFavorite(item) {
toggleFavoriteItem(item)
}
function goHome() {
router.push('/')
}
function updateBreadcrumbs() {
const resolvedTitle = findTitle(route.path)
const fallbackTitle =
resolvedTitle && resolvedTitle !== route.path
? resolvedTitle
: typeof route.meta?.title === 'string'
? route.meta.title
: null
breadcrumbStore.setBreadcrumbs({
path: route.path,
menuItems: mergedMenuItems.value,
favoriteItems: mergedFavoriteItems.value,
fallbackTitle,
homeLabel: '首頁',
homeIcon: mdiHome,
})
}
watch(
[
() => route.path,
() => menuStore.menuItems,
() => menuStore.favoriteItems,
() => favoritesStore.items,
],
() => updateBreadcrumbs(),
{ immediate: true, deep: true }
)
// 監聽路由變化,新增 Tab
watch(
() => route.path,
(newPath) => {
if (!showTabs.value) return
const existingTab = tabs.value.find((t) => t.path === newPath)
if (!existingTab) {
const title = findTitle(newPath)
tabs.value.push({ title, path: newPath })
}
activeTab.value = newPath
},
{ immediate: true }
)
function closeTab(path) {
if (tabs.value.length <= 1) return
const index = tabs.value.findIndex((t) => t.path === path)
if (index === -1) return
tabs.value.splice(index, 1)
// 如果關閉的是當前分頁,則跳轉到其他分頁
if (route.path === path) {
const nextTab = tabs.value[index] || tabs.value[index - 1]
if (nextTab) {
router.push(nextTab.path)
} else {
// 若無剩餘分頁,回到首頁
router.push('/')
}
}
}
</script> </script>
<template>
<AppShell />
</template>
+90
View File
@@ -0,0 +1,90 @@
# Src Guide
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view、page component 與 page driver 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
## 資料流
```txt
router -> AppShell -> layout -> view(Page Driver) -> Page Component -> Section -> Item
page driver / command composable -> store -> service
```
## 主要目錄
- `views/`route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`
- `components/`Vue UI 元件,依 pages / 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`
- `stores/`:跨頁共享狀態與快取。詳見 `src/stores/GUIDE.md`
- `services/`HTTP client、API module、token/session、錯誤處理。詳見 `src/services/GUIDE.md`
- `language/`Vue I18n 文案。詳見 `src/language/GUIDE.md`
## Template Core
一般功能需求預設不修改:
- `main.ts`
- `App.vue`
- `shell/*`
- `components/layouts/*`
- `views/Login.vue`
- `router/index.ts`
- `router/guards.ts`
- `plugins/*`
- `styles/*`
- `services/client.ts`
- `services/interceptors.ts`
- `services/token.ts`
- `services/session.ts`
- `stores/auth.ts`
- `stores/menu.ts`
- `stores/breadcrumbs.ts`
- `stores/favorites.ts`
- `stores/messages.ts`
- `stores/snackbar.ts`
- `stores/app.ts`
- `composables/layout/*`
只有需求明確要求調整 template shell、登入、router guard、HTTP core 或全域狀態時才修改上述檔案。
## Demo / Example
下列檔案偏向示範功能,正式專案可依需求替換或移除:
- `views/Home.vue`
- `views/FncPage.vue`
- `views/Settings.vue`
- `views/maint/*`
- `components/PageIndex.vue`
- `components/PageMaint.vue`
- `components/maint/*`
- `components/pages/*Maintenance.vue`
- `components/sections/*`
- `components/items/*`
- `composables/page-drivers/*MaintenancePage.ts`
- `composables/maint/*`
- `composables/commands/*`
- `stores/students.ts`
- `stores/semesters.ts`
- demo assets 與 demo language keys
移除 demo 時,同步清理 route、menu/favorites/breadcrumb 流程、語系文案與不再使用的 import。
## 新功能流程
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。
## 驗證
- Vue / TypeScript 結構變更:`pnpm -s type-check`
- 需要確認產物:`pnpm -s build`
- route、layout 或主要畫面流程變更:啟動 dev server 並做瀏覽器檢查,除非使用者明確不需要。
+37
View File
@@ -0,0 +1,37 @@
# Components Guide
`src/components` 放 Vue UI 元件,包含 layout、page component、feature/domain component 與少量跨頁共用元件。
## 分層
| 目錄 | 說明 | 指南 |
|------|------|------|
| `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 元件 | — |
## 規則
- 不要假設 `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 中。
## 命名
- 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`
-337
View File
@@ -1,337 +0,0 @@
# PageIndex 規格表
來源元件:`src/components/PageIndex.vue`
`PageIndex` 是首頁主畫面展示元件,負責組合歡迎區、最新消息、訊息中心入口、快速存取與最新消息 dialog。元件本身不直接呼叫後端 API;所有資料都透過 props 傳入,互動則透過 emit 交給外層 view 處理。
目前使用位置:`src/views/Home.vue`
## 功能總覽
| 功能區塊 | 功能說明 | 主要輸入 | 主要輸出事件 | 需要後端 API |
|---|---|---|---|---|
| 歡迎區 | 顯示固定歡迎文字與操作提示。 | 無 | 無 | 否,目前為靜態文案。 |
| 最新消息列表 | 以 `v-data-iterator` 顯示消息清單,包含日期、月份、標題、NEW 標籤、摘要、單位與瀏覽次數。 | `newsItems` | `news(item)` | 一般需要;現況由 `Home.vue` 靜態陣列提供。 |
| 最新消息詳情 dialog | 顯示被選取消息的標題、日期、單位、瀏覽次數與內容。開關狀態由外層控制。 | `selectedNews`, `isNewsDialogOpen` | `update:isNewsDialogOpen` | 視需求;若列表已含完整內容可不另打詳情 API,若需完整內文或附件則建議補 API。 |
| 訊息中心入口 | 顯示訊息中心卡片與未讀文字,點擊後通知外層開啟訊息中心。 | 無 | `message-center` | 一般需要;現況文字 `12 筆未讀` 為靜態內容,點擊後只開啟共用 dialog。 |
| 快速存取 | 顯示常用操作入口卡片。 | `quickItems` | `quick(item)` | 視需求;現況由 `Home.vue` 靜態陣列提供,點擊只顯示 snackbar。 |
## 現況資料來源
| 資料 | 目前來源 | 說明 | 後端需求 |
|---|---|---|---|
| `newsItems` | `src/views/Home.vue` 靜態陣列 | 包含 3 筆示意最新消息。 | 現況否;正式系統通常需要。 |
| `quickItems` | `src/views/Home.vue` 靜態陣列 | 包含線上加選、線上退選、成績查詢、個人課表、網路請假、場地借用。 | 視需求。 |
| `selectedNews` | `src/views/Home.vue` 本機 `ref` | 點擊消息後設定。 | 否。 |
| `isNewsDialogOpen` | `src/views/Home.vue` 本機 `ref` | 控制消息 dialog 開關。 | 否。 |
| 訊息中心開關 | `src/stores/messages.ts` | store 只管理 dialog 開關狀態,不包含訊息資料。 | 否,但訊息內容正式化時需要。 |
| 訊息中心未讀文字 | `PageIndex.vue` 靜態文案 | 固定顯示 `12 筆未讀`。 | 正式系統建議需要。 |
## 一般建議補齊的 API 配合清單
這份清單是以一般後台首頁實作來看,列出 `PageIndex` 常見會需要後端配合的資料。現況沒有任何 API 直接供應 `PageIndex`;所有資料都是外層靜態資料或本機狀態。
| 類別 | 建議 API | 對應首頁功能 | 必要性 | 說明 |
|---|---|---|---|---|
| 最新消息列表 | `Announcement/GetLatest``News/GetLatest` | 最新消息列表 `newsItems` | 建議 | 回傳首頁要顯示的最新消息,通常需支援發布狀態、排序、置頂、有效日期與使用者可見範圍。 |
| 最新消息詳情 | `Announcement/GetDetail``News/GetDetail` | 最新消息詳情 dialog | 視需求 | 若列表只回摘要,點擊後應用 detail API 取得完整內容、附件、連結或已讀狀態。 |
| 消息瀏覽次數 | `Announcement/AddView` 或由 detail API 自動累計 | `views` 顯示 | 視需求 | 若瀏覽次數要準確,通常由後端統計;目前 `views` 是靜態文字。 |
| 訊息未讀數 | `Message/GetUnreadCount``Notification/GetUnreadCounts` | 訊息中心入口未讀文字 | 建議 | 首頁卡片與 layout toolbar badge 可共用同一份未讀數 API。 |
| 訊息清單 | `Message/GetMessages` | 訊息中心 dialog | 建議 | 點擊首頁訊息中心後應取得訊息列表;現況只開啟共用 dialog,訊息內容在 `App.vue` 仍是示意資料。 |
| 訊息已讀 | `Message/MarkAsRead`, `Message/MarkAllAsRead` | 訊息中心互動與未讀數歸零 | 視需求 | 若訊息中心有未讀狀態,通常需要已讀更新 API。 |
| 快速存取查詢 | `Shortcut/GetHomeShortcuts``Menu/GetQuickAccess` | 快速存取 `quickItems` | 視需求 | 若快速存取要依角色、權限或個人偏好變動,應由後端或既有選單資料推導。 |
| 快速存取維護 | `Shortcut/SaveHomeShortcuts`, `Shortcut/UpdateOrder` | 自訂首頁快捷入口 | 可選 | 只有開放使用者自訂首頁快捷時才需要。 |
| 快速存取導頁 | 可沿用 `Menu/GetMenu` 回傳路徑 | 快速存取點擊後導頁 | 建議 | 現況 `quickItems` 沒有 path,點擊只顯示 snackbar;正式系統應提供可導頁資訊。 |
| 首頁統計摘要 | `Dashboard/GetSummary` | 未來若加入待辦、申請、課程、公告統計卡片 | 可選 | 目前 `PageIndex` 沒有統計卡片;只有產品需要首頁 dashboard 時才補。 |
## API 優先順序建議
| 優先順序 | API / 功能 | 建議理由 |
|---|---|---|
| 1 | `Announcement/GetLatest` | 最新消息是首頁主要內容,正式系統不應長期使用靜態資料。 |
| 2 | `Message/GetUnreadCount` 或共用 `Notification/GetUnreadCounts` | 首頁訊息中心入口目前有固定未讀數,需改為真實資料。 |
| 3 | `Message/GetMessages` | 點擊訊息中心後應顯示真實訊息清單。 |
| 4 | 快速存取 path / 導頁資料 | 現況快速存取無法真的導頁,只顯示 snackbar。 |
| 5 | `Announcement/GetDetail` | 若列表資料不足以顯示完整內容,再補詳情 API。 |
| 6 | 快速存取自訂與排序 API | 屬個人化體驗,不影響首頁基本可用性,可後續處理。 |
## 建議 API 回傳格式
以下格式是給後端製作 API 時的建議契約。若沿用現有 service 包裝,前端實際讀取位置可能是 `res.data.data`;欄位命名可配合既有後端規範調整,但資料語意應保持一致。
### `Announcement/GetLatest` 或 `News/GetLatest`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": 1,
"title": "113學年度第2學期加退選開始",
"summary": "加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。",
"departmentId": "academic",
"departmentName": "教務處",
"publishedAt": "2026-01-29T09:00:00+08:00",
"views": 1234,
"isNew": true,
"isPinned": false
}
],
"total": 1
}
}
```
### `Announcement/GetDetail` 或 `News/GetDetail`
```json
{
"success": true,
"message": "",
"data": {
"id": 1,
"title": "113學年度第2學期加退選開始",
"content": "加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。",
"summary": "加退選時間為1月29日至2月9日止。",
"departmentId": "academic",
"departmentName": "教務處",
"publishedAt": "2026-01-29T09:00:00+08:00",
"views": 1235,
"isNew": true,
"attachments": [
{
"id": "file-1",
"fileName": "加退選說明.pdf",
"url": "/service/files/file-1"
}
],
"links": [
{
"title": "前往選課系統",
"url": "/course-add"
}
]
}
}
```
### `Announcement/AddView`
```json
{
"success": true,
"message": "",
"data": {
"id": 1,
"views": 1235
}
}
```
### `Message/GetUnreadCount`
```json
{
"success": true,
"message": "",
"data": {
"unreadCount": 12
}
}
```
### `Notification/GetUnreadCounts`
```json
{
"success": true,
"message": "",
"data": {
"notifications": 3,
"messages": 12
}
}
```
### `Message/GetMessages`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "msg-1",
"title": "系統維護提醒",
"summary": "系統將於週六凌晨維護。",
"sender": "資訊中心",
"sentAt": "2026-05-07T09:00:00+08:00",
"isRead": false,
"link": "/messages/msg-1"
}
],
"total": 1,
"unreadCount": 1
}
}
```
### `Message/MarkAsRead`
```json
{
"success": true,
"message": "已標記為已讀",
"data": {
"id": "msg-1",
"isRead": true,
"unreadCount": 0
}
}
```
### `Message/MarkAllAsRead`
```json
{
"success": true,
"message": "已全部標記為已讀",
"data": {
"unreadCount": 0
}
}
```
### `Shortcut/GetHomeShortcuts` 或 `Menu/GetQuickAccess`
```json
{
"success": true,
"message": "",
"data": [
{
"id": "course-add",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1,
"source": "menu"
},
{
"id": "score-query",
"title": "成績查詢",
"path": "/score-query",
"icon": "mdiChartBar",
"sort": 2,
"source": "menu"
}
]
}
```
### `Shortcut/SaveHomeShortcuts`
```json
{
"success": true,
"message": "快捷已儲存",
"data": [
{
"id": "course-add",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1
}
]
}
```
### `Shortcut/UpdateOrder`
```json
{
"success": true,
"message": "排序已更新",
"data": [
{
"id": "course-add",
"sort": 1
},
{
"id": "score-query",
"sort": 2
}
]
}
```
### `Dashboard/GetSummary`
```json
{
"success": true,
"message": "",
"data": {
"todoCount": 2,
"pendingApplicationCount": 1,
"todayCourseCount": 4,
"unreadMessageCount": 12,
"latestAnnouncementCount": 3
}
}
```
## 可維持前端處理的功能
| 功能 | 原因 |
|---|---|
| 最新消息 dialog 開關 | 純 UI 狀態,使用 `selectedNews``isNewsDialogOpen` 即可。 |
| 最新消息列表解包 `resolveNewsItem` | 只是處理 Vuetify `v-data-iterator` wrapper,不需後端配合。 |
| 歡迎區文案 | 若各角色顯示相同文字,可維持靜態。 |
| 快速存取卡片排版 | 展示方式屬前端 UI,不需後端參與。 |
| 點擊快速存取後的前端導頁 | 只要資料含 path,router push 可由前端處理。 |
## Props 與狀態來源
| Prop / 狀態 | 用途 | 預設值或目前來源 | 後端需求 |
|---|---|---|---|
| `newsItems` | 最新消息列表資料。 | `Home.vue` 靜態陣列。 | 正式系統建議來自最新消息 API。 |
| `quickItems` | 快速存取卡片資料。 | `Home.vue` 靜態陣列。 | 視需求,可由選單、權限或快捷 API 提供。 |
| `selectedNews` | 最新消息 dialog 顯示的目前消息。 | `Home.vue` 點擊消息後設定。 | 否。 |
| `isNewsDialogOpen` | 最新消息 dialog 開關。 | `Home.vue` 本機 `ref`。 | 否。 |
## 建議資料結構
### 最新消息 `NewsItem`
| 欄位 | 型別 | 用途 | API 建議 |
|---|---|---|---|
| `id` | `number` | 列表 key 與詳情查詢識別。 | 後端提供。 |
| `date` | `string` | 日期 badge 的日期。 | 可由後端直接提供,或前端由發布日期格式化。 |
| `month` | `string` | 日期 badge 的月份。 | 可由後端直接提供,或前端由發布日期格式化。 |
| `title` | `string` | 消息標題。 | 後端提供。 |
| `desc` | `string` | 消息摘要或內容。 | 列表 API 可回摘要;詳情 API 可回完整內容。 |
| `dept` | `string` | 發布單位。 | 後端提供。 |
| `views` | `string` | 瀏覽次數顯示文字。 | 建議後端回 number,前端格式化。 |
| `isNew` | `boolean` | 是否顯示 NEW 標籤。 | 可由後端提供,或前端依發布日期推導。 |
### 快速存取 `QuickItem`
| 欄位 | 型別 | 用途 | API 建議 |
|---|---|---|---|
| `icon` | `string` | 卡片圖示文字。 | 若正式使用 MDI icon,建議改為 icon key 或路徑。 |
| `title` | `string` | 卡片標題。 | 後端或前端設定提供。 |
| `path` | `string` | 目前型別尚未定義;正式導頁建議補上。 | 建議由後端或 `Menu/GetMenu` 對應功能提供。 |
## 事件契約
| 事件 | 觸發時機 | 外層目前處理 |
|---|---|---|
| `news(item)` | 點擊最新消息卡片。 | `Home.vue` 設定 `selectedNews` 並開啟 dialog。 |
| `message-center` | 點擊訊息中心卡片。 | `Home.vue` 呼叫 `messageStore.open()`。 |
| `quick(item)` | 點擊快速存取卡片。 | `Home.vue` 顯示 snackbar`前往:${item.title}`。 |
| `update:isNewsDialogOpen(value)` | dialog 開關狀態變更或點擊關閉。 | `Home.vue` 寫回 `isNewsDialogOpen`。 |
-25
View File
@@ -1,25 +0,0 @@
# Components
`src/components` 放 Vue 元件,包含 layout、page component、feature/domain component 與少量跨頁共用元件。
## 目前結構
- `PageLogin.vue``PageIndex.vue``PageMaint.vue`:頁面型元件,接收 view 組好的資料與事件,負責完整頁面主畫面組裝。
- `layouts/*`app shell 與 layout 子元件。`MainLayout.vue` 負責主框架,`PlainLayout.vue` 負責不套主框架的頁面。
- `layouts/main-layout/*``MainLayout.vue` 拆出的 drawer、app bar、breadcrumb、favorites 等骨架子元件。
- `login/*`:登入頁專用 UI 區塊,服務 `PageLogin.vue`
- `maint/*`maintenance 領域元件,服務 `views/maint/*`
- `maint/master-detail/*`master-detail 維護頁專用子元件。
- `base/*`:真正跨頁重用且不屬於特定 domain 的基礎元件。
## 使用規則
- 不要假設 `src/components` 會自動全域註冊元件;需要使用元件時,依照目前 Vue SFC 慣例明確 import。
- 直接被 route 載入的檔案放在 `src/views`,不要放在 `src/components`
- 負責完整頁面主畫面組裝的元件使用 `Page` 前綴。
- 只服務單一功能或 domain 的元件,放在對應資料夾,不要放進 `base`
- layout 元件只處理 app shell 與框架 UI,不放頁面專屬業務流程。
## 資料流
component 以 props 接收資料,以 emit 回報使用者事件。需要跨頁共享的狀態交給 `src/stores`;可重用流程或較複雜 UI state 放到 `src/composables`
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label?: string
items: any[]
labelCharCount?: number
prependMarginEnd?: number
}>()
const modelValue = defineModel<any>({ required: true })
const prependMinWidth = computed(() =>
props.labelCharCount != null ? `${props.labelCharCount * 0.785}rem` : undefined,
)
const marginEndStyle = computed(() => `${props.prependMarginEnd ?? 8}px`)
</script>
<template>
<v-select v-model="modelValue" variant="outlined" density="compact" hide-details :items="items">
<template v-if="label" #prepend>
<span
class="text-title-small"
:style="prependMinWidth ? { minWidth: prependMinWidth } : undefined"
>
{{ label }}
</span>
</template>
</v-select>
</template>
<style scoped>
:deep(.v-input__prepend) {
margin-inline-end: v-bind(marginEndStyle);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label?: string
labelCharCount?: number
prependMarginEnd?: number
readonly?: boolean
}>()
const modelValue = defineModel<string>({ required: true })
const prependMinWidth = computed(() =>
props.labelCharCount != null ? `${props.labelCharCount * 0.785}rem` : undefined
)
const marginEndStyle = computed(() => `${props.prependMarginEnd ?? 8}px`)
</script>
<template>
<v-text-field
v-model="modelValue"
variant="outlined"
density="compact"
hide-details
:readonly="readonly"
>
<template v-if="label" #prepend>
<span
class="text-title-small"
:style="prependMinWidth ? { minWidth: prependMinWidth } : undefined"
>
{{ label }}
</span>
</template>
</v-text-field>
</template>
<style scoped>
:deep(.v-input__prepend) {
margin-inline-end: v-bind(marginEndStyle);
}
</style>
+46
View File
@@ -0,0 +1,46 @@
# Base Components Guide
`src/components/base` 放真正跨頁共用且不屬於特定 domain 的基礎元件。
## 規則
- 只服務單一 domain 的元件不要放進 `base`
- 命名不使用 `Page`/`Section`/`Item` 前綴,直接以功能命名。
## BaseFormTextField
前置 label + `v-text-field`,預設 `variant="outlined"``density="compact"``hide-details`
| Prop | 型別 | 預設 | 說明 |
|------|------|------|------|
| `modelValue` | `string` | — | 雙向綁定字串值 |
| `label` | `string` | `undefined` | `#prepend``<span>` 的文字 |
| `labelCharCount` | `number` | `undefined` | 字數,用於計算 `min-width: 字數 × 0.785rem` |
| `prependMarginEnd` | `number` | `8` | `#prepend``margin-inline-end`px |
| `readonly` | `boolean` | `undefined` | 是否唯讀 |
```vue
<BaseFormTextField
v-model="form.cellPhone"
label="手機"
class="ml-2"
:label-char-count="4"
:prepend-margin-end="16"
/>
```
## BaseFormSelect
前置 label + `v-select`,預設 `variant="outlined"``density="compact"``hide-details`
| Prop | 型別 | 預設 | 說明 |
|------|------|------|------|
| `modelValue` | `any` | — | 雙向綁定值 |
| `items` | `any[]` | — | `v-select``items` |
| `label` | `string` | `undefined` | `#prepend``<span>` 的文字 |
| `labelCharCount` | `number` | `undefined` | 字數,用於計算 `min-width` |
| `prependMarginEnd` | `number` | `8` | `#prepend``margin-inline-end`px |
```vue
<BaseFormSelect v-model="form.status" label="狀態" :items="statusOptions" class="ml-2" />
```
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { StudentFormState } from '@/composables/maint/useStudentMaintenanceForm'
interface GradeOption {
title: string
value: number
}
defineProps<{
departments: string[]
enrollYears: number[]
fieldErrors: Record<keyof StudentFormState, string[]>
gradeOptions: GradeOption[]
isFormLocked: boolean
isFormReadonly: boolean
statuses: string[]
}>()
const form = defineModel<StudentFormState>({ required: true })
const emit = defineEmits<{
(e: 'clear-field-error', field: keyof StudentFormState): void
}>()
</script>
<template>
<v-row density="compact">
<v-col cols="12" md="6">
<v-text-field
id="field-studentId"
v-model="form.studentId"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.studentId"
label="學號"
placeholder="例如:S2024008"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'studentId')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-name"
v-model="form.name"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.name"
label="姓名"
placeholder="例如:陳怡君"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-department"
v-model="form.department"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.department"
:items="departments"
label="系所"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'department')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-grade"
v-model="form.grade"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.grade"
item-title="title"
item-value="value"
:items="gradeOptions"
label="年級"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'grade')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-enrollYear"
v-model="form.enrollYear"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.enrollYear"
:items="enrollYears"
label="入學年度"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'enrollYear')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-credits"
v-model.number="form.credits"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.credits"
label="已修學分"
min="0"
:readonly="isFormReadonly"
type="number"
variant="outlined"
@update:model-value="emit('clear-field-error', 'credits')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-advisor"
v-model="form.advisor"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.advisor"
label="指導老師"
placeholder="例如:林教授"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'advisor')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-email"
v-model="form.email"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.email"
label="Email"
placeholder="name@school.edu"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'email')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-phone"
v-model="form.phone"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.phone"
label="電話"
placeholder="例如:02-2345-6789"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'phone')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-status"
v-model="form.status"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.status"
:items="statuses"
label="狀態"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'status')"
/>
</v-col>
</v-row>
</template>
+39
View File
@@ -0,0 +1,39 @@
# Layouts Guide
`components/layouts` 是 app shell layout。一般功能需求不應修改這裡。
## 可用 Layout
- **MainLayout**`layout: 'default'`):完整 app shell,包含 drawer、app bar、breadcrumb、favorites、toolbar actions 與主內容 slot。
- **PlainLayout**`layout: 'none'`):極簡空白佈局,只提供 `<v-app>` / `<v-main>` 外殼與一個 slot。登入頁、錯誤頁、維護中頁使用此 layout。
一般功能頁面統一使用 `layout: 'default'`
## MainLayout 責任
- drawer
- app bar
- breadcrumb
- favorites
- toolbar actions
- 主內容 slot
## 禁止放入
- 頁面專屬業務流程
- 查詢條件、表單、列表、CRUD
- 特定 dialog 內容
- API 呼叫
- domain-specific 狀態
如果頁面要影響 breadcrumb、favorites、menu 或 toolbar,優先使用 route meta、store 或 `shell/AppShell.vue` 已提供的 props/events。
## `main-layout/` 子目錄
`src/components/layouts/main-layout/` 收納 MainLayout 拆解出的子組件與共用的型別定義:
- `types.ts``AdminLayoutMenuItem``AdminLayoutBreadcrumbItem``AdminLayoutFeatures` 等型別,供 layout composable 與 shell 使用。
- `DrawerDesktopMenu.vue` / `DrawerMobileMenuPanel.vue` / `DrawerMobileFavoritesPanel.vue`:桌面與行動版 drawer 內容。
- `AppBarTopCol.vue` / `AppBarBreadcrumbCol.vue` / `AppBarFavoritesCol.vue`app bar 不同列的組件。
一般功能需求不應修改這裡的檔案。
-430
View File
@@ -1,430 +0,0 @@
# MainLayout 規格表
來源元件:`src/components/layouts/MainLayout.vue`
`MainLayout` 是預設後台版型的 app shell,負責組合側邊選單、頂部工具列、常用功能列、breadcrumb、內容區與輔助視窗。元件本身不直接呼叫後端 API;所有資料都透過 props 傳入,互動則透過 emit 交給外層處理。
## 功能總覽
| 功能區塊 | 功能說明 | 主要輸入 | 主要輸出事件 | 需要後端 API |
|---|---|---|---|---|
| 系統品牌區 | 顯示系統標題、副標題,或由 `title` slot 覆蓋。 | `systemTitle`, `systemSubtitle`, `title` slot | 無 | 否,目前為 props/default。 |
| 使用者資訊 | 顯示使用者頭像文字、姓名、角色。可用 feature toggle 關閉。 | `userProfile`, `features.showUserInfo` | 無 | 否,目前為 props/default。 |
| 側邊選單 | 桌面版顯示多層 drawer menu;支援展開群組、收合 rail、選單項目導頁。 | `menuItems`, `isRail`, `drawerConfig` | `select`, `toggle-sidebar`, `update:isRail` | 是,`menuItems` 目前由 `GetMenu` 取得後轉換。 |
| 行動版選單 | 行動版 drawer 使用階層式選單面板,點擊有子層項目會進入下一層,點擊葉節點會選取並關閉 drawer。 | `menuItems`, Vuetify display 狀態 | `select`, `toggle-sidebar` | 是,資料同 `menuItems`,目前由 `GetMenu` 取得後轉換。 |
| 行動版階層導覽 | 在 drawer 上方顯示「主選單」與目前進入的選單層級,可返回任一層。 | `menuItems` 衍生出的 `mobileMenuLevels` | 無 | 是,層級內容間接來自 `GetMenu`。 |
| 常用功能列 | 桌面版顯示常用功能 chip,可選取、移除,也可顯示新增按鈕。 | `favoriteItems`, `favoritesConfig`, `favoritesBarVisible`, `features.showFavorites` | `select`, `add-favorite`, `remove-favorite`, `update:favoritesBarVisible` | 否,目前不是由後端提供;`GetFavorite` 已有 service/store 方法但登入流程中未啟用。 |
| 行動版常用功能 | 行動版 drawer 可切換到常用功能面板並選取項目。 | `favoriteItems`, `features.showFavorites` | `select` | 否,目前不是由後端提供。 |
| 搜尋列 | 輸入關鍵字後,按 Enter 或按鈕才觸發搜尋;觸發後清空輸入。 | `searchConfig`, `features.showSearch` | `search` | 否,`MainLayout` 只送出關鍵字;目前外層用已載入的選單做前端比對。 |
| 工具列通知 | 顯示通知按鈕與 badge 數量。 | `toolbarActions.notificationsLabel`, `toolbarCounts.notifications` | `action('notifications')` | 否,目前只是按鈕與 props 數字。 |
| 工具列訊息 | 顯示訊息按鈕與 badge 數量。 | `toolbarActions.messagesLabel`, `toolbarCounts.messages` | `action('messages')` | 否,目前外層開啟示意訊息 dialog,沒有 API。 |
| 工具列設定 | 顯示設定 menu,可切換常用功能列與 breadcrumb 顯示狀態。 | `showFavoritesBar`, `breadcrumbBarVisible` | `update:favoritesBarVisible`, `update:breadcrumbBarVisible` | 否,屬本機 UI 狀態。 |
| 登出 | 顯示登出按鈕,點擊後交由外層處理。 | `logoutLabel` | `logout` | 否,layout 本身不呼叫 API。 |
| 主題切換 | feature 開啟時顯示主題切換按鈕,透過 `useThemeToggle` 切換 Vuetify theme。 | `features.showThemeToggle`, `themeToggleLabel` | `toggle-theme` | 否,本機 theme 狀態。 |
| Breadcrumb | 桌面版顯示目前頁面路徑;未傳入項目時顯示預設首頁。可插入 `breadcrumb-actions` slot。 | `breadcrumbItems`, `breadcrumbConfig`, `breadcrumbBarVisible`, `features.showBreadcrumb` | `update:breadcrumbBarVisible`, `update:favoritesBarVisible` | 否,目前由外層依路由與已載入選單推導。 |
| 內容區 | 以 `slot` 承載各頁面內容,並依 app bar 高度動態設定 `v-main` padding。 | default slot | 無 | 否。 |
| 操作說明浮窗 | 收到 `help` action 時顯示暫時說明內容,可關閉。 | `toolbarActions.helpLabel` | `action('help')` | 否,目前內容為靜態文字,且 help 按鈕在子元件中未顯示。 |
## 後端 API 需求
| API | 目前狀態 | 呼叫位置 | 提供給 `MainLayout` 的資料 | Request | Response 對應 |
|---|---|---|---|---|---|
| `Menu/GetMenu` | 已啟用 | `src/views/Login.vue` 登入成功後呼叫 `menuStore.getMenu(authStore.user?.id ?? '')` | `menuItems` | `{ userID: string }` | `res.data.data` 存入 `menuStore.menu`,再由 `toLayoutMenuItems()` 轉成 layout 選單。 |
| `Menu/GetFavorite` | 未啟用 | service/store 已存在,但登入流程呼叫被註解 | 無 | `{ userID: string }` | 若未來啟用,可轉成 `favoriteItems`;目前不列為後端需求。 |
## 一般建議補齊的 API 配合清單
這份清單是以一般後台系統實作來看,列出 `MainLayout` 常見會需要後端配合的資料。現況仍只有 `Menu/GetMenu` 已接上;其餘項目可依產品需求決定是否實作。
| 類別 | 建議 API | 對應 layout 功能 | 必要性 | 說明 |
|---|---|---|---|---|
| 選單與權限 | `Menu/GetMenu` | 側邊選單、行動版選單、前端選單搜尋、breadcrumb 推導 | 必要,已啟用 | 依使用者、角色、權限回傳可用功能。 |
| 使用者資訊 | `User/GetCurrentUser``User/GetProfile` | 使用者資訊區 `userProfile` | 建議 | 回傳姓名、角色、單位、頭像文字或頭像 URL。現況使用 default props。 |
| 常用功能查詢 | `Menu/GetFavorite``Favorite/GetFavorites` | 常用功能列、行動版常用功能 | 建議 | 若常用功能要跨裝置保存,就應由後端提供。現況 service/store 已有 `Menu/GetFavorite`,但登入流程未啟用。 |
| 常用功能維護 | `Favorite/AddFavorite`, `Favorite/RemoveFavorite`, `Favorite/UpdateFavoriteOrder` | 新增常用、移除常用、常用排序 | 建議 | 現況常用功能主要由前端本機 store 處理;若要保存到帳號需補 API。 |
| 未讀數量 | `Notification/GetUnreadCounts` | 通知 badge、訊息 badge `toolbarCounts` | 建議 | 可一次回傳 `{ notifications, messages }`,避免 layout 分別打多支 API。現況預設皆為 `0`。 |
| 通知清單 | `Notification/GetNotifications` | 通知按鈕點擊後的通知列表 | 視需求 | 目前 layout 只 emit `action('notifications')`,外層尚未實作通知 UI。 |
| 通知已讀 | `Notification/MarkAsRead`, `Notification/MarkAllAsRead` | 通知清單互動、badge 歸零 | 視需求 | 若有通知列表,通常需要搭配已讀狀態更新。 |
| 訊息清單 | `Message/GetMessages` | 訊息按鈕點擊後的訊息 dialog | 建議 | 現況 `App.vue` 使用示意資料,不含 API。 |
| 訊息已讀 | `Message/MarkAsRead`, `Message/MarkAllAsRead` | 訊息清單互動、badge 歸零 | 視需求 | 若訊息中心要顯示未讀數,通常需要已讀 API。 |
| 搜尋 | `Search/SearchMenu``Search/GlobalSearch` | 搜尋列 | 視需求 | 只搜尋目前已載入選單可維持前端搜尋;若要搜尋公告、頁面、業務資料或權限內功能,應補後端搜尋 API。 |
| 登出 | `Auth/Logout``Auth/RevokeToken` | 登出按鈕 | 視認證架構 | 若後端有 session、refresh token 或 token revoke 機制,需要呼叫後端;若只是清 local token,可維持前端處理。 |
| 使用者偏好 | `UserPreference/GetLayoutSettings`, `UserPreference/SaveLayoutSettings` | 側欄收合、常用列顯示、breadcrumb 顯示、主題 | 可選 | 目前可用 localStorage/store 處理;只有需要跨裝置同步時才需要 API。 |
| 操作說明 | `Help/GetPageHelp` 或 CMS API | 操作說明浮窗 | 可選 | 現況為靜態暫時文字,且 help 按鈕未顯示;若說明內容需依頁面、角色或版本管理才需要 API。 |
## API 優先順序建議
| 優先順序 | API / 功能 | 建議理由 |
|---|---|---|
| 1 | `Menu/GetMenu` | layout 最核心資料,決定使用者可見功能與導頁入口。 |
| 2 | `User/GetCurrentUser` | 使用者資訊區不應長期使用假資料,且常被其他功能共用。 |
| 3 | `Favorite/GetFavorites` 與常用功能維護 API | 常用功能若要符合使用者帳號體驗,需要後端保存。 |
| 4 | `Notification/GetUnreadCounts` / `Message/GetMessages` | toolbar badge 與訊息中心目前是 demo 狀態,若要上線需補。 |
| 5 | `Search/GlobalSearch` | 只有當搜尋範圍超過目前選單時才需要。 |
| 6 | `UserPreference` 類 API | 屬體驗同步,不影響核心操作,可最後處理。 |
## 建議 API 回傳格式
以下格式是給後端製作 API 時的建議契約。若沿用現有 service 包裝,前端實際讀取位置可能是 `res.data.data`;欄位命名可配合既有後端規範調整,但資料語意應保持一致。
### `Menu/GetMenu`
```json
{
"success": true,
"message": "",
"data": [
{
"mdl_id": "student",
"mdl_name": "學生資訊",
"children": [
{
"unt_id": "course",
"unt_name": "選課作業",
"children": [
{
"fnc_id": "course-add",
"fnc_name": "線上加選"
}
]
}
]
}
]
}
```
### `User/GetCurrentUser` 或 `User/GetProfile`
```json
{
"success": true,
"message": "",
"data": {
"id": "A123456789",
"name": "王小明",
"role": "資訊工程系 - 學生",
"avatarText": "王",
"departmentId": "CS",
"departmentName": "資訊工程系"
}
}
```
### `Menu/GetFavorite` 或 `Favorite/GetFavorites`
```json
{
"success": true,
"message": "",
"data": [
{
"id": "fav-1",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1
}
]
}
```
### `Favorite/AddFavorite`
```json
{
"success": true,
"message": "新增成功",
"data": {
"id": "fav-1",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1
}
}
```
### `Favorite/RemoveFavorite`
```json
{
"success": true,
"message": "移除成功",
"data": {
"id": "fav-1",
"path": "/course-add"
}
}
```
### `Favorite/UpdateFavoriteOrder`
```json
{
"success": true,
"message": "排序已更新",
"data": [
{
"id": "fav-1",
"path": "/course-add",
"sort": 1
},
{
"id": "fav-2",
"path": "/score-query",
"sort": 2
}
]
}
```
### `Notification/GetUnreadCounts`
```json
{
"success": true,
"message": "",
"data": {
"notifications": 3,
"messages": 12
}
}
```
### `Notification/GetNotifications`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "notice-1",
"title": "系統維護提醒",
"content": "系統將於週六凌晨維護。",
"source": "資訊中心",
"publishedAt": "2026-05-07T09:00:00+08:00",
"isRead": false,
"link": "/announcements/notice-1"
}
],
"total": 1,
"unreadCount": 1
}
}
```
### `Notification/MarkAsRead`
```json
{
"success": true,
"message": "已標記為已讀",
"data": {
"id": "notice-1",
"isRead": true,
"unreadCount": 0
}
}
```
### `Notification/MarkAllAsRead`
```json
{
"success": true,
"message": "已全部標記為已讀",
"data": {
"unreadCount": 0
}
}
```
### `Message/GetMessages`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "msg-1",
"title": "教務處公告",
"summary": "加退選時間即將開始。",
"sender": "教務處",
"sentAt": "2026-05-07T10:30:00+08:00",
"isRead": false,
"link": "/messages/msg-1"
}
],
"total": 1,
"unreadCount": 1
}
}
```
### `Message/MarkAsRead`
```json
{
"success": true,
"message": "已標記為已讀",
"data": {
"id": "msg-1",
"isRead": true,
"unreadCount": 0
}
}
```
### `Message/MarkAllAsRead`
```json
{
"success": true,
"message": "已全部標記為已讀",
"data": {
"unreadCount": 0
}
}
```
### `Search/SearchMenu` 或 `Search/GlobalSearch`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "course-add",
"type": "menu",
"title": "線上加選",
"path": "/course-add",
"parents": ["學生資訊", "選課作業"],
"icon": "mdiPlus"
}
],
"total": 1
}
}
```
### `Auth/Logout` 或 `Auth/RevokeToken`
```json
{
"success": true,
"message": "登出成功",
"data": {
"revoked": true
}
}
```
### `UserPreference/GetLayoutSettings`
```json
{
"success": true,
"message": "",
"data": {
"isRail": false,
"favoritesBarVisible": true,
"breadcrumbBarVisible": true,
"themeName": "light"
}
}
```
### `UserPreference/SaveLayoutSettings`
```json
{
"success": true,
"message": "設定已儲存",
"data": {
"isRail": false,
"favoritesBarVisible": true,
"breadcrumbBarVisible": true,
"themeName": "light",
"updatedAt": "2026-05-07T10:30:00+08:00"
}
}
```
### `Help/GetPageHelp`
```json
{
"success": true,
"message": "",
"data": {
"pageKey": "home",
"title": "操作說明",
"content": "這裡顯示目前頁面的操作說明。",
"updatedAt": "2026-05-07T10:30:00+08:00"
}
}
```
## 可維持前端處理的功能
| 功能 | 原因 |
|---|---|
| Breadcrumb 顯示與路徑推導 | 可由目前 route、`menuItems``favoriteItems` 推導,不一定要後端提供。 |
| 側欄收合狀態 | 屬使用者介面偏好,localStorage 即可;除非要求跨裝置同步。 |
| 常用列顯示 / breadcrumb 顯示 | 屬畫面偏好,localStorage/store 即可。 |
| 主題切換 | 本機 theme 狀態即可;除非要求登入後跨裝置一致。 |
| 前端選單搜尋 | 若搜尋範圍只限已載入的 `menuItems`,不需要後端 API。 |
## `GetMenu` 欄位轉換
| 後端節點層級 | 後端欄位 | Layout 欄位 | 說明 |
|---|---|---|---|
| 模組層 | `mdl_name` | `title` | 第一層選單標題。 |
| 模組層 | `children` | `subItems` | 第二層單元清單。 |
| 單元層 | `unt_name` | `title` | 第二層選單標題。 |
| 單元層 | `children` | `subItems` | 第三層功能清單。 |
| 功能層 | `fnc_name` | `title` | 葉節點功能名稱。 |
| 功能層 | `fnc_id` | `path` | 有值時轉成 `/${fnc_id}`,作為 router path。 |
| 模組層與單元層 | 無 | `navigable: false` | 群組節點預設不可導頁。 |
## Props 與狀態來源
| Prop / 狀態 | 用途 | 預設值或目前來源 | 後端需求 |
|---|---|---|---|
| `menuItems` | 桌面與行動版主選單。 | `App.vue` 使用 `menuStore.menuItems` 加上固定選單合併後傳入。 | 是,來自 `GetMenu`。 |
| `favoriteItems` | 常用功能列與行動版常用面板。 | `App.vue` 合併 `menuStore.favoriteItems``favoritesStore.layoutItems`;目前 `GetFavorite` 未啟用。 | 否。 |
| `breadcrumbItems` | Breadcrumb 顯示。 | `breadcrumbStore` 依 route、`menuItems``favoriteItems` 推導。 | 否。 |
| `userProfile` | 使用者資訊區。 | `MainLayout` default props。 | 否。 |
| `toolbarCounts` | 通知、訊息 badge。 | `MainLayout` default props,預設皆為 `0`。 | 否。 |
| `searchConfig` | 搜尋 placeholder 與 label。 | `MainLayout` default props。 | 否。 |
| `toolbarActions` | 通知、訊息、說明、設定 label。 | `MainLayout` default props。 | 否。 |
| `favoritesConfig` | 常用列 label、新增按鈕 label、是否顯示新增。 | `MainLayout` default props。 | 否。 |
| `breadcrumbConfig` | 首頁 breadcrumb label、disabled、icon。 | `MainLayout` default props。 | 否。 |
| `features` | 控制主題切換、常用列、breadcrumb、搜尋、工具列、使用者資訊是否顯示。 | `MainLayout` default props。 | 否。 |
| `drawerConfig` | drawer 寬度與 rail 寬度。 | `MainLayout` default props。 | 否。 |
| `isRail` | 桌面側欄是否收合。 | `App.vue``v-model:is-rail` 綁定 `menuStore.isRail`store 會寫入 localStorage。 | 否。 |
| `favoritesBarVisible` | 常用功能列是否顯示。 | `App.vue``v-model:favorites-bar-visible` 綁定 `favoritesStore`。 | 否。 |
| `breadcrumbBarVisible` | Breadcrumb 是否顯示。 | `App.vue``v-model:breadcrumb-bar-visible` 綁定 `favoritesStore`。 | 否。 |
## 事件契約
| 事件 | 觸發時機 | 外層目前處理 |
|---|---|---|
| `select(item)` | 選取側邊選單、常用功能或搜尋結果延伸選取時。 | `App.vue` 呼叫 `router.push(item.path)`。 |
| `search(keyword)` | 搜尋列按 Enter 或搜尋按鈕。 | `App.vue` 以已載入的合併選單做前端搜尋並顯示 dialog。 |
| `action(type)` | 點擊通知、訊息、說明等工具列 action。 | `messages` 會開啟訊息 dialog;其他目前無處理。 |
| `logout` | 點擊登出按鈕。 | `App.vue` 清除 auth、tabs,導回 login。 |
| `toggle-sidebar(payload)` | 點擊 drawer 收合/展開按鈕。 | 目前外層未綁定。 |
| `toggle-theme(themeName)` | 切換主題成功。 | 目前外層未綁定。 |
| `add-favorite` | 點擊常用功能新增按鈕。 | 目前外層未綁定。 |
| `remove-favorite(item)` | 點擊常用 chip close。 | `App.vue` 從本機常用清單切換移除。 |
| `update:isRail(value)` | 受控模式下更新側欄 rail 狀態。 | `v-model:is-rail` 寫回 `menuStore.isRail`。 |
| `update:favoritesBarVisible(value)` | 更新常用列顯示狀態。 | `v-model:favorites-bar-visible` 寫回 `favoritesStore`。 |
| `update:breadcrumbBarVisible(value)` | 更新 breadcrumb 顯示狀態。 | `v-model:breadcrumb-bar-visible` 寫回 `favoritesStore`。 |
+10 -1
View File
@@ -2,7 +2,7 @@
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<v-card variant="flat"> <v-card variant="flat">
<v-card-title class="d-flex flex-wrap align-center py-0 ga-2"> <v-card-title class="d-flex flex-wrap align-center py-0 ga-2">
<span class="text-h6">可編輯表格維護示範</span> <span class="text-h6">{{ title }}</span>
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal"> <v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
{{ hasAnyChange ? '有未儲存變更' : '已同步' }} {{ hasAnyChange ? '有未儲存變更' : '已同步' }}
</v-chip> </v-chip>
@@ -377,6 +377,15 @@ import { computed, ref, watch } from 'vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue' import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid' import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
withDefaults(
defineProps<{
title?: string
}>(),
{
title: '可編輯表格維護示範',
}
)
const { const {
departments, departments,
enrollYears, enrollYears,
@@ -0,0 +1,12 @@
<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
@@ -0,0 +1,13 @@
<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
@@ -0,0 +1,29 @@
<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
@@ -0,0 +1,34 @@
<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>
@@ -0,0 +1,478 @@
<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
@@ -0,0 +1,11 @@
<script setup lang="ts">
import type { SettingsPageModel } from '@/composables/page-drivers/useSettingsPage'
defineProps<{
page: SettingsPageModel
}>()
</script>
<template>
<div>{{ page.title }}</div>
</template>
+224
View File
@@ -0,0 +1,224 @@
# Section Components Guide
`src/components/sections` 放頁面區塊容器,例如搜尋區、表格、dialog shell、panel。
## 規則
- 決定布局與區塊互動,不知道 route。
- 檔名使用 `Section` 前綴。
## SectionFormPage
表單申請/填寫頁面通用外殼。最外層為 `v-form`,內含標題卡片、表單欄位區、子區段插槽、配合事項與動作按鈕列。
### 使用時機
- 頁面包含**送出/存檔按鈕**`type="submit"`
- 需要**表單驗證**與整體 `v-form` 包覆
- 具有**標題卡片**、**配合事項/注意事項區**、**動作按鈕列**的固定結構
- 例如:申請單、借用單、報名表、維護單等填寫頁面
不適用情境:純粹列表/查詢頁面(無送出按鈕)、結構差異過大的頁面。
### 視覺特徵
- 頂部標題卡片(`bg-primary`
- 中間為表單欄位區(`v-text-field`/`v-select`
- 可能有子區段卡片(明細表格)
- 底部有「配合事項」提示區(`bg-yellow-lighten-5`
- 最底部為動作按鈕列(存檔 + 清除 + 返回)
### Props
| Prop | 型別 | 預設 | 說明 |
|------|------|------|------|
| `title` | `string` | — | 頁面標題 |
| `loading` | `boolean` | `undefined` | 是否顯示 loading |
| `error` | `string` | `undefined` | 錯誤訊息 |
| `message` | `string` | `undefined` | 成功訊息 |
| `submitLabel` | `string` | `'存檔'` | 送出按鈕文字 |
| `resetLabel` | `string` | `'清除'` | 清除按鈕文字 |
| `backLabel` | `string` | `'返回'` | 返回按鈕文字 |
### Slots
| Slot | 用途 |
|------|------|
| `#fields` | 表單欄位區,用 `v-row`/`v-col` 配置 |
| `#sections` | 額外子區段卡片(明細、表格等) |
| `#notices` | 配合事項/注意事項清單 |
### Emits
| Emit | 說明 |
|------|------|
| `@submit` | 點擊存檔時觸發 |
| `@reset` | 點擊清除時觸發 |
| `@back` | 點擊返回時觸發 |
### 範例
```vue
<SectionFormPage
title="設備借用申請"
:loading="loading"
:error="error"
:message="message"
@submit="save"
@reset="reset"
@back="router.push('/venue/apply-choose')"
>
<template #fields>
<v-row density="compact">
<v-col cols="12" md="4">
<BaseFormTextField v-model="form.cellPhone" label="手機" class="ml-2" />
</v-col>
</v-row>
</template>
<template #sections>
<v-card>
<v-card-title class="text-title-medium font-weight-bold">設備明細</v-card-title>
<!-- 明細表格 -->
</v-card>
</template>
<template #notices>
<v-list class="bg-yellow-lighten-5">
<v-list-item>借用設備時請愛惜公物</v-list-item>
</v-list>
</template>
</SectionFormPage>
```
## SectionQueryPage
查詢/列表頁面通用外殼。包含標題卡片、篩選條件區、查詢按鈕、結果表格區與返回按鈕。
### 使用時機
- 頁面具有**篩選條件** + **查詢按鈕** + **結果表格**的固定結構
- 例如:單筆查詢、列表查詢、報表查詢等頁面
不適用情境:
- 純粹 CRUD 維護頁面(含新增/編輯/刪除操作)→ 用 `SectionFormPage`
- 頁面結構差異過大(如沒有篩選條件或沒有結果表格)
### 視覺特徵
- 頂部標題卡片(`bg-primary`
- 標題下方為篩選條件區(`v-text-field`/`v-select` + 查詢按鈕)
- 下方為結果區:可能是單一表格,也可能是多張獨立卡片表格
- 最底部為返回按鈕
-`SectionFormPage` 最大差異:**沒有「存檔」按鈕,也沒有「配合事項」區**
### Props
| Prop | 型別 | 預設 | 說明 |
|------|------|------|------|
| `title` | `string` | — | 頁面標題 |
| `loading` | `boolean` | `undefined` | 是否顯示 loading |
| `error` | `string` | `undefined` | 錯誤訊息 |
| `backLabel` | `string` | `'返回'` | 返回按鈕文字 |
### Slots
| Slot | 用途 |
|------|------|
| `#filters` | 篩選條件欄位,用 `v-col` 配置 |
| `#results` | 單一結果表格區(會自動包一層 `v-card` |
| `#sections` | 多區段結果卡片(需自行用 `v-card` 包覆,適用多表格情境) |
`#results``#sections` 擇一使用:
- 單一表格結果 → 用 `#results`
- 多張獨立表格/列表 → 用 `#sections`,在 slot 內自行配置 `v-card` 與標題
### Emits
| Emit | 說明 |
|------|------|
| `@search` | 點擊查詢時觸發 |
| `@back` | 點擊返回時觸發 |
### 範例:單一結果表格
```vue
<SectionQueryPage
title="全校設備查詢"
:loading="loading"
:error="error"
@search="search"
@back="router.push('/venue/query-choose')"
>
<template #filters>
<v-col cols="12" md="4">
<BaseFormSelect v-model="filters.facId" label="設備" :items="facilityItems" />
</v-col>
<v-col cols="12" md="4">
<BaseFormTextField v-model="filters.asOfDate" label="截止日" />
</v-col>
</template>
<template #results>
<v-table density="compact">
<thead class="bg-primary">
<tr>
<th>設備代碼</th>
<th>名稱</th>
</tr>
</thead>
<tbody>
<tr v-if="!result">
<td class="text-center" colspan="2">尚無查詢結果</td>
</tr>
<tr v-else>
<td>{{ result.facId }}</td>
<td>{{ result.facName }}</td>
</tr>
</tbody>
</v-table>
</template>
</SectionQueryPage>
```
### 範例:多區段結果(多表格)
```vue
<SectionQueryPage
title="我的申請紀錄"
:loading="loading"
:error="error"
@search="search"
@back="router.push('/venue/apply-choose')"
>
<template #filters>
<v-col cols="12" md="3">
<BaseFormTextField v-model="filters.startDate" label="查詢起日" />
</v-col>
<v-col cols="12" md="3">
<BaseFormTextField v-model="filters.endDate" label="查詢迄日" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect v-model="filters.status" label="狀態" :items="statusItems" />
</v-col>
</template>
<template #sections>
<v-card>
<v-card-title class="text-title-medium font-weight-bold py-2">場地申請</v-card-title>
<v-table density="compact">
<!-- 場地表格 -->
</v-table>
</v-card>
<v-card>
<v-card-title class="text-title-medium font-weight-bold py-2">設備申請</v-card-title>
<v-table density="compact">
<!-- 設備表格 -->
</v-table>
</v-card>
</template>
</SectionQueryPage>
```
@@ -0,0 +1,169 @@
<script setup lang="ts">
import { mdiDelete, mdiEye, mdiPencil } from '@mdi/js'
import type { StudentRecord } from '@/models/student'
defineProps<{
currentPage: number
gradeLabel: (grade: number) => string
headers: any[]
items: StudentRecord[]
itemsPerPage: number
pageCount: number
pageSummary: string
rowProps: (data: { item: StudentRecord }) => Record<string, string>
statusColor: (status: string) => string
}>()
const emit = defineEmits<{
(e: 'update:currentPage', page: number): void
(e: 'view', record: StudentRecord): void
(e: 'edit', record: StudentRecord): void
(e: 'delete', record: StudentRecord): void
}>()
</script>
<template>
<v-data-table
class="student-table"
density="compact"
fixed-header
:headers="headers"
height="100%"
hide-default-footer
:items="items"
:items-per-page="itemsPerPage"
:page="currentPage"
:row-props="rowProps"
@update:page="emit('update:currentPage', $event)"
>
<template #[`item.grade`]="{ item }">
{{ gradeLabel(item.grade) }}
</template>
<template #[`item.status`]="{ item }">
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
{{ item.status }}
</v-chip>
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-2">
<v-btn
color="info"
:prepend-icon="mdiEye"
size="small"
variant="text"
@click="emit('view', item)"
>
檢視
</v-btn>
<v-btn
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="text"
@click="emit('edit', item)"
>
修改
</v-btn>
<v-btn
color="error"
:prepend-icon="mdiDelete"
size="small"
variant="text"
@click="emit('delete', item)"
>
刪除
</v-btn>
</div>
</template>
<template #bottom>
<div class="d-flex align-center justify-space-between px-4 py-3">
<div class="text-body-2 text-medium-emphasis">
{{ pageSummary }}
</div>
<div class="d-flex align-center ga-2">
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="emit('update:currentPage', 1)"
>
第一頁
</v-btn>
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="emit('update:currentPage', currentPage - 1)"
>
上一頁
</v-btn>
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="emit('update:currentPage', currentPage + 1)"
>
下一頁
</v-btn>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="emit('update:currentPage', pageCount)"
>
最後頁
</v-btn>
</div>
</div>
</template>
</v-data-table>
</template>
<style scoped>
.student-table {
overflow: auto;
}
.student-table :deep(table) {
min-width: 1400px;
}
.student-table :deep(th),
.student-table :deep(td) {
white-space: nowrap;
}
.student-table :deep(.v-data-table-column--fixed),
.student-table :deep(.v-data-table-column--fixed-end) {
background: rgb(var(--v-theme-surface));
}
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
content: '';
position: absolute;
top: 0;
right: -5px;
bottom: 0;
width: 5px;
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
}
.student-table :deep(.v-data-table-footer) {
padding: 4px 0 0;
}
tbody tr.is-highlighted {
animation: row-highlight 1.6s ease-out;
}
@keyframes row-highlight {
0% {
background-color: rgba(var(--v-theme-primary), 0.18);
}
100% {
background-color: transparent;
}
}
</style>
@@ -0,0 +1,58 @@
<script setup lang="ts">
interface Props {
title: string
backLabel?: string
error?: string
loading?: boolean
message?: string
resetLabel?: string
submitLabel?: string
}
withDefaults(defineProps<Props>(), {
backLabel: '返回',
resetLabel: '清除',
submitLabel: '存檔',
})
const emit = defineEmits<{
back: []
reset: []
submit: []
}>()
</script>
<template>
<v-form @submit.prevent="emit('submit')">
<v-container fluid class="pt-2 px-1">
<v-card>
<v-card-title class="bg-primary text-title-large text-center py-2">
{{ 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>
<v-alert v-if="message" class="mb-4" type="success" variant="tonal">
{{ message }}
</v-alert>
<slot name="fields" />
</v-card-text>
</v-card>
<slot name="sections" />
<v-card>
<v-card-title class="text-title-medium font-weight-bold">配合事項</v-card-title>
<v-card-text>
<slot name="notices" />
</v-card-text>
<v-row justify="center" class="pa-4 ga-2">
<v-btn type="submit" variant="elevated" color="primary" :loading="loading">
{{ 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>
</v-form>
</template>
@@ -0,0 +1,282 @@
<script setup lang="ts">
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import ItemFormFieldGroup from '@/components/items/ItemFormFieldGroup.vue'
import type {
SaveSummaryItem,
StudentFormState,
} from '@/composables/maint/useStudentMaintenanceForm'
interface FieldErrorItem {
field: string
message: string
}
interface GradeOption {
title: string
value: number
}
defineProps<{
confirmCloseVisible: boolean
confirmDeleteVisible: boolean
confirmNavigateVisible: boolean
confirmSaveVisible: boolean
confirmSwitchVisible: boolean
departments: string[]
dialogSubtitle: string
dialogTitle: string
dialogVisible: boolean
enrollYears: number[]
errorSummary: FieldErrorItem[]
fieldErrors: Record<keyof StudentFormState, string[]>
gradeOptions: GradeOption[]
hasNextRecord: boolean
hasPrevRecord: boolean
isDirty: boolean
isEditMode: boolean
isFormLocked: boolean
isFormReadonly: boolean
isLoading: boolean
isSaving: boolean
isViewMode: boolean
pendingDeleteLabel: string
saveSummary: SaveSummaryItem[]
statuses: string[]
}>()
const form = defineModel<StudentFormState>('form', { required: true })
const emit = defineEmits<{
(e: 'update:confirmCloseVisible', value: boolean): void
(e: 'update:confirmDeleteVisible', value: boolean): void
(e: 'update:confirmNavigateVisible', value: boolean): void
(e: 'update:confirmSaveVisible', value: boolean): void
(e: 'update:confirmSwitchVisible', value: boolean): void
(e: 'dialog-visible-change', value: boolean): void
(e: 'clear-field-error', field: keyof StudentFormState): void
(e: 'close'): void
(e: 'confirm-close'): void
(e: 'confirm-delete'): void
(e: 'confirm-navigate'): void
(e: 'confirm-save'): void
(e: 'confirm-switch'): void
(e: 'delete-current'): void
(e: 'first'): void
(e: 'last'): void
(e: 'next'): void
(e: 'prev'): void
(e: 'save'): void
(e: 'scroll-to-field', field: string): void
(e: 'switch-to-edit'): void
(e: 'switch-to-view'): void
}>()
</script>
<template>
<teleport to="body">
<v-overlay
class="dialog-overlay"
:close-on-content-click="false"
:model-value="dialogVisible"
scrim="rgba(0, 0, 0, 0.45)"
scroll-strategy="block"
@update:model-value="emit('dialog-visible-change', $event)"
>
<div class="dialog-panel">
<MntDialogCard
content-class="pa-2 flex-grow-1 overflow-y-auto"
:dialog-subtitle="dialogSubtitle"
:dialog-title="dialogTitle"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
width="100%"
>
<template #toolbar>
<MntRecordNavToolbar
edit-label="進入編輯"
first-label="第一筆"
:has-next-record="hasNextRecord"
:has-prev-record="hasPrevRecord"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
last-label="最後一筆"
view-label="回到檢視"
@first="emit('first')"
@last="emit('last')"
@next="emit('next')"
@prev="emit('prev')"
@switch-to-edit="emit('switch-to-edit')"
@switch-to-view="emit('switch-to-view')"
/>
</template>
<template #content>
<v-alert
v-if="errorSummary.length > 0 && !isLoading"
class="mb-4"
type="error"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
<div class="d-flex flex-column ga-1">
<v-btn
v-for="error in errorSummary"
:key="error.field"
color="error"
size="small"
variant="text"
@click="emit('scroll-to-field', error.field)"
>
{{ error.message }}
</v-btn>
</div>
</v-alert>
<v-skeleton-loader
v-if="isLoading"
class="mt-4"
type="subtitle,paragraph"
width="100%"
/>
<v-form
v-else
:class="{ 'form-readonly': isFormReadonly }"
@submit.prevent="emit('save')"
>
<ItemFormFieldGroup
v-model="form"
:departments="departments"
:enroll-years="enrollYears"
:field-errors="fieldErrors"
:grade-options="gradeOptions"
:is-form-locked="isFormLocked"
:is-form-readonly="isFormReadonly"
:statuses="statuses"
@clear-field-error="emit('clear-field-error', $event)"
/>
</v-form>
</template>
<template #actions>
<v-spacer />
<v-btn :disabled="isSaving" variant="text" @click="emit('close')">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="emit('delete-current')"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="emit('save')"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="emit('close')">關閉</v-btn>
</template>
</MntDialogCard>
</div>
</v-overlay>
</teleport>
<ConfirmDialog
:model-value="confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="emit('confirm-close')"
@update:model-value="emit('update:confirmCloseVisible', $event)"
/>
<ConfirmDialog
:model-value="confirmSaveVisible"
:confirm-loading="isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="emit('confirm-save')"
@update:model-value="emit('update:confirmSaveVisible', $event)"
>
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
<div v-if="item.before !== null" class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</ConfirmDialog>
<ConfirmDialog
:model-value="confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="emit('confirm-delete')"
@update:model-value="emit('update:confirmDeleteVisible', $event)"
/>
<ConfirmDialog
:model-value="confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="emit('confirm-switch')"
@update:model-value="emit('update:confirmSwitchVisible', $event)"
/>
<ConfirmDialog
:model-value="confirmNavigateVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="emit('confirm-navigate')"
@update:model-value="emit('update:confirmNavigateVisible', $event)"
/>
</template>
<style scoped>
.dialog-overlay :deep(.v-overlay__content) {
height: 100vh;
display: flex;
justify-content: flex-end;
width: 100vw;
max-width: 100vw;
}
.dialog-panel {
width: 760px;
max-width: 100%;
height: 100vh;
background: rgb(var(--v-theme-surface));
padding: 12px;
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
display: flex;
}
.form-readonly :deep(.v-field) {
pointer-events: none;
}
</style>
@@ -0,0 +1,46 @@
<script setup lang="ts">
interface Props {
title: string
backLabel?: string
error?: string
loading?: boolean
}
withDefaults(defineProps<Props>(), {
backLabel: '返回',
})
const emit = defineEmits<{
search: []
back: []
}>()
</script>
<template>
<v-container fluid class="pt-2 px-1">
<v-card>
<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>
<v-row density="compact" align="center">
<slot name="filters" />
<v-col cols="12" md="auto" class="d-md-flex justify-md-end pr-md-2">
<v-btn color="primary" :loading="loading" @click="emit('search')">查詢</v-btn>
</v-col>
</v-row>
</v-card-text>
</v-card>
<v-card v-if="$slots.results">
<v-card-text>
<slot name="results" />
</v-card-text>
</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>
@@ -0,0 +1,102 @@
<script setup lang="ts">
import { mdiBroom, mdiMagnify } from '@mdi/js'
interface GradeOption {
title: string
value: number
}
interface SearchState {
studentId: string
name: string
department: string
grade: number | null
status: string
}
defineProps<{
departments: string[]
gradeOptions: GradeOption[]
statuses: string[]
}>()
const search = defineModel<SearchState>({ required: true })
defineEmits<{
(e: 'reset'): void
}>()
</script>
<template>
<v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div>
<v-text-field
id="search-student-id"
v-model="search.studentId"
aria-labelledby="search-student-id-label"
density="compact"
hide-details
name="searchStudentId"
placeholder="例如:S2024001"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
<v-text-field
id="search-name"
v-model="search.name"
aria-labelledby="search-name-label"
density="compact"
hide-details
name="searchName"
placeholder="例如:王小明"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
<v-select
id="search-department"
v-model="search.department"
aria-labelledby="search-department-label"
density="compact"
hide-details
:items="departments"
name="searchDepartment"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
<v-select
id="search-grade"
v-model="search.grade"
aria-labelledby="search-grade-label"
density="compact"
hide-details
item-title="title"
item-value="value"
:items="gradeOptions"
name="searchGrade"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
<v-select
id="search-status"
v-model="search.status"
aria-labelledby="search-status-label"
density="compact"
hide-details
:items="statuses"
name="searchStatus"
variant="outlined"
/>
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn :prepend-icon="mdiBroom" variant="text" @click="$emit('reset')">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template>
+41
View File
@@ -0,0 +1,41 @@
# Composables Guide
`composables` 放可重用流程、page driver、command flow 與較複雜 UI state。簡單模板顯示不要為了形式新增 composable。
## 子目錄
- `page-drivers/`:頁面資料協調與 page model 組裝。
- `commands/`:命令式副作用流程,例如 create/edit/save/delete。
- `layout/`AppShell / layout 狀態與事件協調。
- `maint/`maintenance demo 的表單、CRUD、editable grid 狀態。
頂層也可放通用 composable,例如 `useApiCall.ts`:封裝 loading / data / error / execute 模式、自動 snackbar 錯誤提示與取消請求過濾。適合為單一 API 呼叫提供輕量狀態管理,但不替代 page driver 或 command composable。
## 新增規則
-`useXxx.ts` 命名。
- 參數較多時使用 options object。
- source state 盡量集中,衍生值用 `computed`
- 副作用放在明確 action 或 watcher,不放在 computed。
- 不 import component 或 view。
- 不持有 service module 的底層 HTTP 細節。
## Page Driver
Page driver 負責:
- route param/query 轉成頁面資料
- 組裝 page model
- 組裝 page component 需要的 props/events
- 協調 store、command composable、表單 composable
View 只呼叫 page driver 並掛載 page component。
## Commands
Command composable 負責副作用流程,不負責畫面布局:
- 新增 / 編輯 / 刪除 / 儲存
- 儲存前確認
- 成功後重新載入或 highlight
- 與 store/service 的 mutation 流程
+125
View File
@@ -0,0 +1,125 @@
import { nextTick, type Ref } from 'vue'
interface UseCrudCommandsOptions<TRecord extends { id: number }, TPayload> {
clearAllErrors: () => void
dialogMode: Ref<'create' | 'edit' | 'view'>
dialogVisible: Ref<boolean>
editingId: Ref<number | null>
fieldErrors: Ref<Record<string, string[]>>
form: Ref<TPayload>
highlightedId: Ref<number | null>
isDirty: Readonly<Ref<boolean>>
isLoading: Ref<boolean>
isSaving: Ref<boolean>
isViewMode: Readonly<Ref<boolean>>
loadDelay?: number
loadSequence: Ref<number>
resetForm: () => void
saveDelay?: number
scrollToField: (field: string) => void
setForm: (payload: TPayload) => void
syncInitialForm: () => void
toFormPayload: (record: TRecord) => TPayload
toSavePayload: (form: TPayload) => TPayload
updateRecord: (id: number, payload: TPayload) => unknown
createRecord: (payload: TPayload) => number
validateForm: () => Array<{ field: string; message: string }>
}
export function useCrudCommands<TRecord extends { id: number }, TPayload>(
options: UseCrudCommandsOptions<TRecord, TPayload>
) {
function openAddDialog() {
options.loadSequence.value += 1
options.dialogMode.value = 'create'
options.editingId.value = null
options.resetForm()
options.isLoading.value = false
options.dialogVisible.value = true
}
function loadRecord(record: TRecord, mode: 'edit' | 'view') {
options.loadSequence.value += 1
const sequence = options.loadSequence.value
options.dialogMode.value = mode
options.editingId.value = record.id
options.dialogVisible.value = true
options.isLoading.value = true
options.clearAllErrors()
window.setTimeout(() => {
if (sequence !== options.loadSequence.value || !options.dialogVisible.value) return
options.setForm(options.toFormPayload(record))
options.syncInitialForm()
options.isLoading.value = false
}, options.loadDelay ?? 350)
}
function openEditDialog(record: TRecord) {
loadRecord(record, 'edit')
}
function openViewDialog(record: TRecord) {
loadRecord(record, 'view')
}
async function requestSaveConfirmation(confirmSaveVisible: Ref<boolean>) {
if (
options.isSaving.value ||
options.isLoading.value ||
!options.isDirty.value ||
options.isViewMode.value
) {
return
}
options.clearAllErrors()
const errors = options.validateForm()
if (errors.length > 0) {
for (const error of errors) {
options.fieldErrors.value[error.field] = [error.message]
}
await nextTick()
const firstError = errors[0]
if (firstError) options.scrollToField(firstError.field)
return
}
confirmSaveVisible.value = true
}
function confirmSave(confirmSaveVisible: Ref<boolean>) {
confirmSaveVisible.value = false
return saveRecord()
}
async function saveRecord() {
if (options.isSaving.value || options.isLoading.value) return
options.isSaving.value = true
await new Promise((resolve) => window.setTimeout(resolve, options.saveDelay ?? 450))
const payload = options.toSavePayload(options.form.value)
if (options.editingId.value) {
const updated = options.updateRecord(options.editingId.value, payload)
if (updated) options.highlightedId.value = options.editingId.value
} else {
const createdId = options.createRecord(payload)
options.highlightedId.value = createdId
}
options.syncInitialForm()
options.dialogVisible.value = false
options.isSaving.value = false
window.setTimeout(() => {
options.highlightedId.value = null
}, 1600)
}
return {
confirmSave,
openAddDialog,
openEditDialog,
openViewDialog,
requestSaveConfirmation,
saveRecord,
}
}
+280
View File
@@ -0,0 +1,280 @@
import {
mdiCloseCircle,
mdiCog,
mdiFileDocumentOutline,
mdiFileTreeOutline,
mdiHome,
mdiPlusCircle,
mdiTableEdit,
} from '@mdi/js'
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { SESSION_FORCE_LOGOUT_EVENT } from '@/services/session'
import { useAuthStore } from '@/stores/auth'
import { useBreadcrumbStore } from '@/stores/breadcrumbs'
import { useFavoritesStore } from '@/stores/favorites'
import { useMenuStore, type LayoutMenuItem } from '@/stores/menu'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
const fixedMenuItems: LayoutMenuItem[] = [
{
title: '資料維護',
navigable: false,
subItems: [
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
],
},
{ title: '登入頁', path: '/login' },
]
const menuItemsExample: LayoutMenuItem[] = [
{ title: '首頁', icon: mdiHome, path: '/' },
{
title: '設定',
icon: mdiCog,
path: '/settings',
navigable: false,
},
...fixedMenuItems,
]
function buildMergedMenuItems(items: LayoutMenuItem[]) {
const flatPaths = new Set<string>()
const collectPaths = (list: LayoutMenuItem[]) => {
for (const item of list || []) {
if (item?.path) flatPaths.add(item.path)
if (item?.subItems?.length) collectPaths(item.subItems)
}
}
collectPaths(items)
const mergeFixedItems = (list: LayoutMenuItem[]) => {
return (list || []).map((item) => {
if (!item?.subItems?.length) return item
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
return { ...item, subItems }
})
}
const filteredFixedItems = mergeFixedItems(fixedMenuItems).filter((item) => {
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
return item.subItems.length > 0
})
return [...(items || []), ...filteredFixedItems]
}
type UseAppShellOptions = {
onLogout?: () => void
}
export function useAppShell(options: UseAppShellOptions = {}) {
const route = useRoute()
const router = useRouter()
const snackbar = useSnackbarStore()
const authStore = useAuthStore()
const menuStore = useMenuStore()
const breadcrumbStore = useBreadcrumbStore()
const favoritesStore = useFavoritesStore()
const messageStore = useMessageStore()
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
const mergedFavoriteItems = computed(() => {
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
const seen = new Set<string>()
return combined.filter((item) => {
const key = item.path ?? item.title
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
})
const layoutProps = computed(() => {
const layout = route.meta.layout
if (layout === 'default') {
return {
systemTitle: '測試環境',
favoriteItems: mergedFavoriteItems.value,
menuItems: mergedMenuItems.value,
breadcrumbItems: breadcrumbStore.breadcrumbItems,
}
}
return {}
})
function handleSelect(item: LayoutMenuItem) {
if (item.path) {
router.push(item.path)
}
}
function recursiveFindTitle(path: string, items: LayoutMenuItem[]): string | null {
for (const item of items) {
if (item.path === path) return item.title
if (item.subItems?.length) {
const found = recursiveFindTitle(path, item.subItems)
if (found) return found
}
}
return null
}
function findTitle(path: string) {
const menuTitle = recursiveFindTitle(path, menuStore.menuItems)
if (menuTitle) return menuTitle
const favoriteTitle = recursiveFindTitle(path, menuStore.favoriteItems)
if (favoriteTitle) return favoriteTitle
const exampleTitle = recursiveFindTitle(path, menuItemsExample)
if (exampleTitle) return exampleTitle
if (path === '/') return '首頁'
return path
}
function findMenuItem(path: string) {
const recursiveFind = (items: LayoutMenuItem[]): LayoutMenuItem | null => {
for (const item of items) {
if (item.path === path) return item
if (item.subItems?.length) {
const found = recursiveFind(item.subItems)
if (found) return found
}
}
return null
}
return recursiveFind(mergedMenuItems.value)
}
const currentFavoriteInfo = computed(() => {
const path = route.path
const menuItem = findMenuItem(path)
const title =
menuItem?.title ||
(typeof route.meta?.title === 'string' ? route.meta.title : null) ||
findTitle(path)
return {
title,
path,
icon: menuItem?.icon,
}
})
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
const isFavoriteActionDisabled = computed(
() => !currentFavoriteInfo.value?.path || route.path === '/'
)
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
const favoriteActionIcon = computed(() =>
isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle
)
function toggleFavoriteItem(item: LayoutMenuItem) {
if (!item?.path || item.path === '/') return
favoritesStore.toggle({
title: item.title || findTitle(item.path),
path: item.path,
icon: item.icon,
})
}
function toggleFavorite() {
toggleFavoriteItem(currentFavoriteInfo.value)
}
function handleRemoveFavorite(item: LayoutMenuItem) {
toggleFavoriteItem(item)
}
function goHome() {
router.push('/')
}
function updateBreadcrumbs() {
const resolvedTitle = findTitle(route.path)
const fallbackTitle =
resolvedTitle && resolvedTitle !== route.path
? resolvedTitle
: typeof route.meta?.title === 'string'
? route.meta.title
: null
breadcrumbStore.setBreadcrumbs({
path: route.path,
menuItems: mergedMenuItems.value,
favoriteItems: mergedFavoriteItems.value,
fallbackTitle,
homeLabel: '首頁',
homeIcon: mdiHome,
})
}
function handleLayoutAction(type: string) {
if (type === 'messages') {
messageStore.open()
}
}
function performLogout(feedback: { message: string; color: string }) {
authStore.logout()
options.onLogout?.()
snackbar.show(feedback)
router.replace({ name: 'login' })
}
function handleLogout() {
performLogout({ message: '登出成功', color: 'success' })
}
function handleForceLogout(event: Event) {
const message = (event as CustomEvent)?.detail?.message || '請重新登入'
performLogout({ message, color: 'warning' })
}
watch(
[
() => route.path,
() => menuStore.menuItems,
() => menuStore.favoriteItems,
() => favoritesStore.items,
],
() => updateBreadcrumbs(),
{ immediate: true, deep: true }
)
onMounted(() => {
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
})
onBeforeUnmount(() => {
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
})
return {
favoriteActionIcon,
favoriteActionLabel,
favoritesStore,
goHome,
handleLayoutAction,
handleLogout,
handleRemoveFavorite,
handleSelect,
isFavoriteActionDisabled,
layoutProps,
menuStore,
mergedMenuItems,
toggleFavorite,
}
}
@@ -20,7 +20,7 @@ interface UseMaintenanceCrudFlowOptions<T extends { id: number }> {
onAfterDelete?: (deletedId: number) => void onAfterDelete?: (deletedId: number) => void
} }
interface UseMaintenanceCrudFlowResult<T extends { id: number }> { export interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
confirmCloseVisible: Ref<boolean> confirmCloseVisible: Ref<boolean>
confirmSaveVisible: Ref<boolean> confirmSaveVisible: Ref<boolean>
confirmDeleteVisible: Ref<boolean> confirmDeleteVisible: Ref<boolean>
@@ -0,0 +1,19 @@
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,
}
}
@@ -0,0 +1,18 @@
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,
}
}
+101
View File
@@ -0,0 +1,101 @@
import { computed, ref } from 'vue'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
export interface HomeNewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
export interface HomeQuickItem {
icon: string
title: string
}
export interface HomePageModel {
type: 'home'
newsItems: HomeNewsItem[]
quickItems: HomeQuickItem[]
}
const newsItems: HomeNewsItem[] = [
{
id: 1,
date: '29',
month: '1月',
title: '113學年度第2學期加退選開始',
desc: '加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。',
dept: '教務處',
views: '1,234',
isNew: true,
},
{
id: 2,
date: '27',
month: '1月',
title: '場地借用系統維護通知',
desc: '系統將於本週六凌晨01:00-05:00進行維護,期間暫停服務。',
dept: '總務處',
views: '856',
isNew: false,
},
{
id: 3,
date: '25',
month: '1月',
title: '112學年度第1學期期末成績已開放查詢',
desc: '同學可至「查詢 > 教務資訊查詢 > 學期成績查詢」查看本學期成績。',
dept: '教務處',
views: '3,567',
isNew: false,
},
]
const quickItems: HomeQuickItem[] = [
{ icon: '', title: '線上加選' },
{ icon: '', title: '線上退選' },
{ icon: '📊', title: '成績查詢' },
{ icon: '📅', title: '個人課表' },
{ icon: '📝', title: '網路請假' },
{ icon: '🏢', title: '場地借用' },
]
export function useHomePage() {
const snackbar = useSnackbarStore()
const messageStore = useMessageStore()
const selectedNews = ref<HomeNewsItem | null>(null)
const isNewsDialogOpen = ref(false)
const pageModel = computed<HomePageModel>(() => ({
type: 'home',
newsItems,
quickItems,
}))
function handleNews(item: HomeNewsItem) {
selectedNews.value = item
isNewsDialogOpen.value = true
}
function handleMessageCenter() {
messageStore.open()
}
function handleQuick(item: HomeQuickItem) {
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
}
return {
pageModel,
selectedNews,
isNewsDialogOpen,
handleNews,
handleMessageCenter,
handleQuick,
}
}
@@ -0,0 +1,46 @@
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,
}
}
@@ -0,0 +1,407 @@
import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import {
type StudentFormState,
useStudentMaintenanceForm,
} from '@/composables/maint/useStudentMaintenanceForm'
import type { MaintenancePageModel } from '@/models/page'
import { type SemesterRecord, useSemesterStore } from '@/stores/semesters'
import { type StudentRecord, useStudentStore } from '@/stores/students'
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const itemsPerPage = 10
type StudentPayload = Omit<StudentRecord, 'id'>
function toFormPayload(student: StudentRecord): StudentFormState {
return {
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
}
}
function toSavePayload(form: StudentFormState): StudentPayload {
return {
studentId: form.studentId.trim(),
name: form.name.trim(),
department: form.department,
grade: form.grade,
enrollYear: form.enrollYear,
credits: form.credits,
advisor: form.advisor.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
status: form.status,
}
}
export function useMasterDetailAMaintenancePage() {
const studentStore = useStudentStore()
const semesterStore = useSemesterStore()
const students = computed(() => studentStore.students)
const { smAndUp } = useDisplay()
const isMobile = computed(() => !smAndUp.value)
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範A',
records: students.value,
loading: false,
error: null,
}))
const search = ref({ studentId: '', name: '', department: '', grade: null as number | null, status: '' })
const searchPanelOpen = ref(false)
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = students.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const isLoading = ref(false)
const isSaving = ref(false)
const snackbarVisible = ref(false)
const highlightedId = ref<number | null>(null)
const loadSequence = ref(0)
const studentSemesters = ref<SemesterRecord[]>([])
const selectedSemesterId = ref<number | null>(null)
const activeMobilePanel = ref<'master' | 'detail'>('master')
const selectedSemester = computed(
() => studentSemesters.value.find((semester) => semester.id === selectedSemesterId.value) || null
)
const isDetailEditing = ref(false)
const detailForm = ref<SemesterRecord | null>(null)
const formState = useStudentMaintenanceForm({
departments,
gradeOptions,
enrollYears,
statuses,
students,
editingId,
highlightedId,
})
const isFormLocked = computed(() => isLoading.value || isSaving.value)
const tableHeaders = computed(() => [
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && ('start' as const), width: 120 },
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && ('start' as const), width: 100 },
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '電話', key: 'phone', sortable: true, width: 140 },
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && ('end' as const), width: 'auto', cellProps: { class: 'px-0 bg-background' } },
])
function resetDetailState() {
selectedSemesterId.value = null
activeMobilePanel.value = 'master'
isDetailEditing.value = false
detailForm.value = null
}
function refreshSemesters() {
if (editingId.value) {
studentSemesters.value = semesterStore.getStudentSemesters(editingId.value)
}
}
function handleAddSemester() {
if (!editingId.value) return
const newSemester = semesterStore.addSemester(editingId.value)
refreshSemesters()
selectedSemesterId.value = newSemester.id
activeMobilePanel.value = 'detail'
startDetailEdit()
}
function handleDeleteSemester(id: number) {
if (!confirm('確定要刪除此學期紀錄嗎?')) return
semesterStore.removeSemester(id)
refreshSemesters()
if (selectedSemesterId.value === id) {
resetDetailState()
}
}
function startDetailEdit() {
if (!selectedSemester.value) return
detailForm.value = structuredClone(selectedSemester.value)
isDetailEditing.value = true
}
function cancelDetailEdit() {
isDetailEditing.value = false
detailForm.value = null
if (isMobile.value && selectedSemesterId.value === null) {
activeMobilePanel.value = 'master'
}
}
function saveDetailEdit() {
if (!detailForm.value?.id) return
semesterStore.updateSemester(detailForm.value.id, detailForm.value)
refreshSemesters()
isDetailEditing.value = false
detailForm.value = null
}
function openAddDialog() {
loadSequence.value += 1
dialogMode.value = 'create'
editingId.value = null
studentSemesters.value = []
resetDetailState()
formState.resetForm()
isLoading.value = false
dialogVisible.value = true
}
function loadRecord(student: StudentRecord, mode: 'edit' | 'view') {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = mode
editingId.value = student.id
studentSemesters.value = semesterStore.getStudentSemesters(student.id)
resetDetailState()
dialogVisible.value = true
isLoading.value = true
formState.clearAllErrors()
window.setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
formState.setForm(toFormPayload(student))
formState.syncInitialForm()
isLoading.value = false
}, 350)
}
function openEditDialog(student: StudentRecord) {
loadRecord(student, 'edit')
}
function openViewDialog(student: StudentRecord) {
loadRecord(student, 'view')
}
const flow = useMaintenanceCrudFlow<StudentRecord>({
records: students,
editingId,
dialogMode,
dialogVisible,
isLoading,
isSaving,
isDirty: formState.isDirty,
clearAllErrors: formState.clearAllErrors,
resetForm: formState.resetForm,
openEditDialog,
openViewDialog,
removeRecord: (id) => {
studentStore.removeStudent(id)
semesterStore.removeByStudentId(id)
},
describeRecord: (student) => `${student.studentId} ${student.name}`,
onCloseReset: resetDetailState,
})
const dialogTitle = computed(() => {
if (dialogMode.value === 'view') return '檢視主檔資料示範'
if (dialogMode.value === 'edit') return '修改主檔資料示範'
return '新增主檔資料示範'
})
const dialogSubtitle = computed(() => {
if (!editingId.value) return ''
return `${formState.form.value.studentId || '未填學號'}${formState.form.value.name || '未填姓名'}`
})
watch(pageCount, (value) => {
if (currentPage.value > value) currentPage.value = value
})
function resetSearch() {
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
}
async function requestSaveConfirmation() {
if (isSaving.value || isLoading.value || !formState.isDirty.value || flow.isViewMode.value) return
formState.clearAllErrors()
const errors = formState.validateForm()
if (errors.length > 0) {
for (const error of errors) {
formState.fieldErrors.value[error.field] = [error.message]
}
await nextTick()
const firstError = errors[0]
if (firstError) scrollToField(firstError.field)
return
}
flow.confirmSaveVisible.value = true
}
async function confirmSave() {
flow.confirmSaveVisible.value = false
await saveStudent()
}
async function saveStudent() {
if (isSaving.value || isLoading.value) return
isSaving.value = true
await new Promise((resolve) => window.setTimeout(resolve, 450))
const payload = toSavePayload(formState.form.value)
if (editingId.value) {
const updated = studentStore.updateStudent(editingId.value, payload)
if (updated) highlightedId.value = editingId.value
} else {
const createdId = studentStore.addStudent(payload)
semesterStore.generateForStudent(createdId)
highlightedId.value = createdId
}
formState.syncInitialForm()
dialogVisible.value = false
snackbarVisible.value = true
isSaving.value = false
window.setTimeout(() => {
highlightedId.value = null
}, 1600)
}
function scrollToField(field: string) {
const target = document.getElementById(`field-${field}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
function handleSemesterSelect(id: number) {
if (isMobile.value) {
selectedSemesterId.value = id
activeMobilePanel.value = 'detail'
return
}
selectedSemesterId.value = selectedSemesterId.value === id ? null : id
}
function closeDetailPanel() {
isDetailEditing.value = false
detailForm.value = null
if (isMobile.value) {
activeMobilePanel.value = 'master'
return
}
selectedSemesterId.value = null
}
const masterDetailProps = computed(() => ({
activeMobilePanel: activeMobilePanel.value,
confirmCloseVisible: flow.confirmCloseVisible.value,
confirmDeleteVisible: flow.confirmDeleteVisible.value,
confirmNavigateVisible: flow.confirmNavigateVisible.value,
confirmSaveVisible: flow.confirmSaveVisible.value,
confirmSwitchVisible: flow.confirmSwitchVisible.value,
departments,
detailForm: detailForm.value,
dialogSubtitle: dialogSubtitle.value,
dialogTitle: dialogTitle.value,
dialogVisible: dialogVisible.value,
enrollYears,
errorSummary: formState.errorSummary.value,
fieldErrors: formState.fieldErrors.value,
form: formState.form.value,
gradeOptions,
hasNextRecord: flow.hasNextRecord.value,
hasPrevRecord: flow.hasPrevRecord.value,
isDetailEditing: isDetailEditing.value,
isDirty: formState.isDirty.value,
isEditMode: flow.isEditMode.value,
isFormLocked: isFormLocked.value,
isFormReadonly: flow.isViewMode.value,
isLoading: isLoading.value,
isMobile: isMobile.value,
isSaving: isSaving.value,
isViewMode: flow.isViewMode.value,
pendingDeleteLabel: flow.pendingDeleteLabel.value,
saveSummary: formState.saveSummary.value,
selectedSemester: selectedSemester.value,
selectedSemesterId: selectedSemesterId.value,
semesters: studentSemesters.value,
statuses,
}))
const masterDetailEvents = {
'add-semester': handleAddSemester,
'cancel-detail-edit': cancelDetailEdit,
'clear-field-error': formState.clearFieldError,
close: flow.requestCloseDialog,
'close-detail-panel': closeDetailPanel,
'confirm-close': flow.confirmClose,
'confirm-delete': flow.confirmDelete,
'confirm-navigate': flow.confirmNavigate,
'confirm-save': confirmSave,
'confirm-switch': flow.confirmSwitch,
delete: flow.requestDeleteConfirmation,
'delete-current': flow.requestDeleteCurrent,
'delete-semester': handleDeleteSemester,
'dialog-visible-change': flow.handleDialogVisibility,
first: () => flow.openEdgeRecord('first'),
last: () => flow.openEdgeRecord('last'),
next: () => flow.openAdjacentRecord('next'),
prev: () => flow.openAdjacentRecord('prev'),
save: requestSaveConfirmation,
'save-detail-edit': saveDetailEdit,
'scroll-to-field': scrollToField,
'select-semester': handleSemesterSelect,
'start-detail-edit': startDetailEdit,
'switch-to-edit': flow.switchToEditMode,
'switch-to-view': flow.switchToViewMode,
'update:confirmCloseVisible': (value: boolean) => (flow.confirmCloseVisible.value = value),
'update:confirmDeleteVisible': (value: boolean) => (flow.confirmDeleteVisible.value = value),
'update:confirmNavigateVisible': (value: boolean) => (flow.confirmNavigateVisible.value = value),
'update:confirmSaveVisible': (value: boolean) => (flow.confirmSaveVisible.value = value),
'update:confirmSwitchVisible': (value: boolean) => (flow.confirmSwitchVisible.value = value),
'update:detailForm': (value: SemesterRecord | null) => (detailForm.value = value),
'update:form': (value: StudentFormState) => (formState.form.value = value),
}
return {
currentPage,
departments,
formState,
gradeOptions,
itemsPerPage,
masterDetailEvents,
masterDetailProps,
openAddDialog,
openEditDialog,
openViewDialog,
pageCount,
pageModel,
pageSummary,
resetSearch,
search,
searchPanelOpen,
snackbarVisible,
statuses,
students,
tableHeaders,
}
}
@@ -0,0 +1,20 @@
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,
}
}
@@ -0,0 +1,20 @@
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,13 @@
import { computed } from 'vue'
export interface SettingsPageModel {
title: string
}
export function useSettingsPage() {
const pageModel = computed<SettingsPageModel>(() => ({
title: '設定頁面',
}))
return { pageModel }
}
@@ -0,0 +1,258 @@
import { computed, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useCrudCommands } from '@/composables/commands/useCrudCommands'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import {
type StudentFormState,
useStudentMaintenanceForm,
} from '@/composables/maint/useStudentMaintenanceForm'
import type { MaintenancePageModel } from '@/models/page'
import { type StudentRecord, useStudentStore } from '@/stores/students'
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const itemsPerPage = 10
type StudentPayload = Omit<StudentRecord, 'id'>
function toFormPayload(student: StudentRecord): StudentFormState {
return {
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
}
}
function toSavePayload(form: StudentFormState): StudentPayload {
return {
studentId: form.studentId.trim(),
name: form.name.trim(),
department: form.department,
grade: form.grade,
enrollYear: form.enrollYear,
credits: form.credits,
advisor: form.advisor.trim(),
email: form.email.trim(),
phone: form.phone.trim(),
status: form.status,
}
}
export function useSingleRecordMaintenancePage() {
const studentStore = useStudentStore()
const students = computed(() => studentStore.students)
const { smAndUp } = useDisplay()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '單筆資料維護示範',
records: students.value,
loading: false,
error: null,
}))
const search = ref({ studentId: '', name: '', department: '', grade: null as number | null, status: '' })
const searchPanelOpen = ref(false)
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = students.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const isLoading = ref(false)
const isSaving = ref(false)
const highlightedId = ref<number | null>(null)
const loadSequence = ref(0)
const snackbarVisible = ref(false)
const formState = useStudentMaintenanceForm({
departments,
gradeOptions,
enrollYears,
statuses,
students,
editingId,
highlightedId,
})
const isFormLocked = computed(() => isLoading.value || isSaving.value)
const tableHeaders = computed(() => [
{ title: '學號', key: 'studentId', sortable: true, fixed: smAndUp.value && ('start' as const), width: 120 },
{ title: '姓名', key: 'name', sortable: true, fixed: smAndUp.value && ('start' as const), width: 100 },
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '電話', key: 'phone', sortable: true, width: 140 },
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{ title: '操作', key: 'actions', sortable: false, fixed: smAndUp.value && ('end' as const), width: 'auto', cellProps: { class: 'px-0 bg-background' } },
])
function resetSearch() {
search.value = { studentId: '', name: '', department: '', grade: null, status: '' }
}
function scrollToField(field: string) {
const target = document.getElementById(`field-${field}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const commands = useCrudCommands<StudentRecord, StudentFormState>({
...formState,
dialogMode,
dialogVisible,
editingId,
highlightedId,
isLoading,
isSaving,
isViewMode: computed(() => dialogMode.value === 'view'),
loadSequence,
scrollToField,
toFormPayload,
toSavePayload,
updateRecord: (id, payload) => studentStore.updateStudent(id, payload),
createRecord: (payload) => studentStore.addStudent(payload),
})
const flow = useMaintenanceCrudFlow<StudentRecord>({
records: students,
editingId,
dialogMode,
dialogVisible,
isLoading,
isSaving,
isDirty: formState.isDirty,
clearAllErrors: formState.clearAllErrors,
resetForm: formState.resetForm,
openEditDialog: commands.openEditDialog,
openViewDialog: commands.openViewDialog,
removeRecord: (id) => studentStore.removeStudent(id),
describeRecord: (student) => `${student.studentId} ${student.name}`,
})
const dialogTitle = computed(() => {
if (dialogMode.value === 'view') return '檢視資料示範'
if (dialogMode.value === 'edit') return '修改資料示範'
return '新增資料示範'
})
const dialogSubtitle = computed(() => (!editingId.value ? '' : `${formState.form.value.studentId || '未填學號'}${formState.form.value.name || '未填姓名'}`))
watch(pageCount, (value) => {
if (currentPage.value > value) currentPage.value = value
})
async function requestSaveConfirmation() {
await commands.requestSaveConfirmation(flow.confirmSaveVisible)
}
async function confirmSave() {
await commands.confirmSave(flow.confirmSaveVisible)
snackbarVisible.value = true
}
const formPanelProps = computed(() => ({
confirmCloseVisible: flow.confirmCloseVisible.value,
confirmDeleteVisible: flow.confirmDeleteVisible.value,
confirmNavigateVisible: flow.confirmNavigateVisible.value,
confirmSaveVisible: flow.confirmSaveVisible.value,
confirmSwitchVisible: flow.confirmSwitchVisible.value,
departments,
dialogSubtitle: dialogSubtitle.value,
dialogTitle: dialogTitle.value,
dialogVisible: dialogVisible.value,
enrollYears,
errorSummary: formState.errorSummary.value,
fieldErrors: formState.fieldErrors.value,
form: formState.form.value,
gradeOptions,
hasNextRecord: flow.hasNextRecord.value,
hasPrevRecord: flow.hasPrevRecord.value,
isDirty: formState.isDirty.value,
isEditMode: flow.isEditMode.value,
isFormLocked: isFormLocked.value,
isFormReadonly: flow.isViewMode.value,
isLoading: isLoading.value,
isSaving: isSaving.value,
isViewMode: flow.isViewMode.value,
pendingDeleteLabel: flow.pendingDeleteLabel.value,
saveSummary: formState.saveSummary.value,
statuses,
}))
const formPanelEvents = {
'clear-field-error': formState.clearFieldError,
close: flow.requestCloseDialog,
'confirm-close': flow.confirmClose,
'confirm-delete': flow.confirmDelete,
'confirm-navigate': flow.confirmNavigate,
'confirm-save': confirmSave,
'confirm-switch': flow.confirmSwitch,
'delete-current': flow.requestDeleteCurrent,
'dialog-visible-change': flow.handleDialogVisibility,
first: () => flow.openEdgeRecord('first'),
last: () => flow.openEdgeRecord('last'),
next: () => flow.openAdjacentRecord('next'),
prev: () => flow.openAdjacentRecord('prev'),
save: requestSaveConfirmation,
'scroll-to-field': scrollToField,
'switch-to-edit': flow.switchToEditMode,
'switch-to-view': flow.switchToViewMode,
'update:confirmCloseVisible': (value: boolean) => (flow.confirmCloseVisible.value = value),
'update:confirmDeleteVisible': (value: boolean) => (flow.confirmDeleteVisible.value = value),
'update:confirmNavigateVisible': (value: boolean) => (flow.confirmNavigateVisible.value = value),
'update:confirmSaveVisible': (value: boolean) => (flow.confirmSaveVisible.value = value),
'update:confirmSwitchVisible': (value: boolean) => (flow.confirmSwitchVisible.value = value),
'update:form': (value: StudentFormState) => (formState.form.value = value),
}
return {
commands,
currentPage,
departments,
dialogMode,
dialogSubtitle,
dialogTitle,
dialogVisible,
enrollYears,
flow,
formState,
formPanelEvents,
formPanelProps,
gradeOptions,
isFormLocked,
isFormReadonly: flow.isViewMode,
isLoading,
isSaving,
itemsPerPage,
pageCount,
pageModel,
pageSummary,
requestSaveConfirmation,
confirmSave,
resetSearch,
scrollToField,
search,
searchPanelOpen,
snackbarVisible,
statuses,
students,
tableHeaders,
}
}
+10
View File
@@ -0,0 +1,10 @@
# Language Guide
`language` 放 Vue I18n 文案。新增可見 UI 文案時,若該文字屬於產品功能或會被重用,優先放進語系檔。
## 規則
- 同步維護 `zh-TW.json``en-US.json`
- key 命名以 feature/domain 分組。
- 移除 demo 頁或 feature 時,同步清理不再使用的文案。
- 不把大型靜態資料塞進語系檔;語系檔只放文案。
+26
View File
@@ -0,0 +1,26 @@
# Models Guide
`models` 放 domain model 與 page model 型別定義。model 只定義形狀(interface/type),不含業務邏輯、API 呼叫或 UI 狀態。
## 種類
- **Domain Model**:特定領域的資料型別,例如 `StudentRecord`。檔名用 domain 命名(`student.ts`),型別使用 domain 前綴。
- **Page Model**`page.ts` 定義頁面驅動資料的 union type,供 page driver 組裝後傳給 page component。例如 `BasePageModel``MaintenancePageModel`
## 規則
- 用 interface 或 type,不加 class。
- domain model 應與 service response / store state 共用型別來源。
- page model 僅定義畫面需要的欄位,不鏡像整個 service response。
- 不 import component、view、store、composable。
- 型別 export 時明確命名,避免與其他層混淆。
## Page Model 慣例
`src/models/page.ts` 定義基礎型別與 union
- `BasePageModel`:所有頁面共用欄位(`title``loading``error`)。
- 各頁面的 specific model 擴展 `BasePageModel`(例如 `MaintenancePageModel``type``records`)。
- `PageModel` union 供 page component props 型別使用。
新增頁面類型時,先擴充 `PageModel` union 再新增對應的 page driver。
+12
View File
@@ -0,0 +1,12 @@
export interface BasePageModel {
title: string
loading?: boolean
error?: string | null
}
export interface MaintenancePageModel extends BasePageModel {
type: 'maintenance'
records: unknown[]
}
export type PageModel = MaintenancePageModel
+13
View File
@@ -0,0 +1,13 @@
export interface StudentRecord {
id: number
studentId: string
name: string
department: string
grade: number
enrollYear: number
credits: number
advisor: string
email: string
phone: string
status: string
}
+54
View File
@@ -0,0 +1,54 @@
# Router Guide
`router` 集中管理 route、layout meta、auth meta 與 navigation guard。
## routes.ts
新增 route 時包含:
- `path`
- `name`
- `component`
- `meta.layout`
一般頁面使用:
```ts
meta: { layout: 'default' }
```
只有登入頁、錯誤頁、維護中頁或明確要求獨立頁面時使用:
```ts
meta: { layout: 'none' }
```
## Auth Meta
| Meta | 效果 |
|------|------|
| `requiresAuth: true` | 未登入時導向 login,附 `redirect` query |
| `guestOnly: true` | 已登入時導向 home(含 `VITE_SKIP_LOGIN` 啟用時) |
| `roles: string[]` | RBAC,缺任一角色時導向 `/403` |
以上 meta 只在 `registerGuards` 中消費,不要在 component 裡重複檢查。
## 錯誤頁路由慣例
錯誤頁(403/500/503/network/maintenance/not-found)統一使用:
```ts
meta: { title: 'Forbidden', layout: 'none' }
```
- `layout: 'none'` 使頁面不被 `MainLayout` 包住,自己獨立渲染。
- catch-all `/:pathMatch(.*)*` 放在路由陣列最後。
- 錯誤頁 view 通常使用 `ErrorShell.vue` 共用組件,只傳入 props,不用重複寫佈局。
## Guards
- guard 流程放在 `guards.ts`,不要散落在 view/component。
- `beforeEach`:登入檢查、RBAC、`VITE_SKIP_LOGIN` 跳過。
- `beforeResolve`:輕量前置工作(例如進度條)。
- `afterEach`document title、追蹤。
- `onError`chunk load 失敗等。
+34
View File
@@ -0,0 +1,34 @@
# Services Guide
`services` 是 HTTP 與外部 API 邊界。service 回傳資料,不持有 UI 狀態。
## 資料流
```txt
component/view -> store/composable -> service module -> httpClient -> hooks
```
## 規則
- 新 API 放在 `services/modules/<domain>.ts`
- 使用 `httpClient`,不要直接建立新的 ky instance。
- 不 import component、view 或 store。
- 不管理 loading、dialog、snackbar、AbortController lifecycle。
- request option 可接收 `signal`
- 錯誤正規化交給既有 error/http hooks 流程。
## 錯誤處理體系
- `error.ts`:定義 `ApiRequestError`(正規化後的錯誤類別)、`CanceledRequestError`(取消請求)、`normalizeError()`(將 ky HTTPError / TimeoutError / DOMException 統一轉為 `ApiRequestError`)。
- `http-error.ts`ky `beforeError` hook,將 response body 的錯誤訊息注入 error 物件。
- `http-toast.ts`:全域 HTTP error toast,依 status code 顯示對應 snackbar。
service module 不需要自行 catch 並處理錯誤,交由 interceptors/hooks 與上層 composable(如 `useApiCall`)處理。
## ky 注意事項
- 本專案使用 ky,不使用 axios。
- JSON response 用 `.json<T>()`
- JSON payload 用 `json`FormData 用 `body`
- 取消請求使用原生 `AbortController``signal`
- token 注入與 401 force logout 集中在 hooks,不在單一 API module 重寫。
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute } from 'vue-router'
import { mdiHome } from '@mdi/js'
import MainLayout from '@/components/layouts/MainLayout.vue'
import PlainLayout from '@/components/layouts/PlainLayout.vue'
import { useAppShell } from '@/composables/layout/useAppShell'
import AppTabs from './AppTabs.vue'
import GlobalOverlays from './GlobalOverlays.vue'
type AppTabsInstance = InstanceType<typeof AppTabs>
type GlobalOverlaysInstance = InstanceType<typeof GlobalOverlays>
const route = useRoute()
const appTabs = ref<AppTabsInstance | null>(null)
const globalOverlays = ref<GlobalOverlaysInstance | null>(null)
const layoutMap = {
default: MainLayout,
none: PlainLayout,
}
const activeLayout = computed(
() => layoutMap[route.meta.layout as 'default' | 'none'] || MainLayout
)
const showTabs = computed(() => route.meta.layout === 'default')
function clearTabs() {
appTabs.value?.clearTabs()
}
const {
favoriteActionIcon,
favoriteActionLabel,
favoritesStore,
goHome,
handleLayoutAction,
handleLogout,
handleRemoveFavorite,
handleSelect,
isFavoriteActionDisabled,
layoutProps,
menuStore,
mergedMenuItems,
toggleFavorite,
} = useAppShell({ onLogout: clearTabs })
function handleSearch(value: string) {
globalOverlays.value?.handleSearch(value)
}
</script>
<template>
<component
:is="activeLayout"
v-bind="layoutProps"
v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
v-model:is-rail="menuStore.isRail"
@action="handleLayoutAction"
@logout="handleLogout"
@remove-favorite="handleRemoveFavorite"
@search="handleSearch"
@select="handleSelect"
>
<template #breadcrumb-actions>
<v-btn
color="secondary"
:disabled="isFavoriteActionDisabled"
size="small"
variant="outlined"
@click="toggleFavorite"
>
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
{{ favoriteActionLabel }}
</v-btn>
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
<v-icon class="mr-1" size="14" :icon="mdiHome" />
返回首頁
</v-btn>
</template>
<AppTabs ref="appTabs" :menu-items="mergedMenuItems" :show-tabs="showTabs">
<router-view v-if="showTabs" v-slot="{ Component }">
<keep-alive>
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
<router-view v-else />
</AppTabs>
</component>
<GlobalOverlays ref="globalOverlays" :menu-items="mergedMenuItems" @search-select="handleSelect" />
</template>
+119
View File
@@ -0,0 +1,119 @@
<script setup lang="ts">
import { mdiClose } from '@mdi/js'
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { LayoutMenuItem } from '@/stores/menu'
const props = defineProps<{
menuItems?: LayoutMenuItem[]
showTabs?: boolean
}>()
const emit = defineEmits<{
(e: 'close', path: string): void
}>()
const route = useRoute()
const router = useRouter()
const tabs = ref<Array<{ title: string; path: string }>>([])
const activeTab = ref<string | null>(null)
function findTitle(path: string, items?: LayoutMenuItem[]): string | null {
const searchIn = items || []
for (const item of searchIn) {
if (item.path === path) return item.title
if (item.subItems?.length) {
const found = findTitle(path, item.subItems)
if (found) return found
}
}
return null
}
function resolveTitle(path: string): string {
const fromProps = findTitle(path, props.menuItems)
if (fromProps) return fromProps
if (path === '/') return '首頁'
return path
}
watch(
() => route.path,
(newPath) => {
if (!props.showTabs) return
const existingTab = tabs.value.find((t) => t.path === newPath)
if (!existingTab) {
const title = resolveTitle(newPath)
tabs.value.push({ title, path: newPath })
}
activeTab.value = newPath
},
{ immediate: true }
)
function closeTab(path: string) {
if (tabs.value.length <= 1) return
const index = tabs.value.findIndex((t) => t.path === path)
if (index === -1) return
tabs.value.splice(index, 1)
if (route.path === path) {
const nextTab = tabs.value[index] || tabs.value[index - 1]
if (nextTab) {
router.push(nextTab.path)
} else {
router.push('/')
}
}
emit('close', path)
}
function clearTabs() {
tabs.value = []
activeTab.value = null
}
defineExpose({ tabs, activeTab, closeTab, clearTabs })
</script>
<template>
<div v-if="showTabs" class="d-flex flex-column h-100">
<v-tabs
v-model="activeTab"
bg-color="background"
color="primary"
density="compact"
show-arrows
style="flex-shrink: 0"
>
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
{{ tab.title }}
<v-btn
aria-label="關閉頁籤"
class="pl-2"
color="grey"
density="compact"
:disabled="tabs.length <= 1"
icon
size="x-small"
variant="text"
@click.prevent.stop="closeTab(tab.path)"
>
<v-icon :icon="mdiClose" />
</v-btn>
</v-tab>
</v-tabs>
<div class="flex-grow-1 overflow-auto" style="min-height: 0" tabindex="0">
<slot />
</div>
</div>
<slot v-else />
</template>
+16
View File
@@ -0,0 +1,16 @@
# Shell Guide
`shell` 是 App Shell 層,負責 route layout 切換、tabs、global overlays 與 layout event wiring。一般頁面需求不應修改這裡。
## 檔案
- `AppShell.vue`layout 切換、layout props/events、breadcrumb actions、tabs router-view、`GlobalOverlays` 掛載。
- `AppTabs.vue`default layout 下的 tabs 與 keep-alive router-view 容器。
- `GlobalOverlays.vue`:全域 snackbar、搜尋 dialog、訊息 dialog。
## 規則
- 不放頁面專屬表單、表格或 CRUD 流程。
- 不直接寫 domain-specific dialog 內容,除非是 template 全域 overlay。
- shell 狀態協調放在 `composables/layout/useAppShell.ts`
- 登出、force logout、HTTP toast 等全域流程可以在 shell composable 中協調。
+199
View File
@@ -0,0 +1,199 @@
<script setup lang="ts">
import {
mdiBellOutline,
mdiCalendarOutline,
mdiHomeCityOutline,
mdiSchoolOutline,
} from '@mdi/js'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { HTTP_TOAST_EVENT } from '@/services/http-toast'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
import type { LayoutMenuItem } from '@/stores/menu'
const props = defineProps<{
menuItems?: LayoutMenuItem[]
}>()
const emit = defineEmits<{
(e: 'searchSelect', item: { title: string; path: string; icon?: string }): void
}>()
const snackbar = useSnackbarStore()
const messageStore = useMessageStore()
const searchDialog = ref(false)
const searchKeyword = ref('')
const searchResults = ref<
Array<{ title: string; path: string; icon?: string; parents: string[] }>
>([])
const messageItems = [
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: mdiBellOutline },
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: mdiSchoolOutline },
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: mdiHomeCityOutline },
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: mdiCalendarOutline },
]
function buildSearchResults(
items: LayoutMenuItem[] | undefined,
keyword: string,
parents: string[] = []
): Array<{ title: string; path: string; icon?: string; parents: string[] }> {
const results: Array<{ title: string; path: string; icon?: string; parents: string[] }> = []
for (const item of items || []) {
const currentParents = item?.title ? [...parents, item.title] : parents
if (item?.subItems?.length) {
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
}
if (item?.path && item?.title) {
const hit = item.title.toLowerCase().includes(keyword)
if (hit) {
results.push({
title: item.title,
path: item.path,
icon: item.icon,
parents,
})
}
}
}
return results
}
function handleSearch(value: unknown) {
const keyword = String(value ?? '').trim()
searchKeyword.value = keyword
if (!keyword) {
searchResults.value = []
searchDialog.value = false
return
}
const lowered = keyword.toLowerCase()
searchResults.value = buildSearchResults(props.menuItems, lowered)
searchDialog.value = true
}
function handleSearchSelect(item: { title: string; path: string; icon?: string }) {
searchDialog.value = false
emit('searchSelect', item)
}
function resolveMessageItem(wrapped: unknown) {
if (wrapped && typeof wrapped === 'object' && 'raw' in (wrapped as object)) {
return (wrapped as { raw: (typeof messageItems)[0] }).raw
}
return wrapped as (typeof messageItems)[0]
}
function handleHttpToast(event: Event) {
const detail = (event as CustomEvent)?.detail
const message = detail?.message
if (!message) return
const level = detail?.level
const color =
level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'
snackbar.show({ message, color, timeout: 3000, location: 'top right', variant: 'flat' })
}
onMounted(() => {
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
onBeforeUnmount(() => {
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
defineExpose({ handleSearch })
</script>
<template>
<v-dialog v-model="searchDialog" max-width="640">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
<v-card-subtitle v-if="searchKeyword" class="pt-4">
關鍵字{{ searchKeyword }}
</v-card-subtitle>
<v-card-text class="pt-2">
<v-alert
v-if="searchResults.length === 0"
class="mt-2"
density="compact"
type="info"
variant="tonal"
>
查無結果
</v-alert>
<v-list v-else density="compact">
<v-list-item
v-for="item in searchResults"
:key="item.path"
class="mb-2"
@click="handleSearchSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.parents?.length">
{{ item.parents.join(' / ') }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="searchDialog = false">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="messageStore.isOpen" max-width="720">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
僅示意資料不含延伸功能
</v-card-subtitle>
<v-card-text class="pa-4">
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
<template #default="{ items }">
<v-list density="compact">
<v-list-item
v-for="wrapped in items"
:key="resolveMessageItem(wrapped).id"
border="sm"
class="pa-2 mb-1"
>
<template #prepend>
<v-avatar color="primary" size="28" variant="tonal">
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
</v-avatar>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
{{ resolveMessageItem(wrapped).title }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-medium-emphasis">
{{ resolveMessageItem(wrapped).meta }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
</v-data-iterator>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="messageStore.close()">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.visible"
:color="snackbar.color"
:location="snackbar.location as any"
:timeout="snackbar.timeout"
:variant="snackbar.variant"
>
{{ snackbar.message }}
</v-snackbar>
</template>
+23
View File
@@ -0,0 +1,23 @@
# Stores Guide
`stores` 使用 Pinia 管理跨頁共享狀態、快取與全域顯示狀態。單一頁面暫時 UI 狀態應留在 page driver、component 或 composable。
## 放進 Store 的情況
- auth/session
- menu/favorites/breadcrumbs
- snackbar/messages
- 跨頁共享資料快取
- 多個頁面都需要讀寫的 domain state
## 不放進 Store 的情況
- dialog visible
- 單一頁搜尋條件
- 單一頁分頁狀態
- 單一表單 dirty / validation
- 單一頁 loading/error
## 資料流
store 可以呼叫 service module。component 不應繞過 store/composable 直接處理 token、session 或 HTTP hooks。
+1
View File
@@ -6,6 +6,7 @@ import { menuApi, type MenuNode } from '@/services/modules/menu'
export interface LayoutMenuItem { export interface LayoutMenuItem {
title: string title: string
path?: string path?: string
icon?: string
navigable?: boolean navigable?: boolean
subItems?: LayoutMenuItem[] subItems?: LayoutMenuItem[]
} }
+2 -13
View File
@@ -1,19 +1,8 @@
import type { StudentRecord } from '@/models/student'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
export interface StudentRecord { export type { StudentRecord } from '@/models/student'
id: number
studentId: string
name: string
department: string
grade: number
enrollYear: number
credits: number
advisor: string
email: string
phone: string
status: string
}
const seedStudents: StudentRecord[] = [ const seedStudents: StudentRecord[] = [
{ {
+10
View File
@@ -0,0 +1,10 @@
# Types Guide
`types` 放跨模組共用的 TypeScript 型別定義,例如 API 回應格式(`ApiError`)、認證相關型別(`User``LoginPayload``CaptchaResponse`)。
## 規則
- 只放純型別(interface / type),不放邏輯、class、常數或 runtime code。
- 型別應小而聚焦,避免一個大雜燴檔。
-`models/` 的區別:`types/` 偏向 API 協定層(request/response、錯誤格式),`models/` 偏向領域實體與 page model。
- 不 import component、view、store、composable。
+7 -10
View File
@@ -1,13 +1,10 @@
<template>
<v-sheet height="100%" width="100%">
{{ fncId }}
</v-sheet>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import PageFunction from '@/components/pages/PageFunction.vue'
import { useRoute } from 'vue-router' import { useFunctionPage } from '@/composables/page-drivers/useFunctionPage'
const route = useRoute() const { pageModel } = useFunctionPage()
const fncId = computed(() => String(route.params.fncId ?? ''))
</script> </script>
<template>
<PageFunction :page="pageModel" />
</template>
+32
View File
@@ -0,0 +1,32 @@
# Views Guide
`views` 是 route entry。View 應維持薄層,負責掛載 page driver 與 page component,不承載大段 UI、dialog、表單欄位或 store mutation 細節。
## 規則
- 使用 `<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>`、大型表格或大型表單。
## 建議形狀
```vue
<script setup lang="ts">
import PageReports from '@/components/pages/PageReports.vue'
import { useReportsPage } from '@/composables/page-drivers/useReportsPage'
const page = useReportsPage()
</script>
<template>
<PageReports :page="page.pageModel.value" />
</template>
```
## 子目錄
- `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。
+14 -80
View File
@@ -1,83 +1,17 @@
<script setup lang="ts">
import PageHome from '@/components/pages/PageHome.vue'
import { useHomePage } from '@/composables/page-drivers/useHomePage'
const page = useHomePage()
</script>
<template> <template>
<page-index <PageHome
:is-news-dialog-open="isNewsDialogOpen" v-model:news-dialog-open="page.isNewsDialogOpen.value"
:news-items="newsItems" :page="page.pageModel.value"
:quick-items="quickItems" :selected-news="page.selectedNews.value"
:selected-news="selectedNews" @message-center="page.handleMessageCenter"
@message-center="handleMessageCenter" @news="page.handleNews"
@news="handleNews" @quick="page.handleQuick"
@quick="handleQuick"
@update:is-news-dialog-open="isNewsDialogOpen = $event"
/> />
</template> </template>
<script setup lang="ts">
import { ref } from 'vue'
import PageIndex from '@/components/PageIndex.vue'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
const snackbar = useSnackbarStore()
const messageStore = useMessageStore()
const newsItems = [
{
id: 1,
date: '29',
month: '1月',
title: '113學年度第2學期加退選開始',
desc: '加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。',
dept: '教務處',
views: '1,234',
isNew: true,
},
{
id: 2,
date: '27',
month: '1月',
title: '場地借用系統維護通知',
desc: '系統將於本週六凌晨01:00-05:00進行維護,期間暫停服務。',
dept: '總務處',
views: '856',
isNew: false,
},
{
id: 3,
date: '25',
month: '1月',
title: '112學年度第1學期期末成績已開放查詢',
desc: '同學可至「查詢 > 教務資訊查詢 > 學期成績查詢」查看本學期成績。',
dept: '教務處',
views: '3,567',
isNew: false,
},
]
type NewsItem = (typeof newsItems)[number]
const quickItems = [
{ icon: '', title: '線上加選' },
{ icon: '', title: '線上退選' },
{ icon: '📊', title: '成績查詢' },
{ icon: '📅', title: '個人課表' },
{ icon: '📝', title: '網路請假' },
{ icon: '🏢', title: '場地借用' },
]
const selectedNews = ref<NewsItem | null>(null)
const isNewsDialogOpen = ref(false)
function handleNews(item: NewsItem) {
selectedNews.value = item
isNewsDialogOpen.value = true
}
// 點擊首頁「訊息中心」卡片,開啟共用的訊息清單 dialog
function handleMessageCenter() {
messageStore.open()
}
function handleQuick(item: (typeof quickItems)[number]) {
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
}
</script>
+9 -4
View File
@@ -1,5 +1,10 @@
<template> <script setup lang="ts">
<div>設定頁面</div> import PageSettings from '@/components/pages/PageSettings.vue'
</template> import { useSettingsPage } from '@/composables/page-drivers/useSettingsPage'
<script setup lang="ts"></script> const { pageModel } = useSettingsPage()
</script>
<template>
<PageSettings :page="pageModel" />
</template>
+8 -5
View File
@@ -1,7 +1,10 @@
<template>
<EditableStudentGrid />
</template>
<script setup lang="ts"> <script setup lang="ts">
import EditableStudentGrid from '@/components/maint/EditableGrid.vue' import PageEditableGridMaintenance from '@/components/pages/PageEditableGridMaintenance.vue'
import { useEditableGridMaintenancePage } from '@/composables/page-drivers/useEditableGridMaintenancePage'
const page = useEditableGridMaintenancePage()
</script> </script>
<template>
<PageEditableGridMaintenance :page="page.pageModel.value" />
</template>
+23
View File
@@ -0,0 +1,23 @@
# Maintenance Views Guide
`views/maint` 是維護頁 demo。所有檔案都應是薄 route entry,實際 UI 與流程分別放在 `components/pages``components/sections``components/items``composables/page-drivers`
## 目前範本
- `SingleRecord.vue`:單主檔 CRUD + dialog。
- `EditableGrid.vue`:可編輯表格。
- `MasterDetailA.vue`:主檔 + 側邊明細 panel。
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
- `MasterDetailC.vue`:主檔 + 簡化明細清單。
## 複製規則
複製維護頁時同步調整:
- `router/routes.ts``path``name``component``meta.layout`
- page driver 名稱與 import
- page component 名稱與 import
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
- store、service、model、語系、menu/favorites/breadcrumb 相關資料
正式 domain 不應長期塞在 `maint`,複製後優先移到自己的 feature 目錄。
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+40 -909
View File
@@ -1,921 +1,52 @@
<script setup lang="ts">
import PageMaintenance from '@/components/pages/PageMaintenance.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
import SectionSearchPanel from '@/components/sections/SectionSearchPanel.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const page = useSingleRecordMaintenancePage()
</script>
<template> <template>
<page-maint <PageMaintenance
:search-panel-open="searchPanelOpen" v-model:search-panel-open="page.searchPanelOpen.value"
:title="`單筆資料維護示範`" :page="page.pageModel.value"
@create="openAddDialog" @create="page.commands.openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen"
> >
<template #search-fields> <template #search-fields>
<v-col cols="12" md="2"> <SectionSearchPanel
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div> v-model="page.search.value"
<v-text-field :departments="page.departments"
id="search-student-id" :grade-options="page.gradeOptions"
v-model="search.studentId" :statuses="page.statuses"
aria-labelledby="search-student-id-label" @reset="page.resetSearch"
density="compact" />
hide-details
name="searchStudentId"
placeholder="例如:S2024001"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div>
<v-text-field
id="search-name"
v-model="search.name"
aria-labelledby="search-name-label"
density="compact"
hide-details
name="searchName"
placeholder="例如:王小明"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div>
<v-select
id="search-department"
v-model="search.department"
aria-labelledby="search-department-label"
density="compact"
hide-details
:items="departments"
name="searchDepartment"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div>
<v-select
id="search-grade"
v-model="search.grade"
aria-labelledby="search-grade-label"
density="compact"
hide-details
item-title="title"
item-value="value"
:items="gradeOptions"
name="searchGrade"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="2">
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div>
<v-select
id="search-status"
v-model="search.status"
aria-labelledby="search-status-label"
density="compact"
hide-details
:items="statuses"
name="searchStatus"
variant="outlined"
/>
</v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col>
</template> </template>
<template #table> <template #table>
<v-data-table <SectionDataTable
v-model:page="currentPage" v-model:current-page="page.currentPage.value"
class="student-table" :grade-label="page.formState.gradeLabel"
density="compact" :headers="page.tableHeaders.value"
fixed-header :items="page.students.value"
:headers="tableHeaders" :items-per-page="page.itemsPerPage"
height="100%" :page-count="page.pageCount.value"
hide-default-footer :page-summary="page.pageSummary.value"
:items="students" :row-props="page.formState.rowProps"
:items-per-page="itemsPerPage" :status-color="page.formState.statusColor"
:row-props="rowProps" @delete="page.flow.requestDeleteConfirmation"
> @edit="page.commands.openEditDialog"
<template #[`item.grade`]="{ item }"> @view="page.commands.openViewDialog"
{{ gradeLabel(item.grade) }} />
</template>
<template #[`item.status`]="{ item }">
<v-chip :color="statusColor(item.status)" size="small" variant="tonal">
{{ item.status }}
</v-chip>
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-2">
<v-btn
color="info"
:prepend-icon="mdiEye"
size="small"
variant="text"
@click="openViewDialog(item)"
>
檢視
</v-btn>
<v-btn
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="text"
@click="openEditDialog(item)"
>
修改
</v-btn>
<v-btn
color="error"
:prepend-icon="mdiDelete"
size="small"
variant="text"
@click="requestDeleteConfirmation(item)"
>
刪除
</v-btn>
</div>
</template>
<template #bottom>
<div class="d-flex align-center justify-space-between px-4 py-3">
<div class="text-body-2 text-medium-emphasis">
{{ pageSummary }}
</div>
<div class="d-flex align-center ga-2">
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="currentPage = 1"
>
第一頁
</v-btn>
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="currentPage -= 1"
>
上一頁
</v-btn>
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="currentPage += 1"
>
下一頁
</v-btn>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="currentPage = pageCount"
>
最後頁
</v-btn>
</div>
</div>
</template>
</v-data-table>
</template> </template>
</page-maint> </PageMaintenance>
<!-- 新增 / 編輯 / 檢視側邊欄 --> <SectionFormPanel
<teleport to="body"> v-bind="page.formPanelProps.value"
<!-- 包成元件需要傳高度寬度給dialog-panel --> v-on="page.formPanelEvents"
<v-overlay
class="dialog-overlay"
:close-on-content-click="false"
:model-value="dialogVisible"
scrim="rgba(0, 0, 0, 0.45)"
scroll-strategy="block"
@update:model-value="handleDialogVisibility"
>
<div class="dialog-panel">
<mnt-dialog-card
content-class="pa-2 flex-grow-1 overflow-y-auto"
:dialog-subtitle="dialogSubtitle"
:dialog-title="dialogTitle"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
width="100%"
>
<template #toolbar>
<mnt-record-nav-toolbar
edit-label="進入編輯"
first-label="第一筆"
:has-next-record="hasNextRecord"
:has-prev-record="hasPrevRecord"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
last-label="最後一筆"
view-label="回到檢視"
@first="openEdgeRecord('first')"
@last="openEdgeRecord('last')"
@next="openAdjacentRecord('next')"
@prev="openAdjacentRecord('prev')"
@switch-to-edit="switchToEditMode"
@switch-to-view="switchToViewMode"
/>
</template>
<template #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="scrollToField(error.field)"
>
{{ error.message }}
</v-btn>
</div>
</v-alert>
<!-- 編輯/檢視載入中骨架 -->
<v-skeleton-loader
v-if="isLoading"
class="mt-4"
type="subtitle,paragraph"
width="100%"
/>
<!-- 表單檢視模式使用 readonly避免 focus 狀態 -->
<v-form
v-else
:class="{ 'form-readonly': isFormReadonly }"
@submit.prevent="requestSaveConfirmation"
>
<v-row density="compact">
<v-col cols="12" md="6">
<v-text-field
id="field-studentId"
v-model="form.studentId"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.studentId"
label="學號"
placeholder="例如:S2024008"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('studentId')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-name"
v-model="form.name"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.name"
label="姓名"
placeholder="例如:陳怡君"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-department"
v-model="form.department"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.department"
:items="departments"
label="系所"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('department')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-grade"
v-model="form.grade"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.grade"
item-title="title"
item-value="value"
:items="gradeOptions"
label="年級"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('grade')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-enrollYear"
v-model="form.enrollYear"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.enrollYear"
:items="enrollYears"
label="入學年度"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('enrollYear')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-credits"
v-model.number="form.credits"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.credits"
label="已修學分"
min="0"
:readonly="isFormReadonly"
type="number"
variant="outlined"
@update:model-value="clearFieldError('credits')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-advisor"
v-model="form.advisor"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.advisor"
label="指導老師"
placeholder="例如:林教授"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('advisor')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-email"
v-model="form.email"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.email"
label="Email"
placeholder="name@school.edu"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('email')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-phone"
v-model="form.phone"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.phone"
label="電話"
placeholder="例如:02-2345-6789"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('phone')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-status"
v-model="form.status"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.status"
:items="statuses"
label="狀態"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('status')"
/>
</v-col>
</v-row>
</v-form>
</template>
<template #actions>
<v-spacer />
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="requestDeleteCurrent"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="requestSaveConfirmation"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
</template>
</mnt-dialog-card>
</div>
</v-overlay>
</teleport>
<ConfirmDialog
v-model="confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="confirmClose"
/> />
<ConfirmDialog <v-snackbar v-model="page.snackbarVisible.value" color="success" location="bottom right" :timeout="2200">
v-model="confirmSaveVisible"
:confirm-loading="isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="confirmSave"
>
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
<div v-if="item.before !== null" class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</ConfirmDialog>
<ConfirmDialog
v-model="confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="confirmDelete"
/>
<ConfirmDialog
v-model="confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmSwitch"
/>
<ConfirmDialog
v-model="confirmNavigateVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmNavigate"
/>
<!-- 成功提示 -->
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
儲存成功 儲存成功
</v-snackbar> </v-snackbar>
</template> </template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type StudentRecord, useStudentStore } from '@/stores/students'
// 下拉選項:系所/年級/入學年度/狀態
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const { smAndUp } = useDisplay()
// 表格欄位設定(含固定欄與排序)
const tableHeaders = computed(() => [
{
title: '學號',
key: 'studentId',
sortable: true,
fixed: smAndUp.value && ('start' as const),
width: 120,
},
{
title: '姓名',
key: 'name',
sortable: true,
fixed: smAndUp.value && ('start' as const),
width: 100,
},
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '電話', key: 'phone', sortable: true, width: 140 },
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{
title: '操作',
key: 'actions',
sortable: false,
fixed: smAndUp.value && ('end' as const),
width: 'auto',
cellProps: { class: 'px-0 bg-background' },
},
])
// 查詢條件(示意用,未接 API)
const search = ref({
studentId: '',
name: '',
department: '',
grade: null as number | null,
status: '',
})
// 查詢區塊是否展開
const searchPanelOpen = ref(false)
// 透過 store 管理 Demo 資料與 CRUD
const studentStore = useStudentStore()
const students = computed(() => studentStore.students)
type StudentPayload = Omit<StudentRecord, 'id'>
const itemsPerPage = 10
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = students.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
// 彈窗狀態與流程控制
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const isLoading = ref(false)
const isSaving = ref(false)
// UI 回饋
const snackbarVisible = ref(false)
const highlightedId = ref<number | null>(null)
// 防止快速切換導致的異步覆蓋
const loadSequence = ref(0)
const {
errorSummary,
fieldErrors,
form,
isDirty,
saveSummary,
clearAllErrors,
clearFieldError,
gradeLabel,
resetForm,
rowProps,
setForm,
statusColor,
syncInitialForm,
validateForm,
} = useStudentMaintenanceForm({
departments,
gradeOptions,
enrollYears,
statuses,
students,
editingId,
highlightedId,
})
// 表單欄位簡單排版(無分組)
// 彈窗標題/副標題
const dialogTitle = computed(() => {
if (dialogMode.value === 'view') return '檢視資料示範'
if (dialogMode.value === 'edit') return '修改資料示範'
return '新增資料示範'
})
const dialogSubtitle = computed(() => {
if (!editingId.value) return ''
return `${form.value.studentId || '未填學號'}${form.value.name || '未填姓名'}`
})
// 是否有修改(用於啟用儲存與提示)
// 載入/儲存時鎖定、檢視模式 readonly
const isFormLocked = computed(() => isLoading.value || isSaving.value)
const {
confirmClose,
confirmCloseVisible,
confirmDelete,
confirmDeleteVisible,
confirmNavigate,
confirmNavigateVisible,
confirmSaveVisible,
confirmSwitch,
confirmSwitchVisible,
handleDialogVisibility,
hasNextRecord,
hasPrevRecord,
isEditMode,
isViewMode,
openAdjacentRecord,
openEdgeRecord,
pendingDeleteLabel,
requestCloseDialog,
requestDeleteConfirmation,
requestDeleteCurrent,
switchToEditMode,
switchToViewMode,
} = useMaintenanceCrudFlow<StudentRecord>({
records: students,
editingId,
dialogMode,
dialogVisible,
isLoading,
isSaving,
isDirty,
clearAllErrors,
resetForm,
openEditDialog,
openViewDialog,
removeRecord: (id) => {
studentStore.removeStudent(id)
},
describeRecord: (student) => `${student.studentId} ${student.name}`,
})
const isFormReadonly = computed(() => isViewMode.value)
// 重設查詢條件
function resetSearch() {
search.value = {
studentId: '',
name: '',
department: '',
grade: null,
status: '',
}
}
watch(pageCount, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
// 新增:開啟彈窗,使用預設值
function openAddDialog() {
loadSequence.value += 1
dialogMode.value = 'create'
editingId.value = null
resetForm()
isLoading.value = false
dialogVisible.value = true
}
// 編輯:先開彈窗,資料載入後填入
function openEditDialog(student: StudentRecord) {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = 'edit'
editingId.value = student.id
dialogVisible.value = true
isLoading.value = true
clearAllErrors()
setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
setForm({
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
})
syncInitialForm()
isLoading.value = false
}, 350)
}
// 檢視:只讀模式並預設展開所有分組
function openViewDialog(student: StudentRecord) {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = 'view'
editingId.value = student.id
dialogVisible.value = true
isLoading.value = true
clearAllErrors()
setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
setForm({
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
})
syncInitialForm()
isLoading.value = false
}, 350)
}
// 先檢核再提示儲存確認
async function requestSaveConfirmation() {
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
clearAllErrors()
const errors = validateForm()
if (errors.length > 0) {
for (const error of errors) {
fieldErrors.value[error.field] = [error.message]
}
await nextTick()
const firstError = errors[0]
if (firstError) scrollToField(firstError.field)
return
}
confirmSaveVisible.value = true
}
// 儲存確認後才真正送出
function confirmSave() {
confirmSaveVisible.value = false
saveStudent()
}
// 寫入資料(Demo:直接更新列表)
async function saveStudent() {
if (isSaving.value || isLoading.value) return
isSaving.value = true
await new Promise((resolve) => setTimeout(resolve, 450))
const payload = {
studentId: form.value.studentId.trim(),
name: form.value.name.trim(),
department: form.value.department,
grade: form.value.grade,
enrollYear: form.value.enrollYear,
credits: form.value.credits,
advisor: form.value.advisor.trim(),
email: form.value.email.trim(),
phone: form.value.phone.trim(),
status: form.value.status,
} as StudentPayload
if (editingId.value) {
const updated = studentStore.updateStudent(editingId.value, payload)
if (updated) highlightedId.value = editingId.value
} else {
const createdId = studentStore.addStudent(payload)
highlightedId.value = createdId
}
syncInitialForm()
dialogVisible.value = false
snackbarVisible.value = true
isSaving.value = false
window.setTimeout(() => {
highlightedId.value = null
}, 1600)
}
function scrollToField(field: string) {
const target = document.getElementById(`field-${field}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
</script>
<style scoped>
.dialog-overlay :deep(.v-overlay__content) {
height: 100vh;
display: flex;
justify-content: flex-end;
width: 100vw;
max-width: 100vw;
}
.dialog-panel {
width: 760px;
max-width: 100%;
height: 100vh;
background: rgb(var(--v-theme-surface));
padding: 12px;
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
display: flex;
}
.dialog-actions {
position: sticky;
bottom: 0;
background: rgba(var(--v-theme-surface), 0.95);
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 1;
}
.dialog-title {
position: sticky;
top: 0;
background: rgba(var(--v-theme-surface), 0.95);
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 2;
}
.student-table {
overflow: auto;
}
.student-table :deep(table) {
min-width: 1400px;
}
.student-table :deep(th),
.student-table :deep(td) {
white-space: nowrap;
}
.student-table :deep(.v-data-table-column--fixed),
.student-table :deep(.v-data-table-column--fixed-end) {
background: rgb(var(--v-theme-surface));
}
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
content: '';
position: absolute;
top: 0;
right: -5px;
bottom: 0;
width: 5px;
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
}
.student-table :deep(.v-data-table-footer) {
padding: 4px 0 0;
}
/* 直線 */
/* .student-table :deep(.v-data-table-column--first-fixed-end),
.student-table :deep(.v-data-table-column--last-fixed) {
border-left: none !important;
border-right: none !important;
} */
.form-readonly :deep(.v-field) {
pointer-events: none;
}
tbody tr.is-highlighted {
animation: row-highlight 1.6s ease-out;
}
@keyframes row-highlight {
0% {
background-color: rgba(var(--v-theme-primary), 0.18);
}
100% {
background-color: transparent;
}
}
</style>
+1 -1
View File
@@ -36,7 +36,7 @@ export default defineConfig({
port: 3700, port: 3700,
proxy:{ proxy:{
"/service/": { "/service/": {
target: "http://192.168.89.54:9002", target: process.env.VITE_PROXY_TARGET || "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
}, },
} }