Compare commits

...

46 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This clarifies that page components remain presentation-focused while
routing, dialog state, CRUD side effects, and command handling stay in
the page driver or related composables.
2026-05-22 15:09:54 +08:00
skytek_xinliang 9e8cf28d77 fix: docing 2026-05-22 11:17:32 +08:00
skytek_xinliang f3eb9782c6 feat: 記住帳號, 忘記密碼開關 2026-05-22 10:43:17 +08:00
skytek_xinliang 8378c44ad7 feat: 公告開關 2026-05-22 10:30:04 +08:00
skytek_xinliang 8cf5aacf21 fix: captcha 開關 2026-05-22 09:51:11 +08:00
skytek_xinliang 59d04a4d7e fix: 環境變數讀取 2026-05-22 09:50:54 +08:00
skytek_xinliang b5bf2eb37e Merge branch 'refactor' 2026-05-21 16:12:06 +08:00
skytek_xinliang ea1aec67dc docs: document visual cues for section page selection
Add guidance for choosing SectionFormPage and SectionQueryPage based on
visible UI patterns in prototypes or screenshots. Document required visual
feature descriptions for new page/section components and expand section
component usage notes with query page guidance.docs: document visual cues for section page selection

Add guidance for choosing SectionFormPage and SectionQueryPage based on
visible UI patterns in prototypes or screenshots. Document required visual
feature descriptions for new page/section components and expand section
component usage notes with query page guidance.
2026-05-20 17:41:19 +08:00
skytek_xinliang 8af82f5900 docs: reorganize component guide structure and indexes
Update documentation rules for GUIDE.md files to keep higher-level
guides focused on constraints, conventions, and indexes. Add base and
section component guides to the LLM development index, clarify component
layering responsibilities, and fix architecture references from README to
GUIDE.md.docs: reorganize component guide structure and indexes

Update documentation rules for GUIDE.md files to keep higher-level
guides focused on constraints, conventions, and indexes. Add base and
section component guides to the LLM development index, clarify component
layering responsibilities, and fix architecture references from README to
GUIDE.md.
2026-05-20 17:06:09 +08:00
skytek_xinliang 4d66718b05 chore: document env files and configure proxy target
Add VITE_PROXY_TARGET to the example environment file and use it in
the Vite dev proxy configuration with a localhost fallback. Expand the
LLM development guide with env file loading order, version-control
rules, and variable descriptions. Also clean up ignored local tool
paths in .gitignore and remove duplicated README env examples.chore: document env files and configure proxy target

Add VITE_PROXY_TARGET to the example environment file and use it in
the Vite dev proxy configuration with a localhost fallback. Expand the
LLM development guide with env file loading order, version-control
rules, and variable descriptions. Also clean up ignored local tool
paths in .gitignore and remove duplicated README env examples.
2026-05-20 09:50:35 +08:00
skytek_xinliang e90d412956 docs: update LLM guides for models and layout rules
Document new GUIDE.md expectations for src-layer edits and add index
entries for models and shared types. Clarify layout usage, composable
placement, error page conventions, and model/type ownership so future
changes follow the intended layer boundaries.docs: update LLM guides for models and layout rules

Document new GUIDE.md expectations for src-layer edits and add index
entries for models and shared types. Clarify layout usage, composable
placement, error page conventions, and model/type ownership so future
changes follow the intended layer boundaries.
2026-05-19 17:33:53 +08:00
skytek_xinliang ac7e1959cf docs: update LLM guides for completed architecture phase
Refresh the development guidance to point to layered src GUIDE files
and document Phase 4 completion, including AppShell extraction and
page driver/component adoption for non-maintenance pages.docs: update LLM guides for completed architecture phase

Refresh the development guidance to point to layered src GUIDE files
and document Phase 4 completion, including AppShell extraction and
page driver/component adoption for non-maintenance pages.
2026-05-19 17:17:43 +08:00
skytek_xinliang 51fbbd7101 refactor(app): extract page logic into composable drivers 2026-05-19 16:38:08 +08:00
skytek_xinliang 2b780a12c2 docs: document template naming and maintenance refactor
Update agent and LLM guidance to reference the architecture strategy and
add a template naming rule that keeps reusable abstractions domain-neutral.

Mark maintenance Phase 3 as complete and document the page driver/page
component refactors for EditableGrid and MasterDetail variants.docs: document template naming and maintenance refactor

Update agent and LLM guidance to reference the architecture strategy and
add a template naming rule that keeps reusable abstractions domain-neutral.

Mark maintenance Phase 3 as complete and document the page driver/page
component refactors for EditableGrid and MasterDetail variants.
2026-05-19 14:35:28 +08:00
skytek_xinliang 96b96bcaaa docs: reorganize architecture strategy documentation
Split current project diagnostics into a dedicated analysis document and
trim the main architecture strategy to focus on core guidance. This makes
the documentation easier to navigate and separates observed issues from
recommended architectural principles.docs: reorganize architecture strategy documentation

Split current project diagnostics into a dedicated analysis document and
trim the main architecture strategy to focus on core guidance. This makes
the documentation easier to navigate and separates observed issues from
recommended architectural principles.
2026-05-19 14:13:10 +08:00
skytek_xinliang 9ae91418e0 feat(shell): add app shell and maintenance page driver
Introduce reusable shell components for layout, tabs, and global overlays.
Add maintenance page model, wrapper component, and composable driver to
standardize maintenance page state, search, and pagination handling.feat(shell): add app shell and maintenance page driver

Introduce reusable shell components for layout, tabs, and global overlays.
Add maintenance page model, wrapper component, and composable driver to
standardize maintenance page state, search, and pagination handling.
2026-05-19 11:35:01 +08:00
skytek_xinliang 005ba663d6 docs: add architecture strategy and Vuetify MCP guidance 2026-05-18 16:56:21 +08:00
skytek_xinliang 130a907351 chore: clear 2026-05-14 10:33:29 +08:00
skytek_xinliang 33fe404ca9 chore: ignore local AI tool configuration files
Add MCP and OpenCode configuration files to .gitignore to keep
developer-specific local settings out of version control.chore: ignore local AI tool configuration files

Add MCP and OpenCode configuration files to .gitignore to keep
developer-specific local settings out of version control.
2026-05-11 15:59:16 +08:00
skytek_xinliang 361e969eda chore: ignore local AI agent and Playwright directories
Add generated local tool directories for agents, Claude, Ruler, and
Playwright to .gitignore to prevent environment-specific files from
being committed.chore: ignore local AI agent and Playwright directories

Add generated local tool directories for agents, Claude, Ruler, and
Playwright to .gitignore to prevent environment-specific files from
being committed.
2026-05-11 15:48:49 +08:00
skytek_xinliang a45563685f docs: refresh template documentation and examples
Update README and frontend layering docs to reflect the current template core structure, use relative repository links, and remove outdated demo guidance.

Add expanded API response examples for common features and ignore local Codex configuration.docs: refresh template documentation and examples

Update README and frontend layering docs to reflect the current template core structure, use relative repository links, and remove outdated demo guidance.

Add expanded API response examples for common features and ignore local Codex configuration.
2026-05-11 15:45:31 +08:00
skytek_xinliang 71683482e1 refactor: ky 2026-05-07 11:17:30 +08:00
skytek_xinliang 87fbc1dda8 docs: refresh project guidance and environment setup
Add env example defaults for Vite API and login settings, document
template structure and page creation flow, and align agent guidance with
current LLM development rules. Also allow committing the env example while
keeping local env files ignored.docs: refresh project guidance and environment setup

Add env example defaults for Vite API and login settings, document
template structure and page creation flow, and align agent guidance with
current LLM development rules. Also allow committing the env example while
keeping local env files ignored.
2026-05-05 14:29:52 +08:00
skytek_xinliang 23218703f9 chore: remove ESLint scripts and dependencies
Drop ESLint-related npm scripts and dev dependencies from package.json, and update the pnpm lockfile to remove unused linting packages.chore: remove ESLint scripts and dependencies

Drop ESLint-related npm scripts and dev dependencies from package.json, and update the pnpm lockfile to remove unused linting packages.
2026-05-05 13:34:13 +08:00
skytek_xinliang b37f4363eb feat(stores): add Pinia domain stores and update docs
Implement concrete Pinia stores for app UI and domain data instead of
placeholder re-exports, including seeded student records and snackbar state.

Refresh README guidance for components, plugins, and services to document the
current project structure, data flow, and usage conventions.feat(stores): add Pinia domain stores and update docs

Implement concrete Pinia stores for app UI and domain data instead of
placeholder re-exports, including seeded student records and snackbar state.

Refresh README guidance for components, plugins, and services to document the
current project structure, data flow, and usage conventions.
2026-05-05 11:54:19 +08:00
skytek_xinliang 6eab4d9744 feat: code-review-graph 2026-04-30 14:06:30 +08:00
skytek_xinliang 3b1ac6df92 fix: 樣式修正 2026-04-29 15:27:13 +08:00
skytek_xinliang 3650776ed9 feat: 更新 EditableGrid 元件以支持分頁功能並改善顯示資訊 2026-04-13 14:32:33 +08:00
skytek_xinliang da96d64f75 feat: 為列表元件添加角色屬性以改善可訪問性 2026-03-31 15:03:22 +08:00
skytek_xinliang 8dbae6c614 feat: 增加無障礙標籤及ARIA屬性以改善可訪問性 2026-03-31 11:13:13 +08:00
skytek_xinliang 918da2f79e fix: WCAG fastpass 2026-03-31 09:57:28 +08:00
skytek_xinliang 52eb09eccf feat: Add Vue testing best practices and Playwright setup 2026-03-30 17:05:49 +08:00
skytek_xinliang 81b36c60f2 feat: 更新登入頁面本地存儲鍵名及日誌輸出,簡化 i18n 配置 2026-03-30 16:18:13 +08:00
skytek_xinliang 959d2411a9 feat: 新增中英文語言檔案並更新 i18n 配置 2026-03-30 15:43:22 +08:00
skytek_xinliang d7ebbda3d3 feat: 重構主佈局及相關元件,更新命名規則並新增功能 2026-03-30 15:14:12 +08:00
122 changed files with 8243 additions and 7495 deletions
+27
View File
@@ -0,0 +1,27 @@
# vite / vite dev:預設 mode = development
# vite build:預設 mode = production
# vite --mode staging:改成 staging
# vite build --mode developmentbuild 但用 development mode
# 覆蓋優先從低至高
# .env
# .env.local
# .env.[mode]
# .env.[mode].local
# Vite dev proxy 目標後端 URL。
VITE_PROXY_TARGET=http://192.168.89.54:9002
# Vite API base URL。
# 使用 Vite dev proxy 時,建議維持相對路徑。
VITE_API_BASE_URL=/service/api
# 登入示範開關。只有專案明確支援略過登入時才設為 true。
VITE_SKIP_LOGIN=false
# 本機開發示範帳號。
# 有後端或 demo 帳號時,複製到 .env.development.local 後填入。
VITE_DEV_DEFAULT_USER_ID=
VITE_DEV_DEFAULT_PASSWORD=
+17
View File
@@ -24,6 +24,23 @@ dist
.eslintcache
.stylelintcache
output/playwright/
.env
.env.*
!.env.example
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/playwright/.auth/
.codex
.agents
.claude
.ruler
.playwright
opencode.json
.antigravitycli
+38 -13
View File
@@ -1,16 +1,41 @@
# 1. 強制檢查 Node 版本
engine-strict=true
# 指定下載來源
registry=https://registry.npmjs.org/
# 2. 自動安裝 Peer Dependencies
auto-install-peers=true
# 3. 提升特定套件 (選配)
# 如果遇到某些舊套件找不到 Vue 或 Vuetify,開啟這個可以模擬 npm 的扁平化結構
# shamefully-hoist=true
# 4. 鎖定版本 (如果您希望版本極度穩定)
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
# save-exact=true
# 5. 針對 WSL 的優化 (選配)
# 如果您在 WSL 存取 Windows 磁碟區(如 /mnt/c)時遇到權限問題,可以開啟
# node-linker=hoisted
# 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
# npm v11.10+
min-release-age=4
# pnpm
minimum-release-age=5760
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
# engine-strict=true
# 讓 pnpm 自動安裝缺失的 peer dependencies,減少手動維護的負擔
auto-install-peers=true
# 效能優化:讓 pnpm 盡可能解析出唯一的依賴版本
resolution-mode=highest
# ==========================================
# 團隊協作規範
# ==========================================
# 當 Lockfile 有變動但未對應安裝時,在 CI 環境直接報錯
# frozen-lockfile=true
# ==========================================
# Monorepo 結構與依賴管理
# ==========================================
# 強制使用 workspace: 協議,確保子專案互相引用時是指向原始碼而非 npm 上的版本
# save-workspace-protocol=true
# 禁止子專案之間出現循環依賴,避免構建時陷入死循環
# disallow-workspace-cycles=true
# 從根目錄解析 peerDependencies,確保全專案的 peer 依賴版本統一,減少重複打包
# resolve-peers-from-workspace-root=true
# 執行遞迴指令 (pnpm -r) 時包含根目錄 (適用於根目錄有腳本或工具時)
# include-workspace-root=true
-4
View File
@@ -1,4 +0,0 @@
# Project Rules
## General
- Follow the existing code style and patterns.
-11
View File
@@ -1,11 +0,0 @@
# For a complete example, see: https://okigu.com/ruler#complete-example
# List of agents to configure
default_agents = ["copilot", "claude", "trae"]
[mcp_servers.vuetify]
url = "https://mcp.vuetifyjs.com/mcp"
# https://github.com/vuetifyjs/mcp#authentication
# [mcp_servers.vuetify.headers]
# Authorization = "Bearer <YOUR_API_KEY>"
+42 -3
View File
@@ -4,16 +4,38 @@
- Follow the existing code style and patterns.
- Use pnpm for running project commands.
- 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.
- For bulk verification failures such as `pnpm run lint`, collect the full output first, then split the work into non-overlapping batches by file or concern and assign those batches to subagents in parallel. Keep the integration and final verification pass in the main agent.
- 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 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.
- 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.
## 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` → ✅ `useSingleRecordMaintenancePage.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
- Framework: Vue 3 + Vite
- UI Library: Vuetify
- Enabled Features: ESLint, Vuetify MCP, Pinia, Vue I18n, Vue Router
- Enabled Features: Vuetify MCP, Pinia, Vue I18n, Vue Router
## Icon Usage
```js
@@ -25,3 +47,20 @@ import { mdiAccount } from '@mdi/js'
<v-icon :icon="mdiAccount" />
</template>
```
## Vuetify MCP
- When looking up Vuetify versions, release notes, component APIs, directive APIs, installation guides, FAQs, feature guides, or package exports, use Vuetify MCP first.
- When a question involves Vuetify component props, events, slots, exposed methods, generated DOM, accessibility output, or officially supported extension points, verify with Vuetify MCP before changing the implementation.
- Prefer the official API and documentation information returned by MCP. Do not infer Vuetify behavior.
### 常用工具
- `get_release_notes_by_version`: 查詢指定版本或 latest 的 release notes。
- `get_component_api_by_version`: 查詢指定 Vuetify 元件的 props、events、slots、exposed methods。
- `get_directive_api_by_version`: 查詢指定 Vuetify directive 的 API。
- `get_vuetify_api_by_version`: 下載並快取指定版本的 Vuetify API types。
- `get_installation_guide`: 查詢 Vite、Nuxt、Laravel、CDN 等安裝方式。
- `get_feature_guide`: 查詢 theme、icons、i18n、display、layout 等功能指南。
- `get_exposed_exports`: 查詢 Vuetify npm package 可匯出的項目。
- `get_frequently_asked_questions`: 查詢 Vuetify FAQ。
+101 -56
View File
@@ -1,83 +1,128 @@
# skt-vuetify-templates
Scaffolded with Vuetify CLI.
Vue 3 + Vite + Vuetify template,目標是讓新專案可以直接在 `src` 裡新增 views、components、stores、services 與 composables,並讓一般頁面自動被主框架 layout 包住。
## ❗️ Documentation
## Stack
- Primary docs: https://vuetifyjs.com/
- Getting started guide: https://vuetifyjs.com/en/getting-started/installation/
- Community support: https://community.vuetifyjs.com/
- Issue tracker: https://issues.vuetifyjs.com/
## 🧱 Stack
- Framework: Vue 3 + Vite
- UI Library: Vuetify
- Language: TypeScript
- Package manager: pnpm
## 🧭 Start Here
- Main entry: `src/main.ts`
- Main app component: `src/App.vue`
- Main styles: `src/styles/`
- Plugin setup: `src/plugins/`
## 📁 Project Structure
- `src/main.ts` — application entry point
- `src/App.vue` — root component
- `src/components/` — reusable Vue components
- `src/plugins/` — plugin registration and setup
- `src/styles/` — global styles and theme settings
- `public/` — static public files
## ✨ Enabled Features
- ESLint
- Vuetify MCP
- Vue 3 + Vite
- Vuetify
- TypeScript
- Pinia
- Vue I18n
- Vue Router
- Vue I18n
- pnpm
## 💿 Install
Use your selected package manager (pnpm) to install dependencies:
```bash
pnpm install
```
## 🚀 Quick Start
## Quick Start
```bash
pnpm install
pnpm dev
```
## 🏗️ Build
開發伺服器預設使用 `vite.config.mts` 的 port `3700`
```bash
pnpm build
## Template 使用方式
一般功能開發從 `src` 開始:
1.`src/views``src/views/<feature>` 新增頁面。
2.`src/router/routes.ts` 新增 route。
3. 一般頁面使用 `meta: { layout: 'default' }`
4. 畫面區塊拆到 `src/components/<feature>`
5. 複雜流程放到 `src/composables/<feature>`
6. 跨頁共享狀態放到 `src/stores/*.ts`
7. API module 放到 `src/services/modules/<domain>.ts`
更完整的入口說明見 [src/README.md](./src/README.md)。
## 新增頁面
最小 route 範例:
```ts
{
path: '/reports',
name: 'reports',
component: () => import('@/views/reports/Reports.vue'),
meta: { layout: 'default', requiresAuth: true },
}
```
## 🧪 Available Scripts
完整範例見 [docs/add-page-example.md](./docs/add-page-example.md)。
## Layout
`App.vue` 會根據 `route.meta.layout` 選擇 layout
- `default`:使用 `src/components/layouts/MainLayout.vue`
- `none`:使用 `src/components/layouts/PlainLayout.vue`
一般功能頁使用 `default`。登入頁、錯誤頁、維護中頁、明確要求獨立顯示的頁面才使用 `none`
## API 與環境變數
複製 `.env.example` 作為本機設定起點:
```bash
cp .env.example .env
```
`client.ts` 會優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
實際 `.env``.env.*.local` 不應提交。production API URL 應由使用專案自己的部署環境提供。
## Project Structure
- `src/main.ts`app entry。
- `src/App.vue`app shell 組裝層,依 route meta 切換 layout。
- `src/router`routes、history、guards。
- `src/views`route views。
- `src/components`layout、page component、feature/domain components。
- `src/composables`:可重用流程與 UI state。
- `src/stores`Pinia stores。
- `src/services`HTTP client、API modules、token/session/error。
- `src/plugins`Vuetify、Pinia、I18n、Router 註冊。
- `src/styles`Vuetify SASS settings 與 themes。
- `src/language`i18n JSON。
## Template Core
template core 是 app shell、router、layout、plugins、styles、services 基礎設施與全域 stores。一般專案會保留它們。
## Documentation
- [src/README.md](./src/README.md)`src` 開發入口。
- [docs/frontend-layering.md](./docs/frontend-layering.md):目前前端分層與責任邊界。
- [docs/llm-development-guide.md](./docs/llm-development-guide.md):給 LLM 的操作規則。
- [docs/add-page-example.md](./docs/add-page-example.md):新增頁面範例。
- [src/components/README.md](./src/components/README.md)
- [src/services/README.md](./src/services/README.md)
- [src/plugins/README.md](./src/plugins/README.md)
- [src/styles/README.md](./src/styles/README.md)
## Scripts
- `pnpm dev`
- `pnpm build`
- `pnpm preview`
- `pnpm build-only`
- `pnpm type-check`
- `pnpm lint`
- `pnpm lint:fix`
- `pnpm format`
- `pnpm mcp`
- `pnpm mcp:revert`
## 💪 Support Vuetify Development
## Verification
This project uses Vuetify - an MIT licensed Open Source project. We are glad to welcome contributors and any support for ongoing development:
完成修改後至少執行:
- Contribute to Vuetify and ecosystem projects: https://github.com/vuetifyjs
- Request enterprise support: https://support.vuetifyjs.com/
- Sponsor on GitHub: https://github.com/sponsors/vuetifyjs
- Support on Open Collective: https://opencollective.com/vuetify
```bash
pnpm type-check
pnpm build
```
若變更 route、layout 或主要 UI flow,再啟動 dev server 並用瀏覽器確認。
## Vuetify
- Vuetify docs: https://vuetifyjs.com/
- Installation guide: https://vuetifyjs.com/en/getting-started/installation/
+151
View File
@@ -0,0 +1,151 @@
# 新增頁面範例
這份文件示範如何用目前 `src/` 慣例新增一個被 `MainLayout` 包住的一般功能頁。
範例功能:`reports`
目前新增一般頁面的預設資料流:
```txt
router -> view -> sections/items
composable -> store -> service
```
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
## 1. 新增 view(含 page model
簡單頁面的 page model 直接在 view 裡用 `computed` 組裝,不需要額外建立 page driver。
```vue
<!-- src/views/reports/Reports.vue -->
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useSnackbarStore } from '@/stores/snackbar'
export interface ReportSummary {
id: number
title: string
owner: string
}
const initialRows: ReportSummary[] = [
{ id: 1, title: '學生統計', owner: '教務處' },
{ id: 2, title: '課程統計', owner: '課務組' },
]
const snackbar = useSnackbarStore()
const rows = ref<ReportSummary[]>(initialRows)
const pageModel = computed(() => ({
title: '報表清單',
rows: rows.value,
}))
function openReport(row: ReportSummary) {
snackbar.show({ message: `開啟:${row.title}`, color: 'info' })
}
</script>
<template>
<PageReports :page="pageModel" @open="openReport" />
</template>
```
若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`
若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`
## 2. 加入 route
route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
```ts
// src/router/routes.ts
{
path: '/reports',
name: 'reports',
component: () => import('@/views/reports/Reports.vue'),
meta: { layout: 'default', requiresAuth: true },
}
```
`layout: 'default'` 會讓頁面被 `MainLayout` 包住。登入頁、錯誤頁、維護中頁才使用 `layout: 'none'`
若頁面需要出現在 drawer menu、favorites 或 breadcrumb
- menu 來源目前由 `src/stores/menu.ts` 轉換後端選單資料。
- breadcrumb 會依 route path、menu/favorite items 與 fallback title 產生。
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
- 若只是新增 route,通常不需要修改 `MainLayout.vue``src/shell/*`
## 3. 需要 API 時新增 service module
```ts
// src/services/modules/reports.ts
import { httpClient } from '../client'
export interface ReportSummary {
id: number
title: string
owner: string
}
export const reportsApi = {
list: async () => ({
data: await httpClient.get('Reports').json<ReportSummary[]>(),
}),
}
```
service 只封裝 HTTP 細節,不持有 UI 狀態。
`httpClient``baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`
## 4. 需要共享狀態時新增 store
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
```ts
// src/stores/reports.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { reportsApi, type ReportSummary } from '@/services/modules/reports'
export const useReportsStore = defineStore('reports', () => {
const items = ref<ReportSummary[]>([])
const loading = ref(false)
const load = async () => {
loading.value = true
try {
const { data } = await reportsApi.list()
items.value = data
} finally {
loading.value = false
}
}
return {
items,
loading,
load,
}
})
```
## 5. 驗證
至少執行:
```bash
pnpm -s type-check
```
需要確認建置產物時再執行:
```bash
pnpm -s build
```
若有 route、layout 或主要互動流程變更,再啟動 dev server 並用瀏覽器確認。
+426
View File
@@ -0,0 +1,426 @@
# 資料流與元件分層優化策略
## 一、Apple App Store 專案的核心架構特徵
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)
## 二、我們專案的現況診斷
Read only when needed: [analyse now](./analyse-now.md)
## 三、優化後的資料流策略
### 3.1 核心資料流(單向 + 集中閘道)
```
┌─────────────────────────────────────────────────────────────┐
│ App Shell │
│ (App.vue → Layout → Global Overlays: Snackbar/Dialogs) │
└──────────────────────────┬──────────────────────────────────┘
│ reactive / props
┌─────────────────────────────────────────────────────────────┐
│ View │
│ (views/*.vue — 自含 page model、頁面 UI 與 section 組合) │
└──────────────────────────┬──────────────────────────────────┘
│ section data
┌─────────────────────────────────────────────────────────────┐
│ Section / Shelf │
│ (決定佈局:水平/網格/列表;不關心內部呈現) │
└──────────────────────────┬──────────────────────────────────┘
│ item data
┌─────────────────────────────────────────────────────────────┐
│ Item / Atom │
│ (純粹內容呈現;透過 provide/inject 取得 domain context) │
└─────────────────────────────────────────────────────────────┘
橫向:
composables → 可重用流程(CRUD state machine、form validation、editable grid
stores → 跨頁共享狀態(auth、menu、favorites、messages
services → HTTP 閘道(只封裝 ky,不持有 UI 狀態)
```
### 3.2 Page Model 作為主要資料單位
- **新增 `src/models/page.ts`**:定義各頁面的統一介面。
- View 的職責從「管理資料 + 管理狀態 + 組裝模板」縮減為「呼叫 composable 取得 page model,組裝 section 元件」。
- Page model 可以來自:
- store(已有快取)
- service(直接 API
- composable(組裝多個來源)
範例:
```ts
// views/maint/Example.vue — 簡單頁面直接在 view 組裝 page model
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '單筆資料維護',
records: studentStore.students,
loading: false,
error: null,
}))
```
### 3.3 查詢(Query)與命令(Command)分離
| 類型 | 資料流 | 錯誤處理 | 狀態位置 |
|------|--------|----------|----------|
| **Query** | `usePageDriver``pageModel` → props | `<PageErrorBoundary>` 或 page-level fallback | composable 內部 ref |
| **Command** | `executeCommand()``await service.action()` → 重新載入 query | snackbar / dialog / field error | composable 內部 ref |
- Query 對應 App Store 的 **Intent**:取得資料、回傳 model。
- Command 對應 App Store 的 **Action**:執行副作用、不回傳 model、觸發 Query 重新整理。
### 3.4 Promise-based 頁面載入(可選進階)
```vue
<!-- PageResolver.vue 對齊 App Store PageResolver.svelte -->
<template>
<Suspense>
<template #default>
<component :is="pageComponent" :page="resolvedPage" />
</template>
<template #fallback>
<PageLoadingSpinner />
</template>
</Suspense>
</template>
```
- 若頁面資料支援 async setup 或 `usePageDriver` 回傳 Promise,可用 Vue `<Suspense>` 達到與 App Store `{#await page}` 類似的效果。
- **短期**:維持 reactive ref,但將 loading / error 統一封裝在 `usePageDriver`
- **長期**:當多數頁面都使用統一 driver 後,可考慮引入 Suspense。
### 3.5 全域狀態 vs 頁面狀態的邊界
| 狀態類型 | 存放位置 | 生命周期 |
|----------|----------|----------|
| 認證、選單、語言、主題 | `src/stores/*.ts` | 應用級 |
| 搜尋條件、分頁、dialog visible | `src/composables/useXxxPage.ts` | 頁面級(離開頁面可選保留或重置) |
| 表單 dirty / validation | `src/composables/useXxxForm.ts` | dialog / form 級 |
| 表格排序、過濾 | `src/composables/useXxxTable.ts` | 區塊級 |
---
## 四、優化後的元件分層策略
### 4.1 五層結構
```
src/
├── shell/ ← 新增:App Shell(原 App.vue 拆分)
│ ├── AppShell.vue ← layout 切換、全域 overlay 掛載點
│ ├── GlobalOverlays.vue ← snackbar、確認 dialog、toast
│ └── AppTabs.vue ← 頁籤(從 App.vue 抽出)
├── views/ ← 維持:自含頁面,邏輯與 UI 同檔
│ └── maint/
│ └── SingleRecord.vue ← ~50 行:組裝 pageModel + MaintShell 外殼
├── components/
│ ├── sections/ ← 新增:Section / Shelf 層
│ │ ├
│ │ ├── SectionDataTable.vue
│ │ └── SectionFormPanel.vue
│ │
│ ├── items/ ← 新增:Item / Atom 層(領域獨立)
│ │ ├── ItemDataRow.vue
│ │ └── ItemFormField.vue
│ │
│ ├── layouts/ ← 維持:App Shell Layout
│ │ ├── MainLayout.vue
│ │ └── PlainLayout.vue
│ │
│ └── base/ ← 維持:真正跨頁共用
│ └── DraggableDialog.vue
├── composables/
│ ├── page-drivers/ ← 新增:頁面資料協調(僅複雜頁面需要)
│ │ └── useSingleRecordMaintenancePage.ts
│ ├── commands/ ← 新增:命令流程(對齊 Jet Action)
│ │ └── useCrudCommands.ts
│ ├── forms/ ← 維持/重組:表單狀態機
│ │ └── useForm.ts
│ └── layout/ ← 維持
├── models/ ← 新增:領域模型與 Page Union
│ ├── page.ts
│ └── student.ts
├── stores/ ← 維持:跨頁共享狀態
├── services/ ← 維持:HTTP 閘道
└── router/ ← 維持:路由與 meta
```
### 4.2 各層職責與規範
#### Layer 1: App Shell`src/shell/`
- **職責**layout 切換、全域 overlaysnackbar、dialog)、頁籤容器、事件總線橋接。
- **禁止**:頁面專屬業務流程、頁面資料組裝、特定 dialog 內容。
- **對齊**App Store 的 `App.svelte` + `browser.ts` 的 overlay 部分。
#### Layer 2: Page Driver`src/views/`
- **職責**
1. 呼叫 `useXxxPage()` 取得 `pageModel`
2.`pageModel` 與事件處理器傳給對應的 `PageXxx.vue`
3. 處理 route param 解析(僅限轉換,不含業務邏輯)。
- **目標行數**< 80 行。
- **禁止**:大量模板、dialog 定義、form 欄位、直接操作 store。
```vue
<!-- views/maint/SingleRecord.vue優化後 -->
<script setup lang="ts">
import MaintShell from '@/components/maint/MaintShell.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const { commands, formPanelEvents, formPanelProps, pageModel, searchPanelOpen } = useSingleRecordMaintenancePage()
</script>
<template>
<PageMaintenance :page="pageModel" />
</template>
```
#### Layer 3: View`src/views/`
- **職責**:自含頁面的完整入口 — 組裝 page model、協調 composable、撰寫頁面 template。
- **禁止**:頁面 UI 不再拆到另一個 page component 層。
- **對齊**:標準 Vue SPA 慣例。
```vue
<!-- views/maint/SingleRecord.vue -->
<script setup lang="ts">
import MaintShell from '@/components/maint/MaintShell.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const { pageModel, searchPanelOpen, commands, search, students, ... } = useSingleRecordMaintenancePage()
</script>
<template>
<MaintShell :title="pageModel.title" @create="commands.openAddDialog">
<template #table>...</template>
</MaintShell>
</template>
```
#### Layer 4: Section / Shelf`src/components/sections/`
- **職責**:決定「這一區的佈局方式」(水平捲軸、網格、列表、摺疊面板)。
- **禁止**:知道上層 page 的業務邏輯、不直接呼叫 API。
- **對齊**App Store 的 `Shelf.svelte``ShelfItemLayout.svelte`
```vue
<!-- SectionDataTable.vue -->
<template>
<v-data-table
:headers="resolvedHeaders"
:items="records"
fixed-header
>
<template v-for="slot in customSlots" :key="slot.key" #[slot.key]="{ item }">
<slot :name="slot.key" :item="item">
<!-- 預設 item 渲染 -->
<component :is="slot.itemComponent" :data="item" />
</slot>
</template>
</v-data-table>
</template>
```
#### Layer 5: Item / Atom`src/components/items/`
- **職責**:純粹呈現單一資料單位。
- **禁止**:知道自己是水平捲軸還是網格、不管理任何狀態。
- **對齊**App Store 的 `BrickItem.svelte``LargeLockupItem.svelte`
```vue
<!-- ItemDataRow.vue -->
<template>
<div class="d-flex ga-2">
<v-chip size="small" :color="statusColor(data.status)">
{{ data.status }}
</v-chip>
<span>{{ data.name }}</span>
</div>
</template>
```
### 4.3 容器/內容分離的具體規範
| 場景 | 容器(Section | 內容(Item |
|------|-----------------|--------------|
| 資料表格 | `SectionDataTable`(決定 headers、分頁、排序) | `ItemDataRow`(決定單列呈現) |
| 搜尋面板 | `SectionSearchPanel`(決定展開/收合、grid 佈局) | `ItemFormField`(單一輸入框呈現) |
| 圖文列表 | `SectionCardGrid`(決定欄數、gap、RWD | `ItemProductCard`(卡片內容) |
| 表單對話框 | `SectionFormPanel`(決定 dialog 外殼、actions | `ItemFormFieldGroup`(欄位群組) |
- **原則**:若同一組資料在不同頁面需要「水平捲軸 vs 網格」兩種呈現,只換 Section,不換 Item。
### 4.4 Provide / Inject 作為跨層依賴注入
對齊 App Store 的 `getJet()` / `getI18n()`,在 Vue 中建立明確的 inject API
```ts
// src/providers/page.ts
import { inject, provide } from 'vue'
import type { PageDriver } from '@/composables/page-drivers/types'
const PageDriverKey = Symbol('page-driver')
export function providePageDriver(driver: PageDriver) {
provide(PageDriverKey, driver)
}
export function usePageDriverInjected(): PageDriver {
const driver = inject(PageDriverKey)
if (!driver) throw new Error('usePageDriverInjected called without provider')
return driver
}
```
- **提供時機**Page Component (`PageXxx.vue`) 在掛載時 provide。
- **使用時機**:深層的 Item 元件需要觸發 page-level action 時 inject,避免 props drilling。
- **禁止**:用 provide/inject 傳遞會頻繁變動的 UI 狀態(如 `dialogVisible`)。這類狀態應透過 props + emits。
### 4.5 Dialog 外層化策略
將 dialog 從 view 中完全抽出,形成「Dialog Shell + Content Slot」模式:
```
views/xxx.vue
└── PageXxx.vue
├── SectionDataTable
└── SectionFormPaneldialog shell
├── MntDialogCard(外殼:標題、toolbar、actions
└── ItemFormFieldGroup(內容:欄位)
```
- `SectionFormPanel` 管理 dialog 的開關、mode、loading、saving。
- `ItemFormFieldGroup` 純粹呈現欄位,不知道自己在 dialog 裡。
- View 中不再出現 `<teleport>``<v-overlay>``<v-dialog>` 的具體定義。
---
## 五、重構優先順序與遷移路徑
### Phase 1:建立基礎設施(不動既有 view) ✅ 已完成
1. [x] 新增 `src/models/`:定義領域模型與 Page union type。
- `src/models/student.ts`:抽出 `StudentRecord``stores/students.ts` 改為 re-export 以保持向後相容。
- `src/models/page.ts`:定義 `BasePageModel``MaintenancePageModel``PageModel` union。
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。
- 使用 `MaintShell.vue` 作為佈局外殼,搜尋與表格區塊以 slot 開放,不綁定特定領域型別。
4. [x] 新增 `src/composables/page-drivers/`:建立第一個 page driver 範例。
- 協調搜尋條件、分頁與 `pageModel`
- 提供 `load()``resetSearch()` 供 Page Driver 呼叫。
- 後續已刪除純包裝型 driver(如 `useMaintenancePage`)。僅當頁面需要協調多個 composable 時才建立 page driver。
### Phase 2:遷移最厚的 viewSingleRecord.vue ✅ 已完成
1. [x] 將 `SingleRecord.vue` 縮減為 route-level Page Driver。
- 目前只負責呼叫 `useSingleRecordMaintenancePage()`,並組裝 `PageMaintenance``SectionSearchPanel``SectionDataTable``SectionFormPanel`
- 行數已由 921 行縮減至 52 行,達成 < 80 行目標。
2. [x] 將搜尋區塊抽出到 `src/components/sections/SectionSearchPanel.vue`
- 負責搜尋欄位佈局與 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/useCrudCommands.ts`
- 負責新增、載入編輯/檢視資料、儲存前確認、實際儲存與 highlight 流程。
7. [x] 新增 `src/composables/page-drivers/useSingleRecordMaintenancePage.ts` 作為本頁協調層。
- 集中組裝 page model、搜尋狀態、表格分頁、表單狀態、CRUD command 與 dialog flow。
- `SingleRecord.vue` 不再直接操作 `studentStore`
### Phase 3:推廣到所有 maintenance 頁面 ✅ 已完成
> 後續簡化時,B/C/EditableGrid 的薄 page driver 已 inline 回 view,只保留有真實複雜邏輯的 driver。
1. [x] `EditableGrid.vue` 依 Page Driver + Page Component 模式重構。
- `src/views/maint/EditableGrid.vue` 縮減為 10 行 route-level wiring。
- 新增 `src/composables/page-drivers/useEditableGridMaintenancePage.ts`
- 新增 `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 頁面統一 ✅ 已完成
> 後續簡化時,Settings/FncPage 的薄 page driver 已 inline 回 view,型別移至 page component 自身。
1. [x] `Home.vue``Settings.vue``FncPage.vue` 套用 Page Driver + Page Component 模式。
- `src/views/Home.vue` 縮減為 17 行,新增 `src/components/pages/PageHome.vue``src/composables/page-drivers/useHomePage.ts`
- `src/views/Settings.vue` 縮減為 10 行,新增 `src/components/pages/PageSettings.vue``src/composables/page-drivers/useSettingsPage.ts`
- `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、手動/強制登出流程。
### Phase 5:移除 Page Component 層 ✅ 已完成
> 所有 page component 已合併回對應的 view`src/components/pages/` 目錄已刪除。page driver 簡化為僅複雜頁面才使用的選配層,view 回歸標準 Vue SPA 慣例:自含 page model + 頁面 UI + section 組合。
---
## 六、命名規範總結
| 層級 | 目錄 | 檔名前綴/範例 |
|------|------|---------------|
| App Shell | `src/shell/` | `AppShell.vue``GlobalOverlays.vue` |
| View(自含頁面) | `src/views/` | `SingleRecord.vue` |
| Section / Shelf | `src/components/sections/` | `SectionDataTable.vue``SectionSearchPanel.vue` |
| Item / Atom | `src/components/items/` | `ItemDataRow.vue``ItemFormField.vue` |
| Layout | `src/components/layouts/` | `MainLayout.vue`(維持) |
| Base | `src/components/base/` | `DraggableDialog.vue`(維持) |
| Page Driver Composable | `src/composables/page-drivers/` | `useSingleRecordMaintenancePage.ts` |
| Command Composable | `src/composables/` | `useCrudCommands.ts` |
| Form Composable | `src/composables/forms/` | `useForm.ts` |
| Domain Store | `src/stores/` | `students.ts`(維持) |
| Service Module | `src/services/modules/` | `students.ts`(維持) |
| Domain Model | `src/models/` | `student.ts``page.ts` |
---
## 七、對齊檢查清單(新增/重構時使用)
- [ ] 這個 view 超過 200 行了嗎?→ 考慮抽出 Section 或 composable。
- [ ] 這個元件知道自己是水平捲軸還是網格嗎?→ Item 層不該知道,移到 Section 層。
- [ ] 這個 dialog 定義在 view 裡嗎?→ 抽出到 SectionFormPanel。
- [ ] 這個元件直接呼叫 `studentStore.updateStudent()` 嗎?→ 改為觸發 command 或 emit event。
- [ ] 這個狀態需要跨頁共享嗎?→ 是:store;否:composable。
- [ ] 這個邏輯是「取得資料」還是「執行動作」?→ query 用 page drivercommand 用 command composable。
- [ ] 這個元件只服務單一 domain 嗎?→ 是:留在 `components/items/``components/sections/` 的 domain 子目錄;否:才進 `base/`
- [ ] 這個抽象降低了理解成本嗎?→ 否:不要抽。
---
*本文件取代 `docs/frontend-layering.md` 與 `src/components/GUIDE.md` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。*
+229 -284
View File
@@ -2,19 +2,17 @@
## 目的
這份文件描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案或重構時有一致判斷基準。
這份文件描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案或重構時有一致判斷基準。
重點不是追求理想化架構,而是避免把舊名稱、過渡寫法、或已刪除的結構繼續當成規則
本文件是現況快照;新增功能與重構的細節規範以 `docs/architecture-strategy.md``docs/llm-development-guide.md` 與各層 `src/**/GUIDE.md` 為準
目前專案的主要責任鏈如下:
- `router` 決定 route 與 layout meta
- `App.vue` 根據 route meta 組裝 app shell 與全域 UI
- `views` 承接路由入口與頁面資料協調
- `components` 承接 layout、page component、domain component 與較細的 UI 區塊
- `composables` 承接可重用流程與 UI state
- `stores` 承接跨頁狀態、快取與全域顯示狀態
- `services` 承接 HTTP client、API 模組、token 與錯誤處理
```txt
router -> App.vue -> AppShell -> layout -> view -> page component -> section -> item
page driver / command composable -> store -> service
```
## 目前目錄的責任邊界
@@ -22,365 +20,312 @@
目前路由集中在:
- [routes.ts](/home/carl/git/skt-vuetify-templates/src/router/routes.ts)
- [index.ts](/home/carl/git/skt-vuetify-templates/src/router/index.ts)
- [guards.ts](/home/carl/git/skt-vuetify-templates/src/router/guards.ts)
- [routes.ts](../src/router/routes.ts)
- [index.ts](../src/router/index.ts)
- [guards.ts](../src/router/guards.ts)
責任:
- 定義 route 與 route meta
- 指定頁面使用哪種 layout
- 串接導航守衛
- 定義 route 與 route meta
- 指定頁面使用哪種 layout
- 串接導航守衛
目前 `meta.layout` 是 app shell 切換的正式入口:
目前 `meta.layout` 是 app shell 切換的正式入口:
- `default` 走 [MainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/MainLayout.vue)
- `none` 走 [PlainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/PlainLayout.vue)
- `default` 走 [MainLayout.vue](../src/components/layouts/MainLayout.vue)
- `none` 走 [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
### `src/App.vue`
### `src/App.vue` 與 `src/shell`
[App.vue](/home/carl/git/skt-vuetify-templates/src/App.vue) 目前不是單純掛載入口,而是實際的應用組裝
[App.vue](../src/App.vue) 目前只掛載 [AppShell.vue](../src/shell/AppShell.vue),不再直接承擔全域 UI 組裝。
目前承擔的責任包含
`src/shell` 是 App Shell 層
- 根據 `route.meta.layout` 切換 layout
- 組裝 breadcrumb / favorites / menu 等 layout props
- 放置全域搜尋結果 dialog
- 放置全域訊息中心 dialog
- 放置全域 snackbar
- 串接 layout event 與路由跳轉
- [AppShell.vue](../src/shell/AppShell.vue)layout 切換layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
- [AppTabs.vue](../src/shell/AppTabs.vue)default layout 下的 tabs 與 keep-alive router-view 容器。
- [GlobalOverlays.vue](../src/shell/GlobalOverlays.vue):全域 snackbar、搜尋 dialog、訊息 dialog。
判斷原則:
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可`App.vue`
- 只屬於單一路由頁面的對話框或互動,不應堆到 `App.vue`
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可`src/shell`
- 只屬於單一路由頁面的對話框或互動,不應放進 `src/shell`
- shell 狀態協調優先放在 `src/composables/layout/useAppShell.ts`
### `src/views`
`views` 目前整體方向是「路由入口 + 頁面資料協調 + 頁面事件協調」
`views` 是 route entry,方向是薄層:呼叫 page driver、掛載 page component、協調 route-level 事件
目前較薄 view
目前較典型的薄 view
- [Home.vue](/home/carl/git/skt-vuetify-templates/src/views/Home.vue)
- [Login.vue](/home/carl/git/skt-vuetify-templates/src/views/Login.vue)
- [EditableGrid.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/EditableGrid.vue)
- [Forbidden.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/Forbidden.vue)
- [ServerError.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/ServerError.vue)
- [ServiceUnavailable.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/ServiceUnavailable.vue)
- [NetworkError.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/NetworkError.vue)
- [Maintenance.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/Maintenance.vue)
- [NotFound.vue](/home/carl/git/skt-vuetify-templates/src/views/errors/NotFound.vue)
- [FncPage.vue](/home/carl/git/skt-vuetify-templates/src/views/FncPage.vue)
- [Home.vue](../src/views/Home.vue)
- [Settings.vue](../src/views/Settings.vue)
- [FncPage.vue](../src/views/FncPage.vue)
- [SingleRecord.vue](../src/views/maint/SingleRecord.vue)
- [EditableGrid.vue](../src/views/maint/EditableGrid.vue)
- [MasterDetailA.vue](../src/views/maint/MasterDetailA.vue)
- [MasterDetailB.vue](../src/views/maint/MasterDetailB.vue)
- [MasterDetailC.vue](../src/views/maint/MasterDetailC.vue)
目前仍偏厚的 view
錯誤頁集中在 `src/views/errors`,通常使用 `meta.layout = 'none'`,並由 [ErrorShell.vue](../src/views/errors/ErrorShell.vue) 共用錯誤頁骨架。
- [SingleRecord.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/SingleRecord.vue)
- [MasterDetailA.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailA.vue)
- [MasterDetailB.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailB.vue)
- [MasterDetailC.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailC.vue)
[Login.vue](../src/views/Login.vue) 是 template core 例外:它仍負責登入頁組合、功能開關、小型提示 dialog 與登入流程協調。登入頁 UI 拆在 `components/login/*`captcha 與 announcement 流程拆在頂層 login composable。
`views` 應遵守的原則:
- 可以持有 route、store、頁面資料組裝頁面事件協調
- 可以管理只屬於該頁的 dialog 顯示狀態
- 不應長期承擔大量可抽出的模板片段
- 不應把可重用流程直接留在頁面內重複複製
- 可以持有 route、page driver 掛載、頁面資料組裝頁面事件協調
- 可以管理只屬於該頁的小型 dialog 顯示狀態
- 不應長期承擔大型表格、表單、dialog 模板或可重用流程。
- 不應直接處理底層 HTTP 細節。
### `src/components`
目前 `components` 已經分成幾種不同角色,不再用單一規則描述。
`components` 依角色分層,不再用單一規則描述。
#### 1. 頁面型元件
#### 1. Root page/template components
目前以下元件實際上扮演 page component
目前仍放在 `src/components` 根目錄的頁面外殼
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
- [PageIndex.vue](/home/carl/git/skt-vuetify-templates/src/components/PageIndex.vue)
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue)
- [PageLogin.vue](../src/components/PageLogin.vue)
- [PageIndex.vue](../src/components/PageIndex.vue)
- [PageMaint.vue](../src/components/PageMaint.vue)
這些檔案的責任是:
這些是既有 template 頁面外殼或登入頁組裝元件。新增一般功能頁時,優先使用 `src/components/pages`
- 接收 view 組好的資料與事件
- 組裝某個完整頁面的主畫面
- 再往下使用較小的子元件或 domain component
#### 2. `components/pages`
命名規則
`components/pages` 是完整頁面主畫面組裝層
- 只要是 page component,檔名以 `Page` 為前綴
- page component 可以放在 `components` 根目錄
- 不要把 page component 丟進 `base`
#### 2. `components/login`
登入頁的較細 UI 區塊已集中到:
- [CreateAccountLink.vue](/home/carl/git/skt-vuetify-templates/src/components/login/CreateAccountLink.vue)
- [LoginAnnouncementBoard.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginAnnouncementBoard.vue)
- [LoginBrand.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginBrand.vue)
- [LoginForm.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginForm.vue)
- [LoginHeader.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginHeader.vue)
- [LoginIllustration.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginIllustration.vue)
- [LoginToolBar.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginToolBar.vue)
- [LoginVerify.vue](/home/carl/git/skt-vuetify-templates/src/components/login/LoginVerify.vue)
這一層的定位是:
- 服務 `PageLogin`
- 屬於 login 頁面家族
- 不是全域 base library
#### 3. `components/base`
目前 `components/base` 只剩下:
- [DraggableDialog.vue](/home/carl/git/skt-vuetify-templates/src/components/base/DraggableDialog.vue)
目前判斷原則很直接:
- `base` 只放真正可跨頁重用、且不屬於特定 domain 的元件
- 若元件只服務單一頁面家族或單一 domain,優先放回對應資料夾
#### 4. `components/layouts`
目前 layout 實作集中於:
- [MainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/MainLayout.vue)
- [PlainLayout.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/PlainLayout.vue)
- `src/components/layouts/sk-admin-layout/*`
其中 `sk-admin-layout/*``MainLayout` 底下拆出的骨架子元件:
- [SkAdminAppBarBreadcrumbCol.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue)
- [SkAdminAppBarFavoritesCol.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/sk-admin-layout/SkAdminAppBarFavoritesCol.vue)
- [SkAdminAppBarTopCol.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/sk-admin-layout/SkAdminAppBarTopCol.vue)
- [SkAdminDrawerDesktopMenu.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/sk-admin-layout/SkAdminDrawerDesktopMenu.vue)
- [SkAdminDrawerMobileFavoritesPanel.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue)
- [SkAdminDrawerMobileMenuPanel.vue](/home/carl/git/skt-vuetify-templates/src/components/layouts/sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue)
layout 應只承擔:
- app shell
- drawer / app bar / favorites / breadcrumb 等框架 UI
- 與 layout 視覺結構直接相關的互動
layout 不應承擔:
- 頁面專屬業務流程
- 特定 domain 的資料規則
#### 5. `components/maint`
這個目錄目前是最接近 feature folder 的區域,放 maintenance 領域的 page component 與 domain component
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue)
- [CommonConfirmDialog.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/CommonConfirmDialog.vue)
- [EditableGrid.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/EditableGrid.vue)
- [MasterFileFormFields.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/MasterFileFormFields.vue)
- [MntDialogCard.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/MntDialogCard.vue)
- [MntRecordNavToolbar.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/MntRecordNavToolbar.vue)
- `master-detail/*`
`master-detail/*` 目前屬於維護頁專用的較細組件群:
- [CourseMobilePanel.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/CourseMobilePanel.vue)
- [DetailCollapseGropus.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailCollapseGropus.vue)
- [DetailFullHeightPanel.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailFullHeightPanel.vue)
- [DetailNavigation.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailNavigation.vue)
- [DetailSidePanel.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailSidePanel.vue)
- [DetailSimpleList.vue](/home/carl/git/skt-vuetify-templates/src/components/maint/master-detail/DetailSimpleList.vue)
結論:
- `components/maint` 主要扮演 maintenance domain component 層
- `CommonConfirmDialog` 可以直接在 maintenance 頁或元件使用,不需要再包一層 CRUD dialog aggregator
- 若只是維護頁專用子元件,不要搬到 `base`
### `src/composables`
目前已明確分成兩組:
- `composables/layout/*`
- `composables/maint/*`
代表性檔案:
- [useAdminLayoutState.ts](/home/carl/git/skt-vuetify-templates/src/composables/layout/useAdminLayoutState.ts)
- [useThemeToggle.ts](/home/carl/git/skt-vuetify-templates/src/composables/layout/useThemeToggle.ts)
- [useMaintenanceCrudFlow.ts](/home/carl/git/skt-vuetify-templates/src/composables/maint/useMaintenanceCrudFlow.ts)
- [useStudentMaintenanceForm.ts](/home/carl/git/skt-vuetify-templates/src/composables/maint/useStudentMaintenanceForm.ts)
- [useEditableStudentGrid.ts](/home/carl/git/skt-vuetify-templates/src/composables/maint/useEditableStudentGrid.ts)
- [useApiCall.ts](/home/carl/git/skt-vuetify-templates/src/composables/useApiCall.ts)
`composables` 的責任:
- 放可重用流程
- 放可測試的 UI state
- 放與模板結構耦合較低的狀態機
### `src/stores`
目前 store 已經是正式分層的一部分,而不只是暫時狀態容器。
代表性檔案:
- [auth.ts](/home/carl/git/skt-vuetify-templates/src/stores/auth.ts)
- [menu.ts](/home/carl/git/skt-vuetify-templates/src/stores/menu.ts)
- [breadcrumbs.ts](/home/carl/git/skt-vuetify-templates/src/stores/breadcrumbs.ts)
- [favorites.ts](/home/carl/git/skt-vuetify-templates/src/stores/favorites.ts)
- [messages.ts](/home/carl/git/skt-vuetify-templates/src/stores/messages.ts)
- [snackbar.ts](/home/carl/git/skt-vuetify-templates/src/stores/snackbar.ts)
- [loginAnnouncements.ts](/home/carl/git/skt-vuetify-templates/src/stores/loginAnnouncements.ts)
- [students.ts](/home/carl/git/skt-vuetify-templates/src/stores/students.ts)
- [semesters.ts](/home/carl/git/skt-vuetify-templates/src/stores/semesters.ts)
- [PageHome.vue](../src/components/pages/PageHome.vue)
- [PageSettings.vue](../src/components/pages/PageSettings.vue)
- [PageFunction.vue](../src/components/pages/PageFunction.vue)
- [PageMaintenance.vue](../src/components/pages/PageMaintenance.vue)
- [PageEditableGridMaintenance.vue](../src/components/pages/PageEditableGridMaintenance.vue)
- [PageMasterDetailAMaintenance.vue](../src/components/pages/PageMasterDetailAMaintenance.vue)
- [PageMasterDetailBMaintenance.vue](../src/components/pages/PageMasterDetailBMaintenance.vue)
- [PageMasterDetailCMaintenance.vue](../src/components/pages/PageMasterDetailCMaintenance.vue)
責任:
- 承接跨頁共享狀態
- 承接畫面快取與顯示狀態
- 作為 view 與 services 之間的狀態收斂點
- 接收 view/page driver 組好的資料與事件。
- 組裝完整頁面的主要 section 順序。
- 再往下使用 sections、items、feature/domain components。
注意:
#### 3. `components/sections`
- 目前仍存在 `src/stores/stores/*` 的重複目錄
- 這不是分層設計的一部分,而是待整理的結構噪音
`components/sections` 是頁面區塊容器:
### `src/services`
- [SectionDataTable.vue](../src/components/sections/SectionDataTable.vue)
- [SectionFormPanel.vue](../src/components/sections/SectionFormPanel.vue)
- [SectionFormPage.vue](../src/components/sections/SectionFormPage.vue)
- [SectionQueryPage.vue](../src/components/sections/SectionQueryPage.vue)
`services` 現在已經是一層明確的資料存取邊界,不應再被視為附屬工具資料夾。
責任:
- 決定區塊布局與區塊互動。
- 以 props 接收資料,以 emit 回報事件。
- 不知道 route,不直接呼叫 API。
#### 4. `components/items`
`components/items` 是欄位群組或單筆資料呈現層:
- [ItemFormFieldGroup.vue](../src/components/items/ItemFormFieldGroup.vue)
item 不應知道自己被放在表格、grid、dialog 或頁面哪個位置。
#### 5. `components/login`
登入頁的較細 UI 區塊集中在:
- [CreateAccountLink.vue](../src/components/login/CreateAccountLink.vue)
- [LoginAnnouncementBoard.vue](../src/components/login/LoginAnnouncementBoard.vue)
- [LoginBrand.vue](../src/components/login/LoginBrand.vue)
- [LoginForm.vue](../src/components/login/LoginForm.vue)
- [LoginHeader.vue](../src/components/login/LoginHeader.vue)
- [LoginIllustration.vue](../src/components/login/LoginIllustration.vue)
- [LoginToolBar.vue](../src/components/login/LoginToolBar.vue)
- [LoginVerify.vue](../src/components/login/LoginVerify.vue)
這一層服務 `PageLogin`,不是全域 base library。
#### 6. `components/maint`
`components/maint` 是 maintenance demo / domain component 區域:
- [CommonConfirmDialog.vue](../src/components/maint/CommonConfirmDialog.vue)
- [EditableGrid.vue](../src/components/maint/EditableGrid.vue)
- [MasterFileFormFields.vue](../src/components/maint/MasterFileFormFields.vue)
- [MntDialogCard.vue](../src/components/maint/MntDialogCard.vue)
- [MntRecordNavToolbar.vue](../src/components/maint/MntRecordNavToolbar.vue)
- `master-detail/*`
若只是維護頁專用子元件,不要搬到 `base`
#### 7. `components/layouts`
layout 實作集中於:
- [MainLayout.vue](../src/components/layouts/MainLayout.vue)
- [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
- `src/components/layouts/main-layout/*`
layout 只承擔 app shell、drawer、app bar、favorites、breadcrumb 等框架 UI,不承擔頁面專屬業務流程。
#### 8. `components/base`
`components/base` 放真正跨頁共用且不屬於特定 domain 的基礎元件:
- [DraggableDialog.vue](../src/components/base/DraggableDialog.vue)
- [BaseFormTextField.vue](../src/components/base/BaseFormTextField.vue)
- [BaseFormSelect.vue](../src/components/base/BaseFormSelect.vue)
只服務單一頁面家族或單一 domain 的元件不要放進 `base`
### `src/composables`
目前 composables 分成:
- `page-drivers/*`:頁面資料協調與 page model 組裝。
- `commands/*`:命令式副作用流程,例如 create/edit/save/delete。
- `layout/*`AppShell / layout 狀態與事件協調。
- `maint/*`maintenance demo 的表單、CRUD、editable grid 狀態。
- 頂層 login / utility composable`useLoginCaptcha.ts``useLoginAnnouncements.ts``useApiCall.ts`
責任:
- 放可重用流程。
- 放可測試的 UI state。
- 放與模板結構耦合較低的狀態機。
- 不 import component 或 view。
### `src/stores`
目前 store 是跨頁共享狀態、快取與全域顯示狀態的正式分層。
代表性檔案:
- [client.ts](/home/carl/git/skt-vuetify-templates/src/services/client.ts)
- [interceptors.ts](/home/carl/git/skt-vuetify-templates/src/services/interceptors.ts)
- [error.ts](/home/carl/git/skt-vuetify-templates/src/services/error.ts)
- [http-error.ts](/home/carl/git/skt-vuetify-templates/src/services/http-error.ts)
- [http-toast.ts](/home/carl/git/skt-vuetify-templates/src/services/http-toast.ts)
- [session.ts](/home/carl/git/skt-vuetify-templates/src/services/session.ts)
- [token.ts](/home/carl/git/skt-vuetify-templates/src/services/token.ts)
- [auth.ts](../src/stores/auth.ts)
- [app.ts](../src/stores/app.ts)
- [menu.ts](../src/stores/menu.ts)
- [breadcrumbs.ts](../src/stores/breadcrumbs.ts)
- [favorites.ts](../src/stores/favorites.ts)
- [messages.ts](../src/stores/messages.ts)
- [snackbar.ts](../src/stores/snackbar.ts)
- [students.ts](../src/stores/students.ts)
- [semesters.ts](../src/stores/semesters.ts)
責任:
- 承接跨頁共享狀態。
- 承接畫面快取與全域顯示狀態。
- 作為 view/page driver/composable 與 services 之間的狀態收斂點。
`app.ts` 目前是空的 Pinia scaffold,尚未承擔實際 app state。
### `src/services`
`services` 是 HTTP 與外部 API 邊界。
代表性檔案:
- [client.ts](../src/services/client.ts)
- [interceptors.ts](../src/services/interceptors.ts)
- [error.ts](../src/services/error.ts)
- [http-error.ts](../src/services/http-error.ts)
- [http-toast.ts](../src/services/http-toast.ts)
- [session.ts](../src/services/session.ts)
- [token.ts](../src/services/token.ts)
- `services/modules/*`
責任:
- 提供 HTTP client
- 封裝 API 模組
- 統一 token、session 與錯誤處理
- 提供 `httpClient`
- 封裝 API 模組
- 統一 token、session 與錯誤處理
規則:
- 元件不直接處理底層 HTTP 細節
- 可共享的請求流程優先收斂到 store 或 composable,再由它們呼叫 service
- 元件不直接處理底層 HTTP 細節
- service module 不持有 UI 狀態。
- 可共享的請求流程優先收斂到 store、page driver 或 composable,再由它們呼叫 service。
## 目前已落地的分層模式
### 模式 1`view -> page component -> page family components`
### 模式 1`view -> page driver -> page component`
已落地頁面:
- `Login`
- `Home`
目前的穩定模式是:
- `view` 負責資料準備與事件協調
- page component 負責頁面主畫面組裝
- 較細的視覺區塊再拆到對應頁面家族資料夾,例如 `components/login/*`
### 模式 2`view -> page component / domain components + maint composables`
已落地區域:
- `Settings`
- `FncPage`
- `views/maint/*`
- `components/maint/*`
- `composables/maint/*`
這一層目前是 maintenance 領域最清楚的結構
穩定模式
- `views/maint/*` 承接 route 與頁面流程協調
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue) 承接維護頁共用頁面骨架
- `components/maint/*` 承接維護頁專用元件
- `composables/maint/*` 承接 CRUD 流程、表單狀態與 editable grid 狀態
- view 負責掛載 page driver 與 page component。
- page driver 負責 page model、事件與頁面狀態協調。
- page component 負責頁面主畫面組裝。
[EditableGrid.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/EditableGrid.vue) 是目前最接近薄 view 的 maintenance 頁面。
### 模式 2`Login.vue -> PageLogin -> login components/composables`
### 模式 3`router meta -> App.vue -> layout`
登入頁是 template core,功能開關集中在 `Login.vue`
- `withCaptcha`
- `withAnnouncement`
- `withForgotPassword`
- `withRememberAccount`
資料流與 side effect 分別由 `useLoginCaptcha()``useLoginAnnouncements()``PageLogin``LoginForm` 承接。
### 模式 3`router meta -> AppShell -> layout`
這一層已正式成立:
- route 決定 layout 類型
- `App.vue` 決定套用哪個 shell
- layout 專注在骨架與共用框架 UI
這代表 layout 的責任邊界不應再回頭混入頁面內部流程。
- route 決定 layout 類型
- `AppShell` 決定套用哪個 shell layout。
- layout 專注在骨架與共用框架 UI
## 命名規則
### 頁面與 page component
- 直接被 route 載入的檔案放 `views`
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴
- page component 不放進 `base`
目前例子:
- [PageLogin.vue](/home/carl/git/skt-vuetify-templates/src/components/PageLogin.vue)
- [PageIndex.vue](/home/carl/git/skt-vuetify-templates/src/components/PageIndex.vue)
- [PageMaint.vue](/home/carl/git/skt-vuetify-templates/src/components/PageMaint.vue)
- 直接被 route 載入的檔案放 `views`
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴
- page component 優先放 `components/pages`;既有 template 外殼可保留在 `components` 根目錄。
- page component 不放進 `base`
### 資料夾命名
- 多字資料夾一律使用 `kebab-case`
- 不新增 `snake_case``PascalCase` 資料夾
- 多字資料夾一律使用 `kebab-case`
- 不新增 `snake_case``PascalCase` 資料夾
目前例子:
- `sk-admin-layout`
- `main-layout`
- `master-detail`
- `page-drivers`
### domain component 命名
### component 命名
- 與特定領域強綁定的元件,優先用領域意圖命名
- 不要為了抽象而保留含糊的舊前綴
- 若元件只在 maint 領域使用,就留在 `components/maint`
## 目前仍待整理的區域
### 高優先度
- 繼續瘦身:
- [SingleRecord.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/SingleRecord.vue)
- [MasterDetailA.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailA.vue)
- [MasterDetailB.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailB.vue)
- [MasterDetailC.vue](/home/carl/git/skt-vuetify-templates/src/views/maint/MasterDetailC.vue)
原因:
- 這些頁面仍保留較多頁面內資料轉換與事件協調
### 中優先度
- 清理 `src/stores/stores/*` 重複結構
- 檢查 `components/maint` 內是否仍有可再明確命名的舊名稱
-`PageMaint` 的後續演進,決定是否維持在 `components` 根目錄
### 中低優先度
- 持續檢查 `views` 是否有可再下放到 page component 的模板片段
- 清理命名調整後留下的空資料夾或死連結
- Page component`PageXxx.vue`
- Section component`SectionXxx.vue`
- Item component`ItemXxx.vue`
- Base component:不使用 `Page` / `Section` / `Item` 前綴,直接以功能命名。
## 新增或修改檔案時的判斷準則
1. 這個檔案是否直接被 route 載入?
- 是:優先放 `views`
- 是:優先放 `views`
2. 這個檔案是否負責某個完整頁面的主畫面組裝?
- 是:用 `Page` 前綴,放 page component,不要塞進 `base`
- 是:用 `Page` 前綴,優先放 `components/pages`,不要塞進 `base`
3. 這段重複的是模板還是流程?
- 模板:抽元件
- 流程:抽 composable 或 store
- 模板:抽元件
- 流程:抽 composable、page driver、command 或 store
4. 這個狀態是否跨頁共享,或需要快取 / 全域顯示控制?
- 是:優先考慮 store
- 是:優先考慮 store
5. 這個邏輯是否在處理 API、token、session、錯誤正規化?
- 是:放 `services`
6. 這個元件是否只屬於單一 domain?
- 是:優先放到該 domain 目錄,例如 `components/maint`
- 是:放 `services`
6. 這個元件是否只屬於單一 domain 或單一頁面家族
- 是:優先放到該 domain / feature 目錄,例如 `components/maint``components/login`
7. 這個抽象是否真的降低重複與理解成本?
- 否:不要抽
- 否:不要抽
+185
View File
@@ -0,0 +1,185 @@
# LLM 開發操作指南
## 文件目的
本專案是給其他 Vue/Vuetify 專案使用的 template。LLM 協助修改時,預設應在 `src` 底下依分層規則新增或修改頁面、元件、store、service 與 composable。
本文件只保留全域操作順序與導覽。各層細節規範放在 `src/**/GUIDE.md`,避免重複維護。
## 建議閱讀順序
1. `src/GUIDE.md`
2. `docs/architecture-strategy.md`
3.`maintenanceContract.pageKind` 閱讀對應的 demo 與 `src/**/GUIDE.md`(查 `docs/architecture-strategy.md` 的分層說明)
4. `docs/add-page-example.md`(需要新增頁面時)
`frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md``src/**/GUIDE.md` 為準。
## GUIDE 索引
| 範圍 | 指南 |
|------|------|
| `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` |
## 預設修改策略
一般功能需求優先修改:
- `src/views/*`
- `src/components/sections/*`
- `src/components/items/*`
- `src/composables/page-drivers/*`
- `src/composables/useCrudCommands.ts`
- `src/stores/*`
- `src/services/modules/*`
- `src/router/routes.ts`
- `src/language/*.json`
除非使用者明確要求,避免先修改 template core。template core 清單與 demo/example 邊界見 `src/GUIDE.md`
## 常用判斷
- 新 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`)。
- 申請/填寫頁(送出按鈕):讀 `src/components/sections/GUIDE.md``SectionFormPage`)。
- 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`
- 錯誤頁:讀 `src/views/GUIDE.md`ErrorShell 模式)與 `src/router/GUIDE.md`(錯誤頁路由慣例)。
- 語系文案:讀 `src/language/GUIDE.md`
## 修改前檢查
- 是否碰到 template core。
- 是否已有同類型範例可沿用。
- 是否需要新增 route。
- 是否應拆成 section / item。
- 是否應新增 page driver 或 command composable。
- 是否需要 store,或只需要頁面內 state。
- 是否應定義新的 model 型別(`src/models/`)。
- 是否需要更新語系、menu、breadcrumb、favorites。
## 從視覺特徵選擇 section 元件
當收到 prototype 截圖或設計稿時,依畫面特徵選擇 section 外殼:
| 特徵 | 選擇 |
|------|------|
| 有「送出/存檔」按鈕,且畫面為填寫表單(欄位 + 配合事項 + 動作按鈕) | `SectionFormPage` |
| 有「查詢」按鈕,且畫面為篩選條件 + 結果表格/列表 | `SectionQueryPage` |
| 純粹表格列表(無送出/查詢按鈕,只有 CRUD 操作) | 不用 section 外殼,直接組合 `v-data-table` |
| 混合結構(有查詢也有表單填寫) | 評估是否拆成兩頁;若必須同頁,不用通用外殼 |
判斷順序:先看有無「送出/存檔」→ 再看有無「查詢」→ 其餘視為一般列表頁。
## `.spec.json` 對照指南
當 LLM 依照 `GEN-FE-PROMPT` 讀取 `.ht/spec/{page}.spec.json` 後,依 `maintenanceContract.pageKind` 決定對應的 demo 與 composable 界面,再將 `.spec.json` 的 evidence 欄位對應到 composable 的 reactive state、computed 與 API calls。
### query(查詢頁)→ `SectionQueryPage`
參考:`src/views/demos/SectionQueryPageDemo.vue``src/composables/page-drivers/useSectionsDemoPage.ts`
架構:
```
View(自含 page model + UI → SectionQueryPage
composable (page driver)
```
**composable 必須回傳:**
| 名稱 | 型別 | 對應 `.spec.json` 來源 |
|------|------|------------------------|
| `queryFilters` | `Ref<{ 每個欄位 }>` | `pageContract.forms[0].fields` — 每個 field 建一個 key,型別依 `field.type`text→string, select→string \| null,選項取自 `field.options` |
| `pageModel` | `ComputedRef<{ title, ... }>` | `title` 來自 `pageContract.title``backLabel` 固定為 `'返回'` |
| `handleQuerySearch()` | 函式 | 觸發 `apiContract.endpoints``usage=search` 的 API call;呼叫時機對應 `bddContract.scenarios``type=query` 的 When |
| `handleQueryBack()` | 函式 | 對應 `pageContract.actions``actionType=back` |
| 表格資料 | 在 `pageModel` 中 | `tables[].headers` 對應表格欄;`sampleRows` 對應欄位格式 |
**page component props**
- `v-model:query-filters` — 雙向綁定 `queryFilters`
- `:page` — 傳入 `pageModel`
**page component emits**
- `@search` → 呼叫 `handleQuerySearch`
- `@back` → 呼叫 `handleQueryBack`
### application(申請/表單頁)→ `SectionFormPage`
參考:`src/views/demos/SectionFormPageDemo.vue`
架構:
```
View(自含 page model + UI → SectionFormPage
composable (page driver)
```
**composable 必須回傳:**
| 名稱 | 型別 | 對應 `.spec.json` 來源 |
|------|------|------------------------|
| `demoForm`(應改名為 `formState` | `Ref<{ 每個欄位 }>` | `pageContract.forms[0].fields` — text/select 建 key;不可編輯的 `readonly` 欄位不放進 formState,改從 `pageModel` 單向顯示 |
| `pageModel` | `ComputedRef` | `title` 來自 `pageContract.title` |
| `handleFormSubmit()` | 函式 | 觸發 `apiContract.endpoints``usage=create` 的 POST endpoint;呼叫前驗證 `apiCatalog.fieldRules`;呼叫時機對應 `bddContract.scenarios``type=application-submit` 的 When/Then |
| `resetDemoForm()`(改為 `resetForm` | 函式 | 對應 `pageContract.actions``actionType=reset` |
| `handleFormBack()` | 函式 | 對應 `actionType=back` |
**提交 payload 規則:**
- `apiCatalog.fieldRules` 中的 `field``rule` 決定必填、長度、格式驗證
- 型別轉換依 `field.type`number 欄位不可包成 string 送出
### maintenance(維護/CRUD 頁)→ `maint/*`
參考:`src/views/maint/README.md` — 依資料結構選擇最接近的範本(EditableGrid / SingleRecord / MasterDetail A/B/C
**composable 必須回傳:**
| 名稱 | 對應 `.spec.json` 來源 |
|------|------------------------|
| `search filters` | `pageContract.forms[0].fields` |
| `table data / headers` | `pageContract.tables[].headers` + search API response |
| `row action handlers` | `maintenanceContract.rowActions` — 每個 action 對應一個 handler`enabledWhen` 決定啟用條件(如 `aprvYn === 'Z'` 時才能修改) |
| `create/update/delete calls` | `apiContract.endpoints` 中對應的 POST/PUT/DELETE |
**row action 狀態規則:**
- `enabledWhen` 直接轉為 template 中的 `:disabled``v-if` 條件
- `maintenanceContract.businessRules` 中的額外限制一併套用
### 通用規則
**entity 命名:** 所有 composable、component、store 的名稱以 `maintenanceContract.dataModel.primaryEntity` 為 entity 名,例如 primaryEntity=`FacilityApply``useFacilityApplyPage.ts``PageFacilityApply.vue`
**API 串接:**`src/services/modules/` 新增對應 entity 的 API modulemethod 名稱對齊 `apiContract.endpoints[].usage`search/create/update/delete/print),path 對齊 `endpoint.path`
**錯誤處理:** 檢查 `apiContract.errorHandling.format` — 若為 `ProblemDetailsWithValidationErrors`,須處理 `errors` 物件中的逐欄錯誤訊息;若為 `ProblemDetails`,只顯示 `detail`
**語系文案:** 欄位 label 與按鈕文字取自 `pageContract.forms[].fields[].label``pageContract.actions[].label`,放入 `src/language/` 對應語系 key。
## 完成前驗證
- Vue / TypeScript 結構有變更:`pnpm -s type-check`
- 需要確認產物可建置:`pnpm -s build`
- Markdown 或大量搬移:`git diff --check`
- route、layout 或主要畫面流程有變更:啟動 dev server 並用瀏覽器確認,除非使用者明確不需要。
如果無法執行驗證,回報原因,不要宣稱已驗證。
Vendored
-15
View File
@@ -1,6 +1,5 @@
/// <reference types="vite/client" />
import 'axios'
import 'vue-router'
declare module 'vue-router' {
@@ -13,17 +12,3 @@ declare module 'vue-router' {
}
}
declare module 'axios' {
interface AxiosRequestConfig {
meta?: {
silentToast?: boolean
}
}
interface InternalAxiosRequestConfig {
meta?: {
silentToast?: boolean
}
}
}
-17
View File
@@ -1,17 +0,0 @@
import eslintConfigPrettier from "eslint-config-prettier/flat";
import vuetify from 'eslint-config-vuetify'
export default vuetify({
ts: true,
},{
extends: [eslintConfigPrettier],
rules: {
'vue/no-required-prop-with-default': 'off',
'vue/attributes-order': 'off',
'@typescript-eslint/unified-signatures': 'off',
'@typescript-eslint/member-ordering': 'off',
'unicorn/prefer-query-selector': 'off',
'unicorn/no-array-sort':'off',
'unicorn/prefer-logical-operator-over-ternary': 'off',
}
})
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
+2 -10
View File
@@ -9,15 +9,11 @@
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint --cache",
"lint:fix": "eslint --fix --cache",
"format": "prettier . --write",
"mcp": "ruler apply",
"mcp:revert": "ruler revert"
"format": "prettier . --write"
},
"dependencies": {
"@mdi/js": "^7.4.47",
"axios": "^1.13.6",
"ky": "^2.0.2",
"pinia": "^3.0.4",
"vue": "^3.5.31",
"vue-i18n": "^11.3.0",
@@ -25,14 +21,10 @@
"vuetify": "^4.0.4"
},
"devDependencies": {
"@intellectronica/ruler": "^0.3.37",
"@tsconfig/node22": "^22.0.5",
"@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vue/tsconfig": "^0.9.1",
"eslint": "^9.39.4",
"eslint-config-prettier": "^10.1.8",
"eslint-config-vuetify": "^4.4.0",
"npm-run-all2": "^8.0.4",
"prettier": "^3.8.1",
"sass-embedded": "^1.98.0",
+10 -1837
View File
File diff suppressed because it is too large Load Diff
+6 -587
View File
@@ -1,588 +1,7 @@
<template>
<!-- 根據路由設定 meta.layout 動態切換佈局 -->
<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
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">
<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 setup lang="ts">
import AppShell from '@/shell/AppShell.vue'
</script>
<template>
<AppShell />
</template>
+87
View File
@@ -0,0 +1,87 @@
# Src Guide
`src` 是 template 使用者主要修改的區域。新增功能時,先從 route view 與 composable 開始,除非需求明確牽涉 app shell、登入、router guard 或 HTTP core,否則不要先改 template core。
## 資料流
```txt
router -> AppShell -> layout -> view -> Section -> Item
composable -> store -> service
```
## 主要目錄
- `views/`route entry,維持薄層,只做 route wiring 與 page driver 掛載。詳見 `src/views/GUIDE.md`
- `components/`Vue UI 元件,依 sections / items / layouts / base 分層。詳見 `src/components/GUIDE.md`
- `composables/`page driver、command flow、layout flow 與可重用狀態流程。詳見 `src/composables/GUIDE.md`
- `router/`route、layout meta、auth meta 與 guard。詳見 `src/router/GUIDE.md`
- `shell/`AppShell、tabs、global overlays。詳見 `src/shell/GUIDE.md`
- `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/maint/MaintShell.vue`
- `components/maint/*`
- `components/sections/*`
- `components/items/*`
- `composables/page-drivers/*MaintenancePage.ts`
- `composables/maint/*`
- `composables/useCrudCommands.ts`
- `stores/students.ts`
- `stores/semesters.ts`
- demo assets 與 demo language keys
移除 demo 時,同步清理 route、menu/favorites/breadcrumb 流程、語系文案與不再使用的 import。
## 新功能流程
1. 新增或修改 `views/*` route entry,直接在 view 裡組裝 page model 與 UI。
2. 若有複雜的資料協調(多 composable、搜尋狀態、CRUD flow、dialog 狀態),新增 `composables/page-drivers/useXxxPage.ts`。簡單頁面直接在 view 用 `computed` 組裝。
3. 若畫面有獨立區塊,拆到 `components/sections/*`
4. 若區塊內有欄位群組或單筆資料呈現,拆到 `components/items/*`
5. 跨頁共享狀態才新增或修改 `stores/*`
6. 外部 API 放在 `services/modules/*`
7.`router/routes.ts` 新增 route。
## 驗證
- Vue / TypeScript 結構變更:`pnpm -s type-check`
- 需要確認產物:`pnpm -s build`
- route、layout 或主要畫面流程變更:啟動 dev server 並做瀏覽器檢查,除非使用者明確不需要。
+153
View File
@@ -0,0 +1,153 @@
# Src 開發入口
`src` 是 template 使用者主要修改的區域。新增功能時,先從 `views``router/routes.ts` 開始,除非需求明確牽涉 app shell,否則不要先改 layout。
## 常見開發流程
1.`src/views``src/views/<feature>` 新增 route view。
2.`src/router/routes.ts` 加入 route。
3. 一般頁面使用 `meta: { layout: 'default' }`,讓內容被 `MainLayout` 包住。
4. 畫面超過單一簡單區塊時,拆到 `src/components/<feature>`
5. 可重用流程或較複雜 UI state 放到 `src/composables/<feature>`
6. 跨頁共享狀態放到 `src/stores/*.ts`
7. API 呼叫放到 `src/services/modules/<domain>.ts`
## 主要資料流
```txt
router -> App.vue -> layout -> view -> component -> composable/store -> service
```
責任分工:
- `router`route、layout meta、auth meta 與錯誤頁入口。
- `App.vue`:根據 route meta 組裝 layout、全域 UI 與 layout event。
- `views`:路由入口、頁面資料協調與頁面事件協調。
- `components`:畫面呈現、props/emits 與可拆分 UI 區塊。
- `composables`:可重用流程、頁面狀態機或較複雜 UI state。
- `stores`:跨頁共享狀態、快取與全域顯示狀態。
- `services`HTTP client、API 模組、token、session 與錯誤處理。
## Template Core
一般功能開發時,優先視為 template core
App shell
- `src/main.ts`
- `src/App.vue`
Layout
- `src/components/layouts/MainLayout.vue`
- `src/components/layouts/PlainLayout.vue`
- `src/components/layouts/main-layout/*`
Login entry
- `src/views/Login.vue`
Router core
- `src/router/index.ts`
- `src/router/guards.ts`
Plugin / theme core
- `src/plugins/*`
- `src/styles/*`
HTTP core
- `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`
Global stores
- `src/stores/auth.ts`
- `src/stores/menu.ts`
- `src/stores/breadcrumbs.ts`
- `src/stores/favorites.ts`
- `src/stores/messages.ts`
- `src/stores/snackbar.ts`
Layout composables
- `src/composables/layout/*`
這些檔案支撐 app shell、登入、路由、全域狀態與 API 基礎設施。只有需求明確要求修改 template core 時才調整。
`src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/views/Login.vue``src/components/login/*`
`src/services/modules/auth.ts``src/services/modules/menu.ts` 是預設後端契約的 API adapter。接新後端時常需要調整,但不要把 UI 狀態放進 service module。
## Demo / Example
以下內容偏向示範資料與範例頁,建立新專案時可依需求替換或刪除:
- `src/views/Home.vue`
- `src/views/maint/*`
- `src/components/maint/*`
- `src/composables/maint/*`
- `src/components/maint/MaintShell.vue`
- `src/stores/students.ts`
- `src/stores/semesters.ts`
- `src/views/FncPage.vue`
- `src/views/Settings.vue`
- `src/language/*.json` 中與 starter home、maint、學生資料相關的文案
- `src/assets/logo.png`
- `src/assets/logo.svg`
- `src/assets/robot-svgrepo-com.svg`
刪除 demo/example 時,也要同步清理:
- `src/router/routes.ts`
- `src/stores/menu.ts` 中依賴的選單資料流程
- 相關 `src/language/*.json` 文案
- 不再被 import 的 demo components、composables、stores 與 assets
## 新頁面最小範例
```ts
// src/router/routes.ts
{
path: '/reports',
name: 'reports',
component: () => import('@/views/reports/Reports.vue'),
meta: { layout: 'default', requiresAuth: true },
}
```
```vue
<!-- src/views/reports/Reports.vue -->
<script setup lang="ts">
import ReportsTable from '@/components/reports/ReportsTable.vue'
</script>
<template>
<ReportsTable />
</template>
```
更多範例見 `docs/add-page-example.md`
## 文件導覽
- `docs/add-page-example.md`:新增頁面範例。
- `docs/frontend-layering.md`:完整分層與責任邊界。
- `docs/llm-development-guide.md`LLM 操作邊界。
## 驗證
```bash
pnpm type-check
pnpm build
```
+24
View File
@@ -0,0 +1,24 @@
# Components Guide
`components` 放 Vue UI 元件,依 sections / items / layouts / base 分層。頁面不再有獨立的 page component 層 — 頁面 UI 直接寫在 `views/*` 中。
## 子目錄
- `sections/`:獨立畫面區塊(搜尋面板、資料表格、表單面板),決定佈局,不關心單筆內容。詳見 `src/components/sections/GUIDE.md`
- `items/`:單一資料單位的純粹呈現,不管理狀態。詳見 `src/components/items/GUIDE.md`
- `layouts/`App Shell 層的 layout 元件。詳見 `src/components/layouts/GUIDE.md`
- `base/`:真正跨頁共用的基礎元件。詳見 `src/components/base/GUIDE.md`
`MaintShell.vue` 是 maintenance 頁面的通用外殼元件,放在 `src/components/maint/`
## 規則
- 元件不直接 import store 或 service。
- 元件以 props 接收資料,以 emits 回報使用者意圖。
- 可複用元件不含 domain 名稱(如 `student``course`)。
## 驗證
- Vue / TypeScript 結構變更:`pnpm -s type-check`
- 需要確認產物:`pnpm -s build`
- route、layout 或主要畫面流程變更:啟動 dev server 並做瀏覽器檢查,除非使用者明確不需要。
-235
View File
@@ -1,235 +0,0 @@
<template>
<v-container class="pa-0" fluid>
<div class="d-flex flex-column ga-5 py-4 pr-2 pl-0">
<v-sheet
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
color="surface"
>
<v-avatar color="primary" size="52" variant="tonal">
<span class="text-h5">👋</span>
</v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<!--
使用 v-data-iterator 保留一致的列表輸出結構
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架
-->
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
<!--
Vuetify 會把原始資料包進 wrapper
這裡統一解包可避免模板層散落型別判斷
-->
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="emit('news', resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="emit('message-center')"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="emit('quick', item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<!--
這個 dialog 只做消息內容呈現
開關狀態仍交給 view 管理避免頁面元件自行持有流程狀態
-->
<v-dialog
:model-value="props.isNewsDialogOpen"
max-width="640"
@update:model-value="emit('update:isNewsDialogOpen', $event)"
>
<v-card v-if="props.selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ props.selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
{{ props.selectedNews.dept }} ·
{{ props.selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ props.selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
interface NewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
interface QuickItem {
icon: string
title: string
}
const props = defineProps<{
newsItems: NewsItem[]
quickItems: QuickItem[]
selectedNews: NewsItem | null
isNewsDialogOpen: boolean
}>()
const emit = defineEmits<{
news: [item: NewsItem]
'message-center': []
quick: [item: QuickItem]
'update:isNewsDialogOpen': [value: boolean]
}>()
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
function resolveNewsItem(wrapped: unknown): NewsItem {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return (wrapped as { raw: NewsItem }).raw
}
return wrapped as NewsItem
}
</script>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
-532
View File
@@ -1,532 +0,0 @@
<template>
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
<!-- Side Layouts -->
<v-row
v-if="props.layout !== 'card'"
class="fill-height"
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
no-gutters
>
<!-- Illustration Column -->
<v-col
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
cols="12"
lg="8"
sm="6"
>
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="props.branding.title" />
</div>
<v-sheet
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
max-width="680"
rounded="lg"
width="100%"
>
<LoginAnnouncementBoard
:all-tab-label="props.announcementBoard.allTabLabel"
:date-header="props.announcementBoard.dateHeader"
:empty-text="props.announcementBoard.emptyText"
:items="props.announcementBoard.items"
:items-per-page="props.announcementBoard.itemsPerPage"
:pagination-label="props.announcementBoard.paginationLabel"
:school-header="props.announcementBoard.schoolHeader"
:system-announcements="props.announcementBoard.systemAnnouncements"
:tabs="props.announcementBoard.tabs"
:title="props.announcementBoard.title"
:title-header="props.announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement"
/>
</v-sheet>
</v-col>
<v-col
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
cols="12"
lg="4"
sm="6"
>
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
<v-banner
class="d-sm-none mb-2"
density="comfortable"
lines="one"
:mobile="false"
:stacked="false"
>
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon
class="mobile-banner-icon"
color="primary"
size="small"
:icon="mdiBullhornVariantOutline"
/>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none"
color="primary"
size="small"
variant="text"
@click="mobileAnnouncementSheetVisible = true"
>
{{ props.mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
>
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-1">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</div>
</v-col>
</v-row>
<!-- Card Layout (Centered) -->
<v-row
v-else
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
no-gutters
>
<v-card
class="rounded-lg"
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
elevation="10"
max-width="450"
width="100%"
>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-md-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-1">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet v-model="mobileAnnouncementSheetVisible" class="d-sm-none">
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ props.mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span
>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ props.mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
</template>
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, ref } from 'vue'
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
import LoginBrand from './login/LoginBrand.vue'
import LoginForm from './login/LoginForm.vue'
import LoginHeader from './login/LoginHeader.vue'
import LoginToolBar from './login/LoginToolBar.vue'
import LoginVerify from './login/LoginVerify.vue'
interface BrandingConfig {
title?: string
organization?: string
}
interface IllustrationConfig {
image?: string | null
title?: string
description?: string
}
interface HeaderConfig {
welcomeText?: string
welcomeDescription?: string
}
interface AnnouncementTabConfig {
label: string
value: string
}
interface AnnouncementItemConfig {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface AnnouncementBoardConfig {
title?: string
tabs?: AnnouncementTabConfig[]
items?: AnnouncementItemConfig[]
systemAnnouncements?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
interface MobileAnnouncementConfig {
items?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
show?: boolean
viewAllText?: string
listTitle?: string
closeText?: string
emptyText?: string
}
interface ForgotPasswordConfig {
text?: string
href?: string
target?: string
}
interface FormConfig {
accPlaceholder?: string
passwPlaceholder?: string
rememberMeLabel?: string
submitText?: string
rememberStorageKey?: string
withCaptcha?: boolean
captcha?: {
imgUrl?: string
id?: string
tokenValue?: string
}
captchaValue?: string
captchaLoading?: boolean
captchaErrorMessage?: string
captchaVerified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
forgotPassword: ForgotPasswordConfig
}
interface ToolBarConfig {
show?: boolean
locale?: string
locales?: string[]
}
interface Props {
layout: 'side-left' | 'side-right' | 'card'
branding: BrandingConfig
illustration: IllustrationConfig
announcementBoard: AnnouncementBoardConfig
mobileAnnouncement: MobileAnnouncementConfig
header: HeaderConfig
form: FormConfig
toolbar: ToolBarConfig
}
const props = withDefaults(defineProps<Props>(), {
layout: 'side-left',
branding: () => ({
title: 'Skyteck Login',
organization: 'school',
}),
illustration: () => ({
image: null,
title: 'Login',
description: 'Login to your account',
}),
announcementBoard: () => ({
title: '學校公告區',
tabs: [
{ label: '全部', value: '__all__' },
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: [
{
id: 'announcement-1',
date: '2024-03-19',
school: '市立實踐國中',
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
tab: 'junior',
},
],
systemAnnouncements: [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
}),
mobileAnnouncement: () => ({
items: [],
show: false,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}),
header: () => ({
welcomeText: 'Welcome back 👋🏻',
welcomeDescription: 'Please enter your account password to login',
}),
form: () => ({
accPlaceholder: '請輸入帳號',
passwPlaceholder: '請輸入密碼',
rememberMeLabel: '記住帳號',
submitText: '登入',
rememberStorageKey: 'sklogin.remember.username',
withCaptcha: true,
captcha: undefined,
captchaValue: '',
captchaLoading: false,
captchaErrorMessage: '',
captchaVerified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
forgotPassword: {
text: '忘記密碼?',
href: '',
target: undefined,
},
}),
toolbar: () => ({
show: true,
locale: 'zh-TW',
locales: ['zh-TW', 'en-US'],
}),
})
const emit = defineEmits([
'submit',
'change-locale',
'forgot-password',
'captcha-refresh',
'captcha-change',
'toggle-layout',
'select-announcement',
])
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (props.mobileAnnouncement.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => {
return `layout-${props.layout}`
})
function handleLogin(formData: Record<string, unknown>) {
emit('submit', formData)
}
function handleCaptchaRefresh() {
emit('captcha-refresh')
}
function handleCaptchaChange(value: string) {
emit('captcha-change', value)
}
function handleChangeLocale(nextLocale: string) {
emit('change-locale', nextLocale)
}
function handleToggleLayout() {
emit('toggle-layout')
}
function handleForgotPassword(e: MouseEvent) {
emit('forgot-password', e)
}
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
emit('select-announcement', item)
}
</script>
<style scoped>
:deep(.v-banner__prepend) {
align-self: center;
margin-inline-end: 16px;
}
:deep(.v-banner-actions) {
align-self: center;
}
.mobile-banner-icon {
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
transform-origin: center;
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-banner-icon {
animation: none;
}
}
.illustration-panel {
background: linear-gradient(
135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%
);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
/* Specific styles for side-right to flip border */
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
-35
View File
@@ -1,35 +0,0 @@
# Components
Vue template files in this folder are automatically imported.
## 🚀 Usage
Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it.
The following example assumes a component located at `src/components/MyComponent.vue`:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
//
</script>
```
When your template is rendered, the component's import will automatically be inlined, which renders to this:
```vue
<template>
<div>
<MyComponent />
</div>
</template>
<script lang="ts" setup>
import MyComponent from '@/components/MyComponent.vue'
</script>
```
+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 不同列的組件。
一般功能需求不應修改這裡的檔案。
+17 -17
View File
@@ -1,5 +1,5 @@
<template>
<v-app v-bind="$attrs" class="sk-admin-layout">
<v-app v-bind="$attrs" class="main-layout">
<v-navigation-drawer
v-model="drawer"
class="sk-admin-drawer"
@@ -88,7 +88,7 @@
<!-- 桌面板選單 -->
<template v-if="!isMobile">
<SkAdminDrawerDesktopMenu
<DrawerDesktopMenu
v-model:opened="opened"
:is-shrink="isRail"
:menu-items="menuItems"
@@ -99,12 +99,12 @@
<!-- 行動版選單 -->
<template v-if="isMobile">
<SkAdminDrawerMobileFavoritesPanel
<DrawerMobileFavoritesPanel
v-if="features.showFavorites && mobileFavoritesPanel"
:favorite-items="favoriteItems"
@select="onSelectFavorite"
/>
<SkAdminDrawerMobileMenuPanel
<DrawerMobileMenuPanel
v-else
:mobile-current-items="mobileCurrentItems"
@item-click="onMobileMenuClick"
@@ -114,7 +114,7 @@
<v-app-bar ref="appBarRef" class="" height="auto">
<v-row class="flex-column" no-gutters>
<SkAdminAppBarTopCol
<AppBarTopCol
:features="features"
:is-mobile="isMobile"
:logout-label="logoutLabel"
@@ -134,9 +134,9 @@
<template v-if="$slots.actions" #actions>
<slot name="actions"></slot>
</template>
</SkAdminAppBarTopCol>
</AppBarTopCol>
<SkAdminAppBarFavoritesCol
<AppBarFavoritesCol
:favorite-items="favoriteItems"
:favorites-config="favoritesConfig"
:features="features"
@@ -148,7 +148,7 @@
@toggle-favorites-bar="toggleFavoritesBar"
/>
<SkAdminAppBarBreadcrumbCol
<AppBarBreadcrumbCol
:breadcrumb-items="breadcrumbItems"
:features="features"
:is-mobile="isMobile"
@@ -159,7 +159,7 @@
<template v-if="$slots['breadcrumb-actions']" #breadcrumb-actions>
<slot name="breadcrumb-actions"></slot>
</template>
</SkAdminAppBarBreadcrumbCol>
</AppBarBreadcrumbCol>
</v-row>
</v-app-bar>
@@ -219,18 +219,18 @@ import type {
AdminLayoutToolbarActions,
AdminLayoutToolbarCounts,
AdminLayoutUserProfile,
} from './sk-admin-layout/types'
} from './main-layout/types'
import { mdiClose, mdiHelpCircleOutline, mdiHome, mdiMenu, mdiMenuOpen } from '@mdi/js'
import { computed, ref, toRef } from 'vue'
import { useDisplay } from 'vuetify'
import { useAdminLayoutState } from '@/composables/layout/useAdminLayoutState'
import { useThemeToggle } from '@/composables/layout/useThemeToggle'
import SkAdminAppBarBreadcrumbCol from './sk-admin-layout/SkAdminAppBarBreadcrumbCol.vue'
import SkAdminAppBarFavoritesCol from './sk-admin-layout/SkAdminAppBarFavoritesCol.vue'
import SkAdminAppBarTopCol from './sk-admin-layout/SkAdminAppBarTopCol.vue'
import SkAdminDrawerDesktopMenu from './sk-admin-layout/SkAdminDrawerDesktopMenu.vue'
import SkAdminDrawerMobileFavoritesPanel from './sk-admin-layout/SkAdminDrawerMobileFavoritesPanel.vue'
import SkAdminDrawerMobileMenuPanel from './sk-admin-layout/SkAdminDrawerMobileMenuPanel.vue'
import AppBarBreadcrumbCol from './main-layout/AppBarBreadcrumbCol.vue'
import AppBarFavoritesCol from './main-layout/AppBarFavoritesCol.vue'
import AppBarTopCol from './main-layout/AppBarTopCol.vue'
import DrawerDesktopMenu from './main-layout/DrawerDesktopMenu.vue'
import DrawerMobileFavoritesPanel from './main-layout/DrawerMobileFavoritesPanel.vue'
import DrawerMobileMenuPanel from './main-layout/DrawerMobileMenuPanel.vue'
const emit = defineEmits<{
logout: []
@@ -457,7 +457,7 @@ function getMobileMenuBtnColor(level: number) {
</script>
<style scoped>
.sk-admin-layout {
.main-layout {
background: rgb(var(--v-theme-background));
}
@@ -1,9 +1,9 @@
<template>
<v-col
v-if="features.showBreadcrumb && breadcrumbBarVisible && !isMobile"
class="d-flex align-center justify-space-between pr-2 pl-3 py-1 bg-surface"
class="d-flex align-center justify-space-between pr-2 pl-3 bg-surface"
>
<v-breadcrumbs class="pa-0" density="compact" :items="breadcrumbItems">
<v-breadcrumbs class="ma-2 pa-0" density="compact" :items="breadcrumbItems">
<template #prepend>
<v-btn
v-if="features.showFavorites && !showFavoritesBar"
@@ -17,7 +17,7 @@
</v-btn>
</template>
<template #item="{ item }">
<div class="d-flex align-center ga-1">
<li class="d-flex align-center ga-1">
<v-icon
v-if="getBreadcrumbIcon(item)"
class="mr-1"
@@ -25,7 +25,7 @@
:icon="getBreadcrumbIcon(item)"
/>
<span class="text-caption text-no-wrap">{{ getBreadcrumbTitle(item) }}</span>
</div>
</li>
</template>
<template #divider>
<v-icon color="primary-variant" size="12" :icon="mdiChevronRight" />
@@ -35,7 +35,13 @@
<span class="text-caption">{{ favoritesConfig.addLabel }}</span>
</v-btn>
</div>
<v-btn color="grey" size="small" variant="text" @click="emit('toggle-favorites-bar', false)">
<v-btn
aria-label="隱藏常用功能列"
color="grey"
size="small"
variant="text"
@click="emit('toggle-favorites-bar', false)"
>
<v-icon :icon="mdiEyeOff" />
</v-btn>
</v-col>
@@ -42,7 +42,11 @@
<div v-if="features.showToolbarActions" class="top-actions">
<slot name="actions">
<!-- 通知 -->
<v-tooltip location="bottom" :text="toolbarActions.notificationsLabel">
<v-tooltip
location="bottom"
:text="toolbarActions.notificationsLabel"
:aria-label="toolbarActions.notificationsLabel"
>
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
@@ -67,7 +71,11 @@
</v-tooltip>
<!-- 訊息 -->
<v-tooltip location="bottom" :text="toolbarActions.messagesLabel">
<v-tooltip
location="bottom"
:text="toolbarActions.messagesLabel"
:aria-label="toolbarActions.messagesLabel"
>
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
@@ -110,7 +118,11 @@
<!-- 設定 -->
<v-menu :close-on-content-click="false" location="bottom end">
<template #activator="{ props: menuProps }">
<v-tooltip location="bottom" :text="toolbarActions.settingsLabel">
<v-tooltip
location="bottom"
:text="toolbarActions.settingsLabel"
:aria-label="toolbarActions.settingsLabel"
>
<template #activator="{ props: tooltipProps }">
<v-btn
v-bind="{ ...menuProps, ...tooltipProps }"
@@ -124,7 +136,7 @@
</template>
</v-tooltip>
</template>
<v-list density="compact" width="180">
<v-list role="none" density="compact" width="180">
<v-list-subheader class="text-subtitle-1 py-2">顯示設定</v-list-subheader>
<v-list-item>
<v-switch
@@ -154,7 +166,7 @@
</v-menu>
<!-- 登出 -->
<v-tooltip location="bottom" :text="logoutLabel">
<v-tooltip location="bottom" :text="logoutLabel" :aria-label="logoutLabel">
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
@@ -169,7 +181,12 @@
</template>
</v-tooltip>
<v-tooltip v-if="features.showThemeToggle" location="bottom" :text="themeToggleLabel">
<v-tooltip
v-if="features.showThemeToggle"
location="bottom"
:text="themeToggleLabel"
:aria-label="themeToggleLabel"
>
<template #activator="{ props: activatorProps }">
<v-btn
v-bind="activatorProps"
@@ -0,0 +1,270 @@
<template>
<nav aria-label="sidebar navigation">
<v-list
role="none"
v-model:opened="openedModel"
color="primary"
density="compact"
prepend-gap="8"
>
<template v-for="item in menuItems" :key="item.path ?? item.title">
<v-list-group
v-if="item.subItems?.length"
:id="getGroupId(item)"
:value="getGroupValue(item)"
>
<template #activator="{ props: activatorProps }">
<v-list-item
v-bind="isShrink ? undefined : activatorProps"
role="button"
:aria-selected="undefined"
:class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path"
:to="isNavigable(item) ? item.path : undefined"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
<template #append>
<v-chip
v-if="!isShrink && getItemCount(item) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(item) }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<v-list-group
v-if="subItem.subItems?.length"
:id="getGroupId(subItem, getGroupId(item))"
:value="getGroupValue(subItem, getGroupValue(item))"
>
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps"
role="button"
:aria-selected="undefined"
:link="isNavigable(subItem)"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)"
>
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
<template #append>
<v-chip
v-if="getItemCount(subItem) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(subItem) }}
</v-chip>
</template>
</v-list-item>
</template>
<v-list-item
v-for="subSubItem in subItem.subItems"
:key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path"
:prepend-icon="mdiCircleSmall"
:to="subSubItem.path"
@click="emitSelect(subSubItem)"
>
<template #title>
<v-tooltip location="end" :text="subSubItem.title" :aria-label="subSubItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subSubItem.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-item
v-else
:link="!!subItem.path"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="subItem.path"
@click="emitSelect(subItem)"
>
<template #title>
<v-tooltip location="end" :text="subItem.title" :aria-label="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subItem.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list-group>
<v-list-item
v-else
:class="{ 'px-0': isShrink }"
:link="!!item.path"
:to="item.path"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<v-tooltip v-if="!isShrink" location="end" :text="item.title" :aria-label="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
item.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list>
</nav>
</template>
<script setup lang="ts">
import type { AdminLayoutMenuItem } from './types'
import { mdiCircleSmall, mdiMenuRight } from '@mdi/js'
import { computed, watch } from 'vue'
interface Props {
opened?: string[]
menuItems?: AdminLayoutMenuItem[]
isShrink?: boolean
}
const props = withDefaults(defineProps<Props>(), {
opened: () => [],
menuItems: () => [],
isShrink: false,
})
const emit = defineEmits<{
'update:opened': [value: string[]]
select: [item: AdminLayoutMenuItem]
unshrink: []
}>()
const openedModel = computed({
get: () => (props.isShrink ? [] : props.opened),
set: (value) => {
if (!props.isShrink) {
emit('update:opened', value)
}
},
})
// 當側邊欄收縮時,自動收起所有展開的子選單
watch(
() => props.isShrink,
(newVal) => {
if (newVal) {
openedModel.value = []
}
}
)
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
function emitSelect(item: AdminLayoutMenuItem) {
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
// 這樣可以讓使用者看到完整的選單結構和導航結果
if (props.isShrink) {
emit('unshrink')
}
emit('select', item)
}
function getItemCount(item: AdminLayoutMenuItem) {
if (!item?.subItems?.length) return 0
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
if (current?.subItems?.length) return total + countLeaf(current.subItems)
return total + 1
}, 0)
return countLeaf(item.subItems)
}
function getGroupValue(item: AdminLayoutMenuItem, parentKey?: string) {
const rawKey = item.path ?? item.title ?? 'group'
const normalizedKey = Array.from(rawKey.trim())
.map((char) => {
if (/[a-z0-9]/i.test(char)) {
return char.toLowerCase()
}
return `u${char.codePointAt(0)?.toString(16)}`
})
.join('-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
if (!parentKey) {
return `menu-${normalizedKey || 'group'}`
}
return `${parentKey}__${normalizedKey || 'group'}`
}
function getGroupId(item: AdminLayoutMenuItem, parentId?: string) {
const groupId = getGroupValue(item).replace(/^menu-/, 'group-')
if (!parentId) {
return `nav-${groupId}`
}
return `${parentId}__${groupId}`
}
</script>
<style scoped>
.menu-count {
min-width: 28px;
justify-content: center;
}
.nav-text-overflow {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
@@ -1,6 +1,6 @@
<template>
<v-sheet class="mobile-favorites-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list role="none" class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in favoriteItems"
:key="item.path ?? item.title"
@@ -1,6 +1,6 @@
<template>
<v-sheet class="mobile-menu-panel d-flex flex-column" color="surface">
<v-list class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list role="none" class="px-2 py-2 flex-grow-1 overflow-auto" density="comfortable">
<v-list-item
v-for="item in mobileCurrentItems"
:key="item.path ?? item.title"
@@ -1,258 +0,0 @@
<template>
<v-list v-model:opened="openedModel" color="primary" density="compact" prepend-gap="8">
<template v-for="item in menuItems" :key="item.path ?? item.title">
<v-list-group
v-if="item.subItems?.length"
:id="getGroupId(item)"
:value="getGroupValue(item)"
>
<template #activator="{ props: activatorProps }">
<v-list-item
v-bind="isShrink ? undefined : activatorProps"
:class="{ 'px-0': isShrink }"
:link="isNavigable(item) && !!item.path"
:to="isNavigable(item) ? item.path : undefined"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<span v-if="!isShrink" class="text-body-2 nav-text-overflow">{{ item.title }}</span>
</template>
<template #append>
<v-chip
v-if="!isShrink && getItemCount(item) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(item) }}
</v-chip>
</template>
</v-list-item>
</template>
<template v-for="subItem in item.subItems" :key="subItem.path ?? subItem.title">
<v-list-group
v-if="subItem.subItems?.length"
:id="getGroupId(subItem, getGroupId(item))"
:value="getGroupValue(subItem, getGroupValue(item))"
>
<template #activator="{ props: subProps }">
<v-list-item
v-bind="subProps"
:link="isNavigable(subItem)"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="isNavigable(subItem) ? subItem.path : undefined"
@click="emitSelect(subItem)"
>
<template #title>
<span class="text-body-2 nav-text-overflow">{{ subItem.title }}</span>
</template>
<template #append>
<v-chip
v-if="getItemCount(subItem) > 0"
class="menu-count"
color="secondary"
size="x-small"
variant="tonal"
>
{{ getItemCount(subItem) }}
</v-chip>
</template>
</v-list-item>
</template>
<v-list-item
v-for="subSubItem in subItem.subItems"
:key="subSubItem.path ?? subSubItem.title"
:link="!!subSubItem.path"
:prepend-icon="mdiCircleSmall"
:to="subSubItem.path"
@click="emitSelect(subSubItem)"
>
<template #title>
<v-tooltip location="end" :text="subSubItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subSubItem.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list-group>
<v-list-item
v-else
:link="!!subItem.path"
:prepend-icon="subItem.icon || mdiMenuRight"
:to="subItem.path"
@click="emitSelect(subItem)"
>
<template #title>
<v-tooltip location="end" :text="subItem.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
subItem.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list-group>
<v-list-item
v-else
:class="{ 'px-0': isShrink }"
:link="!!item.path"
:to="item.path"
@click="emitSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="20" :icon="item.icon" />
<v-btn
v-if="isShrink && !item.icon"
class=""
rounded
size="36"
spaced="start"
variant="text"
>{{ item.title?.charAt(0) }}</v-btn
>
</template>
<template #title>
<v-tooltip v-if="!isShrink" location="end" :text="item.title">
<template #activator="{ props: tooltipProps }">
<span v-bind="tooltipProps" class="text-body-2 nav-text-overflow">{{
item.title
}}</span>
</template>
</v-tooltip>
</template>
</v-list-item>
</template>
</v-list>
</template>
<script setup lang="ts">
import type { AdminLayoutMenuItem } from './types'
import { mdiCircleSmall, mdiMenuRight } from '@mdi/js'
import { computed, watch } from 'vue'
interface Props {
opened?: string[]
menuItems?: AdminLayoutMenuItem[]
isShrink?: boolean
}
const props = withDefaults(defineProps<Props>(), {
opened: () => [],
menuItems: () => [],
isShrink: false,
})
const emit = defineEmits<{
'update:opened': [value: string[]]
select: [item: AdminLayoutMenuItem]
unshrink: []
}>()
const openedModel = computed({
get: () => (props.isShrink ? [] : props.opened),
set: (value) => {
if (!props.isShrink) {
emit('update:opened', value)
}
},
})
// 當側邊欄收縮時,自動收起所有展開的子選單
watch(
() => props.isShrink,
(newVal) => {
if (newVal) {
openedModel.value = []
}
}
)
const isNavigable = (item: AdminLayoutMenuItem) => item?.navigable !== false
function emitSelect(item: AdminLayoutMenuItem) {
// 收縮狀態下點擊選單項目時,先解除收縮再進行選擇
// 這樣可以讓使用者看到完整的選單結構和導航結果
if (props.isShrink) {
emit('unshrink')
}
emit('select', item)
}
function getItemCount(item: AdminLayoutMenuItem) {
if (!item?.subItems?.length) return 0
const countLeaf = (list: AdminLayoutMenuItem[]): number =>
(list || []).reduce((total: number, current: AdminLayoutMenuItem) => {
if (current?.subItems?.length) return total + countLeaf(current.subItems)
return total + 1
}, 0)
return countLeaf(item.subItems)
}
function getGroupValue(item: AdminLayoutMenuItem, parentKey?: string) {
const rawKey = item.path ?? item.title ?? 'group'
const normalizedKey = Array.from(rawKey.trim())
.map((char) => {
if (/[a-z0-9]/i.test(char)) {
return char.toLowerCase()
}
return `u${char.codePointAt(0)?.toString(16)}`
})
.join('-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
if (!parentKey) {
return `menu-${normalizedKey || 'group'}`
}
return `${parentKey}__${normalizedKey || 'group'}`
}
function getGroupId(item: AdminLayoutMenuItem, parentId?: string) {
const groupId = getGroupValue(item).replace(/^menu-/, 'group-')
if (!parentId) {
return `nav-${groupId}`
}
return `${parentId}__${groupId}`
}
</script>
<style scoped>
.menu-count {
min-width: 28px;
justify-content: center;
}
.nav-text-overflow {
display: block;
max-width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
+29 -2
View File
@@ -1,5 +1,5 @@
<template>
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })">
<v-form @submit.prevent="handleSubmit">
<v-text-field
v-model="username"
bg-color="surface"
@@ -27,8 +27,12 @@
<slot name="verify"></slot>
<div class="d-flex align-center justify-space-between mb-6 mb-md-4">
<div
v-if="props.withRememberAccount || props.withForgotPassword"
class="d-flex align-center justify-space-between mb-6 mb-md-4"
>
<v-checkbox
v-if="props.withRememberAccount"
v-model="rememberMe"
color="primary"
density="compact"
@@ -36,6 +40,7 @@
:label="props.rememberMeLabel"
></v-checkbox>
<a
v-if="props.withForgotPassword"
class="text-body-2 text-primary text-decoration-none"
:href="props.forgotPasswordHref || '#'"
:target="props.forgotPasswordTarget"
@@ -100,11 +105,21 @@ const props = defineProps({
type: String,
default: 'sklogin.remember.username',
},
withRememberAccount: {
type: Boolean,
default: true,
},
withForgotPassword: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['submit', 'forgot-password'])
onMounted(() => {
if (!props.withRememberAccount) return
const saved = localStorage.getItem(props.rememberStorageKey)
if (saved) {
username.value = saved
@@ -113,6 +128,8 @@ onMounted(() => {
})
watch([rememberMe, username], ([nextRemember, nextUsername]) => {
if (!props.withRememberAccount) return
if (!nextRemember) {
localStorage.removeItem(props.rememberStorageKey)
return
@@ -126,7 +143,17 @@ watch([rememberMe, username], ([nextRemember, nextUsername]) => {
localStorage.setItem(props.rememberStorageKey, nextUsername)
})
function handleSubmit() {
emit('submit', {
username: username.value,
password: password.value,
rememberMe: props.withRememberAccount ? rememberMe.value : false,
})
}
function handleForgotPasswordClick(e: MouseEvent) {
if (!props.withForgotPassword) return
emit('forgot-password', e)
if (!props.forgotPasswordHref) {
e.preventDefault()
+1 -1
View File
@@ -1,7 +1,7 @@
<template>
<div class="login-header-wrapper">
<h2 class="text-h5 text-primary font-weight-bold mb-2">{{ props.welcomeText }}</h2>
<p class="text-subtitle-1 text-secondary">{{ props.welcomeDescription }}</p>
<p class="text-subtitle-1 text-grey-darken-2">{{ props.welcomeDescription }}</p>
</div>
</template>
+5 -1
View File
@@ -2,6 +2,7 @@
<div class="d-flex justify-end py-0 py-sm-2">
<v-btn
class="d-none d-md-block"
:aria-label="t('pages.login.toolbar.toggleTheme')"
color="grey-darken-1"
:icon="mdiPaletteOutline"
size="small"
@@ -12,13 +13,14 @@
<template #activator="{ props: menuActivatorProps }">
<v-btn
v-bind="menuActivatorProps"
:aria-label="t('pages.login.toolbar.selectLocale')"
color="grey-darken-1"
:icon="mdiTranslate"
size="small"
variant="text"
></v-btn>
</template>
<v-list density="compact">
<v-list role="none" density="compact">
<v-list-item
v-for="localeOption in localeOptions"
:key="localeOption"
@@ -35,6 +37,7 @@
<script setup lang="ts">
import { mdiPaletteOutline, mdiTranslate } from '@mdi/js'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTheme } from 'vuetify'
import { getNextThemeName } from '@/utils/theme'
@@ -55,6 +58,7 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits(['change-locale', 'toggle-layout'])
const { t } = useI18n()
const theme = useTheme()
const availableThemeNames = computed(() =>
+90 -27
View File
@@ -2,14 +2,14 @@
<div class="d-flex flex-column">
<v-card variant="flat">
<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">
{{ hasAnyChange ? '有未儲存變更' : '已同步' }}
</v-chip>
<v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
<v-spacer />
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
>條件搜尋</v-btn
>顯示條件搜尋</v-btn
>
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
</v-card-title>
@@ -19,37 +19,31 @@
<v-card-text class="pb-0 pt-2">
<v-row v-if="isSearchVisible" class="mb-2" density="compact">
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">學號</div>
<v-text-field
<BaseFormTextField
v-model="search.studentId"
clearable
density="compact"
hide-details
label="學號"
:label-char-count="2"
placeholder="例如:S2024001"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">姓名</div>
<v-text-field
<BaseFormTextField
v-model="search.name"
clearable
density="compact"
hide-details
label="姓名"
:label-char-count="2"
placeholder="例如:王小明"
variant="outlined"
/>
</v-col>
<v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">系所</div>
<v-select
<BaseFormSelect
v-model="search.department"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }"
clearable
density="compact"
hide-details
label="系所"
:label-char-count="2"
:items="departments"
variant="outlined"
/>
</v-col>
</v-row>
@@ -85,15 +79,16 @@
<div ref="tableContainerRef">
<v-data-table
v-model:page="currentPage"
class="student-table"
density="comfortable"
fixed-header
:headers="tableHeaders"
:height="tableHeight"
hide-default-footer
item-value="id"
:items="filteredStudents"
:items-per-page="10"
items-per-page-text="每頁筆數"
page-text=" {0}-{1} / {2} "
:items-per-page="itemsPerPage"
>
<template #[`header.select`]>
<v-checkbox-btn
@@ -295,6 +290,48 @@
</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>
</div>
</v-card-text>
@@ -330,10 +367,21 @@
<script setup lang="ts">
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
withDefaults(
defineProps<{
title?: string
}>(),
{
title: '可編輯表格維護示範',
}
)
const {
departments,
enrollYears,
@@ -360,6 +408,20 @@ const {
resetAllRows,
} = useEditableStudentGrid()
const itemsPerPage = 10
const currentPage = ref(1)
const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredStudents.value.length / itemsPerPage))
)
const pageSummary = computed(() => {
const total = filteredStudents.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
const confirmDeleteSingleVisible = ref(false)
const confirmDeleteSelectedVisible = ref(false)
const confirmSaveVisible = ref(false)
@@ -379,10 +441,15 @@ const singleDeleteMessage = computed(() => {
})
const selectedDeleteMessage = computed(
() =>
`確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
() => `確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
)
watch(pageCount, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
function requestDeleteSingleRow(id: number) {
pendingDeleteRowId.value = id
confirmDeleteSingleVisible.value = true
@@ -427,8 +494,4 @@ function confirmSaveAllRows() {
padding-bottom: 0 !important;
min-height: 32px;
}
:deep(.v-data-table-footer) {
padding: 4px 0 0;
}
</style>
@@ -8,7 +8,7 @@
:icon="mdAndUp ? false : mdiMagnify"
:prepend-icon="mdAndUp ? mdiMagnify : undefined"
size="small"
:text="mdAndUp ? '搜尋條件' : false"
:text="mdAndUp ? '顯示搜尋條件' : false"
variant="text"
@click="$emit('toggle-search')"
>
+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,55 @@
<script setup lang="ts">
interface Props {
title: string
error?: string
loading?: boolean
message?: string
resetLabel?: string
submitLabel?: string
}
withDefaults(defineProps<Props>(), {
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 class="mb-2">
<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-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,40 @@
<script setup lang="ts">
interface Props {
title: string
backLabel?: string
error?: string
loading?: boolean
}
withDefaults(defineProps<Props>(), {})
const emit = defineEmits<{
search: []
back: []
}>()
</script>
<template>
<v-container fluid class="pt-2 px-1">
<v-card class="mb-2">
<v-card-title class="text-title-large bg-primary">{{ title }}</v-card-title>
<v-card-text class="pa-4">
<v-alert v-if="error" class="mb-4" type="error" variant="tonal">{{ error }}</v-alert>
<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-container>
</template>
+44
View File
@@ -0,0 +1,44 @@
# Composables Guide
`composables` 放可重用流程、page driver、command flow 與較複雜 UI state。簡單模板顯示不要為了形式新增 composable。
## 子目錄
- `page-drivers/`:頁面資料協調與 page model 組裝(僅複雜頁面使用)。
- `maint/`maintenance demo 的表單、CRUD、editable grid 狀態。
- `layout/`AppShell / layout 狀態與事件協調。
頂層放通用 composable
- `useApiCall.ts`:封裝 loading / data / error / execute 模式。
- `useCrudCommands.ts`:通用 CRUD 狀態機(新增 / 編輯 / 檢視 / 儲存 / highlight)。
## 新增規則
- 用 `useXxx.ts` 命名。
- 參數較多時使用 options object。
- source state 盡量集中,衍生值用 `computed`
- 副作用放在明確 action 或 watcher,不放在 computed。
- 不 import component 或 view。
- 不持有 service module 的底層 HTTP 細節。
## Page Driver
Page driver 只應在「需要協調多個 composable / store / route」時才成立。若頁面邏輯只有:
- 組裝一個 `computed` page model3-5 個欄位)
- 沒有搜尋、沒有 dialog、沒有複雜事件
則**不要建立 page driver**,直接在 view 裡寫 `computed` 即可。
當需要 page driver 時,它負責:
- route param/query 轉成頁面資料
- 協調 store、command composable、表單 composable
- 組裝 page component 需要的 props/events
View 以 destructure 方式取用 page driver 回傳值:
```ts
const { pageModel, search, handleSubmit } = useXxxPage()
```
模板中直接使用,不寫 `.value``:page="pageModel"``v-model="search"`
- 與 store/service 的 mutation 流程
@@ -1,4 +1,4 @@
import type { AdminLayoutMenuItem } from '@/components/layouts/sk-admin-layout/types'
import type { AdminLayoutMenuItem } from '@/components/layouts/main-layout/types'
import { computed, onBeforeUnmount, onMounted, ref, type Ref, watch } from 'vue'
type ToggleSidebarPayload = {
+292
View File
@@ -0,0 +1,292 @@
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: '範例頁面',
navigable: false,
subItems: [
{
title: 'SectionQueryPage',
icon: mdiFileDocumentOutline,
path: '/demos/sections/query-page',
},
{ title: 'SectionFormPage', icon: mdiFileDocumentOutline, path: '/demos/sections/form-page' },
],
},
{ 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
}
interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
export interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
confirmCloseVisible: Ref<boolean>
confirmSaveVisible: Ref<boolean>
confirmDeleteVisible: Ref<boolean>
+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,412 @@
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 {
confirmSave,
currentPage,
departments,
detailForm,
flow,
formState,
gradeOptions,
itemsPerPage,
masterDetailEvents,
masterDetailProps,
openAddDialog,
openEditDialog,
openViewDialog,
pageCount,
pageModel,
pageSummary,
requestSaveConfirmation,
resetSearch,
scrollToField,
search,
searchPanelOpen,
snackbarVisible,
statuses,
students,
tableHeaders,
}
}
@@ -0,0 +1,410 @@
import { computed, ref, watch } from 'vue'
import type { StudentRecord } from '@/models/student'
import { useSnackbarStore } from '@/stores/snackbar'
import type {
SaveSummaryItem,
StudentFormState,
} from '@/composables/maint/useStudentMaintenanceForm'
export interface ReportSummary {
id: number
title: string
owner: string
status: string
updatedAt: string
}
export interface ReportFilters {
keyword: string
owner: string
}
export interface DemoFormState {
title: string
owner: string
category: string
description: string
}
export interface MaintenanceSearchState {
studentId: string
name: string
department: string
grade: number | null
status: string
}
export interface SectionsDemoPageModel {
title: string
ownerOptions: string[]
categoryOptions: string[]
queryMessage: string
formMessage: string
reports: ReportSummary[]
departments: string[]
gradeOptions: GradeOption[]
enrollYears: number[]
statuses: string[]
maintenanceHeaders: Array<Record<string, unknown>>
maintenanceItems: StudentRecord[]
maintenanceItemsPerPage: number
maintenancePageCount: number
maintenancePageSummary: string
formPanelProps: FormPanelProps
}
interface GradeOption {
title: string
value: number
}
type FieldErrors = Record<keyof StudentFormState, string[]>
interface FormPanelProps {
confirmCloseVisible: boolean
confirmDeleteVisible: boolean
confirmNavigateVisible: boolean
confirmSaveVisible: boolean
confirmSwitchVisible: boolean
departments: string[]
dialogSubtitle: string
dialogTitle: string
dialogVisible: boolean
enrollYears: number[]
errorSummary: Array<{ field: string; message: string }>
fieldErrors: FieldErrors
gradeOptions: GradeOption[]
hasNextRecord: boolean
hasPrevRecord: boolean
isDirty: boolean
isEditMode: boolean
isFormLocked: boolean
isFormReadonly: boolean
isLoading: boolean
isSaving: boolean
isViewMode: boolean
pendingDeleteLabel: string
saveSummary: SaveSummaryItem[]
statuses: string[]
}
const reports: ReportSummary[] = [
{ id: 1, title: '學生統計', owner: '教務處', status: '已發布', updatedAt: '2026-05-01' },
{ id: 2, title: '課程統計', owner: '課務組', status: '草稿', updatedAt: '2026-05-08' },
{ id: 3, title: '系統使用量', owner: '資訊中心', status: '已發布', updatedAt: '2026-05-15' },
]
const ownerOptions = ['全部', '教務處', '課務組', '資訊中心']
const categoryOptions = ['一般報表', '申請表單', '維護資料']
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions: GradeOption[] = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
]
const enrollYears = [2026, 2025, 2024, 2023]
const statuses = ['在學', '休學', '畢業']
const maintenanceItemsPerPage = 5
const students: StudentRecord[] = [
{
id: 1,
studentId: 'S2026001',
name: '王小明',
department: '資訊工程',
grade: 1,
enrollYear: 2026,
credits: 18,
advisor: '陳教授',
email: 'ming@example.edu',
phone: '0912000001',
status: '在學',
},
{
id: 2,
studentId: 'S2025007',
name: '林雅婷',
department: '企業管理',
grade: 2,
enrollYear: 2025,
credits: 42,
advisor: '李教授',
email: 'yating@example.edu',
phone: '0912000002',
status: '在學',
},
{
id: 3,
studentId: 'S2024012',
name: '張志豪',
department: '應用外語',
grade: 3,
enrollYear: 2024,
credits: 86,
advisor: '黃教授',
email: 'zhihao@example.edu',
phone: '0912000003',
status: '休學',
},
]
const maintenanceHeaders = [
{ title: '學號', key: 'studentId', sortable: true, width: 120 },
{ title: '姓名', key: 'name', sortable: true, width: 100 },
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{ title: '操作', key: 'actions', sortable: false, width: 220 },
]
const defaultQueryFilters: ReportFilters = {
keyword: '',
owner: '全部',
}
const defaultDemoForm: DemoFormState = {
title: '',
owner: '教務處',
category: '一般報表',
description: '',
}
const defaultMaintenanceSearch: MaintenanceSearchState = {
studentId: '',
name: '',
department: '',
grade: null,
status: '',
}
const defaultFormPanelForm: StudentFormState = {
studentId: '',
name: '',
department: departments[0] ?? '',
grade: gradeOptions[0]?.value ?? 1,
enrollYear: enrollYears[0] ?? 2026,
credits: 0,
advisor: '',
email: '',
phone: '',
status: statuses[0] ?? '',
}
function createEmptyFieldErrors(): FieldErrors {
return {
studentId: [],
name: [],
department: [],
grade: [],
enrollYear: [],
credits: [],
advisor: [],
email: [],
phone: [],
status: [],
}
}
export function useSectionsDemoPage() {
const snackbar = useSnackbarStore()
const queryFilters = ref<ReportFilters>({ ...defaultQueryFilters })
const demoForm = ref<DemoFormState>({ ...defaultDemoForm })
const maintenanceSearch = ref<MaintenanceSearchState>({ ...defaultMaintenanceSearch })
const maintenanceCurrentPage = ref(1)
const formPanelVisible = ref(false)
const formPanelForm = ref<StudentFormState>({ ...defaultFormPanelForm })
const fieldErrors = ref<FieldErrors>(createEmptyFieldErrors())
const queryMessage = ref('')
const formMessage = ref('')
const filteredReports = computed(() => {
const keyword = queryFilters.value.keyword.trim().toLowerCase()
const owner = queryFilters.value.owner
return reports.filter((item) => {
const keywordMatched =
!keyword ||
item.title.toLowerCase().includes(keyword) ||
item.owner.toLowerCase().includes(keyword)
const ownerMatched = owner === '全部' || item.owner === owner
return keywordMatched && ownerMatched
})
})
const maintenanceItems = computed(() => {
const keywordId = maintenanceSearch.value.studentId.trim().toLowerCase()
const keywordName = maintenanceSearch.value.name.trim().toLowerCase()
return students.filter((item) => {
const idMatched = !keywordId || item.studentId.toLowerCase().includes(keywordId)
const nameMatched = !keywordName || item.name.toLowerCase().includes(keywordName)
const departmentMatched =
!maintenanceSearch.value.department || item.department === maintenanceSearch.value.department
const gradeMatched =
maintenanceSearch.value.grade == null || item.grade === maintenanceSearch.value.grade
const statusMatched = !maintenanceSearch.value.status || item.status === maintenanceSearch.value.status
return idMatched && nameMatched && departmentMatched && gradeMatched && statusMatched
})
})
const maintenancePageCount = computed(() =>
Math.max(1, Math.ceil(maintenanceItems.value.length / maintenanceItemsPerPage))
)
const maintenancePageSummary = computed(() => {
const total = maintenanceItems.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (maintenanceCurrentPage.value - 1) * maintenanceItemsPerPage + 1
const end = Math.min(maintenanceCurrentPage.value * maintenanceItemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
const isFormPanelDirty = computed(
() => JSON.stringify(formPanelForm.value) !== JSON.stringify(defaultFormPanelForm)
)
const formPanelProps = computed<FormPanelProps>(() => ({
confirmCloseVisible: false,
confirmDeleteVisible: false,
confirmNavigateVisible: false,
confirmSaveVisible: false,
confirmSwitchVisible: false,
departments,
dialogSubtitle: formPanelForm.value.studentId || '尚未輸入學號',
dialogTitle: 'SectionFormPanel 範例',
dialogVisible: formPanelVisible.value,
enrollYears,
errorSummary: [],
fieldErrors: fieldErrors.value,
gradeOptions,
hasNextRecord: false,
hasPrevRecord: false,
isDirty: isFormPanelDirty.value,
isEditMode: false,
isFormLocked: false,
isFormReadonly: false,
isLoading: false,
isSaving: false,
isViewMode: false,
pendingDeleteLabel: formPanelForm.value.name || '目前資料',
saveSummary: [],
statuses,
}))
const pageModel = computed<SectionsDemoPageModel>(() => ({
title: '新增頁面與 Section 範例',
ownerOptions,
categoryOptions,
queryMessage: queryMessage.value,
formMessage: formMessage.value,
reports: filteredReports.value,
departments,
gradeOptions,
enrollYears,
statuses,
maintenanceHeaders,
maintenanceItems: maintenanceItems.value,
maintenanceItemsPerPage,
maintenancePageCount: maintenancePageCount.value,
maintenancePageSummary: maintenancePageSummary.value,
formPanelProps: formPanelProps.value,
}))
watch(maintenancePageCount, (value) => {
if (maintenanceCurrentPage.value > value) maintenanceCurrentPage.value = value
})
function handleQuerySearch() {
queryMessage.value = `查詢完成,共 ${filteredReports.value.length}`
}
function handleQueryBack() {
snackbar.show({ message: '查詢頁返回事件', color: 'info' })
}
function handleFormSubmit() {
formMessage.value = demoForm.value.title.trim()
? `已送出:${demoForm.value.title.trim()}`
: '請輸入標題後再送出'
}
function resetDemoForm() {
demoForm.value = { ...defaultDemoForm }
formMessage.value = ''
}
function handleFormBack() {
snackbar.show({ message: '表單頁返回事件', color: 'info' })
}
function resetMaintenanceSearch() {
maintenanceSearch.value = { ...defaultMaintenanceSearch }
maintenanceCurrentPage.value = 1
}
function handleMaintenanceAction(action: string, record: StudentRecord) {
snackbar.show({ message: `${action}${record.studentId} ${record.name}`, color: 'info' })
}
function openFormPanel() {
formPanelVisible.value = true
}
function closeFormPanel() {
formPanelVisible.value = false
}
function handleFormPanelVisibleChange(value: boolean) {
formPanelVisible.value = value
}
function handleFormPanelSave() {
formPanelVisible.value = false
snackbar.show({ message: 'SectionFormPanel 儲存事件', color: 'success' })
}
function clearFormPanelFieldError(field: keyof StudentFormState | string) {
const key = field as keyof StudentFormState
if (!fieldErrors.value[key]?.length) return
fieldErrors.value[key] = []
}
function gradeLabel(grade: number) {
return gradeOptions.find((option) => option.value === grade)?.title ?? `年級 ${grade}`
}
function statusColor(status: string) {
if (status === '在學') return 'success'
if (status === '休學') return 'warning'
if (status === '畢業') return 'secondary'
return 'default'
}
function rowProps() {
return {}
}
return {
demoForm,
formPanelForm,
maintenanceCurrentPage,
maintenanceSearch,
pageModel,
queryFilters,
clearFormPanelFieldError,
closeFormPanel,
gradeLabel,
handleFormBack,
handleFormPanelSave,
handleFormPanelVisibleChange,
handleFormSubmit,
handleMaintenanceAction,
handleQueryBack,
handleQuerySearch,
openFormPanel,
resetDemoForm,
resetMaintenanceSearch,
rowProps,
statusColor,
}
}
@@ -0,0 +1,258 @@
import { computed, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useCrudCommands } from '@/composables/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,
}
}
+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,
}
}
@@ -1,5 +1,4 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref, toValue, watch, type MaybeRefOrGetter } from 'vue'
export interface LoginAnnouncementItem {
id: string | number
@@ -25,6 +24,10 @@ export interface LoginMobileAnnouncementItem {
createdAt?: string
}
interface UseLoginAnnouncementsOptions {
enabled: MaybeRefOrGetter<boolean>
}
const storageKey = 'sk_playground_login_announcements'
const defaultItems: LoginAnnouncementItem[] = [
@@ -110,10 +113,11 @@ async function mockFetchMobileAnnouncementsApi(): Promise<LoginMobileAnnouncemen
]
}
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => {
const items = ref<LoginAnnouncementItem[]>(readItems())
export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
const items = ref<LoginAnnouncementItem[]>([])
const selectedId = ref<string | number | null>(null)
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
const enabled = computed(() => toValue(options.enabled))
const listItems = computed<LoginAnnouncementListItem[]>(() =>
items.value.map((item) => ({
@@ -132,17 +136,19 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: listItems.value,
systemAnnouncements: mobileAnnouncements.value,
items: enabled.value ? listItems.value : [],
systemAnnouncements: enabled.value ? mobileAnnouncements.value : [],
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
paginationLabel: '總筆數:',
allTabLabel: '全部',
emptyText: '目前沒有公告資料',
}))
const selectedAnnouncement = computed(() => {
if (selectedId.value === null) return null
if (!enabled.value || selectedId.value === null) return null
return items.value.find((item) => item.id === selectedId.value) ?? null
})
@@ -151,59 +157,54 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
})
const mobileAnnouncementConfig = computed(() => ({
items: mobileAnnouncements.value,
show: mobileAnnouncements.value.length > 0,
items: enabled.value ? mobileAnnouncements.value : [],
show: enabled.value && mobileAnnouncements.value.length > 0,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}))
const hydrate = () => {
function hydrate() {
if (!enabled.value) return
items.value = readItems()
}
const replaceAll = (nextItems: LoginAnnouncementItem[]) => {
items.value = Array.isArray(nextItems) ? nextItems : []
}
async function fetchMobileAnnouncements() {
if (!enabled.value) return
const selectById = (id: string | number) => {
selectedId.value = id
}
const clearSelection = () => {
selectedId.value = null
}
const fetchMobileAnnouncements = async () => {
const result = await mockFetchMobileAnnouncementsApi()
mobileAnnouncements.value = Array.isArray(result) ? result : []
}
const fetchMobileAnnouncement = async () => {
async function load() {
hydrate()
await fetchMobileAnnouncements()
}
function selectById(id: string | number) {
if (!enabled.value) return
selectedId.value = id
}
watch(
items,
(val) => {
if (!enabled.value) return
writeItems(val)
},
{ deep: true }
)
return {
items,
listItems,
boardConfig,
mobileAnnouncementConfig,
selectedAnnouncement,
selectedAnnouncementDetail,
hydrate,
replaceAll,
load,
selectById,
clearSelection,
fetchMobileAnnouncements,
fetchMobileAnnouncement,
}
})
}
+84
View File
@@ -0,0 +1,84 @@
import type { CaptchaResponse } from '@/types/api'
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue'
import { normalizeError } from '@/services/error'
import { authApi } from '@/services/modules/auth'
interface UseLoginCaptchaOptions {
enabled: MaybeRefOrGetter<boolean>
}
export function useLoginCaptcha(options: UseLoginCaptchaOptions) {
const captcha = ref<CaptchaResponse | null>(null)
const captchaValue = ref('')
const captchaLoading = ref(false)
const captchaErrorMessage = ref<string | null>(null)
const enabled = computed(() => toValue(options.enabled))
const formCaptcha = computed(() => {
if (!enabled.value || !captcha.value) return undefined
return {
imgUrl: captcha.value.dntCaptchaImgUrl,
id: captcha.value.dntCaptchaId,
tokenValue: captcha.value.dntCaptchaTokenValue,
}
})
async function loadCaptcha() {
if (!enabled.value) return null
captchaLoading.value = true
captchaErrorMessage.value = null
try {
const { data } = await authApi.getCaptcha()
captcha.value = data
return data
} catch (error_) {
const normalizedError = normalizeError(error_)
captcha.value = null
captchaErrorMessage.value = normalizedError.message
throw normalizedError
} finally {
captchaLoading.value = false
}
}
async function refreshCaptcha() {
if (!enabled.value) return null
captchaValue.value = ''
return await loadCaptcha()
}
function setCaptchaValue(value: string) {
if (!enabled.value) return
captchaValue.value = value
}
function getLoginCaptchaPayload() {
if (!enabled.value) return undefined
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
}
return {
DNTCaptchaInputText: captchaValue.value,
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
}
}
return {
captchaValue,
captchaLoading,
captchaErrorMessage,
formCaptcha,
loadCaptcha,
refreshCaptcha,
setCaptchaValue,
getLoginCaptchaPayload,
}
}
+10
View File
@@ -0,0 +1,10 @@
# Language Guide
`language` 放 Vue I18n 文案。新增可見 UI 文案時,若該文字屬於產品功能或會被重用,優先放進語系檔。
## 規則
- 同步維護 `zh-TW.json``en-US.json`
- key 命名以 feature/domain 分組。
- 移除 demo 頁或 feature 時,同步清理不再使用的文案。
- 不把大型靜態資料塞進語系檔;語系檔只放文案。
+40
View File
@@ -0,0 +1,40 @@
{
"common": {
"ok": "OK",
"notice": "Notice"
},
"components": {},
"features": {},
"pages": {
"home": "Home",
"login": {
"title": "Test System",
"illustrationTitle": "Login Page",
"illustrationDescription": "Login to your account",
"welcomeText": "Welcome back 👋🏻",
"welcomeDescription": "Please enter your account password to login",
"organization": "Hyakkaou Private Academy",
"accPlaceholder": "Enter account",
"passwPlaceholder": "Enter password",
"rememberMeLabel": "Remember me",
"forgotPasswordText": "Forgot password?",
"createAccountPromptText": "Don't have an account?",
"createAccountLinkText": "Create an account",
"submitText": "Login",
"verifyText": "Verify",
"captchaPlaceholder": "Enter captcha",
"refreshTitle": "Refresh captcha",
"alert": {
"verifyRequired": "Please complete the captcha first",
"verifySuccess": "Verification successful",
"verifyFailed": "Verification failed",
"loginFailed": "Login failed",
"loginSuccess": "Login success!"
},
"toolbar": {
"toggleTheme": "Toggle theme",
"selectLocale": "Select language"
}
}
}
}
+40
View File
@@ -0,0 +1,40 @@
{
"common": {
"ok": "確定",
"notice": "提示"
},
"components": {},
"features": {},
"pages": {
"home": "首頁",
"login": {
"title": "測試系統",
"illustrationTitle": "這是登入頁",
"illustrationDescription": "你可以在這頁登入",
"welcomeText": "歡迎回來 👋🏻",
"welcomeDescription": "請輸入您的帳號密碼進行登入",
"organization": "私立百花王學園",
"accPlaceholder": "請輸入帳號",
"passwPlaceholder": "請輸入密碼",
"rememberMeLabel": "記住帳號",
"forgotPasswordText": "忘記密碼?",
"createAccountPromptText": "還沒有帳號?",
"createAccountLinkText": "註冊帳號",
"submitText": "登入",
"verifyText": "驗證",
"captchaPlaceholder": "請輸入驗證碼",
"refreshTitle": "點擊刷新驗證碼",
"alert": {
"verifyRequired": "請先完成驗證碼",
"verifySuccess": "驗證成功",
"verifyFailed": "驗證失敗",
"loginFailed": "登入失敗",
"loginSuccess": "登入成功!"
},
"toolbar": {
"toggleTheme": "切換主題",
"selectLocale": "選擇語系"
}
}
}
}
+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。若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),再建立對應的 page driver;簡單頁面直接在 view 用 `computed` 組裝 page model 即可。
+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
}
+14 -1
View File
@@ -1,3 +1,16 @@
# Plugins
Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally.
`src/plugins` 負責集中註冊全域 Vue plugin。`src/main.ts` 只需要呼叫 `registerPlugins(app)`
## 目前檔案
- `index.ts`:統一註冊 Vuetify、Pinia、Vue I18n 與 Vue Router。
- `vuetify.ts`:建立 Vuetify instance,設定 MDI SVG icon set 與預設 theme。
- `i18n.ts`:建立 Vue I18n instance,載入 `src/language/en-US.json``src/language/zh-TW.json`
## 使用規則
- 新增需要 app-wide 安裝的 plugin 時,先建立獨立設定檔,再在 `index.ts` 註冊。
- 不要在 view 或 component 內重複安裝 plugin。
- Vuetify theme 設定放在 `src/styles/themes.ts`,不要直接塞在 component。
- 語系文字放在 `src/language/*.json`,不要散落在 plugin 設定檔。
+9 -12
View File
@@ -1,21 +1,18 @@
import { createI18n } from 'vue-i18n'
import en from '@/language/en-US.json'
import zh from '@/language/zh-TW.json'
const messages = {
en: {
message: {
hello: 'hello world',
},
},
ja: {
message: {
hello: 'こんにちは、世界',
},
},
'zh-TW': zh,
'en-US': en,
}
const defaultLocale = localStorage.getItem('locale') ?? 'zh-TW'
export default createI18n({
legacy: false,
locale: 'en',
fallbackLocale: 'en',
locale: defaultLocale,
fallbackLocale: 'en-US',
messages,
})
+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 失敗等。
+18 -2
View File
@@ -5,7 +5,7 @@ export const routes: RouteRecordRaw[] = [
path: '/',
name: 'home',
component: () => import('@/views/Home.vue'),
meta: { layout: 'default', requiresAuth: true },
meta: { layout: 'default', requiresAuth: false },
},
{
path: '/settings',
@@ -17,7 +17,7 @@ export const routes: RouteRecordRaw[] = [
path: '/login',
name: 'login',
component: () => import('@/views/Login.vue'),
meta: { layout: 'none', guestOnly: true },
meta: { layout: 'none', guestOnly: false },
},
{
path: '/single-record-maintenance',
@@ -49,6 +49,22 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/maint/EditableGrid.vue'),
meta: { layout: 'default' },
},
{
path: '/demos/sections',
redirect: '/demos/sections/query-page',
},
{
path: '/demos/sections/query-page',
name: 'demo-section-query-page',
component: () => import('@/views/demos/SectionQueryPageDemo.vue'),
meta: { title: 'SectionQueryPage 示範', layout: 'default' },
},
{
path: '/demos/sections/form-page',
name: 'demo-section-form-page',
component: () => import('@/views/demos/SectionFormPageDemo.vue'),
meta: { title: 'SectionFormPage 示範', layout: 'default' },
},
{
path: '/:fncId([0-9A-Z]{5,6})',
name: 'fnc-page',
+35
View File
@@ -0,0 +1,35 @@
# 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 重寫。
- 重複請求取消策略(key 命名、何時 abort、何時清理)由 store/composable 決定,service module 不應持有 controller map。
+62 -85
View File
@@ -1,115 +1,92 @@
元件 (Component)
↓ 呼叫
Store (Pinia) ← 管理狀態、快取
↓ 呼叫
API Service ← 封裝業務邏輯
↓ 呼叫
HTTP Client ← Axios 實例、攔截器
# Services
## 目前的資料流(以登入為例)
`src/services` 是資料存取與 HTTP 邊界,負責封裝 ky client、hooks、token/session、錯誤處理與 API 模組。
1. `views/Login.vue`Playground 頁面)只負責表單/驗證碼/導頁等 UI 行為
2. `stores/auth.ts` 統一負責登入狀態(`user`/`token`/`loading`/`error`
3. `services/modules/user.ts` 封裝 `login/getProfile/...` 端點
4. `services/client.ts` 建立 `axios` instance
5. `services/interceptors.ts` 統一注入 token 與處理 HTTP 錯誤
## 目前資料流
## Menu API 與資料結構
選單系統採用 API 驅動設計:
### API 端點
- `GET /service/api/menu`:取得完整選單樹
- `GET /service/api/menu/favorite`:取得使用者收藏選單
### 資料結構
```ts
interface MenuNode {
mdl_id: string // 模組 ID
mdl_name: string // 模組名稱
unt_id?: string // 單位 ID
unt_name?: string // 單位名稱
fnc_id?: string // 功能 ID
fnc_name?: string // 功能名稱
children?: MenuNode[]
}
```txt
component/view -> store/composable -> service module -> httpClient -> hooks
```
### 階層關係
原則:
- **第一層**:模組(mdl
- **第二層**:單位(unt
- **第三層**:功能(fnc),作為葉節點使用 `fnc_id` 作為路由路徑
- component 不直接處理底層 HTTP client、token、hooks 或錯誤正規化。
- store 或 composable 負責協調 UI 狀態與呼叫 service。
- service 回傳資料,不持有 UI 狀態。
- service 不 import component、view 或 store。
### Store 持久化
## 目前檔案
`stores/menu.ts` 提供:
- `client.ts`:建立單一 ky instance,設定 `prefix`、timeout、credentials 與 hooks。
- `interceptors.ts`:集中提供 ky hooks,處理 request token 注入與 response 錯誤。
- `error.ts`:提供 `normalizeError()` 與統一錯誤型別。
- `http-error.ts`:提供全域 HTTP 錯誤事件。
- `http-toast.ts`:提供 HTTP 錯誤提示相關流程。
- `token.ts`:提供 token 單一來源,並同步 localStorage。
- `session.ts`:提供 session 相關流程。
- `modules/auth.ts`:封裝登入與驗證碼 API。
- `modules/menu.ts`:封裝選單與收藏選單 API。
- 自動 localStorage 持久化選單與收藏
- 初始化時自動還原資料
- 登出時清除快取
## API 模組規則
## API 前綴:`/api`
新增 API 時,優先放在 `src/services/modules/<domain>.ts`
目前 Playground 已將 `api` 資料夾更名為 `services`,避免與 API 前綴 `/api` 衝突。
API module 應:
在開發模式下:
- 使用 `httpClient` 發 request。
- 匯出清楚命名的 API 物件,例如 `authApi``menuApi`
- 定義與該 module 相關的 request/response 型別。
- 接收 `AbortSignal` 等 request option,但不管理頁面 loading 或 controller 狀態。
- 前端呼叫一律使用 `/service/api/*`
- Vite dev server 透過 proxy 將 `/service/*` 轉送到後端(目前指向 `http://192.168.89.54:9002`
## ky 使用注意事項
本專案使用 ky,不使用 axios。新增或調整 API module 時注意:
- ky 不回傳 axios 的 `{ data, status, headers }` 物件。需要 JSON 時使用 `.json<T>()`
- 若呼叫端已經依賴 `{ data }` 形狀,請在 API module 內包回 `{ data: await ... }`,不要讓 store 或 component 混用多種 response 形狀。
- ky 的錯誤型別是 `HTTPError``TimeoutError` 等,不是 `AxiosError`。錯誤一律交給 `normalizeError()`,呼叫端不要直接判斷 ky error。
- ky 基於 Fetch API,取消請求使用原生 `AbortController``signal`
- token 注入、401 force logout、HTTP 錯誤導頁與 toast 都集中在 ky hooks。不要在單一 service module 裡重複實作。
- FormData 請用 `body: formData`JSON payload 請用 `json: payload`
- 如果需求需要 upload progress、request/response transform、或其他 axios 專屬行為,先確認 ky/fetch 是否有等價做法,再決定是否擴充 service layer。
## HTTP Client 設定
- `baseURL`優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`(搭配 Vite proxy
- `Content-Type`:預設 `application/json`
`client.ts` `baseURL` 優先使用 `VITE_API_BASE_URL`,否則使用 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
## Token Service(單一來源)
template 提供 `.env.example` 作為環境變數範本。建立新專案時,複製成 `.env` 或對應 mode 的 env 檔,再填入實際 API 設定。
為避免「Pinia token」與「localStorage token」不同步的問題,這裡採用單一來源:
```bash
cp .env.example .env
```
- `services/token.ts` 使用 `ref` 保存 token,並同步 localStorage
- Store 與 Interceptor 都只透過 `tokenService` 讀寫 token
- 401 時清除 token,可立即同步到 UI
目前 `vite.config.mts` 的 dev proxy 會將 `/service/` 轉送到範例後端。正式專案若後端不同,優先調整 env 或 Vite proxy 設定,不要逐一修改 service module 裡的 endpoint。
## Token 注入策略(Interceptor
production 不應沿用 template 內的示範後端位址,應由使用專案自己的部署環境提供 `VITE_API_BASE_URL`
Interceptor 會從 `tokenService` 讀取 `token` 並注入 `Authorization: Bearer <token>`
目前 API 呼叫範例:
這樣做的原因是避免循環依賴:
- `authApi.getCaptcha()` -> `/Auth/get-captcha`
- `authApi.login()` -> `/Auth/login`
- `menuApi.getMenu()` -> `/Menu/GetMenu`
- `menuApi.getFavorite()` -> `/Menu/GetFavorite`
`store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)`
## Token 與錯誤處理
Store 仍然是「唯一負責更新 token 的地方」,Interceptor 只負責「讀取 token 並附加到 request」。
token 由 `tokenService` 作為單一來源:
## 錯誤正規化(normalizeError
- store 負責登入成功後寫入 token,以及登出時清除 token。
- hooks 只讀取 token 並附加到 request。
- 401 或 HTTP 錯誤由 hooks 與錯誤事件流程集中處理。
為了讓 UI 不需要理解 AxiosError,這裡將錯誤統一成 `ApiRequestError`
錯誤透過 `normalizeError()` 轉成 UI 可理解的格式。UI 或 store 不需要直接理解 ky 的 HTTPError
- `services/error.ts` 提供 `normalizeError()``ApiRequestError`
- Interceptor 在 response error 時呼叫 `normalizeError()`
- Store 只需要處理 `error.message / error.code / error.status`
## 請求取消
最低限度的映射規則:
需要取消請求時,由 store 或 composable 建立 `AbortController`service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
- 有 `response.data.message` 優先使用
- 其次使用 `AxiosError.message`
- 都沒有則顯示 `請求失敗`
建議做法:
## 請求取消(AbortController
取消策略採「同類型請求互斥」,目前示範在 `login`
- Store 建立 `AbortController`,每次登入前先取消前一次
- Service 只接收 `signal`,不管理 controller 狀態
- `normalizeError` 會將取消行為轉為 `CanceledRequestError`
- UI 不顯示取消造成的錯誤訊息
| DECISION | WHY | WHY NOT |
| -------------------------------- | -------------------------------------- | -------------------------------------------------------------------- |
| Store 呼叫 API,不是元件直接呼叫 | 狀態集中管理、可快取、易測試 | 元件直接呼叫會導致狀態散落 |
| API 模組化(userApi、orderApi | 關注點分離、好維護 | 全塞一個檔案會變超大 |
| Interceptor 獨立檔案 | 單一職責、好測試 | 寫在 client.ts 會雜亂 |
| 泛型 ApiResponse<T> | 型別安全、IDE 提示 | 用 any 會失去 TS 優勢 |
| API 前綴使用 `/api` | 使用習慣一致、搭配 Vite proxy 容易理解 | 若前端資料夾也叫 `api` 容易造成路徑衝突,因此此專案改名為 `services` |
- 在 store/composable 以 key 管理同類請求(例如 `auth/login``menu/get-menu`)。
- 發新請求前先取消同 key 舊請求,避免競態與多餘流量。
- 請求結束後於 `finally` 清理該 key;離開流程(如 `clear``logout`)時清理全部 key。
+7 -14
View File
@@ -1,21 +1,14 @@
import axios, { type AxiosInstance } from 'axios'
import { setupInterceptors } from './interceptors'
import ky, { type KyInstance } from 'ky'
import { createHooks } from './interceptors'
// HTTP ClientAxios instance
//
// 設計重點:
// - 透過單一 axios instance 統一管理 baseURL、timeout、headers 與攔截器
// - 預設 baseURL 使用 `/api`:搭配 Vite proxy 轉送到後端 dev server
// - 攔截器集中在 `interceptors.ts`,避免 client.ts 變得難維護
function createClient(): AxiosInstance {
function createClient(): KyInstance {
const baseURL = import.meta.env.VITE_API_BASE_URL || '/service/api'
const client = axios.create({
baseURL,
return ky.create({
prefix: baseURL,
timeout: 10_000,
withCredentials: true,
credentials: 'include',
hooks: createHooks(),
})
setupInterceptors(client)
return client
}
export const httpClient = createClient()
+11 -13
View File
@@ -1,5 +1,5 @@
import type { ApiError } from '@/types/api'
import { isAxiosError } from 'axios'
import { isHTTPError, isTimeoutError } from 'ky'
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -51,12 +51,6 @@ export function extractErrorMessage(data: unknown): string | undefined {
return firstString(data)
}
// 統一錯誤格式
//
// 設計重點:
// - 將 AxiosError 與非預期錯誤統一轉成 ApiRequestError
// - Store 只需要處理 message/code/status,不需理解 Axios 結構
// - 取消請求(AbortController)會轉成 CanceledRequestError
export class ApiRequestError extends Error {
code?: number
status?: number
@@ -87,9 +81,6 @@ export class CanceledRequestError extends ApiRequestError {
}
export function isRequestCanceled(error: unknown): boolean {
if (isAxiosError(error)) {
return error.code === 'ERR_CANCELED'
}
return error instanceof DOMException && error.name === 'AbortError'
}
@@ -102,9 +93,9 @@ export function normalizeError(error: unknown): ApiRequestError {
return new CanceledRequestError()
}
if (isAxiosError(error)) {
const status = error.response?.status
const data = error.response?.data as unknown
if (isHTTPError(error)) {
const status = error.response.status
const data = error.data
const message = extractErrorMessage(data) || error.message || '請求失敗'
const apiError = isRecord(data) ? (data as Partial<ApiError>) : undefined
const code = apiError?.code ?? status
@@ -117,6 +108,13 @@ export function normalizeError(error: unknown): ApiRequestError {
})
}
if (isTimeoutError(error)) {
return new ApiRequestError({
message: '請求逾時',
raw: error,
})
}
if (error instanceof Error) {
return new ApiRequestError({ message: error.message, raw: error })
}
+1 -1
View File
@@ -1,7 +1,7 @@
// 全域 HTTP 錯誤事件(Playground 專用)
//
// 目的:
// - 避免 axios interceptor 直接 import router 造成耦合
// - 避免 HTTP hooks 直接 import router 造成耦合
// - 由 router 層或 App 層決定要導到哪個錯誤頁與顯示哪些訊息
export const HTTP_ERROR_EVENT = 'sk-playground:http-error'
+1 -1
View File
@@ -1,7 +1,7 @@
// 全域 HTTP Toast 事件(Playground 專用)
//
// 目的:
// - 讓 axios interceptor 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
// - 讓 HTTP hooks 能用「事件」通知 UI 顯示錯誤提示,不需直接依賴 Pinia
// - 預設只用於「非阻斷」錯誤(例如 500 / 網路中斷),避免導頁打斷使用者
export const HTTP_TOAST_EVENT = 'sk-playground:http-toast'
+23 -60
View File
@@ -1,104 +1,65 @@
import { type AxiosError, AxiosHeaders, type AxiosInstance } from 'axios'
import type { Hooks } from 'ky'
import { isHTTPError } from 'ky'
import { extractErrorMessage, normalizeError } from './error'
import { emitHttpError } from './http-error'
import { emitHttpToast } from './http-toast'
import { emitForceLogout } from './session'
import { tokenService } from './token'
// Axios 攔截器
//
// 設計重點:
// - Request:自動注入 token(從 localStorage 讀取)
// - 使用 tokenService 作為單一來源,避免 interceptor 直接 import Pinia store 造成循環依賴
// store(auth) -> services(userApi) -> httpClient -> interceptors -> store(auth)
// - Response:統一處理 HTTP 錯誤(目前示範 401/403/500
// - 使用 normalizeError 將錯誤轉成 ApiRequestError
//
// 注意:
// - Store 仍然是唯一負責「寫入/清除 token」的地方(login/logout
// - Interceptor 只負責「讀取 token 並附加到 request」
export function setupInterceptors(client: AxiosInstance) {
// Request: 自動注入 token
client.interceptors.request.use(
(config) => {
export function createHooks(): Hooks {
return {
beforeRequest: [
({ request }) => {
const token = tokenService.getToken()
const url = config.url ?? ''
const url = request.url
const shouldAttachToken = !url.includes('/Auth/login')
if (token && shouldAttachToken) {
const headers = AxiosHeaders.from(config.headers ?? {})
headers.set('Authorization', `Bearer ${token}`)
config.headers = headers
request.headers.set('Authorization', `Bearer ${token}`)
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// Response: 統一錯誤處理
client.interceptors.response.use(
(response) => response,
(error: AxiosError) => {
],
beforeError: [
({ request, options, error }) => {
const normalized = normalizeError(error)
// 取消請求不做全域錯誤導頁
if (error.code === 'ERR_CANCELED') {
return Promise.reject(normalized)
if (normalized.name === 'CanceledRequestError') {
return normalized
}
const requestUrl = error.config?.url ?? ''
const requestUrl = request.url
const isLoginRequest = requestUrl.includes('/Auth/login')
const silentToast = Boolean(error.config?.meta?.silentToast)
const silentToast = Boolean(options.context.silentToast)
const status = isHTTPError(error) ? error.response.status : undefined
// 統一處理 HTTP 狀態碼錯誤
const status = error.response?.status
switch (status) {
// 401 Unauthorized
case 401: {
// 不是所有 401 都代表「token 過期」:
// - 登入失敗通常也會是 401,但不應觸發全域登出流程
// 這裡以「是否帶 Authorization header」作為判斷依據
{
const url = error.config?.url ?? ''
if (url.includes('/Auth/login')) break
if (requestUrl.includes('/Auth/login')) break
const requestHeaders = AxiosHeaders.from(error.config?.headers ?? {})
const hasAuthHeader = Boolean(requestHeaders.get('Authorization'))
const hasAuthHeader = request.headers.has('Authorization')
if (hasAuthHeader) {
tokenService.clearToken()
const backendMessage = extractErrorMessage(error.response?.data)
const backendMessage = isHTTPError(error) ? extractErrorMessage(error.data) : undefined
emitForceLogout({ message: backendMessage })
}
}
break
}
// 403 Forbidden
case 403: {
emitHttpError({ status, message: normalized.message })
break
}
// 404 Not FoundAPI 端點不存在/被移除)
case 404: {
emitHttpError({ status, message: normalized.message })
break
}
// 500 Internal Server Error
case 500: {
// 500 通常是「單一 API 失敗」:交由呼叫端決定 UI(snackbar/區塊錯誤/重試)
// 避免同一頁多支 API 時,其中一支 500 就把整個頁面導走
break
}
// 503 Service Unavailable
case 503: {
// 503 通常對使用者來說就是「系統維護/暫時無法使用」
emitHttpError({ status, message: normalized.message })
break
}
default:
// 無 response status 時,多半是網路/跨網域/連線問題:
// 交由呼叫端決定 UI(snackbar/區塊錯誤/重試),避免全域導頁打斷使用者操作
}
const shouldToast =
@@ -116,7 +77,9 @@ export function setupInterceptors(client: AxiosInstance) {
message: normalized.message,
})
}
return Promise.reject(normalized)
return normalized
},
],
}
)
}
+18 -4
View File
@@ -1,4 +1,4 @@
import type { CaptchaResponse } from '@/types/api'
import type { CaptchaResponse, LoginRequestBody } from '@/types/api'
import { httpClient } from '../client'
export interface RequestOptions {
@@ -7,9 +7,23 @@ export interface RequestOptions {
}
export const authApi = {
getCaptcha: () => httpClient.get<CaptchaResponse>('/Auth/get-captcha'),
login: (payload: FormData, options?: RequestOptions) =>
httpClient.post<unknown>('/Auth/login', payload, {
getCaptcha: async () => ({
data: await httpClient.get('Auth/get-captcha').json<CaptchaResponse>(),
}),
loginWithFormData: async (payload: FormData, options?: RequestOptions) => ({
data: await httpClient
.post('Auth/login', {
body: payload,
signal: options?.signal,
})
.json<unknown>(),
}),
loginWithJson: async (payload: LoginRequestBody, options?: RequestOptions) => ({
data: await httpClient
.post('Auth/login', {
json: payload,
signal: options?.signal,
})
.json<unknown>(),
}),
}
+12 -4
View File
@@ -18,12 +18,20 @@ export interface MenuOuterResponse {
}
export const menuApi = {
getMenu: (payload: MenuPayload, options?: RequestOptions) =>
httpClient.post<MenuOuterResponse>('/Menu/GetMenu', payload, {
getMenu: async (payload: MenuPayload, options?: RequestOptions) => ({
data: await httpClient
.post('Menu/GetMenu', {
json: payload,
signal: options?.signal,
})
.json<MenuOuterResponse>(),
}),
getFavorite: (payload: MenuPayload, options?: RequestOptions) =>
httpClient.post<MenuOuterResponse>('/Menu/GetFavorite', payload, {
getFavorite: async (payload: MenuPayload, options?: RequestOptions) => ({
data: await httpClient
.post('Menu/GetFavorite', {
json: payload,
signal: options?.signal,
})
.json<MenuOuterResponse>(),
}),
}
+1 -1
View File
@@ -1,7 +1,7 @@
// 全域 Session 事件(Playground 專用)
//
// 目的:
// - 避免 axios interceptor 直接 import Pinia store / router 造成循環依賴
// - 避免 HTTP hooks 直接 import Pinia store / router 造成循環依賴
// - 由 App.vue 在 UI 層統一處理登出流程(清狀態、導頁、提示訊息)
export const SESSION_FORCE_LOGOUT_EVENT = 'sk-playground:session-force-logout'
+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>
+30
View File
@@ -0,0 +1,30 @@
# 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。
## 請求取消慣例
- 需要避免重複提交或快速切換造成的舊請求殘留時,在 store 層管理 `AbortController`
- 同一類請求使用固定 key(例如 `auth/login``menu/get-menu`),新請求前先取消舊請求。
- service module 只接收 `signal`,不管理 controller lifecycle。
- store 在 `finally` 清理該 key,在 `clear/logout` 清理全部 key。
+148 -1
View File
@@ -1 +1,148 @@
export * from './stores/auth'
import type { LoginPayload, LoginRequestBody, LoginRequestFormat, User } from '@/types/api'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { normalizeError } from '@/services/error'
import { authApi } from '@/services/modules/auth'
import { tokenService } from '@/services/token'
import { useMenuStore } from '@/stores/menu'
import { createRequestControllerManager } from '@/stores/request-controller'
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
interface LoginOptions {
requestFormat?: LoginRequestFormat
}
function createLoginRequestBody(payload: LoginPayload): LoginRequestBody {
return {
UserID: payload.UserID,
Password: payload.Password,
...(payload.captcha
? {
DNTCaptchaInputText: payload.captcha.DNTCaptchaInputText,
DNTCaptchaText: payload.captcha.DNTCaptchaText,
DNTCaptchaToken: payload.captcha.DNTCaptchaToken,
}
: {}),
}
}
function createLoginFormData(payload: LoginRequestBody) {
const formData = new FormData()
formData.append('UserID', payload.UserID)
formData.append('Password', payload.Password)
if (payload.DNTCaptchaInputText) {
formData.append('DNTCaptchaInputText', payload.DNTCaptchaInputText)
}
if (payload.DNTCaptchaText) {
formData.append('DNTCaptchaText', payload.DNTCaptchaText)
}
if (payload.DNTCaptchaToken) {
formData.append('DNTCaptchaToken', payload.DNTCaptchaToken)
}
return formData
}
// - 只在 store 管理登入狀態:user/token/loading/error
// - Component 不直接呼叫 API,避免狀態散落
// - token 單一來源:透過 tokenService 同步 ref + localStorage
// - store 負責寫入/清除 tokenlogin/logout
// - HTTP hooks 只讀 tokenService
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null)
const token = tokenService.token
const loading = ref(false)
const error = ref<string | null>(null)
const requestControllerManager = createRequestControllerManager()
const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
const signal = requestControllerManager.replace('auth/login')
loading.value = true
error.value = null
try {
const requestBody = createLoginRequestBody(payload)
const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
const requestOptions = { signal }
const { data } =
requestFormat === 'json'
? await authApi.loginWithJson(requestBody, requestOptions)
: await authApi.loginWithFormData(createLoginFormData(requestBody), requestOptions)
const parseUser = (val: unknown): User | undefined => {
if (!val || typeof val !== 'object') return
const obj = val as Record<string, unknown>
const id = obj.id
const name = obj.name
const role = obj.role
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
return { id, name, role }
}
const parseLoginResult = (
raw: unknown
): {
accessToken?: string
tokenType?: string
expiresIn?: number
user?: User
message?: string
} => {
if (!raw || typeof raw !== 'object') return {}
const obj = raw as Record<string, unknown>
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
const user = parseUser(obj.user)
const message = typeof obj.message === 'string' ? obj.message : undefined
return { accessToken, tokenType, expiresIn, user, message }
}
const result = parseLoginResult(data)
if (!result.accessToken) {
throw new Error(result.message || '登入回傳缺少 access_token')
}
user.value = result.user ?? null
// 使用者token寫入
tokenService.setToken(result.accessToken)
} catch (error_) {
const normalizedError = normalizeError(error_)
if (normalizedError.name !== 'CanceledRequestError') {
error.value = normalizedError.message
}
throw normalizedError
} finally {
loading.value = false
requestControllerManager.clear('auth/login')
}
}
const logout = () => {
requestControllerManager.clearAll()
user.value = null
tokenService.clearToken()
useMenuStore().clear()
}
return {
user,
token,
loading,
error,
isAuthenticated,
roles,
login,
logout,
}
})
+124 -1
View File
@@ -1 +1,124 @@
export * from './stores/breadcrumbs'
import type { LayoutMenuItem } from './menu'
import { mdiHome } from '@mdi/js'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface BreadcrumbItem {
title: string
to?: string
disabled?: boolean
icon?: string
}
interface BreadcrumbPayload {
path: string
menuItems: LayoutMenuItem[]
favoriteItems?: LayoutMenuItem[]
fallbackTitle?: string | null
homeLabel?: string
homeIcon?: string
}
function buildTrail(items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
for (const node of nodes) {
const nextTrail = [...trail, node]
if (node.path && node.path === targetPath) return nextTrail
if (node.subItems?.length) {
const found = walk(node.subItems, nextTrail)
if (found) return found
}
}
return null
}
return walk(items || [], [])
}
function toBreadcrumbItems(
trail: LayoutMenuItem[],
homeLabel: string,
homeIcon: string
): BreadcrumbItem[] {
const isHomePath = (path?: string) => path === '/' || path === ''
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
const crumbs: BreadcrumbItem[] = []
if (!startsWithHome) {
crumbs.push({
title: homeLabel,
to: '/',
icon: homeIcon,
})
}
for (const [index, node] of trail.entries()) {
const isLast = index === trail.length - 1
crumbs.push({
title: node.title,
to: isLast ? undefined : node.path,
icon: startsWithHome && index === 0 ? homeIcon : undefined,
})
}
return crumbs
}
export const useBreadcrumbStore = defineStore('breadcrumbs', () => {
const items = ref<BreadcrumbItem[]>([])
const homeLabel = ref('首頁')
const homeIcon = ref(mdiHome)
const setBreadcrumbs = (payload: BreadcrumbPayload) => {
if (!payload?.path) return
homeLabel.value = payload.homeLabel ?? homeLabel.value
homeIcon.value = payload.homeIcon ?? homeIcon.value
const trailFromMenu = buildTrail(payload.menuItems || [], payload.path)
const trailFromFavorite = payload.favoriteItems?.length
? buildTrail(payload.favoriteItems, payload.path)
: null
const trail = trailFromMenu || trailFromFavorite
if (trail?.length) {
items.value = toBreadcrumbItems(trail, homeLabel.value, homeIcon.value)
return
}
if (payload.fallbackTitle && payload.fallbackTitle !== homeLabel.value) {
items.value = [
{
title: homeLabel.value,
to: '/',
icon: homeIcon.value,
},
{
title: payload.fallbackTitle,
},
]
return
}
items.value = [
{
title: homeLabel.value,
to: '/',
icon: homeIcon.value,
},
]
}
const reset = () => {
items.value = []
}
const breadcrumbItems = computed(() => items.value)
return {
items,
breadcrumbItems,
setBreadcrumbs,
reset,
}
})
+147 -1
View File
@@ -1 +1,147 @@
export * from './stores/favorites'
import type { LayoutMenuItem } from './menu'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
export interface FavoriteItem {
title: string
path: string
icon?: string
}
const storageKey = 'sk_playground_user_favorites'
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
function readFavorites(): FavoriteItem[] {
if (typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as FavoriteItem[]) : []
} catch {
return []
}
}
function writeFavorites(items: FavoriteItem[]) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(storageKey, JSON.stringify(items))
} catch {
return
}
}
export const useFavoritesStore = defineStore('favorites', () => {
const items = ref<FavoriteItem[]>(readFavorites())
const favoritesBarVisible = ref(true)
const breadcrumbBarVisible = ref(true)
const loadFavoritesBarVisible = () => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(favoritesBarStorageKey)
if (stored === null) return
favoritesBarVisible.value = stored === '1'
}
const persistFavoritesBarVisible = () => {
if (typeof window === 'undefined') return
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
}
const loadBreadcrumbBarVisible = () => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
if (stored === null) return
breadcrumbBarVisible.value = stored === '1'
}
const persistBreadcrumbBarVisible = () => {
if (typeof window === 'undefined') return
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
}
const add = (item: FavoriteItem) => {
if (!item?.path) return
if (items.value.some((x) => x.path === item.path)) return
items.value = [...items.value, item]
}
const remove = (path: string) => {
if (!path) return
items.value = items.value.filter((x) => x.path !== path)
}
const toggle = (item: FavoriteItem) => {
if (!item?.path) return
const exists = items.value.some((x) => x.path === item.path)
if (exists) remove(item.path)
else add(item)
}
const isFavorite = (path: string) => {
if (!path) return false
return items.value.some((x) => x.path === path)
}
const layoutItems = computed<LayoutMenuItem[]>(() =>
items.value.map((item) => ({
title: item.title,
path: item.path,
icon: item.icon,
}))
)
watch(
items,
(val) => {
writeFavorites(val)
},
{ deep: true }
)
const setFavoritesBarVisible = (value: boolean) => {
favoritesBarVisible.value = value
persistFavoritesBarVisible()
}
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
if (typeof nextValue === 'boolean') {
setFavoritesBarVisible(nextValue)
return
}
setFavoritesBarVisible(!favoritesBarVisible.value)
}
loadFavoritesBarVisible()
loadBreadcrumbBarVisible()
const setBreadcrumbBarVisible = (value: boolean) => {
breadcrumbBarVisible.value = value
persistBreadcrumbBarVisible()
}
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
if (typeof nextValue === 'boolean') {
setBreadcrumbBarVisible(nextValue)
return
}
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
}
return {
items,
layoutItems,
add,
remove,
toggle,
isFavorite,
favoritesBarVisible,
setFavoritesBarVisible,
toggleFavoritesBarVisible,
breadcrumbBarVisible,
setBreadcrumbBarVisible,
toggleBreadcrumbBarVisible,
}
})
-1
View File
@@ -1 +0,0 @@
export * from './stores/loginAnnouncements'
+244 -1
View File
@@ -1 +1,244 @@
export * from './stores/menu'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { normalizeError } from '@/services/error'
import { menuApi, type MenuNode } from '@/services/modules/menu'
import { createRequestControllerManager } from '@/stores/request-controller'
export interface LayoutMenuItem {
title: string
path?: string
icon?: string
navigable?: boolean
subItems?: LayoutMenuItem[]
}
export const useMenuStore = defineStore('menu', () => {
const menu = ref<MenuNode[]>([])
const favorite = ref<MenuNode[]>([])
const isRail = ref(false)
const error = ref<string | null>(null)
const loading = ref(false)
const requestControllerManager = createRequestControllerManager()
const menuStorageKey = 'sk_playground_menu'
const favoriteStorageKey = 'sk_playground_favorite'
const isRailStorageKey = 'sk_playground_is_rail'
const readNodes = (key: string): MenuNode[] => {
if (typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
} catch {
return []
}
}
const readBoolean = (key: string, defaultValue = false): boolean => {
if (typeof window === 'undefined') return defaultValue
try {
const raw = window.localStorage.getItem(key)
return raw === null ? defaultValue : raw === 'true'
} catch {
return defaultValue
}
}
const writeValue = (key: string, value: any) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(key, String(value))
} catch {
return
}
}
const writeNodes = (key: string, nodes: MenuNode[]) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(key, JSON.stringify(nodes))
} catch {
return
}
}
const removeValue = (key: string) => {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(key)
} catch {
return
}
}
const hydrate = () => {
menu.value = readNodes(menuStorageKey)
favorite.value = readNodes(favoriteStorageKey)
isRail.value = readBoolean(isRailStorageKey)
}
hydrate()
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
const getString = (node: MenuNode, key: string): string | undefined => {
const v = node?.[key]
return typeof v === 'string' ? v : undefined
}
const getChildren = (node: MenuNode): MenuNode[] => {
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
}
return nodes
.map((mdl) => {
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
const untItems = getChildren(mdl)
.map((unt) => {
const untTitle = getString(unt, 'unt_name') ?? ''
const fncItems = getChildren(unt)
.map((fnc) => {
const fncTitle = getString(fnc, 'fnc_name') ?? ''
const fncId = getString(fnc, 'fnc_id')
return {
title: fncTitle,
path: fncId ? `/${fncId}` : undefined,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
return {
title: untTitle,
navigable: false,
subItems: fncItems,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
return {
title: mdlTitle,
navigable: false,
subItems: untItems,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
}
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
const getString = (node: MenuNode, key: string): string | undefined => {
const v = node?.[key]
return typeof v === 'string' ? v : undefined
}
const getChildren = (node: MenuNode): MenuNode[] => {
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
}
return nodes
.map((unt) => {
const untTitle = getString(unt, 'unt_name') ?? ''
const fncItems = getChildren(unt)
.map((fnc) => {
const fncTitle = getString(fnc, 'fnc_name') ?? ''
const fncId = getString(fnc, 'fnc_id')
return {
title: fncTitle,
path: fncId ? `/${fncId}` : undefined,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
return {
title: untTitle,
navigable: false,
subItems: fncItems,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
}
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
watch(
menu,
(val) => {
writeNodes(menuStorageKey, val)
},
{ deep: true }
)
watch(
favorite,
(val) => {
writeNodes(favoriteStorageKey, val)
},
{ deep: true }
)
watch(isRail, (val) => {
writeValue(isRailStorageKey, val)
})
const clear = () => {
requestControllerManager.clearAll()
menu.value = []
favorite.value = []
isRail.value = false
error.value = null
removeValue(menuStorageKey)
removeValue(favoriteStorageKey)
removeValue(isRailStorageKey)
}
const getMenu = async (id: string) => {
const signal = requestControllerManager.replace('menu/get-menu')
try {
loading.value = true
const res = await menuApi.getMenu({ userID: id }, { signal })
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) {
const normalizedError = normalizeError(error_)
if (normalizedError.name !== 'CanceledRequestError') {
error.value = normalizedError.message
}
throw normalizedError
} finally {
loading.value = false
requestControllerManager.clear('menu/get-menu')
}
}
const getFavorite = async (id: string) => {
const signal = requestControllerManager.replace('menu/get-favorite')
try {
loading.value = true
const res = await menuApi.getFavorite({ userID: id }, { signal })
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) {
const normalizedError = normalizeError(error_)
if (normalizedError.name !== 'CanceledRequestError') {
error.value = normalizedError.message
}
throw normalizedError
} finally {
loading.value = false
requestControllerManager.clear('menu/get-favorite')
}
}
return {
menu,
favorite,
isRail,
menuItems,
favoriteItems,
error,
loading,
hydrate,
clear,
getMenu,
getFavorite,
}
})
+30 -1
View File
@@ -1 +1,30 @@
export * from './stores/messages'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useMessageStore = defineStore('messages', () => {
const openState = ref(false)
// 開啟訊息中心 Dialog
const open = () => {
openState.value = true
}
// 關閉訊息中心 Dialog
const close = () => {
openState.value = false
}
// 提供 v-model 綁定用的 computed
const isOpen = computed({
get: () => openState.value,
set: (value) => {
openState.value = value
},
})
return {
isOpen,
open,
close,
}
})
+33
View File
@@ -0,0 +1,33 @@
export interface RequestControllerManager {
replace: (key: string) => AbortSignal
clear: (key: string) => void
clearAll: () => void
}
export function createRequestControllerManager(): RequestControllerManager {
const controllers = new Map<string, AbortController>()
const replace = (key: string): AbortSignal => {
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
return controller.signal
}
const clear = (key: string) => {
controllers.delete(key)
}
const clearAll = () => {
controllers.forEach((controller) => {
controller.abort()
})
controllers.clear()
}
return {
replace,
clear,
clearAll,
}
}
+157 -1
View File
@@ -1 +1,157 @@
export * from './stores/semesters'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface CourseRecord {
code: string
name: string
credits: number
score: number
}
export interface SemesterRecord {
id: number
studentId: number
semesterName: string
courses: CourseRecord[]
rank: number
average: number
}
const seedSemesters: SemesterRecord[] = []
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
export function generateMockSemesters(studentId: number) {
const semesters = [
{ name: '111 學年度第 1 學期', baseId: 1000 },
{ name: '111 學年度第 2 學期', baseId: 2000 },
{ name: '112 學年度第 1 學期', baseId: 3000 },
{ name: '112 學年度第 2 學期', baseId: 4000 },
{ name: '113 學年度第 1 學期', baseId: 5000 },
{ name: '113 學年度第 2 學期', baseId: 6000 },
]
const subjects = [
{ name: '資料結構', credits: 3 },
{ name: '演算法', credits: 3 },
{ name: '作業系統', credits: 3 },
{ name: '計算機組織', credits: 3 },
{ name: '線性代數', credits: 3 },
{ name: '機率與統計', credits: 3 },
{ name: '資料庫系統', credits: 3 },
{ name: '人工智慧導論', credits: 3 },
{ name: '網頁程式設計', credits: 3 },
{ name: '計算機網路', credits: 3 },
]
const count = 5 + (studentId % 2)
const result: SemesterRecord[] = []
for (let i = 0; i < count; i++) {
const sem = semesters[i]
if (!sem) continue
const courseCount = 8 + (studentId % 3)
const courses: CourseRecord[] = []
const usedSubjects = new Set<number>()
let totalScore = 0
let totalCredits = 0
while (courses.length < courseCount) {
const idx = Math.floor(Math.random() * subjects.length)
if (usedSubjects.has(idx)) continue
usedSubjects.add(idx)
const score = randomScore()
const subject = subjects[idx]
if (!subject) continue
courses.push({
code: `CS${1000 + idx}`,
name: subject.name,
credits: subject.credits,
score,
})
totalScore += score * subject.credits
totalCredits += subject.credits
}
result.push({
id: sem.baseId + studentId,
studentId,
semesterName: sem.name,
courses,
rank: Math.floor(Math.random() * 20) + 1,
average: Number((totalScore / totalCredits).toFixed(2)),
})
}
return result
}
for (let i = 1; i <= 20; i++) {
seedSemesters.push(...generateMockSemesters(i))
}
export const useSemesterStore = defineStore('semesters', () => {
const semesters = ref<SemesterRecord[]>([...seedSemesters])
const getStudentSemesters = (studentId: number) => {
return semesters.value.filter((s) => s.studentId === studentId)
}
const generateForStudent = (studentId: number) => {
const newSemesters = generateMockSemesters(studentId)
semesters.value.push(...newSemesters)
}
const addSemester = (studentId: number) => {
const newId = Math.max(...semesters.value.map((s) => s.id), 0) + 1
const newSemester: SemesterRecord = {
id: newId,
studentId,
semesterName: '新學期',
courses: [],
rank: 0,
average: 0,
}
semesters.value.push(newSemester)
return newSemester
}
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
const index = semesters.value.findIndex((s) => s.id === id)
if (index === -1) return
const current = semesters.value[index]
if (!current) return
if (payload.courses) {
const totalScore = payload.courses.reduce((sum, c) => sum + c.score * c.credits, 0)
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
}
Object.assign(current, payload)
}
const removeSemester = (id: number) => {
const index = semesters.value.findIndex((s) => s.id === id)
if (index !== -1) {
semesters.value.splice(index, 1)
}
}
const removeByStudentId = (studentId: number) => {
semesters.value = semesters.value.filter((s) => s.studentId !== studentId)
}
return {
semesters,
getStudentSemesters,
generateForStudent,
addSemester,
updateSemester,
removeSemester,
removeByStudentId,
}
})
+52 -1
View File
@@ -1 +1,52 @@
export * from './stores/snackbar'
import { defineStore } from 'pinia'
import { ref } from 'vue'
type SnackbarColor = string
type SnackbarVariant = 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
type SnackbarLocation = string
interface ShowOptions {
message: string
color?: SnackbarColor
timeout?: number
location?: SnackbarLocation
variant?: SnackbarVariant
}
export const useSnackbarStore = defineStore('snackbar', () => {
const visible = ref(false)
const message = ref('')
const color = ref<SnackbarColor>('success')
const timeout = ref(2000)
const location = ref<SnackbarLocation>('top right')
const variant = ref<SnackbarVariant>('flat')
const show = (options: ShowOptions) => {
message.value = options.message
color.value = options.color ?? 'success'
timeout.value = options.timeout ?? 2000
location.value = options.location ?? 'top right'
variant.value = options.variant ?? 'flat'
visible.value = false
requestAnimationFrame(() => {
visible.value = true
})
}
const hide = () => {
visible.value = false
}
return {
visible,
message,
color,
timeout,
location,
variant,
show,
hide,
}
})
-149
View File
@@ -1,149 +0,0 @@
import type { CaptchaResponse, LoginPayload, User } from '@/types/api'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { normalizeError } from '@/services/error'
import { authApi } from '@/services/modules/auth'
import { tokenService } from '@/services/token'
import { useMenuStore } from '@/stores/menu'
// - 只在 store 管理登入狀態:user/token/loading/error
// - Component 不直接呼叫 API,避免狀態散落
// - token 單一來源:透過 tokenService 同步 ref + localStorage
// - store 負責寫入/清除 tokenlogin/logout
// - axios interceptor 只讀 tokenService
export const useAuthStore = defineStore('auth', () => {
// State
const user = ref<User | null>(null)
const token = tokenService.token
const loading = ref(false)
const error = ref<string | null>(null)
const captcha = ref<CaptchaResponse | null>(null)
const captchaLoading = ref(false)
const captchaErrorMessage = ref<string | null>(null)
// 只針對 login 取消重複請求,避免競態與重複提交
const loginController = ref<AbortController | null>(null)
// Getters
const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => (user.value?.role ? [user.value.role] : []))
// Actions
const getCaptcha = async () => {
captchaLoading.value = true
captchaErrorMessage.value = null
try {
const { data } = await authApi.getCaptcha()
captcha.value = data
return data
} catch (error_) {
const normalizedError = normalizeError(error_)
captcha.value = null
captchaErrorMessage.value = normalizedError.message
throw normalizedError
} finally {
captchaLoading.value = false
}
}
const login = async (payload: LoginPayload) => {
loginController.value?.abort()
loginController.value = new AbortController()
loading.value = true
error.value = null
try {
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) {
throw new Error('驗證碼資料缺失,請先刷新驗證碼')
}
const requestPayload = {
UserID: payload.UserID,
Password: payload.Password,
DNTCaptchaInputText: payload.DNTCaptchaInputText,
DNTCaptchaText: captcha.value.dntCaptchaTextValue,
DNTCaptchaToken: captcha.value.dntCaptchaTokenValue,
}
const formData = new FormData()
formData.append('UserID', requestPayload.UserID)
formData.append('Password', requestPayload.Password)
formData.append('DNTCaptchaInputText', requestPayload.DNTCaptchaInputText)
formData.append('DNTCaptchaText', requestPayload.DNTCaptchaText)
formData.append('DNTCaptchaToken', requestPayload.DNTCaptchaToken)
const { data } = await authApi.login(formData, {
signal: loginController.value.signal,
})
const parseUser = (val: unknown): User | undefined => {
if (!val || typeof val !== 'object') return
const obj = val as Record<string, unknown>
const id = obj.id
const name = obj.name
const role = obj.role
if (typeof id !== 'string' || typeof name !== 'string' || typeof role !== 'string') return
return { id, name, role }
}
const parseLoginResult = (
raw: unknown
): {
accessToken?: string
tokenType?: string
expiresIn?: number
user?: User
message?: string
} => {
if (!raw || typeof raw !== 'object') return {}
const obj = raw as Record<string, unknown>
const accessToken = typeof obj.access_token === 'string' ? obj.access_token : undefined
const tokenType = typeof obj.token_type === 'string' ? obj.token_type : undefined
const expiresIn = typeof obj.expires_in === 'number' ? obj.expires_in : undefined
const user = parseUser(obj.user)
const message = typeof obj.message === 'string' ? obj.message : undefined
return { accessToken, tokenType, expiresIn, user, message }
}
const result = parseLoginResult(data)
if (!result.accessToken) {
throw new Error(result.message || '登入回傳缺少 access_token')
}
user.value = result.user ?? null
tokenService.setToken(result.accessToken)
} catch (error_) {
const normalizedError = normalizeError(error_)
if (normalizedError.name !== 'CanceledRequestError') {
error.value = normalizedError.message
}
throw normalizedError
} finally {
loading.value = false
loginController.value = null
}
}
const logout = () => {
user.value = null
tokenService.clearToken()
useMenuStore().clear()
}
return {
getCaptcha,
captcha,
captchaLoading,
captchaErrorMessage,
user,
token,
loading,
error,
isAuthenticated,
roles,
login,
logout,
}
})
-124
View File
@@ -1,124 +0,0 @@
import type { LayoutMenuItem } from './menu'
import { mdiHome } from '@mdi/js'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export interface BreadcrumbItem {
title: string
to?: string
disabled?: boolean
icon?: string
}
interface BreadcrumbPayload {
path: string
menuItems: LayoutMenuItem[]
favoriteItems?: LayoutMenuItem[]
fallbackTitle?: string | null
homeLabel?: string
homeIcon?: string
}
function buildTrail(items: LayoutMenuItem[], targetPath: string): LayoutMenuItem[] | null {
const walk = (nodes: LayoutMenuItem[], trail: LayoutMenuItem[]): LayoutMenuItem[] | null => {
for (const node of nodes) {
const nextTrail = [...trail, node]
if (node.path && node.path === targetPath) return nextTrail
if (node.subItems?.length) {
const found = walk(node.subItems, nextTrail)
if (found) return found
}
}
return null
}
return walk(items || [], [])
}
function toBreadcrumbItems(
trail: LayoutMenuItem[],
homeLabel: string,
homeIcon: string
): BreadcrumbItem[] {
const isHomePath = (path?: string) => path === '/' || path === ''
const startsWithHome = trail.length > 0 && isHomePath(trail[0]?.path)
const crumbs: BreadcrumbItem[] = []
if (!startsWithHome) {
crumbs.push({
title: homeLabel,
to: '/',
icon: homeIcon,
})
}
for (const [index, node] of trail.entries()) {
const isLast = index === trail.length - 1
crumbs.push({
title: node.title,
to: isLast ? undefined : node.path,
icon: startsWithHome && index === 0 ? homeIcon : undefined,
})
}
return crumbs
}
export const useBreadcrumbStore = defineStore('breadcrumbs', () => {
const items = ref<BreadcrumbItem[]>([])
const homeLabel = ref('首頁')
const homeIcon = ref(mdiHome)
const setBreadcrumbs = (payload: BreadcrumbPayload) => {
if (!payload?.path) return
homeLabel.value = payload.homeLabel ?? homeLabel.value
homeIcon.value = payload.homeIcon ?? homeIcon.value
const trailFromMenu = buildTrail(payload.menuItems || [], payload.path)
const trailFromFavorite = payload.favoriteItems?.length
? buildTrail(payload.favoriteItems, payload.path)
: null
const trail = trailFromMenu || trailFromFavorite
if (trail?.length) {
items.value = toBreadcrumbItems(trail, homeLabel.value, homeIcon.value)
return
}
if (payload.fallbackTitle && payload.fallbackTitle !== homeLabel.value) {
items.value = [
{
title: homeLabel.value,
to: '/',
icon: homeIcon.value,
},
{
title: payload.fallbackTitle,
},
]
return
}
items.value = [
{
title: homeLabel.value,
to: '/',
icon: homeIcon.value,
},
]
}
const reset = () => {
items.value = []
}
const breadcrumbItems = computed(() => items.value)
return {
items,
breadcrumbItems,
setBreadcrumbs,
reset,
}
})
-147
View File
@@ -1,147 +0,0 @@
import type { LayoutMenuItem } from './menu'
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
export interface FavoriteItem {
title: string
path: string
icon?: string
}
const storageKey = 'sk_playground_user_favorites'
const favoritesBarStorageKey = 'sk_admin_favorites_bar_visible'
const breadcrumbBarStorageKey = 'sk_admin_breadcrumb_bar_visible'
function readFavorites(): FavoriteItem[] {
if (typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(storageKey)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as FavoriteItem[]) : []
} catch {
return []
}
}
function writeFavorites(items: FavoriteItem[]) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(storageKey, JSON.stringify(items))
} catch {
return
}
}
export const useFavoritesStore = defineStore('favorites', () => {
const items = ref<FavoriteItem[]>(readFavorites())
const favoritesBarVisible = ref(true)
const breadcrumbBarVisible = ref(true)
const loadFavoritesBarVisible = () => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(favoritesBarStorageKey)
if (stored === null) return
favoritesBarVisible.value = stored === '1'
}
const persistFavoritesBarVisible = () => {
if (typeof window === 'undefined') return
window.localStorage.setItem(favoritesBarStorageKey, favoritesBarVisible.value ? '1' : '0')
}
const loadBreadcrumbBarVisible = () => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(breadcrumbBarStorageKey)
if (stored === null) return
breadcrumbBarVisible.value = stored === '1'
}
const persistBreadcrumbBarVisible = () => {
if (typeof window === 'undefined') return
window.localStorage.setItem(breadcrumbBarStorageKey, breadcrumbBarVisible.value ? '1' : '0')
}
const add = (item: FavoriteItem) => {
if (!item?.path) return
if (items.value.some((x) => x.path === item.path)) return
items.value = [...items.value, item]
}
const remove = (path: string) => {
if (!path) return
items.value = items.value.filter((x) => x.path !== path)
}
const toggle = (item: FavoriteItem) => {
if (!item?.path) return
const exists = items.value.some((x) => x.path === item.path)
if (exists) remove(item.path)
else add(item)
}
const isFavorite = (path: string) => {
if (!path) return false
return items.value.some((x) => x.path === path)
}
const layoutItems = computed<LayoutMenuItem[]>(() =>
items.value.map((item) => ({
title: item.title,
path: item.path,
icon: item.icon,
}))
)
watch(
items,
(val) => {
writeFavorites(val)
},
{ deep: true }
)
const setFavoritesBarVisible = (value: boolean) => {
favoritesBarVisible.value = value
persistFavoritesBarVisible()
}
const toggleFavoritesBarVisible = (nextValue?: boolean) => {
if (typeof nextValue === 'boolean') {
setFavoritesBarVisible(nextValue)
return
}
setFavoritesBarVisible(!favoritesBarVisible.value)
}
loadFavoritesBarVisible()
loadBreadcrumbBarVisible()
const setBreadcrumbBarVisible = (value: boolean) => {
breadcrumbBarVisible.value = value
persistBreadcrumbBarVisible()
}
const toggleBreadcrumbBarVisible = (nextValue?: boolean) => {
if (typeof nextValue === 'boolean') {
setBreadcrumbBarVisible(nextValue)
return
}
setBreadcrumbBarVisible(!breadcrumbBarVisible.value)
}
return {
items,
layoutItems,
add,
remove,
toggle,
isFavorite,
favoritesBarVisible,
setFavoritesBarVisible,
toggleFavoritesBarVisible,
breadcrumbBarVisible,
setBreadcrumbBarVisible,
toggleBreadcrumbBarVisible,
}
})
-236
View File
@@ -1,236 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { normalizeError } from '@/services/error'
import { menuApi, type MenuNode } from '@/services/modules/menu'
export interface LayoutMenuItem {
title: string
path?: string
navigable?: boolean
subItems?: LayoutMenuItem[]
}
export const useMenuStore = defineStore('menu', () => {
const menu = ref<MenuNode[]>([])
const favorite = ref<MenuNode[]>([])
const isRail = ref(false)
const error = ref<string | null>(null)
const loading = ref(false)
const menuStorageKey = 'sk_playground_menu'
const favoriteStorageKey = 'sk_playground_favorite'
const isRailStorageKey = 'sk_playground_is_rail'
const readNodes = (key: string): MenuNode[] => {
if (typeof window === 'undefined') return []
try {
const raw = window.localStorage.getItem(key)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? (parsed as MenuNode[]) : []
} catch {
return []
}
}
const readBoolean = (key: string, defaultValue = false): boolean => {
if (typeof window === 'undefined') return defaultValue
try {
const raw = window.localStorage.getItem(key)
return raw === null ? defaultValue : raw === 'true'
} catch {
return defaultValue
}
}
const writeValue = (key: string, value: any) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(key, String(value))
} catch {
return
}
}
const writeNodes = (key: string, nodes: MenuNode[]) => {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(key, JSON.stringify(nodes))
} catch {
return
}
}
const removeValue = (key: string) => {
if (typeof window === 'undefined') return
try {
window.localStorage.removeItem(key)
} catch {
return
}
}
const hydrate = () => {
menu.value = readNodes(menuStorageKey)
favorite.value = readNodes(favoriteStorageKey)
isRail.value = readBoolean(isRailStorageKey)
}
hydrate()
const toLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
const getString = (node: MenuNode, key: string): string | undefined => {
const v = node?.[key]
return typeof v === 'string' ? v : undefined
}
const getChildren = (node: MenuNode): MenuNode[] => {
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
}
return nodes
.map((mdl) => {
const mdlTitle = getString(mdl, 'mdl_name') ?? ''
const untItems = getChildren(mdl)
.map((unt) => {
const untTitle = getString(unt, 'unt_name') ?? ''
const fncItems = getChildren(unt)
.map((fnc) => {
const fncTitle = getString(fnc, 'fnc_name') ?? ''
const fncId = getString(fnc, 'fnc_id')
return {
title: fncTitle,
path: fncId ? `/${fncId}` : undefined,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
return {
title: untTitle,
navigable: false,
subItems: fncItems,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
return {
title: mdlTitle,
navigable: false,
subItems: untItems,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
}
const toFavoriteLayoutMenuItems = (nodes: MenuNode[]): LayoutMenuItem[] => {
const getString = (node: MenuNode, key: string): string | undefined => {
const v = node?.[key]
return typeof v === 'string' ? v : undefined
}
const getChildren = (node: MenuNode): MenuNode[] => {
return Array.isArray(node?.children) ? (node.children as MenuNode[]) : []
}
return nodes
.map((unt) => {
const untTitle = getString(unt, 'unt_name') ?? ''
const fncItems = getChildren(unt)
.map((fnc) => {
const fncTitle = getString(fnc, 'fnc_name') ?? ''
const fncId = getString(fnc, 'fnc_id')
return {
title: fncTitle,
path: fncId ? `/${fncId}` : undefined,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
return {
title: untTitle,
navigable: false,
subItems: fncItems,
} satisfies LayoutMenuItem
})
.filter((x) => x.title)
}
const menuItems = computed<LayoutMenuItem[]>(() => toLayoutMenuItems(menu.value))
const favoriteItems = computed<LayoutMenuItem[]>(() => toFavoriteLayoutMenuItems(favorite.value))
watch(
menu,
(val) => {
writeNodes(menuStorageKey, val)
},
{ deep: true }
)
watch(
favorite,
(val) => {
writeNodes(favoriteStorageKey, val)
},
{ deep: true }
)
watch(isRail, (val) => {
writeValue(isRailStorageKey, val)
})
const clear = () => {
menu.value = []
favorite.value = []
isRail.value = false
error.value = null
removeValue(menuStorageKey)
removeValue(favoriteStorageKey)
removeValue(isRailStorageKey)
}
const getMenu = async (id: string) => {
try {
loading.value = true
const res = await menuApi.getMenu({ userID: id })
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) {
const normalizedError = normalizeError(error_)
if (normalizedError.name !== 'CanceledRequestError') {
error.value = normalizedError.message
}
throw normalizedError
} finally {
loading.value = false
}
}
const getFavorite = async (id: string) => {
try {
loading.value = true
const res = await menuApi.getFavorite({ userID: id })
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) {
const normalizedError = normalizeError(error_)
if (normalizedError.name !== 'CanceledRequestError') {
error.value = normalizedError.message
}
throw normalizedError
} finally {
loading.value = false
}
}
return {
menu,
favorite,
isRail,
menuItems,
favoriteItems,
error,
loading,
hydrate,
clear,
getMenu,
getFavorite,
}
})
-30
View File
@@ -1,30 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useMessageStore = defineStore('messages', () => {
const openState = ref(false)
// 開啟訊息中心 Dialog
const open = () => {
openState.value = true
}
// 關閉訊息中心 Dialog
const close = () => {
openState.value = false
}
// 提供 v-model 綁定用的 computed
const isOpen = computed({
get: () => openState.value,
set: (value) => {
openState.value = value
},
})
return {
isOpen,
open,
close,
}
})
-165
View File
@@ -1,165 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export interface CourseRecord {
code: string
name: string
credits: number
score: number
}
export interface SemesterRecord {
id: number
studentId: number
semesterName: string
courses: CourseRecord[]
rank: number
average: number
}
const seedSemesters: SemesterRecord[] = []
// Helper to generate random score
const randomScore = (min = 60, max = 99) => Math.floor(Math.random() * (max - min + 1)) + min
// Helper to generate mock semesters for a student
export function generateMockSemesters(studentId: number) {
const semesters = [
{ name: '111 學年度第 1 學期', baseId: 1000 },
{ name: '111 學年度第 2 學期', baseId: 2000 },
{ name: '112 學年度第 1 學期', baseId: 3000 },
{ name: '112 學年度第 2 學期', baseId: 4000 },
{ name: '113 學年度第 1 學期', baseId: 5000 },
{ name: '113 學年度第 2 學期', baseId: 6000 },
]
const subjects = [
{ name: '資料結構', credits: 3 },
{ name: '演算法', credits: 3 },
{ name: '作業系統', credits: 3 },
{ name: '計算機組織', credits: 3 },
{ name: '線性代數', credits: 3 },
{ name: '機率與統計', credits: 3 },
{ name: '資料庫系統', credits: 3 },
{ name: '人工智慧導論', credits: 3 },
{ name: '網頁程式設計', credits: 3 },
{ name: '計算機網路', credits: 3 },
]
// Assign 5-6 semesters per student
const count = 5 + (studentId % 2)
const result: SemesterRecord[] = []
for (let i = 0; i < count; i++) {
const sem = semesters[i]
if (!sem) continue
// Pick 8-10 random courses
const courseCount = 8 + (studentId % 3)
const courses: CourseRecord[] = []
const usedSubjects = new Set<number>()
let totalScore = 0
let totalCredits = 0
while (courses.length < courseCount) {
const idx = Math.floor(Math.random() * subjects.length)
if (usedSubjects.has(idx)) continue
usedSubjects.add(idx)
const score = randomScore()
const subject = subjects[idx]
if (!subject) continue
courses.push({
code: `CS${1000 + idx}`,
name: subject.name,
credits: subject.credits,
score,
})
totalScore += score * subject.credits
totalCredits += subject.credits
}
result.push({
id: sem.baseId + studentId,
studentId,
semesterName: sem.name,
courses,
rank: Math.floor(Math.random() * 20) + 1,
average: Number((totalScore / totalCredits).toFixed(2)),
})
}
return result
}
// Generate for initial seed students (assuming IDs 1-20)
for (let i = 1; i <= 20; i++) {
seedSemesters.push(...generateMockSemesters(i))
}
export const useSemesterStore = defineStore('semesters', () => {
// State
const semesters = ref<SemesterRecord[]>([...seedSemesters])
// Actions
const getStudentSemesters = (studentId: number) => {
return semesters.value.filter((s) => s.studentId === studentId)
}
const generateForStudent = (studentId: number) => {
const newSemesters = generateMockSemesters(studentId)
semesters.value.push(...newSemesters)
}
const addSemester = (studentId: number) => {
const newId = Math.max(...semesters.value.map((s) => s.id), 0) + 1
const newSemester: SemesterRecord = {
id: newId,
studentId,
semesterName: '新學期',
courses: [],
rank: 0,
average: 0,
}
semesters.value.push(newSemester)
return newSemester
}
const updateSemester = (id: number, payload: Partial<SemesterRecord>) => {
const index = semesters.value.findIndex((s) => s.id === id)
if (index === -1) return
const current = semesters.value[index]
if (!current) return
// Recalculate average if courses are updated
if (payload.courses) {
const totalScore = payload.courses.reduce((sum, c) => sum + c.score * c.credits, 0)
const totalCredits = payload.courses.reduce((sum, c) => sum + c.credits, 0)
payload.average = totalCredits > 0 ? Number((totalScore / totalCredits).toFixed(2)) : 0
}
Object.assign(current, payload)
}
const removeSemester = (id: number) => {
const index = semesters.value.findIndex((s) => s.id === id)
if (index !== -1) {
semesters.value.splice(index, 1)
}
}
const removeByStudentId = (studentId: number) => {
semesters.value = semesters.value.filter((s) => s.studentId !== studentId)
}
return {
semesters,
getStudentSemesters,
generateForStudent,
addSemester,
updateSemester,
removeSemester,
removeByStudentId,
}
})
-52
View File
@@ -1,52 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
type SnackbarColor = string
type SnackbarVariant = 'flat' | 'text' | 'elevated' | 'tonal' | 'outlined' | 'plain'
type SnackbarLocation = string
interface ShowOptions {
message: string
color?: SnackbarColor
timeout?: number
location?: SnackbarLocation
variant?: SnackbarVariant
}
export const useSnackbarStore = defineStore('snackbar', () => {
const visible = ref(false)
const message = ref('')
const color = ref<SnackbarColor>('success')
const timeout = ref(2000)
const location = ref<SnackbarLocation>('top right')
const variant = ref<SnackbarVariant>('flat')
const show = (options: ShowOptions) => {
message.value = options.message
color.value = options.color ?? 'success'
timeout.value = options.timeout ?? 2000
location.value = options.location ?? 'top right'
variant.value = options.variant ?? 'flat'
visible.value = false
requestAnimationFrame(() => {
visible.value = true
})
}
const hide = () => {
visible.value = false
}
return {
visible,
message,
color,
timeout,
location,
variant,
show,
hide,
}
})
-345
View File
@@ -1,345 +0,0 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
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
}
const seedStudents: StudentRecord[] = [
{
id: 1,
studentId: 'S2024001',
name: '王小明',
department: '資訊工程',
grade: 1,
enrollYear: 2024,
credits: 18,
advisor: '林育成',
email: 'ming.wang@school.edu',
phone: '02-2345-1001',
status: '在學',
},
{
id: 2,
studentId: 'S2023017',
name: '陳怡君',
department: '企業管理',
grade: 2,
enrollYear: 2023,
credits: 36,
advisor: '許雅婷',
email: 'yijun.chen@school.edu',
phone: '02-2345-1002',
status: '在學',
},
{
id: 3,
studentId: 'S2022008',
name: '林冠宇',
department: '財務金融',
grade: 3,
enrollYear: 2022,
credits: 64,
advisor: '張國華',
email: 'kuanyu.lin@school.edu',
phone: '02-2345-1003',
status: '休學',
},
{
id: 4,
studentId: 'S2021022',
name: '郭雅婷',
department: '視覺設計',
grade: 4,
enrollYear: 2021,
credits: 92,
advisor: '蔡怡芳',
email: 'yating.kuo@school.edu',
phone: '02-2345-1004',
status: '在學',
},
{
id: 5,
studentId: 'S2019013',
name: '張柏翰',
department: '應用外語',
grade: 5,
enrollYear: 2019,
credits: 28,
advisor: '吳佳玲',
email: 'bohan.chang@school.edu',
phone: '02-2345-1005',
status: '畢業',
},
{
id: 6,
studentId: 'S2024024',
name: '李詩涵',
department: '視覺設計',
grade: 1,
enrollYear: 2024,
credits: 16,
advisor: '蔡怡芳',
email: 'shihan.li@school.edu',
phone: '02-2345-1006',
status: '在學',
},
{
id: 7,
studentId: 'S2023044',
name: '黃俊豪',
department: '資訊工程',
grade: 2,
enrollYear: 2023,
credits: 40,
advisor: '林育成',
email: 'junhao.huang@school.edu',
phone: '02-2345-1007',
status: '在學',
},
{
id: 8,
studentId: 'S2022066',
name: '周佳穎',
department: '企業管理',
grade: 3,
enrollYear: 2022,
credits: 58,
advisor: '許雅婷',
email: 'jiaying.chou@school.edu',
phone: '02-2345-1008',
status: '在學',
},
{
id: 9,
studentId: 'S2021088',
name: '許景皓',
department: '財務金融',
grade: 4,
enrollYear: 2021,
credits: 88,
advisor: '張國華',
email: 'jinghao.hsu@school.edu',
phone: '02-2345-1009',
status: '在學',
},
{
id: 10,
studentId: 'S2020019',
name: '鄭婉如',
department: '應用外語',
grade: 5,
enrollYear: 2020,
credits: 22,
advisor: '吳佳玲',
email: 'wanru.cheng@school.edu',
phone: '02-2345-1010',
status: '在學',
},
{
id: 11,
studentId: 'S2024031',
name: '謝承翰',
department: '資訊工程',
grade: 1,
enrollYear: 2024,
credits: 20,
advisor: '林育成',
email: 'chenghan.hsieh@school.edu',
phone: '02-2345-1011',
status: '在學',
},
{
id: 12,
studentId: 'S2023055',
name: '邱雅雯',
department: '視覺設計',
grade: 2,
enrollYear: 2023,
credits: 34,
advisor: '蔡怡芳',
email: 'yawin.chiu@school.edu',
phone: '02-2345-1012',
status: '在學',
},
{
id: 13,
studentId: 'S2022073',
name: '何柏勳',
department: '財務金融',
grade: 3,
enrollYear: 2022,
credits: 62,
advisor: '張國華',
email: 'boxun.he@school.edu',
phone: '02-2345-1013',
status: '休學',
},
{
id: 14,
studentId: 'S2021095',
name: '鄒庭安',
department: '企業管理',
grade: 4,
enrollYear: 2021,
credits: 96,
advisor: '許雅婷',
email: 'tingan.tsou@school.edu',
phone: '02-2345-1014',
status: '在學',
},
{
id: 15,
studentId: 'S2020028',
name: '潘子涵',
department: '應用外語',
grade: 5,
enrollYear: 2020,
credits: 26,
advisor: '吳佳玲',
email: 'zihan.pan@school.edu',
phone: '02-2345-1015',
status: '畢業',
},
{
id: 16,
studentId: 'S2024042',
name: '賴昀潔',
department: '視覺設計',
grade: 1,
enrollYear: 2024,
credits: 14,
advisor: '蔡怡芳',
email: 'yunjie.lai@school.edu',
phone: '02-2345-1016',
status: '在學',
},
{
id: 17,
studentId: 'S2023068',
name: '高宇辰',
department: '資訊工程',
grade: 2,
enrollYear: 2023,
credits: 38,
advisor: '林育成',
email: 'yuchen.kao@school.edu',
phone: '02-2345-1017',
status: '在學',
},
{
id: 18,
studentId: 'S2022089',
name: '游品妤',
department: '企業管理',
grade: 3,
enrollYear: 2022,
credits: 60,
advisor: '許雅婷',
email: 'pinyu.yu@school.edu',
phone: '02-2345-1018',
status: '在學',
},
{
id: 19,
studentId: 'S2021106',
name: '羅子軒',
department: '財務金融',
grade: 4,
enrollYear: 2021,
credits: 84,
advisor: '張國華',
email: 'zixuan.lo@school.edu',
phone: '02-2345-1019',
status: '在學',
},
{
id: 20,
studentId: 'S2020036',
name: '謝佳玲',
department: '應用外語',
grade: 5,
enrollYear: 2020,
credits: 24,
advisor: '吳佳玲',
email: 'jialing.hsieh@school.edu',
phone: '02-2345-1020',
status: '畢業',
},
]
export const useStudentStore = defineStore('students', () => {
// State
const students = ref<StudentRecord[]>([...seedStudents])
const deletedIds = ref<Set<number>>(new Set())
// Actions
const addStudent = (payload: Omit<StudentRecord, 'id'>) => {
const nextId = students.value.reduce((max, item) => Math.max(max, item.id), 0) + 1
const created = { id: nextId, ...payload }
students.value.push(created)
return created.id
}
const updateStudent = (id: number, payload: Omit<StudentRecord, 'id'>) => {
const target = students.value.find((item) => item.id === id)
if (!target) return false
Object.assign(target, payload)
return true
}
const removeStudent = (id: number) => {
const before = students.value.length
students.value = students.value.filter((item) => item.id !== id)
return students.value.length !== before
}
// 標記刪除(軟刪除,還原用)
const markAsDeleted = (id: number) => {
deletedIds.value.add(id)
}
// 清除所有標記
const clearDeletedIds = () => {
deletedIds.value.clear()
}
// 提交刪除(實際刪除)
const commitDeleted = () => {
for (const id of deletedIds.value) {
removeStudent(id)
}
deletedIds.value.clear()
}
// 還原標記(取消刪除)
const restoreDeleted = () => {
deletedIds.value.clear()
}
// 檢查是否已標記刪除
const isMarkedAsDeleted = (id: number) => deletedIds.value.has(id)
return {
students,
deletedIds,
addStudent,
updateStudent,
removeStudent,
markAsDeleted,
clearDeletedIds,
commitDeleted,
restoreDeleted,
isMarkedAsDeleted,
}
})

Some files were not shown because too many files have changed in this diff Show More