Compare commits

...

29 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
82 changed files with 5788 additions and 5209 deletions
+16
View File
@@ -1,3 +1,19 @@
# 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 API base URL。
# 使用 Vite dev proxy 時,建議維持相對路徑。 # 使用 Vite dev proxy 時,建議維持相對路徑。
VITE_API_BASE_URL=/service/api VITE_API_BASE_URL=/service/api
+7 -9
View File
@@ -36,13 +36,11 @@ output/playwright/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
/playwright/.auth/ /playwright/.auth/
# Added by code-review-graph
.code-review-graph/
.codex/config.toml .codex
.agents/ .agents
.claude/ .claude
.ruler/ .ruler
.playwright/ .playwright
.mcp.json opencode.json
.opencode.json .antigravitycli
+3 -3
View File
@@ -4,11 +4,11 @@ registry=https://registry.npmjs.org/
# 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移 # 自動儲存精確版本號 (不帶 ^ 或 ~),避免版本漂移
# save-exact=true # save-exact=true
# 安全防禦:禁止安裝發布未滿 7 天的套件 (預防供應鏈攻擊) # 安全防禦:禁止安裝發布未滿 4 天的套件 (預防供應鏈攻擊)
# npm v11.10+ # npm v11.10+
min-release-age=7 min-release-age=4
# pnpm # pnpm
minimum-release-age=10080 minimum-release-age=5760
# 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯 # 嚴格版本檢查:若 Node 或 pnpm 版本不符 package.json 定義則報錯
# engine-strict=true # engine-strict=true
+37 -37
View File
@@ -4,12 +4,34 @@
- Follow the existing code style and patterns. - Follow the existing code style and patterns.
- Use pnpm for running project commands. - Use pnpm for running project commands.
- Keep code in TypeScript unless migration is required. - Keep code in TypeScript unless migration is required.
- When refactoring or creating new components, review `docs/frontend-layering.md` first and follow its layering and responsibility guidelines. - Before modifying or adding files in a `src/` subdirectory, read the corresponding `src/**/GUIDE.md` to understand the layer's constraints and conventions. Use `docs/llm-development-guide.md` as the index to find which GUIDE applies.
- When a change affects LLM editing boundaries, page creation flow, layout usage, login-page boundaries, or frontend layering rules, update `docs/llm-development-guide.md` in the same change. - When the change introduces a new pattern, directory, or convention that affects layer boundaries, create or update the relevant `src/**/GUIDE.md` and ensure `docs/llm-development-guide.md` indexes it.
- When refactoring or creating new components, review `docs/architecture-strategy.md` first and follow its layering and responsibility guidelines.
- For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause. - For UI debugging that spans implementation, state flow, and live browser behavior, use subagents deliberately: one for component contracts and event flow, one for data sources / routing / integration, and one for live browser verification. Do not edit files until the evidence from those threads converges on a root cause.
- Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits. - Treat subagent output as scoped evidence, not as a final answer. Use subagents to narrow the search space, confirm or eliminate hypotheses, and reduce local context load before making edits.
- When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient. - When a problem is directly tied to Vuetify component behavior, props, slots, accessibility output, or generated DOM, consult Vuetify MCP and official Vuetify API documentation before changing the implementation. Prefer supported Vuetify props, slots, and documented extension points over custom replacements unless the documented API is insufficient.
## Naming Generalization Rule
- This project is a **template** intended to be reused across different data domains (student, course, teacher, etc.).
- **Reusable abstractions** (Page Components, Sections, Items, generic composables, base components) **must not contain domain-specific names** (e.g., `Student`, `Course`) in their file names, type names, or export names.
- Domain-specific names are **only allowed** in:
- `src/models/<domain>.ts` — domain models
- `src/stores/<domain>.ts` — domain stores
- `src/services/modules/<domain>.ts` — service modules
- Examples of correct vs. incorrect naming:
-`PageStudentMaintenance.vue` → ✅ `PageMaintenance.vue`
-`useStudentMaintenancePage.ts` → ✅ `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 ## Stack
- Framework: Vue 3 + Vite - Framework: Vue 3 + Vite
- UI Library: Vuetify - UI Library: Vuetify
@@ -26,41 +48,19 @@ import { mdiAccount } from '@mdi/js'
</template> </template>
``` ```
<!-- code-review-graph MCP tools --> ## Vuetify MCP
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the - When looking up Vuetify versions, release notes, component APIs, directive APIs, installation guides, FAQs, feature guides, or package exports, use Vuetify MCP first.
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore - 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.
the codebase.** The graph is faster, cheaper (fewer tokens), and gives - Prefer the official API and documentation information returned by MCP. Do not infer Vuetify behavior.
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST ### 常用工具
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep - `get_release_notes_by_version`: 查詢指定版本或 latest 的 release notes。
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports - `get_component_api_by_version`: 查詢指定 Vuetify 元件的 props、events、slots、exposed methods。
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files - `get_directive_api_by_version`: 查詢指定 Vuetify directive 的 API。
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for - `get_vuetify_api_by_version`: 下載並快取指定版本的 Vuetify API types。
- **Architecture questions**: `get_architecture_overview` + `list_communities` - `get_installation_guide`: 查詢 Vite、Nuxt、Laravel、CDN 等安裝方式。
- `get_feature_guide`: 查詢 theme、icons、i18n、display、layout 等功能指南。
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need. - `get_exposed_exports`: 查詢 Vuetify npm package 可匯出的項目。
- `get_frequently_asked_questions`: 查詢 Vuetify FAQ。
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
-38
View File
@@ -1,38 +0,0 @@
<!-- code-review-graph MCP tools -->
## MCP Tools: code-review-graph
**IMPORTANT: This project has a knowledge graph. ALWAYS use the
code-review-graph MCP tools BEFORE using Grep/Glob/Read to explore
the codebase.** The graph is faster, cheaper (fewer tokens), and gives
you structural context (callers, dependents, test coverage) that file
scanning cannot.
### When to use graph tools FIRST
- **Exploring code**: `semantic_search_nodes` or `query_graph` instead of Grep
- **Understanding impact**: `get_impact_radius` instead of manually tracing imports
- **Code review**: `detect_changes` + `get_review_context` instead of reading entire files
- **Finding relationships**: `query_graph` with callers_of/callees_of/imports_of/tests_for
- **Architecture questions**: `get_architecture_overview` + `list_communities`
Fall back to Grep/Glob/Read **only** when the graph doesn't cover what you need.
### Key Tools
| Tool | Use when |
|------|----------|
| `detect_changes` | Reviewing code changes — gives risk-scored analysis |
| `get_review_context` | Need source snippets for review — token-efficient |
| `get_impact_radius` | Understanding blast radius of a change |
| `get_affected_flows` | Finding which execution paths are impacted |
| `query_graph` | Tracing callers, callees, imports, tests, dependencies |
| `semantic_search_nodes` | Finding functions/classes by name or keyword |
| `get_architecture_overview` | Understanding high-level codebase structure |
| `refactor_tool` | Planning renames, finding dead code |
### Workflow
1. The graph auto-updates on file changes (via hooks).
2. Use `detect_changes` for code review.
3. Use `get_affected_flows` to understand impact.
4. Use `query_graph` pattern="tests_for" to check coverage.
-9
View File
@@ -67,15 +67,6 @@ pnpm dev
cp .env.example .env cp .env.example .env
``` ```
常用設定:
```env
VITE_API_BASE_URL=/service/api
VITE_SKIP_LOGIN=false
VITE_DEV_DEFAULT_USER_ID=
VITE_DEV_DEFAULT_PASSWORD=
```
`client.ts` 會優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。 `client.ts` 會優先使用 `VITE_API_BASE_URL`,否則預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到後端。
實際 `.env``.env.*.local` 不應提交。production API URL 應由使用專案自己的部署環境提供。 實際 `.env``.env.*.local` 不應提交。production API URL 應由使用專案自己的部署環境提供。
+50 -52
View File
@@ -4,66 +4,59 @@
範例功能:`reports` 範例功能:`reports`
## 1. 新增 route view 目前新增一般頁面的預設資料流:
```txt
router -> view -> sections/items
composable -> store -> service
```
Page driver 不是必備層:若頁面邏輯只有簡單的 `computed` page model(無搜尋、無 dialog、無複雜事件協調),直接在 view 裡寫即可,不需要建立 page driver。
## 1. 新增 view(含 page model
簡單頁面的 page model 直接在 view 裡用 `computed` 組裝,不需要額外建立 page driver。
```vue ```vue
<!-- src/views/reports/Reports.vue --> <!-- src/views/reports/Reports.vue -->
<script setup lang="ts"> <script setup lang="ts">
import ReportsTable from '@/components/reports/ReportsTable.vue' import { computed, ref } from 'vue'
import { useSnackbarStore } from '@/stores/snackbar'
const rows = [ export interface ReportSummary {
{ id: 1, title: '學生統計', owner: '教務處' },
{ id: 2, title: '課程統計', owner: '課務組' },
]
</script>
<template>
<ReportsTable :rows="rows" />
</template>
```
view 的責任是頁面資料組裝與事件協調。畫面區塊交給 feature component。
## 2. 新增 feature component
```vue
<!-- src/components/reports/ReportsTable.vue -->
<script setup lang="ts">
interface ReportRow {
id: number id: number
title: string title: string
owner: string owner: string
} }
defineProps<{ const initialRows: ReportSummary[] = [
rows: ReportRow[] { 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> </script>
<template> <template>
<v-card flat> <PageReports :page="pageModel" @open="openReport" />
<v-card-title class="text-h6">報表清單</v-card-title>
<v-table>
<thead>
<tr>
<th>名稱</th>
<th>負責單位</th>
</tr>
</thead>
<tbody>
<tr v-for="row in rows" :key="row.id">
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
</tr>
</tbody>
</v-table>
</v-card>
</template> </template>
``` ```
component 以 props 接收資料,以 emit 回報事件。不要在 component 裡直接處理 route 或底層 HTTP 若頁面需要協調多個 composable(搜尋、表單、CRUD flow、dialog 狀態),才建立 page driver。page driver 的慣例見 `src/composables/GUIDE.md`
## 3. 加入 route 若畫面是固定的「篩選條件 + 查詢按鈕 + 結果表格」,優先使用 `components/sections/SectionQueryPage.vue`。若是「表單欄位 + 送出/存檔按鈕」,優先使用 `components/sections/SectionFormPage.vue`
## 2. 加入 route
route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。 route 加在 `src/router/routes.ts``routes` 陣列中,並放在 `/:pathMatch(.*)*` catch-all route 前面。
@@ -84,9 +77,9 @@ route 加在 `src/router/routes.ts` 的 `routes` 陣列中,並放在 `/:pathMa
- menu 來源目前由 `src/stores/menu.ts` 轉換後端選單資料。 - menu 來源目前由 `src/stores/menu.ts` 轉換後端選單資料。
- breadcrumb 會依 route path、menu/favorite items 與 fallback title 產生。 - breadcrumb 會依 route path、menu/favorite items 與 fallback title 產生。
- 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。 - 新功能若使用後端選單,優先調整後端選單資料或對應 API mock,不要把頁面專屬選單邏輯塞進 layout。
- 若只是新增 route,通常不需要修改 `MainLayout.vue` - 若只是新增 route,通常不需要修改 `MainLayout.vue``src/shell/*`
## 4. 需要 API 時新增 service module ## 3. 需要 API 時新增 service module
```ts ```ts
// src/services/modules/reports.ts // src/services/modules/reports.ts
@@ -107,9 +100,11 @@ export const reportsApi = {
service 只封裝 HTTP 細節,不持有 UI 狀態。 service 只封裝 HTTP 細節,不持有 UI 狀態。
`httpClient``baseURL` 來自 `VITE_API_BASE_URL`。template 預設值見 `.env.example`,通常使用 `/service/api` 搭配 Vite proxy `httpClient``baseURL` 來自 `VITE_API_BASE_URL`,沒有設定時預設 `/service/api`。開發模式下,Vite proxy 會將 `/service/*` 轉送到 `VITE_PROXY_TARGET`
## 5. 需要共享狀態時新增 store ## 4. 需要共享狀態時新增 store
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。
```ts ```ts
// src/stores/reports.ts // src/stores/reports.ts
@@ -139,15 +134,18 @@ export const useReportsStore = defineStore('reports', () => {
}) })
``` ```
只有跨頁共享、需要快取、或全域狀態才新增 store。單頁暫時狀態留在 view、component 或 composable。 ## 5. 驗證
## 6. 驗證
至少執行: 至少執行:
```bash ```bash
pnpm type-check pnpm -s type-check
pnpm build ```
需要確認建置產物時再執行:
```bash
pnpm -s build
``` ```
若有 route、layout 或主要互動流程變更,再啟動 dev server 並用瀏覽器確認。 若有 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` 成為新增功能與重構的最高準則。既有檔案可保留作為歷史參考,但後續開發以本文為準。*
+166 -198
View File
@@ -2,17 +2,17 @@
## 目的 ## 目的
這份文件描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案或重構時有一致判斷基準。 這份文件描述目前 repo 已經落地的前端分層與命名規則,讓後續新增檔案、搬移檔案或重構時有一致判斷基準。
本文件是現況快照;新增功能與重構的細節規範以 `docs/architecture-strategy.md``docs/llm-development-guide.md` 與各層 `src/**/GUIDE.md` 為準。
目前專案的主要責任鏈如下: 目前專案的主要責任鏈如下:
- `router` 決定 route 與 layout meta ```txt
- `App.vue` 根據 route meta 組裝 app shell 與全域 UI router -> App.vue -> AppShell -> layout -> view -> page component -> section -> item
- `views` 承接路由入口與頁面資料協調
- `components` 承接 layout、page component、domain component 與較細的 UI 區塊 page driver / command composable -> store -> service
- `composables` 承接可重用流程與 UI state ```
- `stores` 承接跨頁狀態、快取與全域顯示狀態
- `services` 承接 HTTP client、API 模組、token 與錯誤處理
## 目前目錄的責任邊界 ## 目前目錄的責任邊界
@@ -26,93 +26,116 @@
責任: 責任:
- 定義 route 與 route meta - 定義 route 與 route meta
- 指定頁面使用哪種 layout - 指定頁面使用哪種 layout
- 串接導航守衛 - 串接導航守衛
目前 `meta.layout` 是 app shell 切換的正式入口: 目前 `meta.layout` 是 app shell 切換的正式入口:
- `default` 走 [MainLayout.vue](../src/components/layouts/MainLayout.vue) - `default` 走 [MainLayout.vue](../src/components/layouts/MainLayout.vue)
- `none` 走 [PlainLayout.vue](../src/components/layouts/PlainLayout.vue) - `none` 走 [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
### `src/App.vue` ### `src/App.vue` 與 `src/shell`
[App.vue](../src/App.vue) 目前不是單純掛載入口,而是實際的應用組裝 [App.vue](../src/App.vue) 目前只掛載 [AppShell.vue](../src/shell/AppShell.vue),不再直接承擔全域 UI 組裝。
目前承擔的責任包含 `src/shell` 是 App Shell 層
- 根據 `route.meta.layout` 切換 layout - [AppShell.vue](../src/shell/AppShell.vue)layout 切換layout props/events、breadcrumb actions、tabs router-view 與 `GlobalOverlays` 掛載。
- 組裝 breadcrumb / favorites / menu 等 layout props - [AppTabs.vue](../src/shell/AppTabs.vue)default layout 下的 tabs 與 keep-alive router-view 容器。
- 放置全域搜尋結果 dialog - [GlobalOverlays.vue](../src/shell/GlobalOverlays.vue):全域 snackbar、搜尋 dialog、訊息 dialog。
- 放置全域訊息中心 dialog
- 放置全域 snackbar
- 串接 layout event 與路由跳轉
判斷原則: 判斷原則:
- 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可`App.vue` - 與整個 app shell 共享、且不屬於單一路由頁面的 UI,可`src/shell`
- 只屬於單一路由頁面的對話框或互動,不應堆到 `App.vue` - 只屬於單一路由頁面的對話框或互動,不應放進 `src/shell`
- shell 狀態協調優先放在 `src/composables/layout/useAppShell.ts`
### `src/views` ### `src/views`
`views` 目前整體方向是「路由入口 + 頁面資料協調 + 頁面事件協調」 `views` 是 route entry,方向是薄層:呼叫 page driver、掛載 page component、協調 route-level 事件
目前較薄 view 目前較典型的薄 view
- [Home.vue](../src/views/Home.vue) - [Home.vue](../src/views/Home.vue)
- [Login.vue](../src/views/Login.vue)
- [EditableGrid.vue](../src/views/maint/EditableGrid.vue)
- [Forbidden.vue](../src/views/errors/Forbidden.vue)
- [ServerError.vue](../src/views/errors/ServerError.vue)
- [ServiceUnavailable.vue](../src/views/errors/ServiceUnavailable.vue)
- [NetworkError.vue](../src/views/errors/NetworkError.vue)
- [Maintenance.vue](../src/views/errors/Maintenance.vue)
- [NotFound.vue](../src/views/errors/NotFound.vue)
- [ErrorShell.vue](../src/views/errors/ErrorShell.vue)
- [FncPage.vue](../src/views/FncPage.vue)
- [Settings.vue](../src/views/Settings.vue) - [Settings.vue](../src/views/Settings.vue)
- [FncPage.vue](../src/views/FncPage.vue)
目前仍偏厚的 view
- [SingleRecord.vue](../src/views/maint/SingleRecord.vue) - [SingleRecord.vue](../src/views/maint/SingleRecord.vue)
- [EditableGrid.vue](../src/views/maint/EditableGrid.vue)
- [MasterDetailA.vue](../src/views/maint/MasterDetailA.vue) - [MasterDetailA.vue](../src/views/maint/MasterDetailA.vue)
- [MasterDetailB.vue](../src/views/maint/MasterDetailB.vue) - [MasterDetailB.vue](../src/views/maint/MasterDetailB.vue)
- [MasterDetailC.vue](../src/views/maint/MasterDetailC.vue) - [MasterDetailC.vue](../src/views/maint/MasterDetailC.vue)
錯誤頁集中在 `src/views/errors`,通常使用 `meta.layout = 'none'`,並由 [ErrorShell.vue](../src/views/errors/ErrorShell.vue) 共用錯誤頁骨架。
[Login.vue](../src/views/Login.vue) 是 template core 例外:它仍負責登入頁組合、功能開關、小型提示 dialog 與登入流程協調。登入頁 UI 拆在 `components/login/*`captcha 與 announcement 流程拆在頂層 login composable。
`views` 應遵守的原則: `views` 應遵守的原則:
- 可以持有 route、store、頁面資料組裝頁面事件協調 - 可以持有 route、page driver 掛載、頁面資料組裝頁面事件協調
- 可以管理只屬於該頁的 dialog 顯示狀態 - 可以管理只屬於該頁的小型 dialog 顯示狀態
- 不應長期承擔大量可抽出的模板片段 - 不應長期承擔大型表格、表單、dialog 模板或可重用流程。
- 不應把可重用流程直接留在頁面內重複複製 - 不應直接處理底層 HTTP 細節。
### `src/components` ### `src/components`
目前 `components` 已經分成幾種不同角色,不再用單一規則描述。 `components` 依角色分層,不再用單一規則描述。
#### 1. 頁面型元件 #### 1. Root page/template components
目前以下元件實際上扮演 page component 目前仍放在 `src/components` 根目錄的頁面外殼
- [PageLogin.vue](../src/components/PageLogin.vue) - [PageLogin.vue](../src/components/PageLogin.vue)
- [PageIndex.vue](../src/components/PageIndex.vue) - [PageIndex.vue](../src/components/PageIndex.vue)
- [PageMaint.vue](../src/components/PageMaint.vue) - [PageMaint.vue](../src/components/PageMaint.vue)
這些檔案的責任是: 這些是既有 template 頁面外殼或登入頁組裝元件。新增一般功能頁時,優先使用 `src/components/pages`
- 接收 view 組好的資料與事件 #### 2. `components/pages`
- 組裝某個完整頁面的主畫面
- 再往下使用較小的子元件或 domain component
命名規則 `components/pages` 是完整頁面主畫面組裝層
- 只要是 page component,檔名以 `Page` 為前綴 - [PageHome.vue](../src/components/pages/PageHome.vue)
- page component 可以放在 `components` 根目錄 - [PageSettings.vue](../src/components/pages/PageSettings.vue)
- 不要把 page component 丟進 `base` - [PageFunction.vue](../src/components/pages/PageFunction.vue)
- [PageMaintenance.vue](../src/components/pages/PageMaintenance.vue)
- [PageEditableGridMaintenance.vue](../src/components/pages/PageEditableGridMaintenance.vue)
- [PageMasterDetailAMaintenance.vue](../src/components/pages/PageMasterDetailAMaintenance.vue)
- [PageMasterDetailBMaintenance.vue](../src/components/pages/PageMasterDetailBMaintenance.vue)
- [PageMasterDetailCMaintenance.vue](../src/components/pages/PageMasterDetailCMaintenance.vue)
#### 2. `components/login` 責任:
登入頁的較細 UI 區塊已集中到: - 接收 view/page driver 組好的資料與事件。
- 組裝完整頁面的主要 section 順序。
- 再往下使用 sections、items、feature/domain components。
#### 3. `components/sections`
`components/sections` 是頁面區塊容器:
- [SectionDataTable.vue](../src/components/sections/SectionDataTable.vue)
- [SectionFormPanel.vue](../src/components/sections/SectionFormPanel.vue)
- [SectionFormPage.vue](../src/components/sections/SectionFormPage.vue)
- [SectionQueryPage.vue](../src/components/sections/SectionQueryPage.vue)
責任:
- 決定區塊布局與區塊互動。
- 以 props 接收資料,以 emit 回報事件。
- 不知道 route,不直接呼叫 API。
#### 4. `components/items`
`components/items` 是欄位群組或單筆資料呈現層:
- [ItemFormFieldGroup.vue](../src/components/items/ItemFormFieldGroup.vue)
item 不應知道自己被放在表格、grid、dialog 或頁面哪個位置。
#### 5. `components/login`
登入頁的較細 UI 區塊集中在:
- [CreateAccountLink.vue](../src/components/login/CreateAccountLink.vue) - [CreateAccountLink.vue](../src/components/login/CreateAccountLink.vue)
- [LoginAnnouncementBoard.vue](../src/components/login/LoginAnnouncementBoard.vue) - [LoginAnnouncementBoard.vue](../src/components/login/LoginAnnouncementBoard.vue)
@@ -123,56 +146,12 @@
- [LoginToolBar.vue](../src/components/login/LoginToolBar.vue) - [LoginToolBar.vue](../src/components/login/LoginToolBar.vue)
- [LoginVerify.vue](../src/components/login/LoginVerify.vue) - [LoginVerify.vue](../src/components/login/LoginVerify.vue)
這一層的定位是: 這一層服務 `PageLogin`,不是全域 base library。
- 服務 `PageLogin` #### 6. `components/maint`
- 屬於 login 頁面家族
- 不是全域 base library
#### 3. `components/base` `components/maint` 是 maintenance demo / domain component 區域:
目前 `components/base` 只剩下:
- [DraggableDialog.vue](../src/components/base/DraggableDialog.vue)
目前判斷原則很直接:
- `base` 只放真正可跨頁重用、且不屬於特定 domain 的元件
- 若元件只服務單一頁面家族或單一 domain,優先放回對應資料夾
#### 4. `components/layouts`
目前 layout 實作集中於:
- [MainLayout.vue](../src/components/layouts/MainLayout.vue)
- [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
- `src/components/layouts/main-layout/*`
其中 `main-layout/*``MainLayout` 底下拆出的骨架子元件:
- [AppBarBreadcrumbCol.vue](../src/components/layouts/main-layout/AppBarBreadcrumbCol.vue)
- [AppBarFavoritesCol.vue](../src/components/layouts/main-layout/AppBarFavoritesCol.vue)
- [AppBarTopCol.vue](../src/components/layouts/main-layout/AppBarTopCol.vue)
- [DrawerDesktopMenu.vue](../src/components/layouts/main-layout/DrawerDesktopMenu.vue)
- [DrawerMobileFavoritesPanel.vue](../src/components/layouts/main-layout/DrawerMobileFavoritesPanel.vue)
- [DrawerMobileMenuPanel.vue](../src/components/layouts/main-layout/DrawerMobileMenuPanel.vue)
layout 應只承擔:
- app shell
- drawer / app bar / favorites / breadcrumb 等框架 UI
- 與 layout 視覺結構直接相關的互動
layout 不應承擔:
- 頁面專屬業務流程
- 特定 domain 的資料規則
#### 5. `components/maint`
這個目錄目前是最接近 feature folder 的區域,放 maintenance 領域的 page component 與 domain component
- [PageMaint.vue](../src/components/PageMaint.vue)
- [CommonConfirmDialog.vue](../src/components/maint/CommonConfirmDialog.vue) - [CommonConfirmDialog.vue](../src/components/maint/CommonConfirmDialog.vue)
- [EditableGrid.vue](../src/components/maint/EditableGrid.vue) - [EditableGrid.vue](../src/components/maint/EditableGrid.vue)
- [MasterFileFormFields.vue](../src/components/maint/MasterFileFormFields.vue) - [MasterFileFormFields.vue](../src/components/maint/MasterFileFormFields.vue)
@@ -180,46 +159,48 @@ layout 不應承擔:
- [MntRecordNavToolbar.vue](../src/components/maint/MntRecordNavToolbar.vue) - [MntRecordNavToolbar.vue](../src/components/maint/MntRecordNavToolbar.vue)
- `master-detail/*` - `master-detail/*`
`master-detail/*` 目前屬於維護頁專用的較細組件群: 若只是維護頁專用子元件,不要搬到 `base`
- [CourseMobilePanel.vue](../src/components/maint/master-detail/CourseMobilePanel.vue) #### 7. `components/layouts`
- [DetailCollapseGropus.vue](../src/components/maint/master-detail/DetailCollapseGropus.vue)
- [DetailFullHeightPanel.vue](../src/components/maint/master-detail/DetailFullHeightPanel.vue)
- [DetailNavigation.vue](../src/components/maint/master-detail/DetailNavigation.vue)
- [DetailSidePanel.vue](../src/components/maint/master-detail/DetailSidePanel.vue)
- [DetailSimpleList.vue](../src/components/maint/master-detail/DetailSimpleList.vue)
結論 layout 實作集中於
- `components/maint` 主要扮演 maintenance domain component 層 - [MainLayout.vue](../src/components/layouts/MainLayout.vue)
- `CommonConfirmDialog` 可以直接在 maintenance 頁或元件使用,不需要再包一層 CRUD dialog aggregator - [PlainLayout.vue](../src/components/layouts/PlainLayout.vue)
- 若只是維護頁專用子元件,不要搬到 `base` - `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` ### `src/composables`
目前已明確分成兩組 目前 composables 分成
- `composables/layout/*` - `page-drivers/*`:頁面資料協調與 page model 組裝。
- `composables/maint/*` - `commands/*`:命令式副作用流程,例如 create/edit/save/delete。
- `layout/*`AppShell / layout 狀態與事件協調。
- `maint/*`maintenance demo 的表單、CRUD、editable grid 狀態。
- 頂層 login / utility composable`useLoginCaptcha.ts``useLoginAnnouncements.ts``useApiCall.ts`
代表性檔案 責任
- [useAdminLayoutState.ts](../src/composables/layout/useAdminLayoutState.ts) - 放可重用流程。
- [useThemeToggle.ts](../src/composables/layout/useThemeToggle.ts) - 放可測試的 UI state。
- [useMaintenanceCrudFlow.ts](../src/composables/maint/useMaintenanceCrudFlow.ts) - 放與模板結構耦合較低的狀態機。
- [useStudentMaintenanceForm.ts](../src/composables/maint/useStudentMaintenanceForm.ts) - 不 import component 或 view。
- [useEditableStudentGrid.ts](../src/composables/maint/useEditableStudentGrid.ts)
- [useApiCall.ts](../src/composables/useApiCall.ts)
`composables` 的責任:
- 放可重用流程
- 放可測試的 UI state
- 放與模板結構耦合較低的狀態機
### `src/stores` ### `src/stores`
目前 store 已經是正式分層的一部分,而不只是暫時狀態容器 目前 store 是跨頁共享狀態、快取與全域顯示狀態的正式分層
代表性檔案: 代表性檔案:
@@ -230,25 +211,20 @@ layout 不應承擔:
- [favorites.ts](../src/stores/favorites.ts) - [favorites.ts](../src/stores/favorites.ts)
- [messages.ts](../src/stores/messages.ts) - [messages.ts](../src/stores/messages.ts)
- [snackbar.ts](../src/stores/snackbar.ts) - [snackbar.ts](../src/stores/snackbar.ts)
- [loginAnnouncements.ts](../src/stores/loginAnnouncements.ts)
- [students.ts](../src/stores/students.ts) - [students.ts](../src/stores/students.ts)
- [semesters.ts](../src/stores/semesters.ts) - [semesters.ts](../src/stores/semesters.ts)
責任: 責任:
- 承接跨頁共享狀態 - 承接跨頁共享狀態
- 承接畫面快取與顯示狀態 - 承接畫面快取與全域顯示狀態
- 作為 view 與 services 之間的狀態收斂點 - 作為 view/page driver/composable 與 services 之間的狀態收斂點
- `app.ts` 目前是空的 Pinia scaffold,尚未承擔實際 app state
規則: `app.ts` 目前是空的 Pinia scaffold,尚未承擔實際 app state。
- store 檔案直接放在 `src/stores/*.ts`
- 不要建立 `src/stores/stores/*` 這類重複巢狀目錄
### `src/services` ### `src/services`
`services` 現在已經是一層明確的資料存取邊界,不應再被視為附屬工具資料夾 `services` 是 HTTP 與外部 API 邊界
代表性檔案: 代表性檔案:
@@ -263,101 +239,93 @@ layout 不應承擔:
責任: 責任:
- 提供 HTTP client - 提供 `httpClient`
- 封裝 API 模組 - 封裝 API 模組
- 統一 token、session 與錯誤處理 - 統一 token、session 與錯誤處理
規則: 規則:
- 元件不直接處理底層 HTTP 細節 - 元件不直接處理底層 HTTP 細節
- 可共享的請求流程優先收斂到 store 或 composable,再由它們呼叫 service - service module 不持有 UI 狀態。
- 可共享的請求流程優先收斂到 store、page driver 或 composable,再由它們呼叫 service。
## 目前已落地的分層模式 ## 目前已落地的分層模式
### 模式 1`view -> page component -> page family components` ### 模式 1`view -> page driver -> page component`
已落地頁面: 已落地頁面:
- `Login`
- `Home` - `Home`
- `Settings`
目前的穩定模式是: - `FncPage`
- `view` 負責資料準備與事件協調
- page component 負責頁面主畫面組裝
- 較細的視覺區塊再拆到對應頁面家族資料夾,例如 `components/login/*`
### 模式 2`view -> page component / domain components + maint composables`
已落地區域:
- `views/maint/*` - `views/maint/*`
- `components/maint/*`
- `composables/maint/*`
這一層目前是 maintenance 領域最清楚的結構 穩定模式
- `views/maint/*` 承接 route 與頁面流程協調 - view 負責掛載 page driver 與 page component。
- [PageMaint.vue](../src/components/PageMaint.vue) 承接維護頁共用頁面骨架 - page driver 負責 page model、事件與頁面狀態協調。
- `components/maint/*` 承接維護頁專用元件 - page component 負責頁面主畫面組裝。
- `composables/maint/*` 承接 CRUD 流程、表單狀態與 editable grid 狀態
[EditableGrid.vue](../src/views/maint/EditableGrid.vue) 是目前最接近薄 view 的 maintenance 頁面。 ### 模式 2`Login.vue -> PageLogin -> login components/composables`
### 模式 3`router meta -> App.vue -> layout` 登入頁是 template core,功能開關集中在 `Login.vue`
- `withCaptcha`
- `withAnnouncement`
- `withForgotPassword`
- `withRememberAccount`
資料流與 side effect 分別由 `useLoginCaptcha()``useLoginAnnouncements()``PageLogin``LoginForm` 承接。
### 模式 3`router meta -> AppShell -> layout`
這一層已正式成立: 這一層已正式成立:
- route 決定 layout 類型 - route 決定 layout 類型
- `App.vue` 決定套用哪個 shell - `AppShell` 決定套用哪個 shell layout。
- layout 專注在骨架與共用框架 UI - layout 專注在骨架與共用框架 UI
這代表 layout 的責任邊界不應再回頭混入頁面內部流程。
## 命名規則 ## 命名規則
### 頁面與 page component ### 頁面與 page component
- 直接被 route 載入的檔案放 `views` - 直接被 route 載入的檔案放 `views`
- 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴 - 負責完整頁面畫面組裝的元件,檔名用 `Page` 前綴
- page component 不放進 `base` - page component 優先放 `components/pages`;既有 template 外殼可保留在 `components` 根目錄。
- page component 不放進 `base`
目前例子:
- [PageLogin.vue](../src/components/PageLogin.vue)
- [PageIndex.vue](../src/components/PageIndex.vue)
- [PageMaint.vue](../src/components/PageMaint.vue)
### 資料夾命名 ### 資料夾命名
- 多字資料夾一律使用 `kebab-case` - 多字資料夾一律使用 `kebab-case`
- 不新增 `snake_case``PascalCase` 資料夾 - 不新增 `snake_case``PascalCase` 資料夾
目前例子: 目前例子:
- `main-layout` - `main-layout`
- `master-detail` - `master-detail`
- `page-drivers`
### domain component 命名 ### component 命名
- 與特定領域強綁定的元件,優先用領域意圖命名 - Page component`PageXxx.vue`
- 不要為了抽象而保留含糊的舊前綴 - Section component`SectionXxx.vue`
- 若元件只在 maint 領域使用,就留在 `components/maint` - Item component`ItemXxx.vue`
- Base component:不使用 `Page` / `Section` / `Item` 前綴,直接以功能命名。
## 新增或修改檔案時的判斷準則 ## 新增或修改檔案時的判斷準則
1. 這個檔案是否直接被 route 載入? 1. 這個檔案是否直接被 route 載入?
- 是:優先放 `views` - 是:優先放 `views`
2. 這個檔案是否負責某個完整頁面的主畫面組裝? 2. 這個檔案是否負責某個完整頁面的主畫面組裝?
- 是:用 `Page` 前綴,放 page component,不要塞進 `base` - 是:用 `Page` 前綴,優先放 `components/pages`,不要塞進 `base`
3. 這段重複的是模板還是流程? 3. 這段重複的是模板還是流程?
- 模板:抽元件 - 模板:抽元件
- 流程:抽 composable 或 store - 流程:抽 composable、page driver、command 或 store
4. 這個狀態是否跨頁共享,或需要快取 / 全域顯示控制? 4. 這個狀態是否跨頁共享,或需要快取 / 全域顯示控制?
- 是:優先考慮 store - 是:優先考慮 store
5. 這個邏輯是否在處理 API、token、session、錯誤正規化? 5. 這個邏輯是否在處理 API、token、session、錯誤正規化?
- 是:放 `services` - 是:放 `services`
6. 這個元件是否只屬於單一 domain? 6. 這個元件是否只屬於單一 domain 或單一頁面家族
- 是:優先放到該 domain 目錄,例如 `components/maint` - 是:優先放到該 domain / feature 目錄,例如 `components/maint``components/login`
7. 這個抽象是否真的降低重複與理解成本? 7. 這個抽象是否真的降低重複與理解成本?
- 否:不要抽 - 否:不要抽
+158 -277
View File
@@ -2,303 +2,184 @@
## 文件目的 ## 文件目的
本專案是給其他 Vue/Vuetify 專案使用的 template。LLM 協助修改時,主要工作應集中`src` 底下新增或修改頁面、元件、store、service 與 composable,並讓一般頁面自然被 `MainLayout` 包住 本專案是給其他 Vue/Vuetify 專案使用的 template。LLM 協助修改時,預設應`src` 底下依分層規則新增或修改頁面、元件、store、service 與 composable。
本文件描述的是後續 LLM 依照 template 修改專案時的預設操作規則,不是目前對話 session 的永久限制。若使用者明確要求修改 template shell 或登入頁入口,可依需求處理 本文件只保留全域操作順序與導覽。各層細節規範放在 `src/**/GUIDE.md`,避免重複維護
建議閱讀順序 ## 建議閱讀順序
1. `README.md` 1. `src/GUIDE.md`
2. `src/README.md` 2. `docs/architecture-strategy.md`
3. `docs/add-page-example.md` 3. `maintenanceContract.pageKind` 閱讀對應的 demo 與 `src/**/GUIDE.md`(查 `docs/architecture-strategy.md` 的分層說明)
4. `docs/frontend-layering.md` 4. `docs/add-page-example.md`(需要新增頁面時)
## 預設不可修改的檔案 `frontend-layering.md` 是歷史參考,後續以 `docs/architecture-strategy.md``src/**/GUIDE.md` 為準。
以下檔案視為 template 核心邊界。LLM 依本文件處理一般功能需求時,不應修改: ## GUIDE 索引
- `src/components/layouts/MainLayout.vue` | 範圍 | 指南 |
- `src/views/Login.vue` |------|------|
| `src` 總覽、資料流、template core、demo 邊界 | `src/GUIDE.md` |
| route view 與薄 view 規則 | `src/views/GUIDE.md` |
| maintenance demo view | `src/views/maint/GUIDE.md` |
| Vue component 分層 | `src/components/GUIDE.md` |
| base 元件 | `src/components/base/GUIDE.md` |
| section 元件 | `src/components/sections/GUIDE.md` |
| layout 邊界 | `src/components/layouts/GUIDE.md` |
| page driver、command、layout composable | `src/composables/GUIDE.md` |
| route 與 guard | `src/router/GUIDE.md` |
| AppShell、tabs、global overlays | `src/shell/GUIDE.md` |
| Pinia store | `src/stores/GUIDE.md` |
| HTTP service / ky / API module | `src/services/GUIDE.md` |
| domain model 與 page model 型別 | `src/models/GUIDE.md` |
| 跨模組共用 API 型別 | `src/types/GUIDE.md` |
| i18n 文案 | `src/language/GUIDE.md` |
如果一般功能需求看起來需要修改上述檔案,先停下來回報原因,並改從可修改檔案尋找解法。只有在使用者明確指定要調整 layout shell 或登入頁入口時,才可以修改。 ## 預設修改策略
## 優先修改的位置 一般功能需求優先修改:
一般功能需求應優先落在:
- `src/views/*` - `src/views/*`
- `src/views/<feature>/*` - `src/components/sections/*`
- `src/components/<feature>/*` - `src/components/items/*`
- `src/composables/<feature>/*` - `src/composables/page-drivers/*`
- `src/composables/useCrudCommands.ts`
- `src/stores/*` - `src/stores/*`
- `src/services/*` - `src/services/modules/*`
- `src/router/routes.ts` - `src/router/routes.ts`
- `src/language/*.json` - `src/language/*.json`
新增頁面時,通常只需要新增 view、必要的 feature components,並在 `src/router/routes.ts` 加入 route 除非使用者明確要求,避免先修改 template core。template core 清單與 demo/example 邊界見 `src/GUIDE.md`
## 依循 `src/` 既有慣例 ## 常用判斷
開始實作前,先檢查需求最接近的既有檔案,並沿用目前 `src/` 的責任分工、命名方式、資料流與 import 寫法。不要另起一套資料夾結構、狀態管理方式或 API 呼叫方式 - 新 route:讀 `src/router/GUIDE.md`
- 一般頁面:讀 `src/views/GUIDE.md``src/components/GUIDE.md``src/composables/GUIDE.md`
目前主要資料流是: - 維護頁:讀 `src/views/maint/GUIDE.md`
- 查詢/列表頁(篩選 + 表格):讀 `src/components/sections/GUIDE.md``SectionQueryPage`)。
```txt - 申請/填寫頁(送出按鈕):讀 `src/components/sections/GUIDE.md``SectionFormPage`)。
router -> App.vue -> layout -> view -> component -> composable/store -> service - layout / AppShell / tabs / global overlay:讀 `src/shell/GUIDE.md``src/components/layouts/GUIDE.md`
``` - API 串接:讀 `src/services/GUIDE.md`
- 跨頁共享狀態:讀 `src/stores/GUIDE.md`
判斷原則: - 定義 page model 或 domain model 型別:讀 `src/models/GUIDE.md`
- 共用 API 型別定義:讀 `src/types/GUIDE.md`
- `router` 決定 route、layout meta、auth meta 與錯誤頁入口 - 錯誤頁:讀 `src/views/GUIDE.md`ErrorShell 模式)與 `src/router/GUIDE.md`(錯誤頁路由慣例)。
- `App.vue` 組裝 layout props、全域 UI 與 layout event - 語系文案:讀 `src/language/GUIDE.md`
- `views` 承接路由入口、頁面資料協調與頁面事件協調
- `components` 承接畫面呈現、props/emits 與可拆分 UI 區塊
- `composables` 承接可重用流程、頁面狀態機或較複雜的 UI state
- `stores` 承接跨頁共享狀態、快取與全域顯示狀態
- `services` 承接 HTTP client、API 模組、token、session 與錯誤處理
新增功能時,優先找相同類型的現有範例再修改:
- 新 route:參考 `src/router/routes.ts`
- 一般被主 layout 包住的頁面:參考 `src/views/Home.vue``src/views/maint/EditableGrid.vue`
- 登入相關 UI:參考 `src/components/PageLogin.vue``src/components/login/*`
- 維護頁:參考 `src/views/maint/*``src/components/maint/*``src/composables/maint/*`
- 維護頁範本選擇:參考 `src/views/maint/README.md`
- API 呼叫:參考 `src/services/modules/*` 與使用它們的 store/composable
- 全域提示:參考 `src/stores/snackbar.ts``src/composables/useApiCall.ts`
## Template Core 與 Demo 邊界
一般功能需求應避免先修改 template core。這些檔案支撐 app shell、route/layout、登入、全域狀態與 API 基礎設施:
- `src/main.ts`
- `src/App.vue`
- `src/router/index.ts`
- `src/router/guards.ts`
- `src/components/layouts/*`
- `src/views/Login.vue`
- `src/plugins/*`
- `src/styles/*`
- `src/services/client.ts`
- `src/services/interceptors.ts`
- `src/services/token.ts`
- `src/services/session.ts`
- `src/services/error.ts`
- `src/services/http-error.ts`
- `src/services/http-toast.ts`
- `src/stores/auth.ts`
- `src/stores/menu.ts`
- `src/stores/breadcrumbs.ts`
- `src/stores/favorites.ts`
- `src/stores/messages.ts`
- `src/stores/snackbar.ts`
- `src/stores/app.ts`
- `src/composables/layout/*`
以下內容偏向 demo/example,建立正式專案時可依需求替換或刪除:
- `src/views/Home.vue`
- `src/components/PageIndex.vue`
- `src/views/maint/*`
- `src/components/maint/*`
- `src/composables/maint/*`
- `src/components/PageMaint.vue`
- `src/stores/students.ts`
- `src/stores/semesters.ts`
- `src/views/FncPage.vue`
- `src/views/Settings.vue`
- `src/assets/logo.png`
- `src/assets/logo.svg`
- `src/assets/robot-svgrepo-com.svg`
`maint` 是可參考的 demo feature,不是所有新專案都必須保留的核心功能。
移除 demo/example 時,同步清理 `src/router/routes.ts`、相關 menu/favorites/breadcrumb 流程、語系文案、assets 與 import。
## 新增一般頁面的流程
1.`src/views``src/views/<feature>` 新增頁面檔案。
2. 若頁面超過單一簡單畫面,將主要 UI 拆到 `src/components/<feature>`
3. 若有可重用或狀態較多的流程,放到 `src/composables/<feature>`
4. 若資料需要跨頁共享,才新增或修改 `src/stores`
5.`src/router/routes.ts` 新增 route。
6. 一般頁面的 route 必須使用 `meta: { layout: 'default' }`
範例:
```ts
{
path: '/reports',
name: 'reports',
component: () => import('@/views/reports/Reports.vue'),
meta: { layout: 'default' },
}
```
使用 `layout: 'default'` 的頁面會由 `App.vue` 放進 `MainLayout` 的預設 slot,不需要也不可以直接 import 或包裝 `MainLayout.vue`
完整範例見 `docs/add-page-example.md`
## 何時使用 `layout: 'none'`
只有下列頁面應使用 `meta: { layout: 'none' }`
- 登入頁
- 錯誤頁
- 維護中頁
- 明確要求不要被主框架包住的獨立頁
不要為了一般功能頁使用 `layout: 'none'`
## Layout 邊界
`MainLayout` 只負責 app shell
- drawer
- app bar
- breadcrumb
- favorites
- toolbar actions
- 主內容 slot
頁面專屬業務流程、資料規則、表單、列表、對話框、查詢條件與 CRUD 行為都不應放進 layout。
如果頁面需要在主內容區呈現特定畫面,請修改該 route 對應的 view 或 feature component。
如果頁面需要影響 breadcrumb、favorites、menu 或 toolbar,優先使用現有 store、route meta 或 `App.vue` 已經提供的 layout props/event 流程,不要修改 `MainLayout.vue`
## Login 邊界
`src/views/Login.vue` 是登入頁入口。一般功能需求預設不修改這個檔案。
若需求是調整登入頁內部區塊,優先檢查並修改:
- `src/components/PageLogin.vue`
- `src/components/login/*`
- `src/stores/auth.ts`
- `src/stores/loginAnnouncements.ts`
- `src/services/modules/auth.ts`
若需求明確要求改變登入頁 route、guard 或登入後導向,優先檢查:
- `src/router/routes.ts`
- `src/router/guards.ts`
- `src/stores/auth.ts`
## View 與 Component 分工
`views` 是路由入口,負責:
- 接 route params/query
- 組合頁面資料
- 串接 store、service、composable
- 管理頁面專屬事件
- 組裝頁面主 component
`components/<feature>` 負責:
- 呈現頁面區塊
- 接 props
- emit 使用者事件
- 拆分表單、列表、工具列、dialog 等 UI
不要把完整功能長期塞在單一 view。當畫面有多個區塊、表單、列表、dialog 或重複 UI 時,應拆到 feature components。
## Router 安排規則
route 集中放在 `src/router/routes.ts`。不要在 view 或 component 裡臨時建立 route 設定。
一般 route 應包含:
- `path`
- `name`
- `component`
- `meta.layout`
需要登入才可進入的頁面使用既有 `requiresAuth` meta。訪客專用頁面使用既有 `guestOnly` meta。導航守衛流程放在 `src/router/guards.ts`,不要散落在頁面 component。
## Composable 使用規則
只有在邏輯有下列特性時才新增 composable
- 會被多個元件使用
- 狀態流程明顯
- 有副作用或非簡單 UI 邏輯
- 從 component 抽出後可讓 component 責任更清楚
目前既有 composable 的使用定位:
- `src/composables/useApiCall.ts`:包裝可重用 API 呼叫狀態與錯誤提示流程
- `src/composables/layout/useAdminLayoutState.ts`:提供 layout shell 所需的狀態組裝
- `src/composables/layout/useThemeToggle.ts`:提供主題切換流程
- `src/composables/maint/useMaintenanceCrudFlow.ts`:提供維護頁 CRUD 流程狀態
- `src/composables/maint/useStudentMaintenanceForm.ts`:提供學生維護表單狀態
- `src/composables/maint/useEditableStudentGrid.ts`:提供 editable grid 狀態
新增 composable 時,用 `useXxx.ts` 命名。若只服務單一 feature,放在 `src/composables/<feature>`;若確定跨 feature 使用,才放在 `src/composables` 根目錄。
## Store 與 Service 資料流規則
只有在資料或狀態跨頁共享時才使用 store。單一頁面的暫時 UI 狀態應留在 view、feature component 或 composable。
store 檔案直接放在 `src/stores/*.ts`。不要建立 `src/stores/stores/*` 或其他重複巢狀 store 目錄。
service 放在 `src/services`,負責外部 API 與 HTTP 細節。component 不應直接處理底層 HTTP client、token、hooks 或錯誤正規化。
資料流優先順序:
1. component 透過 props/emits 與 view 或 page component 溝通。
2. view 或 composable 協調頁面流程。
3. 跨頁共享狀態由 store 管理。
4. store 或 composable 呼叫 service。
5. service 回傳資料,不持有 UI 狀態。
不要讓 service import component、view 或 store。不要讓 component 直接繞過既有 store/composable 去操作 token、session 或 HTTP hooks。
## 環境變數規則
template 提供 `.env.example` 作為範本。不要提交實際 `.env``.env.*.local`
常用變數:
- `VITE_API_BASE_URL`API base URL,預設使用 `/service/api` 搭配 Vite proxy。
- `VITE_SKIP_LOGIN`:登入示範開關,只有專案明確支援時才使用。
- `VITE_DEV_DEFAULT_USER_ID``VITE_DEV_DEFAULT_PASSWORD`:本機開發示範帳號,放在本機 env,不要寫進程式。
## Vuetify 使用規則
優先使用 Vuetify 原生元件、props、slots 與事件。當需求直接牽涉 Vuetify 元件行為、DOM、可及性輸出或 slot 結構時,先查官方 Vuetify API 文件,再修改實作。
icon 使用 `@mdi/js`
```vue
<script setup lang="ts">
import { mdiAccount } from '@mdi/js'
</script>
<template>
<v-icon :icon="mdiAccount" />
</template>
```
## 修改前檢查 ## 修改前檢查
開始改檔前先確認: - 是否碰到 template core。
- 是否已有同類型範例可沿用。
- 是否需要新增 route。
- 是否應拆成 section / item。
- 是否應新增 page driver 或 command composable。
- 是否需要 store,或只需要頁面內 state。
- 是否應定義新的 model 型別(`src/models/`)。
- 是否需要更新語系、menu、breadcrumb、favorites。
- 是否會碰到禁止修改檔案 ## 從視覺特徵選擇 section 元件
- 是否只需要新增或修改 view
- 是否需要新增 route 當收到 prototype 截圖或設計稿時,依畫面特徵選擇 section 外殼:
- 是否需要拆 feature component
- 是否已閱讀 `docs/frontend-layering.md` | 特徵 | 選擇 |
- 是否已參考 `src/` 裡相同類型的既有範例 |------|------|
- 是否符合 Vue 3、Composition API、`<script setup lang="ts">` 的既有寫法 | 有「送出/存檔」按鈕,且畫面為填寫表單(欄位 + 配合事項 + 動作按鈕) | `SectionFormPage` |
| 有「查詢」按鈕,且畫面為篩選條件 + 結果表格/列表 | `SectionQueryPage` |
| 純粹表格列表(無送出/查詢按鈕,只有 CRUD 操作) | 不用 section 外殼,直接組合 `v-data-table` |
| 混合結構(有查詢也有表單填寫) | 評估是否拆成兩頁;若必須同頁,不用通用外殼 |
判斷順序:先看有無「送出/存檔」→ 再看有無「查詢」→ 其餘視為一般列表頁。
## `.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`
- TypeScript 或 Vue 結構有變更:`pnpm type-check` - Markdown 或大量搬移:`git diff --check`
- 需要確認產物可建置:`pnpm build` - route、layout 或主要畫面流程有變更:啟動 dev server 並用瀏覽器確認,除非使用者明確不需要。
- route、layout 或主要畫面流程有變更:啟動 dev server 並用瀏覽器確認頁面被正確包在主 layout 中
如果無法執行驗證,回報原因,不要宣稱已驗證。 如果無法執行驗證,回報原因,不要宣稱已驗證。
+1 -6
View File
@@ -9,9 +9,7 @@
"preview": "vite preview", "preview": "vite preview",
"build-only": "vite build", "build-only": "vite build",
"type-check": "vue-tsc --build --force", "type-check": "vue-tsc --build --force",
"format": "prettier . --write", "format": "prettier . --write"
"mcp": "ruler apply",
"mcp:revert": "ruler revert"
}, },
"dependencies": { "dependencies": {
"@mdi/js": "^7.4.47", "@mdi/js": "^7.4.47",
@@ -23,9 +21,6 @@
"vuetify": "^4.0.4" "vuetify": "^4.0.4"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.11.1",
"@intellectronica/ruler": "^0.3.37",
"@playwright/test": "^1.58.2",
"@tsconfig/node22": "^22.0.5", "@tsconfig/node22": "^22.0.5",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@vitejs/plugin-vue": "^6.0.5", "@vitejs/plugin-vue": "^6.0.5",
-70
View File
@@ -1,70 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// import dotenv from 'dotenv';
// import path from 'path';
// dotenv.config({ path: path.resolve(__dirname, '.env') });
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './tests/e2e',
/* Run tests in files in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Base URL to use in actions like `await page.goto('')`. */
// baseURL: 'http://localhost:3000',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: { ...devices['Pixel 5'] },
// },
// {
// name: 'Mobile Safari',
// use: { ...devices['iPhone 12'] },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: { ...devices['Desktop Edge'], channel: 'msedge' },
// },
// {
// name: 'Google Chrome',
// use: { ...devices['Desktop Chrome'], channel: 'chrome' },
// },
],
/* Run your local dev server before starting the tests */
// webServer: {
// command: 'npm run start',
// url: 'http://localhost:3000',
// reuseExistingServer: !process.env.CI,
// },
});
-187
View File
@@ -30,15 +30,6 @@ importers:
specifier: ^4.0.4 specifier: ^4.0.4
version: 4.0.4(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.31(typescript@5.9.3)) version: 4.0.4(typescript@5.9.3)(vite-plugin-vuetify@2.1.3)(vue@3.5.31(typescript@5.9.3))
devDependencies: devDependencies:
'@axe-core/playwright':
specifier: ^4.11.1
version: 4.11.1(playwright-core@1.58.2)
'@intellectronica/ruler':
specifier: ^0.3.37
version: 0.3.37
'@playwright/test':
specifier: ^1.58.2
version: 1.58.2
'@tsconfig/node22': '@tsconfig/node22':
specifier: ^22.0.5 specifier: ^22.0.5
version: 22.0.5 version: 22.0.5
@@ -75,11 +66,6 @@ importers:
packages: packages:
'@axe-core/playwright@4.11.1':
resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==}
peerDependencies:
playwright-core: '>= 1.0.0'
'@babel/generator@7.29.1': '@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -113,14 +99,6 @@ packages:
'@emnapi/wasi-threads@1.2.0': '@emnapi/wasi-threads@1.2.0':
resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==} resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
'@iarna/toml@2.2.5':
resolution: {integrity: sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==}
'@intellectronica/ruler@0.3.37':
resolution: {integrity: sha512-ZGBRHrwLugGyzMl5dY3TSmnuzqjuBmuy8msSj/37I7iyOi8sIhuBgGG+/EIE2ku94TuZDRQR3h5zFpKMQiUerw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
hasBin: true
'@intlify/core-base@11.3.0': '@intlify/core-base@11.3.0':
resolution: {integrity: sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==} resolution: {integrity: sha512-NNX5jIwF4TJBe7RtSKDMOA6JD9mp2mRcBHAwt2X+Q8PvnZub0yj5YYXlFu2AcESdgQpEv/5Yx2uOCV/yh7YkZg==}
engines: {node: '>= 16'} engines: {node: '>= 16'}
@@ -250,11 +228,6 @@ packages:
resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
hasBin: true
'@rolldown/binding-android-arm64@1.0.0-rc.11': '@rolldown/binding-android-arm64@1.0.0-rc.11':
resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==} resolution: {integrity: sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
@@ -468,17 +441,10 @@ packages:
alien-signals@3.1.2: alien-signals@3.1.2:
resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==} resolution: {integrity: sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==}
ansi-regex@6.2.2:
resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==}
engines: {node: '>=12'}
ansi-styles@6.2.3: ansi-styles@6.2.3:
resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==}
engines: {node: '>=12'} engines: {node: '>=12'}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
ast-kit@2.2.0: ast-kit@2.2.0:
resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
@@ -487,10 +453,6 @@ packages:
resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==} resolution: {integrity: sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==}
engines: {node: '>=20.19.0'} engines: {node: '>=20.19.0'}
axe-core@4.11.1:
resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==}
engines: {node: '>=4'}
birpc@2.9.0: birpc@2.9.0:
resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==}
@@ -502,10 +464,6 @@ packages:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'} engines: {node: '>= 20.19.0'}
cliui@9.0.1:
resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==}
engines: {node: '>=20'}
colorjs.io@0.5.2: colorjs.io@0.5.2:
resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==}
@@ -539,17 +497,10 @@ packages:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
emoji-regex@10.6.0:
resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==}
entities@7.0.1: entities@7.0.1:
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
engines: {node: '>=0.12'} engines: {node: '>=0.12'}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
estree-walker@2.0.2: estree-walker@2.0.2:
resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
@@ -565,24 +516,11 @@ packages:
picomatch: picomatch:
optional: true optional: true
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin] os: [darwin]
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-east-asian-width@1.5.0:
resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==}
engines: {node: '>=18'}
has-flag@4.0.0: has-flag@4.0.0:
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -612,10 +550,6 @@ packages:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'} engines: {node: '>=18'}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsesc@3.1.0: jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -795,16 +729,6 @@ packages:
pkg-types@2.3.0: pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
playwright-core@1.58.2:
resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
engines: {node: '>=18'}
hasBin: true
playwright@1.58.2:
resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
engines: {node: '>=18'}
hasBin: true
postcss@8.5.8: postcss@8.5.8:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
@@ -985,14 +909,6 @@ packages:
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
string-width@7.2.0:
resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==}
engines: {node: '>=18'}
strip-ansi@7.2.0:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
superjson@2.2.6: superjson@2.2.6:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'} engines: {node: '>=16'}
@@ -1159,37 +1075,13 @@ packages:
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
hasBin: true hasBin: true
wrap-ansi@9.0.2:
resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==}
engines: {node: '>=18'}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yaml@2.8.3: yaml@2.8.3:
resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==}
engines: {node: '>= 14.6'} engines: {node: '>= 14.6'}
hasBin: true hasBin: true
yargs-parser@22.0.0:
resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
yargs@18.0.0:
resolution: {integrity: sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=23}
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
snapshots: snapshots:
'@axe-core/playwright@4.11.1(playwright-core@1.58.2)':
dependencies:
axe-core: 4.11.1
playwright-core: 1.58.2
'@babel/generator@7.29.1': '@babel/generator@7.29.1':
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
@@ -1229,15 +1121,6 @@ snapshots:
tslib: 2.8.1 tslib: 2.8.1
optional: true optional: true
'@iarna/toml@2.2.5': {}
'@intellectronica/ruler@0.3.37':
dependencies:
'@iarna/toml': 2.2.5
js-yaml: 4.1.1
yargs: 18.0.0
zod: 4.3.6
'@intlify/core-base@11.3.0': '@intlify/core-base@11.3.0':
dependencies: dependencies:
'@intlify/devtools-types': 11.3.0 '@intlify/devtools-types': 11.3.0
@@ -1347,10 +1230,6 @@ snapshots:
'@parcel/watcher-win32-x64': 2.5.6 '@parcel/watcher-win32-x64': 2.5.6
optional: true optional: true
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
'@rolldown/binding-android-arm64@1.0.0-rc.11': '@rolldown/binding-android-arm64@1.0.0-rc.11':
optional: true optional: true
@@ -1553,12 +1432,8 @@ snapshots:
alien-signals@3.1.2: {} alien-signals@3.1.2: {}
ansi-regex@6.2.2: {}
ansi-styles@6.2.3: {} ansi-styles@6.2.3: {}
argparse@2.0.1: {}
ast-kit@2.2.0: ast-kit@2.2.0:
dependencies: dependencies:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
@@ -1569,8 +1444,6 @@ snapshots:
'@babel/parser': 7.29.2 '@babel/parser': 7.29.2
ast-kit: 2.2.0 ast-kit: 2.2.0
axe-core@4.11.1: {}
birpc@2.9.0: {} birpc@2.9.0: {}
chokidar@4.0.3: chokidar@4.0.3:
@@ -1582,12 +1455,6 @@ snapshots:
dependencies: dependencies:
readdirp: 5.0.0 readdirp: 5.0.0
cliui@9.0.1:
dependencies:
string-width: 7.2.0
strip-ansi: 7.2.0
wrap-ansi: 9.0.2
colorjs.io@0.5.2: {} colorjs.io@0.5.2: {}
confbox@0.1.8: {} confbox@0.1.8: {}
@@ -1612,12 +1479,8 @@ snapshots:
detect-libc@2.1.2: {} detect-libc@2.1.2: {}
emoji-regex@10.6.0: {}
entities@7.0.1: {} entities@7.0.1: {}
escalade@3.2.0: {}
estree-walker@2.0.2: {} estree-walker@2.0.2: {}
exsolve@1.0.8: {} exsolve@1.0.8: {}
@@ -1626,16 +1489,9 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.4 picomatch: 4.0.4
fsevents@2.3.2:
optional: true
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
get-caller-file@2.0.5: {}
get-east-asian-width@1.5.0: {}
has-flag@4.0.0: {} has-flag@4.0.0: {}
hookable@5.5.3: {} hookable@5.5.3: {}
@@ -1656,10 +1512,6 @@ snapshots:
isexe@3.1.5: {} isexe@3.1.5: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {} jsesc@3.1.0: {}
json-parse-even-better-errors@4.0.0: {} json-parse-even-better-errors@4.0.0: {}
@@ -1799,14 +1651,6 @@ snapshots:
exsolve: 1.0.8 exsolve: 1.0.8
pathe: 2.0.3 pathe: 2.0.3
playwright-core@1.58.2: {}
playwright@1.58.2:
dependencies:
playwright-core: 1.58.2
optionalDependencies:
fsevents: 2.3.2
postcss@8.5.8: postcss@8.5.8:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
@@ -1964,16 +1808,6 @@ snapshots:
speakingurl@14.0.1: {} speakingurl@14.0.1: {}
string-width@7.2.0:
dependencies:
emoji-regex: 10.6.0
get-east-asian-width: 1.5.0
strip-ansi: 7.2.0
strip-ansi@7.2.0:
dependencies:
ansi-regex: 6.2.2
superjson@2.2.6: superjson@2.2.6:
dependencies: dependencies:
copy-anything: 4.0.5 copy-anything: 4.0.5
@@ -2108,25 +1942,4 @@ snapshots:
dependencies: dependencies:
isexe: 3.1.5 isexe: 3.1.5
wrap-ansi@9.0.2:
dependencies:
ansi-styles: 6.2.3
string-width: 7.2.0
strip-ansi: 7.2.0
y18n@5.0.8: {}
yaml@2.8.3: {} yaml@2.8.3: {}
yargs-parser@22.0.0: {}
yargs@18.0.0:
dependencies:
cliui: 9.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
string-width: 7.2.0
y18n: 5.0.8
yargs-parser: 22.0.0
zod@4.3.6: {}
-30
View File
@@ -1,30 +0,0 @@
{
"version": 1,
"skills": {
"playwright": {
"source": "openai/skills",
"sourceType": "github",
"computedHash": "595a334de3bd5335f442a33d1bdefb01207c88c99fe43e3bd99b0d5435f230de"
},
"playwright-generate-test": {
"source": "github/awesome-copilot",
"sourceType": "github",
"computedHash": "8f963da3e25f705ff39755b8b990f20da0c0320cb86ac83fcc4d2d4eca91f6e4"
},
"vue-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "d7d22c8cb343583c3904692c4d1d7b50382945e433e4f6e053f4aabb9846cbc3"
},
"vue-router-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "e27384f4e6c8c70a612e76b74e4387efb8e291a6a1e3aa14a69102a4ce4b4654"
},
"vue-testing-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "18c7d8f42f350f927e37de055e34c97b8cfb9f79c12cf942f7f3d2a0821057b5"
}
}
}
+6 -588
View File
@@ -1,589 +1,7 @@
<template> <script setup lang="ts">
<!-- 根據路由設定 meta.layout 動態切換佈局 --> import AppShell from '@/shell/AppShell.vue'
<component
:is="activeLayout"
v-bind="layoutProps"
v-model:breadcrumb-bar-visible="favoritesStore.breadcrumbBarVisible"
v-model:favorites-bar-visible="favoritesStore.favoritesBarVisible"
v-model:is-rail="menuStore.isRail"
@action="handleLayoutAction"
@logout="handleLogout"
@remove-favorite="handleRemoveFavorite"
@search="handleSearch"
@select="handleSelect"
>
<template #breadcrumb-actions>
<v-btn
color="secondary"
:disabled="isFavoriteActionDisabled"
size="small"
variant="outlined"
@click="toggleFavorite"
>
<v-icon class="mr-1" size="14" :icon="favoriteActionIcon" />
{{ favoriteActionLabel }}
</v-btn>
<v-btn class="ml-2" color="primary" size="small" variant="text" @click="goHome">
<v-icon class="mr-1" size="14" :icon="mdiHome" />
返回首頁
</v-btn>
</template>
<!-- 如果是預設佈局顯示分頁標籤 -->
<template v-if="showTabs">
<div class="d-flex flex-column h-100">
<v-tabs
v-model="activeTab"
bg-color="background"
color="primary"
density="compact"
show-arrows
style="flex-shrink: 0"
>
<v-tab v-for="tab in tabs" :key="tab.path" border="sm" :to="tab.path" :value="tab.path">
{{ tab.title }}
<v-btn
aria-label="關閉頁籤"
class="pl-2"
color="grey"
density="compact"
:disabled="tabs.length <= 1"
icon
size="x-small"
variant="text"
@click.prevent.stop="closeTab(tab.path)"
>
<v-icon :icon="mdiClose" />
</v-btn>
</v-tab>
</v-tabs>
<div class="flex-grow-1 overflow-auto" style="min-height: 0" tabindex="0">
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component" :key="route.fullPath" />
</keep-alive>
</router-view>
</div>
</div>
</template>
<!-- 其他佈局直接顯示內容 -->
<router-view v-else />
</component>
<v-dialog v-model="searchDialog" max-width="640">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">搜尋結果</v-card-title>
<v-card-subtitle v-if="searchKeyword" class="pt-4"
>關鍵字{{ searchKeyword }}</v-card-subtitle
>
<v-card-text class="pt-2">
<v-alert
v-if="searchResults.length === 0"
class="mt-2"
density="compact"
type="info"
variant="tonal"
>
查無結果
</v-alert>
<v-list v-else density="compact">
<v-list-item
v-for="item in searchResults"
:key="item.path"
class="mb-2"
@click="handleSearchSelect(item)"
>
<template #prepend>
<v-icon v-if="item.icon" size="18" :icon="item.icon" />
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.parents?.length">
{{ item.parents.join(' / ') }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="primary" variant="text" @click="searchDialog = false">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!--
訊息中心 Dialog
放在 App.vue 的原因是要被首頁卡片頂部工具列訊息按鈕共同觸發
並且避免在 layout/template 層放入業務 UI維持模板的純展示特性
-->
<v-dialog v-model="messageStore.isOpen" max-width="720">
<v-card>
<v-card-title class="text-h6 bg-primary-variant pa-4">訊息清單</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis"
>僅示意資料不含延伸功能</v-card-subtitle
>
<v-card-text class="pa-4">
<!--
使用 v-data-iterator 進行資料展示
這樣若未來要加排序或分頁不需改動結構
-->
<v-data-iterator item-key="id" :items="messageItems" :items-per-page="-1">
<template #default="{ items }">
<v-list density="compact">
<v-list-item
v-for="wrapped in items"
:key="resolveMessageItem(wrapped).id"
border="sm"
class="pa-2 mb-1"
>
<template #prepend>
<v-avatar color="primary" size="28" variant="tonal">
<v-icon size="16" :icon="resolveMessageItem(wrapped).icon" />
</v-avatar>
</template>
<v-list-item-title class="text-body-2 font-weight-medium">
{{ resolveMessageItem(wrapped).title }}
</v-list-item-title>
<v-list-item-subtitle class="text-caption text-medium-emphasis">
{{ resolveMessageItem(wrapped).meta }}
</v-list-item-subtitle>
</v-list-item>
</v-list>
</template>
</v-data-iterator>
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="messageStore.close()">關閉</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar
v-model="snackbar.visible"
:color="snackbar.color"
:location="snackbar.location"
:timeout="snackbar.timeout"
:variant="snackbar.variant"
>
{{ snackbar.message }}
</v-snackbar>
</template>
<script setup>
import {
mdiBellOutline,
mdiCalendarOutline,
mdiClose,
mdiCloseCircle,
mdiCog,
mdiFileDocumentOutline,
mdiFileTreeOutline,
mdiHome,
mdiHomeCityOutline,
mdiPlusCircle,
mdiSchoolOutline,
mdiTableEdit,
} from '@mdi/js'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import MainLayout from '@/components/layouts/MainLayout.vue'
import PlainLayout from '@/components/layouts/PlainLayout.vue'
import { HTTP_TOAST_EVENT } from './services/http-toast'
import { SESSION_FORCE_LOGOUT_EVENT } from './services/session'
import { useAuthStore } from './stores/auth'
import { useBreadcrumbStore } from './stores/breadcrumbs'
import { useFavoritesStore } from './stores/favorites'
import { useMenuStore } from './stores/menu'
import { useMessageStore } from './stores/messages'
import { useSnackbarStore } from './stores/snackbar'
const route = useRoute()
const router = useRouter()
const snackbar = useSnackbarStore()
const authStore = useAuthStore()
const menuStore = useMenuStore()
const breadcrumbStore = useBreadcrumbStore()
const favoritesStore = useFavoritesStore()
// 訊息中心:集中控制 dialog 顯示狀態
const messageStore = useMessageStore()
// 固定選單(合併到 API 回傳的選單)
const _fixedMenuItems = [
{
title: '資料維護',
navigable: false,
subItems: [
{ title: '單筆資料維護', icon: mdiFileDocumentOutline, path: '/single-record-maintenance' },
{ title: '主從資料維護A', icon: mdiFileTreeOutline, path: '/master-detail-maintenance' },
{ title: '主從資料維護B', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-b' },
{ title: '主從資料維護C', icon: mdiFileTreeOutline, path: '/master-detail-maintenance-c' },
{ title: '可編輯表格維護', icon: mdiTableEdit, path: '/editable-grid-maintenance' },
],
},
{ title: '登入頁', path: '/login' },
]
// 範例選單(用於 tab 顯示名稱的保底資料)
const _menuItemsExample = [
{ title: '首頁', icon: mdiHome, path: '/' },
{
title: '設定',
icon: mdiCog,
path: '/settings',
navigable: false,
},
..._fixedMenuItems,
]
/**
* 佈局對映表
*/
const layoutMap = {
default: MainLayout,
none: PlainLayout,
}
// 取得當前應使用的組件
const activeLayout = computed(() => {
return layoutMap[route.meta.layout] || MainLayout
})
function buildMergedMenuItems(items) {
const flatPaths = new Set()
const collectPaths = (list) => {
for (const item of list || []) {
if (item?.path) flatPaths.add(item.path)
if (item?.subItems?.length) collectPaths(item.subItems)
}
}
collectPaths(items)
const mergeFixedItems = (list) => {
return (list || []).map((item) => {
if (!item?.subItems?.length) return item
const subItems = item.subItems.filter((sub) => !sub?.path || !flatPaths.has(sub.path))
return { ...item, subItems }
})
}
const fixedItems = mergeFixedItems(_fixedMenuItems).filter((item) => {
if (!item?.subItems?.length) return !item?.path || !flatPaths.has(item.path)
return item.subItems.length > 0
})
return [...(items || []), ...fixedItems]
}
// 根據不同 Layout 傳遞不同的 Props
const mergedMenuItems = computed(() => buildMergedMenuItems(menuStore.menuItems))
const mergedFavoriteItems = computed(() => {
const combined = [...menuStore.favoriteItems, ...favoritesStore.layoutItems]
const seen = new Set()
return combined.filter((item) => {
const key = item.path ?? item.title
if (!key) return false
if (seen.has(key)) return false
seen.add(key)
return true
})
})
const layoutProps = computed(() => {
const layout = route.meta.layout
if (layout === 'default') {
return {
systemTitle: '測試環境',
favoriteItems: mergedFavoriteItems.value,
menuItems: mergedMenuItems.value,
breadcrumbItems: breadcrumbStore.breadcrumbItems,
}
}
return {}
})
function handleSelect(item) {
console.log('Selected:', item)
if (item.path) {
router.push(item.path)
}
}
const searchDialog = ref(false)
const searchKeyword = ref('')
const searchResults = ref([])
function buildSearchResults(items, keyword, parents = []) {
const results = []
for (const item of items || []) {
const currentParents = item?.title ? [...parents, item.title] : parents
if (item?.subItems?.length) {
results.push(...buildSearchResults(item.subItems, keyword, currentParents))
}
if (item?.path && item?.title) {
const hit = item.title.toLowerCase().includes(keyword)
if (hit) {
results.push({
title: item.title,
path: item.path,
icon: item.icon,
parents: parents,
})
}
}
}
return results
}
// 接收 Layout 的搜尋事件:僅在「按鈕或 Enter」時觸發
function handleSearch(value) {
const keyword = String(value ?? '').trim()
searchKeyword.value = keyword
if (!keyword) {
// 空字串時不顯示結果彈窗
searchResults.value = []
searchDialog.value = false
return
}
const lowered = keyword.toLowerCase()
// 依合併後的 menuItems 進行比對
searchResults.value = buildSearchResults(mergedMenuItems.value, lowered)
// 開啟彈窗顯示搜尋結果
searchDialog.value = true
}
// 點擊搜尋結果後導頁(行為等同選單點擊)
function handleSearchSelect(item) {
searchDialog.value = false
handleSelect(item)
}
// 訊息中心的示意資料,僅用於展示列表,不進行 API 呼叫
const messageItems = [
{ id: 1, title: '系統維護提醒', meta: '今天 09:00 · 資訊中心', icon: mdiBellOutline },
{ id: 2, title: '教務處公告', meta: '昨天 16:20 · 教務處', icon: mdiSchoolOutline },
{ id: 3, title: '宿舍申請結果', meta: '昨天 13:05 · 學務處', icon: mdiHomeCityOutline },
{ id: 4, title: '課表異動通知', meta: '2 天前 · 教務處', icon: mdiCalendarOutline },
]
// v-data-iterator 會包裝 items,這裡取回原始資料物件
function resolveMessageItem(wrapped) {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return wrapped.raw
}
return wrapped
}
// 由 layout 的 action 事件統一進入此處處理
// 目前只處理訊息中心,其他 action 可在此擴充
function handleLayoutAction(type) {
if (type === 'messages') {
messageStore.open()
return
}
}
function performLogout({ message, color }) {
authStore.logout()
tabs.value = []
activeTab.value = null
snackbar.show({ message, color })
router.replace({ name: 'login' })
}
function handleLogout() {
performLogout({ message: '登出成功', color: 'success' })
}
function handleForceLogout(event) {
const message = event?.detail?.message || '請重新登入'
performLogout({ message, color: 'warning' })
}
function handleHttpToast(event) {
const detail = event?.detail
const message = detail?.message
if (!message) return
const level = detail?.level
const color = level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info'
snackbar.show({ message, color, timeout: 3000, location: 'top right', variant: 'flat' })
}
onMounted(() => {
window.addEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
window.addEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
onBeforeUnmount(() => {
window.removeEventListener(SESSION_FORCE_LOGOUT_EVENT, handleForceLogout)
window.removeEventListener(HTTP_TOAST_EVENT, handleHttpToast)
})
// --- Tabs Logic ---
const tabs = ref([])
const activeTab = ref(null)
const showTabs = computed(() => {
return route.meta.layout === 'default'
})
// 遞迴尋找標題
function findTitle(path) {
const recursiveFind = (items) => {
for (const item of items) {
if (item.path === path) return item.title
if (item.subItems?.length) {
const found = recursiveFind(item.subItems)
if (found) return found
}
}
return null
}
// 1. 搜尋 Store 中的選單
let title = recursiveFind(menuStore.menuItems)
if (title) return title
// 2. 搜尋最愛選單
title = recursiveFind(menuStore.favoriteItems)
if (title) return title
// 3. 搜尋靜態範例選單
title = recursiveFind(_menuItemsExample)
if (title) return title
// 4. 特殊路徑處理
if (path === '/') return '首頁'
return path
}
function findMenuItem(path) {
const recursiveFind = (items) => {
for (const item of items) {
if (item.path === path) return item
if (item.subItems?.length) {
const found = recursiveFind(item.subItems)
if (found) return found
}
}
return null
}
return recursiveFind(mergedMenuItems.value)
}
const currentFavoriteInfo = computed(() => {
const path = route.path
const menuItem = findMenuItem(path)
const title =
menuItem?.title ||
(typeof route.meta?.title === 'string' ? route.meta.title : null) ||
findTitle(path)
return {
title,
path,
icon: menuItem?.icon,
}
})
const isCurrentFavorite = computed(() => favoritesStore.isFavorite(route.path))
const isFavoriteActionDisabled = computed(
() => !currentFavoriteInfo.value?.path || route.path === '/'
)
const favoriteActionLabel = computed(() => (isCurrentFavorite.value ? '移除常用' : '加入常用'))
const favoriteActionIcon = computed(() =>
isCurrentFavorite.value ? mdiCloseCircle : mdiPlusCircle
)
function toggleFavoriteItem(item) {
if (!item?.path || item.path === '/') return
favoritesStore.toggle({
title: item.title || findTitle(item.path),
path: item.path,
icon: item.icon,
})
}
function toggleFavorite() {
toggleFavoriteItem(currentFavoriteInfo.value)
}
function handleRemoveFavorite(item) {
toggleFavoriteItem(item)
}
function goHome() {
router.push('/')
}
function updateBreadcrumbs() {
const resolvedTitle = findTitle(route.path)
const fallbackTitle =
resolvedTitle && resolvedTitle !== route.path
? resolvedTitle
: typeof route.meta?.title === 'string'
? route.meta.title
: null
breadcrumbStore.setBreadcrumbs({
path: route.path,
menuItems: mergedMenuItems.value,
favoriteItems: mergedFavoriteItems.value,
fallbackTitle,
homeLabel: '首頁',
homeIcon: mdiHome,
})
}
watch(
[
() => route.path,
() => menuStore.menuItems,
() => menuStore.favoriteItems,
() => favoritesStore.items,
],
() => updateBreadcrumbs(),
{ immediate: true, deep: true }
)
// 監聽路由變化,新增 Tab
watch(
() => route.path,
(newPath) => {
if (!showTabs.value) return
const existingTab = tabs.value.find((t) => t.path === newPath)
if (!existingTab) {
const title = findTitle(newPath)
tabs.value.push({ title, path: newPath })
}
activeTab.value = newPath
},
{ immediate: true }
)
function closeTab(path) {
if (tabs.value.length <= 1) return
const index = tabs.value.findIndex((t) => t.path === path)
if (index === -1) return
tabs.value.splice(index, 1)
// 如果關閉的是當前分頁,則跳轉到其他分頁
if (route.path === path) {
const nextTab = tabs.value[index] || tabs.value[index - 1]
if (nextTab) {
router.push(nextTab.path)
} else {
// 若無剩餘分頁,回到首頁
router.push('/')
}
}
}
</script> </script>
<template>
<AppShell />
</template>
+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 並做瀏覽器檢查,除非使用者明確不需要。
+3 -3
View File
@@ -84,7 +84,7 @@ Layout composables
`src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。 `src/router/routes.ts` 是功能開發時可新增 route 的入口,但不要改壞既有 layout meta、auth meta 與 catch-all route 規則。
登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/components/PageLogin.vue``src/components/login/*` 登入 route 入口是 `src/views/Login.vue`。若只是調整登入畫面內容,優先修改 `src/views/Login.vue``src/components/login/*`
`src/services/modules/auth.ts``src/services/modules/menu.ts` 是預設後端契約的 API adapter。接新後端時常需要調整,但不要把 UI 狀態放進 service module。 `src/services/modules/auth.ts``src/services/modules/menu.ts` 是預設後端契約的 API adapter。接新後端時常需要調整,但不要把 UI 狀態放進 service module。
@@ -93,11 +93,11 @@ Layout composables
以下內容偏向示範資料與範例頁,建立新專案時可依需求替換或刪除: 以下內容偏向示範資料與範例頁,建立新專案時可依需求替換或刪除:
- `src/views/Home.vue` - `src/views/Home.vue`
- `src/components/PageIndex.vue`
- `src/views/maint/*` - `src/views/maint/*`
- `src/components/maint/*` - `src/components/maint/*`
- `src/composables/maint/*` - `src/composables/maint/*`
- `src/components/PageMaint.vue` - `src/components/maint/MaintShell.vue`
- `src/stores/students.ts` - `src/stores/students.ts`
- `src/stores/semesters.ts` - `src/stores/semesters.ts`
- `src/views/FncPage.vue` - `src/views/FncPage.vue`
+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 並做瀏覽器檢查,除非使用者明確不需要。
-337
View File
@@ -1,337 +0,0 @@
# PageIndex 規格表
來源元件:`src/components/PageIndex.vue`
`PageIndex` 是首頁主畫面展示元件,負責組合歡迎區、最新消息、訊息中心入口、快速存取與最新消息 dialog。元件本身不直接呼叫後端 API;所有資料都透過 props 傳入,互動則透過 emit 交給外層 view 處理。
目前使用位置:`src/views/Home.vue`
## 功能總覽
| 功能區塊 | 功能說明 | 主要輸入 | 主要輸出事件 | 需要後端 API |
|---|---|---|---|---|
| 歡迎區 | 顯示固定歡迎文字與操作提示。 | 無 | 無 | 否,目前為靜態文案。 |
| 最新消息列表 | 以 `v-data-iterator` 顯示消息清單,包含日期、月份、標題、NEW 標籤、摘要、單位與瀏覽次數。 | `newsItems` | `news(item)` | 一般需要;現況由 `Home.vue` 靜態陣列提供。 |
| 最新消息詳情 dialog | 顯示被選取消息的標題、日期、單位、瀏覽次數與內容。開關狀態由外層控制。 | `selectedNews`, `isNewsDialogOpen` | `update:isNewsDialogOpen` | 視需求;若列表已含完整內容可不另打詳情 API,若需完整內文或附件則建議補 API。 |
| 訊息中心入口 | 顯示訊息中心卡片與未讀文字,點擊後通知外層開啟訊息中心。 | 無 | `message-center` | 一般需要;現況文字 `12 筆未讀` 為靜態內容,點擊後只開啟共用 dialog。 |
| 快速存取 | 顯示常用操作入口卡片。 | `quickItems` | `quick(item)` | 視需求;現況由 `Home.vue` 靜態陣列提供,點擊只顯示 snackbar。 |
## 現況資料來源
| 資料 | 目前來源 | 說明 | 後端需求 |
|---|---|---|---|
| `newsItems` | `src/views/Home.vue` 靜態陣列 | 包含 3 筆示意最新消息。 | 現況否;正式系統通常需要。 |
| `quickItems` | `src/views/Home.vue` 靜態陣列 | 包含線上加選、線上退選、成績查詢、個人課表、網路請假、場地借用。 | 視需求。 |
| `selectedNews` | `src/views/Home.vue` 本機 `ref` | 點擊消息後設定。 | 否。 |
| `isNewsDialogOpen` | `src/views/Home.vue` 本機 `ref` | 控制消息 dialog 開關。 | 否。 |
| 訊息中心開關 | `src/stores/messages.ts` | store 只管理 dialog 開關狀態,不包含訊息資料。 | 否,但訊息內容正式化時需要。 |
| 訊息中心未讀文字 | `PageIndex.vue` 靜態文案 | 固定顯示 `12 筆未讀`。 | 正式系統建議需要。 |
## 一般建議補齊的 API 配合清單
這份清單是以一般後台首頁實作來看,列出 `PageIndex` 常見會需要後端配合的資料。現況沒有任何 API 直接供應 `PageIndex`;所有資料都是外層靜態資料或本機狀態。
| 類別 | 建議 API | 對應首頁功能 | 必要性 | 說明 |
|---|---|---|---|---|
| 最新消息列表 | `Announcement/GetLatest``News/GetLatest` | 最新消息列表 `newsItems` | 建議 | 回傳首頁要顯示的最新消息,通常需支援發布狀態、排序、置頂、有效日期與使用者可見範圍。 |
| 最新消息詳情 | `Announcement/GetDetail``News/GetDetail` | 最新消息詳情 dialog | 視需求 | 若列表只回摘要,點擊後應用 detail API 取得完整內容、附件、連結或已讀狀態。 |
| 消息瀏覽次數 | `Announcement/AddView` 或由 detail API 自動累計 | `views` 顯示 | 視需求 | 若瀏覽次數要準確,通常由後端統計;目前 `views` 是靜態文字。 |
| 訊息未讀數 | `Message/GetUnreadCount``Notification/GetUnreadCounts` | 訊息中心入口未讀文字 | 建議 | 首頁卡片與 layout toolbar badge 可共用同一份未讀數 API。 |
| 訊息清單 | `Message/GetMessages` | 訊息中心 dialog | 建議 | 點擊首頁訊息中心後應取得訊息列表;現況只開啟共用 dialog,訊息內容在 `App.vue` 仍是示意資料。 |
| 訊息已讀 | `Message/MarkAsRead`, `Message/MarkAllAsRead` | 訊息中心互動與未讀數歸零 | 視需求 | 若訊息中心有未讀狀態,通常需要已讀更新 API。 |
| 快速存取查詢 | `Shortcut/GetHomeShortcuts``Menu/GetQuickAccess` | 快速存取 `quickItems` | 視需求 | 若快速存取要依角色、權限或個人偏好變動,應由後端或既有選單資料推導。 |
| 快速存取維護 | `Shortcut/SaveHomeShortcuts`, `Shortcut/UpdateOrder` | 自訂首頁快捷入口 | 可選 | 只有開放使用者自訂首頁快捷時才需要。 |
| 快速存取導頁 | 可沿用 `Menu/GetMenu` 回傳路徑 | 快速存取點擊後導頁 | 建議 | 現況 `quickItems` 沒有 path,點擊只顯示 snackbar;正式系統應提供可導頁資訊。 |
| 首頁統計摘要 | `Dashboard/GetSummary` | 未來若加入待辦、申請、課程、公告統計卡片 | 可選 | 目前 `PageIndex` 沒有統計卡片;只有產品需要首頁 dashboard 時才補。 |
## API 優先順序建議
| 優先順序 | API / 功能 | 建議理由 |
|---|---|---|
| 1 | `Announcement/GetLatest` | 最新消息是首頁主要內容,正式系統不應長期使用靜態資料。 |
| 2 | `Message/GetUnreadCount` 或共用 `Notification/GetUnreadCounts` | 首頁訊息中心入口目前有固定未讀數,需改為真實資料。 |
| 3 | `Message/GetMessages` | 點擊訊息中心後應顯示真實訊息清單。 |
| 4 | 快速存取 path / 導頁資料 | 現況快速存取無法真的導頁,只顯示 snackbar。 |
| 5 | `Announcement/GetDetail` | 若列表資料不足以顯示完整內容,再補詳情 API。 |
| 6 | 快速存取自訂與排序 API | 屬個人化體驗,不影響首頁基本可用性,可後續處理。 |
## 建議 API 回傳格式
以下格式是給後端製作 API 時的建議契約。若沿用現有 service 包裝,前端實際讀取位置可能是 `res.data.data`;欄位命名可配合既有後端規範調整,但資料語意應保持一致。
### `Announcement/GetLatest` 或 `News/GetLatest`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": 1,
"title": "113學年度第2學期加退選開始",
"summary": "加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。",
"departmentId": "academic",
"departmentName": "教務處",
"publishedAt": "2026-01-29T09:00:00+08:00",
"views": 1234,
"isNew": true,
"isPinned": false
}
],
"total": 1
}
}
```
### `Announcement/GetDetail` 或 `News/GetDetail`
```json
{
"success": true,
"message": "",
"data": {
"id": 1,
"title": "113學年度第2學期加退選開始",
"content": "加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。",
"summary": "加退選時間為1月29日至2月9日止。",
"departmentId": "academic",
"departmentName": "教務處",
"publishedAt": "2026-01-29T09:00:00+08:00",
"views": 1235,
"isNew": true,
"attachments": [
{
"id": "file-1",
"fileName": "加退選說明.pdf",
"url": "/service/files/file-1"
}
],
"links": [
{
"title": "前往選課系統",
"url": "/course-add"
}
]
}
}
```
### `Announcement/AddView`
```json
{
"success": true,
"message": "",
"data": {
"id": 1,
"views": 1235
}
}
```
### `Message/GetUnreadCount`
```json
{
"success": true,
"message": "",
"data": {
"unreadCount": 12
}
}
```
### `Notification/GetUnreadCounts`
```json
{
"success": true,
"message": "",
"data": {
"notifications": 3,
"messages": 12
}
}
```
### `Message/GetMessages`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "msg-1",
"title": "系統維護提醒",
"summary": "系統將於週六凌晨維護。",
"sender": "資訊中心",
"sentAt": "2026-05-07T09:00:00+08:00",
"isRead": false,
"link": "/messages/msg-1"
}
],
"total": 1,
"unreadCount": 1
}
}
```
### `Message/MarkAsRead`
```json
{
"success": true,
"message": "已標記為已讀",
"data": {
"id": "msg-1",
"isRead": true,
"unreadCount": 0
}
}
```
### `Message/MarkAllAsRead`
```json
{
"success": true,
"message": "已全部標記為已讀",
"data": {
"unreadCount": 0
}
}
```
### `Shortcut/GetHomeShortcuts` 或 `Menu/GetQuickAccess`
```json
{
"success": true,
"message": "",
"data": [
{
"id": "course-add",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1,
"source": "menu"
},
{
"id": "score-query",
"title": "成績查詢",
"path": "/score-query",
"icon": "mdiChartBar",
"sort": 2,
"source": "menu"
}
]
}
```
### `Shortcut/SaveHomeShortcuts`
```json
{
"success": true,
"message": "快捷已儲存",
"data": [
{
"id": "course-add",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1
}
]
}
```
### `Shortcut/UpdateOrder`
```json
{
"success": true,
"message": "排序已更新",
"data": [
{
"id": "course-add",
"sort": 1
},
{
"id": "score-query",
"sort": 2
}
]
}
```
### `Dashboard/GetSummary`
```json
{
"success": true,
"message": "",
"data": {
"todoCount": 2,
"pendingApplicationCount": 1,
"todayCourseCount": 4,
"unreadMessageCount": 12,
"latestAnnouncementCount": 3
}
}
```
## 可維持前端處理的功能
| 功能 | 原因 |
|---|---|
| 最新消息 dialog 開關 | 純 UI 狀態,使用 `selectedNews``isNewsDialogOpen` 即可。 |
| 最新消息列表解包 `resolveNewsItem` | 只是處理 Vuetify `v-data-iterator` wrapper,不需後端配合。 |
| 歡迎區文案 | 若各角色顯示相同文字,可維持靜態。 |
| 快速存取卡片排版 | 展示方式屬前端 UI,不需後端參與。 |
| 點擊快速存取後的前端導頁 | 只要資料含 path,router push 可由前端處理。 |
## Props 與狀態來源
| Prop / 狀態 | 用途 | 預設值或目前來源 | 後端需求 |
|---|---|---|---|
| `newsItems` | 最新消息列表資料。 | `Home.vue` 靜態陣列。 | 正式系統建議來自最新消息 API。 |
| `quickItems` | 快速存取卡片資料。 | `Home.vue` 靜態陣列。 | 視需求,可由選單、權限或快捷 API 提供。 |
| `selectedNews` | 最新消息 dialog 顯示的目前消息。 | `Home.vue` 點擊消息後設定。 | 否。 |
| `isNewsDialogOpen` | 最新消息 dialog 開關。 | `Home.vue` 本機 `ref`。 | 否。 |
## 建議資料結構
### 最新消息 `NewsItem`
| 欄位 | 型別 | 用途 | API 建議 |
|---|---|---|---|
| `id` | `number` | 列表 key 與詳情查詢識別。 | 後端提供。 |
| `date` | `string` | 日期 badge 的日期。 | 可由後端直接提供,或前端由發布日期格式化。 |
| `month` | `string` | 日期 badge 的月份。 | 可由後端直接提供,或前端由發布日期格式化。 |
| `title` | `string` | 消息標題。 | 後端提供。 |
| `desc` | `string` | 消息摘要或內容。 | 列表 API 可回摘要;詳情 API 可回完整內容。 |
| `dept` | `string` | 發布單位。 | 後端提供。 |
| `views` | `string` | 瀏覽次數顯示文字。 | 建議後端回 number,前端格式化。 |
| `isNew` | `boolean` | 是否顯示 NEW 標籤。 | 可由後端提供,或前端依發布日期推導。 |
### 快速存取 `QuickItem`
| 欄位 | 型別 | 用途 | API 建議 |
|---|---|---|---|
| `icon` | `string` | 卡片圖示文字。 | 若正式使用 MDI icon,建議改為 icon key 或路徑。 |
| `title` | `string` | 卡片標題。 | 後端或前端設定提供。 |
| `path` | `string` | 目前型別尚未定義;正式導頁建議補上。 | 建議由後端或 `Menu/GetMenu` 對應功能提供。 |
## 事件契約
| 事件 | 觸發時機 | 外層目前處理 |
|---|---|---|
| `news(item)` | 點擊最新消息卡片。 | `Home.vue` 設定 `selectedNews` 並開啟 dialog。 |
| `message-center` | 點擊訊息中心卡片。 | `Home.vue` 呼叫 `messageStore.open()`。 |
| `quick(item)` | 點擊快速存取卡片。 | `Home.vue` 顯示 snackbar`前往:${item.title}`。 |
| `update:isNewsDialogOpen(value)` | dialog 開關狀態變更或點擊關閉。 | `Home.vue` 寫回 `isNewsDialogOpen`。 |
-234
View File
@@ -1,234 +0,0 @@
<template>
<v-container class="pa-0" fluid>
<div class="d-flex flex-column ga-5 pt-1 pb-4 pr-2 pl-0">
<v-sheet
class="d-flex flex-column flex-sm-row align-start align-sm-center ga-4 pa-5 elevation-1"
color="surface"
>
<v-avatar color="primary" size="52" variant="tonal">
<span class="text-h5">👋</span>
</v-avatar>
<div>
<div class="text-h5 font-weight-bold">歡迎使用校務資訊系統</div>
<div class="text-body-2 text-medium-emphasis mt-1">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</div>
</div>
</v-sheet>
<section class="d-flex flex-column">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">📰 最新消息</div>
<!--
使用 v-data-iterator 保留一致的列表輸出結構
讓首頁消息之後若要加排序或分頁時不需要再重寫畫面骨架
-->
<v-data-iterator class="mt-2" item-key="id" :items="props.newsItems" :items-per-page="-1">
<!--
Vuetify 會把原始資料包進 wrapper
這裡統一解包可避免模板層散落型別判斷
-->
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card
class="news-item d-flex flex-column flex-sm-row ga-4 pa-4 bg-surface"
variant="outlined"
@click="emit('news', resolveNewsItem(wrapped))"
>
<v-sheet class="news-badge">
<div class="news-badge-date">{{ resolveNewsItem(wrapped).date }}</div>
<div class="news-badge-month">{{ resolveNewsItem(wrapped).month }}</div>
</v-sheet>
<div class="flex-grow-1">
<div class="d-flex flex-wrap align-center font-weight-bold">
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="primary"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</div>
<div class="text-body-2 text-medium-emphasis mt-2">
{{ resolveNewsItem(wrapped).desc }}
</div>
<div class="d-flex ga-4 mt-3 text-caption text-medium-emphasis">
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiFolderOutline" />
<span>{{ resolveNewsItem(wrapped).dept }}</span>
</div>
<div class="d-flex align-center ga-1">
<v-icon size="14" :icon="mdiEyeOutline" />
<span>{{ resolveNewsItem(wrapped).views }} 次瀏覽</span>
</div>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</section>
<v-card
class="d-flex align-center justify-space-between ga-3 px-5 py-4"
color="secondary"
rounded="xl"
variant="tonal"
@click="emit('message-center')"
>
<div class="d-flex align-center ga-4">
<v-avatar color="secondary" size="44" variant="flat">
<span class="text-h6"></span>
</v-avatar>
<div>
<div class="text-subtitle-1 font-weight-bold">訊息中心</div>
<div class="text-body-2 font-weight-bold text-on-secondary">12 筆未讀</div>
</div>
</div>
<div class="text-body-2 font-weight-medium">查看全部 </div>
</v-card>
<section class="d-flex flex-column pb-4">
<div class="d-flex align-center ga-2 text-h6 font-weight-bold">🚀 快速存取</div>
<v-row class="mt-2" density="compact">
<v-col v-for="item in props.quickItems" :key="item.title" cols="6" md="2" sm="4">
<v-card
class="d-flex flex-column align-center ga-2 text-center py-4 px-2 quick-item"
variant="outlined"
@click="emit('quick', item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-2 font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</section>
</div>
<!--
這個 dialog 只做消息內容呈現
開關狀態仍交給 view 管理避免頁面元件自行持有流程狀態
-->
<v-dialog
:model-value="props.isNewsDialogOpen"
max-width="640"
@update:model-value="emit('update:isNewsDialogOpen', $event)"
>
<v-card v-if="props.selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ props.selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ props.selectedNews.month }} {{ props.selectedNews.date }} ·
{{ props.selectedNews.dept }} · {{ props.selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ props.selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="emit('update:isNewsDialogOpen', false)">
關閉
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-container>
</template>
<script setup lang="ts">
import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
interface NewsItem {
id: number
date: string
month: string
title: string
desc: string
dept: string
views: string
isNew: boolean
}
interface QuickItem {
icon: string
title: string
}
const props = defineProps<{
newsItems: NewsItem[]
quickItems: QuickItem[]
selectedNews: NewsItem | null
isNewsDialogOpen: boolean
}>()
const emit = defineEmits<{
news: [item: NewsItem]
'message-center': []
quick: [item: QuickItem]
'update:isNewsDialogOpen': [value: boolean]
}>()
// Vuetify 的 iterator 會回傳包裝後的資料,這裡集中解包可維持模板單純。
function resolveNewsItem(wrapped: unknown): NewsItem {
if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
return (wrapped as { raw: NewsItem }).raw
}
return wrapped as NewsItem
}
</script>
<style scoped>
.news-item {
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.news-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
.news-badge {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgb(var(--v-theme-primary));
color: rgb(var(--v-theme-on-primary));
border-radius: 12px;
padding: 10px 6px;
min-height: 64px;
min-width: 64px;
}
.news-badge-date {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.news-badge-month {
font-size: 12px;
margin-top: 4px;
}
.quick-item {
display: flex;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.quick-item:hover {
transform: translateY(-2px);
box-shadow: 0 10px 24px rgba(var(--v-theme-on-surface), 0.12);
}
</style>
-532
View File
@@ -1,532 +0,0 @@
<template>
<v-sheet class="bg-surface" :class="layoutClass" height="100%">
<!-- Side Layouts -->
<v-row
v-if="props.layout !== 'card'"
class="fill-height"
:class="{ 'flex-row-reverse': props.layout === 'side-right' }"
no-gutters
>
<!-- Illustration Column -->
<v-col
class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
cols="12"
lg="8"
sm="6"
>
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="props.branding.title" />
</div>
<v-sheet
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
max-width="680"
rounded="lg"
width="100%"
>
<LoginAnnouncementBoard
:all-tab-label="props.announcementBoard.allTabLabel"
:date-header="props.announcementBoard.dateHeader"
:empty-text="props.announcementBoard.emptyText"
:items="props.announcementBoard.items"
:items-per-page="props.announcementBoard.itemsPerPage"
:pagination-label="props.announcementBoard.paginationLabel"
:school-header="props.announcementBoard.schoolHeader"
:system-announcements="props.announcementBoard.systemAnnouncements"
:tabs="props.announcementBoard.tabs"
:title="props.announcementBoard.title"
:title-header="props.announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement"
/>
</v-sheet>
</v-col>
<v-col
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
cols="12"
lg="4"
sm="6"
>
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
<v-banner
class="d-sm-none mb-2"
density="comfortable"
lines="one"
:mobile="false"
:stacked="false"
>
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon
class="mobile-banner-icon"
color="primary"
size="small"
:icon="mdiBullhornVariantOutline"
/>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none"
color="primary"
size="small"
variant="text"
@click="mobileAnnouncementSheetVisible = true"
>
{{ props.mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
>
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</div>
</v-col>
</v-row>
<!-- Card Layout (Centered) -->
<v-row
v-else
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
no-gutters
>
<v-card
class="rounded-lg"
:class="props.toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
elevation="10"
max-width="450"
width="100%"
>
<LoginToolBar
v-if="props.toolbar.show"
:locale="props.toolbar.locale"
:locales="props.toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="props.branding.title" />
</div>
<LoginHeader
class="d-none d-md-block"
:welcome-description="props.header.welcomeDescription"
:welcome-text="props.header.welcomeText"
/>
<LoginForm
:acc-placeholder="props.form.accPlaceholder"
:forgot-password-href="props.form.forgotPassword.href"
:forgot-password-target="props.form.forgotPassword.target"
:forgot-password-text="props.form.forgotPassword.text"
:passw-placeholder="props.form.passwPlaceholder"
:remember-me-label="props.form.rememberMeLabel"
:remember-storage-key="props.form.rememberStorageKey"
:submit-text="props.form.submitText"
@forgot-password="handleForgotPassword"
@submit="handleLogin"
>
<template v-if="props.form.withCaptcha" #verify>
<LoginVerify
:captcha="props.form.captcha"
:captcha-placeholder="props.form.captchaPlaceholder"
:error-message="props.form.captchaErrorMessage"
:loading="props.form.captchaLoading"
:model-value="props.form.captchaValue"
:refresh-title="props.form.refreshTitle"
:verified="props.form.captchaVerified"
:verify-text="props.form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ props.branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet v-model="mobileAnnouncementSheetVisible" class="d-sm-none">
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ props.mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span
>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ props.mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ props.mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
</template>
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, ref } from 'vue'
import LoginAnnouncementBoard from './login/LoginAnnouncementBoard.vue'
import LoginBrand from './login/LoginBrand.vue'
import LoginForm from './login/LoginForm.vue'
import LoginHeader from './login/LoginHeader.vue'
import LoginToolBar from './login/LoginToolBar.vue'
import LoginVerify from './login/LoginVerify.vue'
interface BrandingConfig {
title?: string
organization?: string
}
interface IllustrationConfig {
image?: string | null
title?: string
description?: string
}
interface HeaderConfig {
welcomeText?: string
welcomeDescription?: string
}
interface AnnouncementTabConfig {
label: string
value: string
}
interface AnnouncementItemConfig {
id: string | number
date: string
school: string
title: string
tab?: string
}
interface AnnouncementBoardConfig {
title?: string
tabs?: AnnouncementTabConfig[]
items?: AnnouncementItemConfig[]
systemAnnouncements?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
allTabLabel?: string
itemsPerPage?: number
dateHeader?: string
schoolHeader?: string
titleHeader?: string
emptyText?: string
paginationLabel?: string
}
interface MobileAnnouncementConfig {
items?: {
id: string | number
content: string
title?: string
createdAt?: string
}[]
show?: boolean
viewAllText?: string
listTitle?: string
closeText?: string
emptyText?: string
}
interface ForgotPasswordConfig {
text?: string
href?: string
target?: string
}
interface FormConfig {
accPlaceholder?: string
passwPlaceholder?: string
rememberMeLabel?: string
submitText?: string
rememberStorageKey?: string
withCaptcha?: boolean
captcha?: {
imgUrl?: string
id?: string
tokenValue?: string
}
captchaValue?: string
captchaLoading?: boolean
captchaErrorMessage?: string
captchaVerified?: boolean
verifyText?: string
captchaPlaceholder?: string
refreshTitle?: string
forgotPassword: ForgotPasswordConfig
}
interface ToolBarConfig {
show?: boolean
locale?: string
locales?: string[]
}
interface Props {
layout: 'side-left' | 'side-right' | 'card'
branding: BrandingConfig
illustration: IllustrationConfig
announcementBoard: AnnouncementBoardConfig
mobileAnnouncement: MobileAnnouncementConfig
header: HeaderConfig
form: FormConfig
toolbar: ToolBarConfig
}
const props = withDefaults(defineProps<Props>(), {
layout: 'side-left',
branding: () => ({
title: 'Skyteck Login',
organization: 'school',
}),
illustration: () => ({
image: null,
title: 'Login',
description: 'Login to your account',
}),
announcementBoard: () => ({
title: '學校公告區',
tabs: [
{ label: '全部', value: '__all__' },
{ label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' },
],
items: [
{
id: 'announcement-1',
date: '2024-03-19',
school: '市立實踐國中',
title: '臺北市立實踐國中徵求113學年度教學支援工作人員',
tab: 'junior',
},
],
systemAnnouncements: [],
allTabLabel: '全部',
itemsPerPage: 5,
dateHeader: '公告時間',
schoolHeader: '公告學校',
titleHeader: '公告標題',
emptyText: '目前沒有公告資料',
paginationLabel: '總筆數:',
}),
mobileAnnouncement: () => ({
items: [],
show: false,
viewAllText: '查看全部',
listTitle: '系統公告',
closeText: '關閉',
emptyText: '目前沒有公告',
}),
header: () => ({
welcomeText: 'Welcome back 👋🏻',
welcomeDescription: 'Please enter your account password to login',
}),
form: () => ({
accPlaceholder: '請輸入帳號',
passwPlaceholder: '請輸入密碼',
rememberMeLabel: '記住帳號',
submitText: '登入',
rememberStorageKey: 'sklogin.remember.username',
withCaptcha: true,
captcha: undefined,
captchaValue: '',
captchaLoading: false,
captchaErrorMessage: '',
captchaVerified: false,
verifyText: '驗證',
captchaPlaceholder: '驗證碼',
refreshTitle: '點擊刷新驗證碼',
forgotPassword: {
text: '忘記密碼?',
href: '',
target: undefined,
},
}),
toolbar: () => ({
show: true,
locale: 'zh-TW',
locales: ['zh-TW', 'en-US'],
}),
})
const emit = defineEmits([
'submit',
'change-locale',
'forgot-password',
'captcha-refresh',
'captcha-change',
'toggle-layout',
'select-announcement',
])
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => props.mobileAnnouncement.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (props.mobileAnnouncement.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => {
return `layout-${props.layout}`
})
function handleLogin(formData: Record<string, unknown>) {
emit('submit', formData)
}
function handleCaptchaRefresh() {
emit('captcha-refresh')
}
function handleCaptchaChange(value: string) {
emit('captcha-change', value)
}
function handleChangeLocale(nextLocale: string) {
emit('change-locale', nextLocale)
}
function handleToggleLayout() {
emit('toggle-layout')
}
function handleForgotPassword(e: MouseEvent) {
emit('forgot-password', e)
}
function handleSelectAnnouncement(item: AnnouncementItemConfig) {
emit('select-announcement', item)
}
</script>
<style scoped>
:deep(.v-banner__prepend) {
align-self: center;
margin-inline-end: 16px;
}
:deep(.v-banner-actions) {
align-self: center;
}
.mobile-banner-icon {
animation: mobile-banner-breathe 2.8s ease-in-out infinite;
transform-origin: center;
}
@keyframes mobile-banner-breathe {
0%,
100% {
opacity: 0.9;
transform: scale(1);
}
50% {
opacity: 1;
transform: scale(1.08);
}
}
@media (prefers-reduced-motion: reduce) {
.mobile-banner-icon {
animation: none;
}
}
.illustration-panel {
background: linear-gradient(
135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%
);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
/* Specific styles for side-right to flip border */
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
-25
View File
@@ -1,25 +0,0 @@
# Components
`src/components` 放 Vue 元件,包含 layout、page component、feature/domain component 與少量跨頁共用元件。
## 目前結構
- `PageLogin.vue``PageIndex.vue``PageMaint.vue`:頁面型元件,接收 view 組好的資料與事件,負責完整頁面主畫面組裝。
- `layouts/*`app shell 與 layout 子元件。`MainLayout.vue` 負責主框架,`PlainLayout.vue` 負責不套主框架的頁面。
- `layouts/main-layout/*``MainLayout.vue` 拆出的 drawer、app bar、breadcrumb、favorites 等骨架子元件。
- `login/*`:登入頁專用 UI 區塊,服務 `PageLogin.vue`
- `maint/*`maintenance 領域元件,服務 `views/maint/*`
- `maint/master-detail/*`master-detail 維護頁專用子元件。
- `base/*`:真正跨頁重用且不屬於特定 domain 的基礎元件。
## 使用規則
- 不要假設 `src/components` 會自動全域註冊元件;需要使用元件時,依照目前 Vue SFC 慣例明確 import。
- 直接被 route 載入的檔案放在 `src/views`,不要放在 `src/components`
- 負責完整頁面主畫面組裝的元件使用 `Page` 前綴。
- 只服務單一功能或 domain 的元件,放在對應資料夾,不要放進 `base`
- layout 元件只處理 app shell 與框架 UI,不放頁面專屬業務流程。
## 資料流
component 以 props 接收資料,以 emit 回報使用者事件。需要跨頁共享的狀態交給 `src/stores`;可重用流程或較複雜 UI state 放到 `src/composables`
+37
View File
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label?: string
items: any[]
labelCharCount?: number
prependMarginEnd?: number
}>()
const modelValue = defineModel<any>({ required: true })
const prependMinWidth = computed(() =>
props.labelCharCount != null ? `${props.labelCharCount * 0.785}rem` : undefined,
)
const marginEndStyle = computed(() => `${props.prependMarginEnd ?? 8}px`)
</script>
<template>
<v-select v-model="modelValue" variant="outlined" density="compact" hide-details :items="items">
<template v-if="label" #prepend>
<span
class="text-title-small"
:style="prependMinWidth ? { minWidth: prependMinWidth } : undefined"
>
{{ label }}
</span>
</template>
</v-select>
</template>
<style scoped>
:deep(.v-input__prepend) {
margin-inline-end: v-bind(marginEndStyle);
}
</style>
+43
View File
@@ -0,0 +1,43 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label?: string
labelCharCount?: number
prependMarginEnd?: number
readonly?: boolean
}>()
const modelValue = defineModel<string>({ required: true })
const prependMinWidth = computed(() =>
props.labelCharCount != null ? `${props.labelCharCount * 0.785}rem` : undefined
)
const marginEndStyle = computed(() => `${props.prependMarginEnd ?? 8}px`)
</script>
<template>
<v-text-field
v-model="modelValue"
variant="outlined"
density="compact"
hide-details
:readonly="readonly"
>
<template v-if="label" #prepend>
<span
class="text-title-small"
:style="prependMinWidth ? { minWidth: prependMinWidth } : undefined"
>
{{ label }}
</span>
</template>
</v-text-field>
</template>
<style scoped>
:deep(.v-input__prepend) {
margin-inline-end: v-bind(marginEndStyle);
}
</style>
+46
View File
@@ -0,0 +1,46 @@
# Base Components Guide
`src/components/base` 放真正跨頁共用且不屬於特定 domain 的基礎元件。
## 規則
- 只服務單一 domain 的元件不要放進 `base`
- 命名不使用 `Page`/`Section`/`Item` 前綴,直接以功能命名。
## BaseFormTextField
前置 label + `v-text-field`,預設 `variant="outlined"``density="compact"``hide-details`
| Prop | 型別 | 預設 | 說明 |
|------|------|------|------|
| `modelValue` | `string` | — | 雙向綁定字串值 |
| `label` | `string` | `undefined` | `#prepend``<span>` 的文字 |
| `labelCharCount` | `number` | `undefined` | 字數,用於計算 `min-width: 字數 × 0.785rem` |
| `prependMarginEnd` | `number` | `8` | `#prepend``margin-inline-end`px |
| `readonly` | `boolean` | `undefined` | 是否唯讀 |
```vue
<BaseFormTextField
v-model="form.cellPhone"
label="手機"
class="ml-2"
:label-char-count="4"
:prepend-margin-end="16"
/>
```
## BaseFormSelect
前置 label + `v-select`,預設 `variant="outlined"``density="compact"``hide-details`
| Prop | 型別 | 預設 | 說明 |
|------|------|------|------|
| `modelValue` | `any` | — | 雙向綁定值 |
| `items` | `any[]` | — | `v-select``items` |
| `label` | `string` | `undefined` | `#prepend``<span>` 的文字 |
| `labelCharCount` | `number` | `undefined` | 字數,用於計算 `min-width` |
| `prependMarginEnd` | `number` | `8` | `#prepend``margin-inline-end`px |
```vue
<BaseFormSelect v-model="form.status" label="狀態" :items="statusOptions" class="ml-2" />
```
+172
View File
@@ -0,0 +1,172 @@
<script setup lang="ts">
import type { StudentFormState } from '@/composables/maint/useStudentMaintenanceForm'
interface GradeOption {
title: string
value: number
}
defineProps<{
departments: string[]
enrollYears: number[]
fieldErrors: Record<keyof StudentFormState, string[]>
gradeOptions: GradeOption[]
isFormLocked: boolean
isFormReadonly: boolean
statuses: string[]
}>()
const form = defineModel<StudentFormState>({ required: true })
const emit = defineEmits<{
(e: 'clear-field-error', field: keyof StudentFormState): void
}>()
</script>
<template>
<v-row density="compact">
<v-col cols="12" md="6">
<v-text-field
id="field-studentId"
v-model="form.studentId"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.studentId"
label="學號"
placeholder="例如:S2024008"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'studentId')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-name"
v-model="form.name"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.name"
label="姓名"
placeholder="例如:陳怡君"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-department"
v-model="form.department"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.department"
:items="departments"
label="系所"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'department')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-grade"
v-model="form.grade"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.grade"
item-title="title"
item-value="value"
:items="gradeOptions"
label="年級"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'grade')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-enrollYear"
v-model="form.enrollYear"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.enrollYear"
:items="enrollYears"
label="入學年度"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'enrollYear')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-credits"
v-model.number="form.credits"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.credits"
label="已修學分"
min="0"
:readonly="isFormReadonly"
type="number"
variant="outlined"
@update:model-value="emit('clear-field-error', 'credits')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-advisor"
v-model="form.advisor"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.advisor"
label="指導老師"
placeholder="例如:林教授"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'advisor')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-email"
v-model="form.email"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.email"
label="Email"
placeholder="name@school.edu"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'email')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-phone"
v-model="form.phone"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.phone"
label="電話"
placeholder="例如:02-2345-6789"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'phone')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-status"
v-model="form.status"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.status"
:items="statuses"
label="狀態"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="emit('clear-field-error', 'status')"
/>
</v-col>
</v-row>
</template>
+39
View File
@@ -0,0 +1,39 @@
# Layouts Guide
`components/layouts` 是 app shell layout。一般功能需求不應修改這裡。
## 可用 Layout
- **MainLayout**`layout: 'default'`):完整 app shell,包含 drawer、app bar、breadcrumb、favorites、toolbar actions 與主內容 slot。
- **PlainLayout**`layout: 'none'`):極簡空白佈局,只提供 `<v-app>` / `<v-main>` 外殼與一個 slot。登入頁、錯誤頁、維護中頁使用此 layout。
一般功能頁面統一使用 `layout: 'default'`
## MainLayout 責任
- drawer
- app bar
- breadcrumb
- favorites
- toolbar actions
- 主內容 slot
## 禁止放入
- 頁面專屬業務流程
- 查詢條件、表單、列表、CRUD
- 特定 dialog 內容
- API 呼叫
- domain-specific 狀態
如果頁面要影響 breadcrumb、favorites、menu 或 toolbar,優先使用 route meta、store 或 `shell/AppShell.vue` 已提供的 props/events。
## `main-layout/` 子目錄
`src/components/layouts/main-layout/` 收納 MainLayout 拆解出的子組件與共用的型別定義:
- `types.ts``AdminLayoutMenuItem``AdminLayoutBreadcrumbItem``AdminLayoutFeatures` 等型別,供 layout composable 與 shell 使用。
- `DrawerDesktopMenu.vue` / `DrawerMobileMenuPanel.vue` / `DrawerMobileFavoritesPanel.vue`:桌面與行動版 drawer 內容。
- `AppBarTopCol.vue` / `AppBarBreadcrumbCol.vue` / `AppBarFavoritesCol.vue`app bar 不同列的組件。
一般功能需求不應修改這裡的檔案。
-430
View File
@@ -1,430 +0,0 @@
# MainLayout 規格表
來源元件:`src/components/layouts/MainLayout.vue`
`MainLayout` 是預設後台版型的 app shell,負責組合側邊選單、頂部工具列、常用功能列、breadcrumb、內容區與輔助視窗。元件本身不直接呼叫後端 API;所有資料都透過 props 傳入,互動則透過 emit 交給外層處理。
## 功能總覽
| 功能區塊 | 功能說明 | 主要輸入 | 主要輸出事件 | 需要後端 API |
|---|---|---|---|---|
| 系統品牌區 | 顯示系統標題、副標題,或由 `title` slot 覆蓋。 | `systemTitle`, `systemSubtitle`, `title` slot | 無 | 否,目前為 props/default。 |
| 使用者資訊 | 顯示使用者頭像文字、姓名、角色。可用 feature toggle 關閉。 | `userProfile`, `features.showUserInfo` | 無 | 否,目前為 props/default。 |
| 側邊選單 | 桌面版顯示多層 drawer menu;支援展開群組、收合 rail、選單項目導頁。 | `menuItems`, `isRail`, `drawerConfig` | `select`, `toggle-sidebar`, `update:isRail` | 是,`menuItems` 目前由 `GetMenu` 取得後轉換。 |
| 行動版選單 | 行動版 drawer 使用階層式選單面板,點擊有子層項目會進入下一層,點擊葉節點會選取並關閉 drawer。 | `menuItems`, Vuetify display 狀態 | `select`, `toggle-sidebar` | 是,資料同 `menuItems`,目前由 `GetMenu` 取得後轉換。 |
| 行動版階層導覽 | 在 drawer 上方顯示「主選單」與目前進入的選單層級,可返回任一層。 | `menuItems` 衍生出的 `mobileMenuLevels` | 無 | 是,層級內容間接來自 `GetMenu`。 |
| 常用功能列 | 桌面版顯示常用功能 chip,可選取、移除,也可顯示新增按鈕。 | `favoriteItems`, `favoritesConfig`, `favoritesBarVisible`, `features.showFavorites` | `select`, `add-favorite`, `remove-favorite`, `update:favoritesBarVisible` | 否,目前不是由後端提供;`GetFavorite` 已有 service/store 方法但登入流程中未啟用。 |
| 行動版常用功能 | 行動版 drawer 可切換到常用功能面板並選取項目。 | `favoriteItems`, `features.showFavorites` | `select` | 否,目前不是由後端提供。 |
| 搜尋列 | 輸入關鍵字後,按 Enter 或按鈕才觸發搜尋;觸發後清空輸入。 | `searchConfig`, `features.showSearch` | `search` | 否,`MainLayout` 只送出關鍵字;目前外層用已載入的選單做前端比對。 |
| 工具列通知 | 顯示通知按鈕與 badge 數量。 | `toolbarActions.notificationsLabel`, `toolbarCounts.notifications` | `action('notifications')` | 否,目前只是按鈕與 props 數字。 |
| 工具列訊息 | 顯示訊息按鈕與 badge 數量。 | `toolbarActions.messagesLabel`, `toolbarCounts.messages` | `action('messages')` | 否,目前外層開啟示意訊息 dialog,沒有 API。 |
| 工具列設定 | 顯示設定 menu,可切換常用功能列與 breadcrumb 顯示狀態。 | `showFavoritesBar`, `breadcrumbBarVisible` | `update:favoritesBarVisible`, `update:breadcrumbBarVisible` | 否,屬本機 UI 狀態。 |
| 登出 | 顯示登出按鈕,點擊後交由外層處理。 | `logoutLabel` | `logout` | 否,layout 本身不呼叫 API。 |
| 主題切換 | feature 開啟時顯示主題切換按鈕,透過 `useThemeToggle` 切換 Vuetify theme。 | `features.showThemeToggle`, `themeToggleLabel` | `toggle-theme` | 否,本機 theme 狀態。 |
| Breadcrumb | 桌面版顯示目前頁面路徑;未傳入項目時顯示預設首頁。可插入 `breadcrumb-actions` slot。 | `breadcrumbItems`, `breadcrumbConfig`, `breadcrumbBarVisible`, `features.showBreadcrumb` | `update:breadcrumbBarVisible`, `update:favoritesBarVisible` | 否,目前由外層依路由與已載入選單推導。 |
| 內容區 | 以 `slot` 承載各頁面內容,並依 app bar 高度動態設定 `v-main` padding。 | default slot | 無 | 否。 |
| 操作說明浮窗 | 收到 `help` action 時顯示暫時說明內容,可關閉。 | `toolbarActions.helpLabel` | `action('help')` | 否,目前內容為靜態文字,且 help 按鈕在子元件中未顯示。 |
## 後端 API 需求
| API | 目前狀態 | 呼叫位置 | 提供給 `MainLayout` 的資料 | Request | Response 對應 |
|---|---|---|---|---|---|
| `Menu/GetMenu` | 已啟用 | `src/views/Login.vue` 登入成功後呼叫 `menuStore.getMenu(authStore.user?.id ?? '')` | `menuItems` | `{ userID: string }` | `res.data.data` 存入 `menuStore.menu`,再由 `toLayoutMenuItems()` 轉成 layout 選單。 |
| `Menu/GetFavorite` | 未啟用 | service/store 已存在,但登入流程呼叫被註解 | 無 | `{ userID: string }` | 若未來啟用,可轉成 `favoriteItems`;目前不列為後端需求。 |
## 一般建議補齊的 API 配合清單
這份清單是以一般後台系統實作來看,列出 `MainLayout` 常見會需要後端配合的資料。現況仍只有 `Menu/GetMenu` 已接上;其餘項目可依產品需求決定是否實作。
| 類別 | 建議 API | 對應 layout 功能 | 必要性 | 說明 |
|---|---|---|---|---|
| 選單與權限 | `Menu/GetMenu` | 側邊選單、行動版選單、前端選單搜尋、breadcrumb 推導 | 必要,已啟用 | 依使用者、角色、權限回傳可用功能。 |
| 使用者資訊 | `User/GetCurrentUser``User/GetProfile` | 使用者資訊區 `userProfile` | 建議 | 回傳姓名、角色、單位、頭像文字或頭像 URL。現況使用 default props。 |
| 常用功能查詢 | `Menu/GetFavorite``Favorite/GetFavorites` | 常用功能列、行動版常用功能 | 建議 | 若常用功能要跨裝置保存,就應由後端提供。現況 service/store 已有 `Menu/GetFavorite`,但登入流程未啟用。 |
| 常用功能維護 | `Favorite/AddFavorite`, `Favorite/RemoveFavorite`, `Favorite/UpdateFavoriteOrder` | 新增常用、移除常用、常用排序 | 建議 | 現況常用功能主要由前端本機 store 處理;若要保存到帳號需補 API。 |
| 未讀數量 | `Notification/GetUnreadCounts` | 通知 badge、訊息 badge `toolbarCounts` | 建議 | 可一次回傳 `{ notifications, messages }`,避免 layout 分別打多支 API。現況預設皆為 `0`。 |
| 通知清單 | `Notification/GetNotifications` | 通知按鈕點擊後的通知列表 | 視需求 | 目前 layout 只 emit `action('notifications')`,外層尚未實作通知 UI。 |
| 通知已讀 | `Notification/MarkAsRead`, `Notification/MarkAllAsRead` | 通知清單互動、badge 歸零 | 視需求 | 若有通知列表,通常需要搭配已讀狀態更新。 |
| 訊息清單 | `Message/GetMessages` | 訊息按鈕點擊後的訊息 dialog | 建議 | 現況 `App.vue` 使用示意資料,不含 API。 |
| 訊息已讀 | `Message/MarkAsRead`, `Message/MarkAllAsRead` | 訊息清單互動、badge 歸零 | 視需求 | 若訊息中心要顯示未讀數,通常需要已讀 API。 |
| 搜尋 | `Search/SearchMenu``Search/GlobalSearch` | 搜尋列 | 視需求 | 只搜尋目前已載入選單可維持前端搜尋;若要搜尋公告、頁面、業務資料或權限內功能,應補後端搜尋 API。 |
| 登出 | `Auth/Logout``Auth/RevokeToken` | 登出按鈕 | 視認證架構 | 若後端有 session、refresh token 或 token revoke 機制,需要呼叫後端;若只是清 local token,可維持前端處理。 |
| 使用者偏好 | `UserPreference/GetLayoutSettings`, `UserPreference/SaveLayoutSettings` | 側欄收合、常用列顯示、breadcrumb 顯示、主題 | 可選 | 目前可用 localStorage/store 處理;只有需要跨裝置同步時才需要 API。 |
| 操作說明 | `Help/GetPageHelp` 或 CMS API | 操作說明浮窗 | 可選 | 現況為靜態暫時文字,且 help 按鈕未顯示;若說明內容需依頁面、角色或版本管理才需要 API。 |
## API 優先順序建議
| 優先順序 | API / 功能 | 建議理由 |
|---|---|---|
| 1 | `Menu/GetMenu` | layout 最核心資料,決定使用者可見功能與導頁入口。 |
| 2 | `User/GetCurrentUser` | 使用者資訊區不應長期使用假資料,且常被其他功能共用。 |
| 3 | `Favorite/GetFavorites` 與常用功能維護 API | 常用功能若要符合使用者帳號體驗,需要後端保存。 |
| 4 | `Notification/GetUnreadCounts` / `Message/GetMessages` | toolbar badge 與訊息中心目前是 demo 狀態,若要上線需補。 |
| 5 | `Search/GlobalSearch` | 只有當搜尋範圍超過目前選單時才需要。 |
| 6 | `UserPreference` 類 API | 屬體驗同步,不影響核心操作,可最後處理。 |
## 建議 API 回傳格式
以下格式是給後端製作 API 時的建議契約。若沿用現有 service 包裝,前端實際讀取位置可能是 `res.data.data`;欄位命名可配合既有後端規範調整,但資料語意應保持一致。
### `Menu/GetMenu`
```json
{
"success": true,
"message": "",
"data": [
{
"mdl_id": "student",
"mdl_name": "學生資訊",
"children": [
{
"unt_id": "course",
"unt_name": "選課作業",
"children": [
{
"fnc_id": "course-add",
"fnc_name": "線上加選"
}
]
}
]
}
]
}
```
### `User/GetCurrentUser` 或 `User/GetProfile`
```json
{
"success": true,
"message": "",
"data": {
"id": "A123456789",
"name": "王小明",
"role": "資訊工程系 - 學生",
"avatarText": "王",
"departmentId": "CS",
"departmentName": "資訊工程系"
}
}
```
### `Menu/GetFavorite` 或 `Favorite/GetFavorites`
```json
{
"success": true,
"message": "",
"data": [
{
"id": "fav-1",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1
}
]
}
```
### `Favorite/AddFavorite`
```json
{
"success": true,
"message": "新增成功",
"data": {
"id": "fav-1",
"title": "線上加選",
"path": "/course-add",
"icon": "mdiPlus",
"sort": 1
}
}
```
### `Favorite/RemoveFavorite`
```json
{
"success": true,
"message": "移除成功",
"data": {
"id": "fav-1",
"path": "/course-add"
}
}
```
### `Favorite/UpdateFavoriteOrder`
```json
{
"success": true,
"message": "排序已更新",
"data": [
{
"id": "fav-1",
"path": "/course-add",
"sort": 1
},
{
"id": "fav-2",
"path": "/score-query",
"sort": 2
}
]
}
```
### `Notification/GetUnreadCounts`
```json
{
"success": true,
"message": "",
"data": {
"notifications": 3,
"messages": 12
}
}
```
### `Notification/GetNotifications`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "notice-1",
"title": "系統維護提醒",
"content": "系統將於週六凌晨維護。",
"source": "資訊中心",
"publishedAt": "2026-05-07T09:00:00+08:00",
"isRead": false,
"link": "/announcements/notice-1"
}
],
"total": 1,
"unreadCount": 1
}
}
```
### `Notification/MarkAsRead`
```json
{
"success": true,
"message": "已標記為已讀",
"data": {
"id": "notice-1",
"isRead": true,
"unreadCount": 0
}
}
```
### `Notification/MarkAllAsRead`
```json
{
"success": true,
"message": "已全部標記為已讀",
"data": {
"unreadCount": 0
}
}
```
### `Message/GetMessages`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "msg-1",
"title": "教務處公告",
"summary": "加退選時間即將開始。",
"sender": "教務處",
"sentAt": "2026-05-07T10:30:00+08:00",
"isRead": false,
"link": "/messages/msg-1"
}
],
"total": 1,
"unreadCount": 1
}
}
```
### `Message/MarkAsRead`
```json
{
"success": true,
"message": "已標記為已讀",
"data": {
"id": "msg-1",
"isRead": true,
"unreadCount": 0
}
}
```
### `Message/MarkAllAsRead`
```json
{
"success": true,
"message": "已全部標記為已讀",
"data": {
"unreadCount": 0
}
}
```
### `Search/SearchMenu` 或 `Search/GlobalSearch`
```json
{
"success": true,
"message": "",
"data": {
"items": [
{
"id": "course-add",
"type": "menu",
"title": "線上加選",
"path": "/course-add",
"parents": ["學生資訊", "選課作業"],
"icon": "mdiPlus"
}
],
"total": 1
}
}
```
### `Auth/Logout` 或 `Auth/RevokeToken`
```json
{
"success": true,
"message": "登出成功",
"data": {
"revoked": true
}
}
```
### `UserPreference/GetLayoutSettings`
```json
{
"success": true,
"message": "",
"data": {
"isRail": false,
"favoritesBarVisible": true,
"breadcrumbBarVisible": true,
"themeName": "light"
}
}
```
### `UserPreference/SaveLayoutSettings`
```json
{
"success": true,
"message": "設定已儲存",
"data": {
"isRail": false,
"favoritesBarVisible": true,
"breadcrumbBarVisible": true,
"themeName": "light",
"updatedAt": "2026-05-07T10:30:00+08:00"
}
}
```
### `Help/GetPageHelp`
```json
{
"success": true,
"message": "",
"data": {
"pageKey": "home",
"title": "操作說明",
"content": "這裡顯示目前頁面的操作說明。",
"updatedAt": "2026-05-07T10:30:00+08:00"
}
}
```
## 可維持前端處理的功能
| 功能 | 原因 |
|---|---|
| Breadcrumb 顯示與路徑推導 | 可由目前 route、`menuItems``favoriteItems` 推導,不一定要後端提供。 |
| 側欄收合狀態 | 屬使用者介面偏好,localStorage 即可;除非要求跨裝置同步。 |
| 常用列顯示 / breadcrumb 顯示 | 屬畫面偏好,localStorage/store 即可。 |
| 主題切換 | 本機 theme 狀態即可;除非要求登入後跨裝置一致。 |
| 前端選單搜尋 | 若搜尋範圍只限已載入的 `menuItems`,不需要後端 API。 |
## `GetMenu` 欄位轉換
| 後端節點層級 | 後端欄位 | Layout 欄位 | 說明 |
|---|---|---|---|
| 模組層 | `mdl_name` | `title` | 第一層選單標題。 |
| 模組層 | `children` | `subItems` | 第二層單元清單。 |
| 單元層 | `unt_name` | `title` | 第二層選單標題。 |
| 單元層 | `children` | `subItems` | 第三層功能清單。 |
| 功能層 | `fnc_name` | `title` | 葉節點功能名稱。 |
| 功能層 | `fnc_id` | `path` | 有值時轉成 `/${fnc_id}`,作為 router path。 |
| 模組層與單元層 | 無 | `navigable: false` | 群組節點預設不可導頁。 |
## Props 與狀態來源
| Prop / 狀態 | 用途 | 預設值或目前來源 | 後端需求 |
|---|---|---|---|
| `menuItems` | 桌面與行動版主選單。 | `App.vue` 使用 `menuStore.menuItems` 加上固定選單合併後傳入。 | 是,來自 `GetMenu`。 |
| `favoriteItems` | 常用功能列與行動版常用面板。 | `App.vue` 合併 `menuStore.favoriteItems``favoritesStore.layoutItems`;目前 `GetFavorite` 未啟用。 | 否。 |
| `breadcrumbItems` | Breadcrumb 顯示。 | `breadcrumbStore` 依 route、`menuItems``favoriteItems` 推導。 | 否。 |
| `userProfile` | 使用者資訊區。 | `MainLayout` default props。 | 否。 |
| `toolbarCounts` | 通知、訊息 badge。 | `MainLayout` default props,預設皆為 `0`。 | 否。 |
| `searchConfig` | 搜尋 placeholder 與 label。 | `MainLayout` default props。 | 否。 |
| `toolbarActions` | 通知、訊息、說明、設定 label。 | `MainLayout` default props。 | 否。 |
| `favoritesConfig` | 常用列 label、新增按鈕 label、是否顯示新增。 | `MainLayout` default props。 | 否。 |
| `breadcrumbConfig` | 首頁 breadcrumb label、disabled、icon。 | `MainLayout` default props。 | 否。 |
| `features` | 控制主題切換、常用列、breadcrumb、搜尋、工具列、使用者資訊是否顯示。 | `MainLayout` default props。 | 否。 |
| `drawerConfig` | drawer 寬度與 rail 寬度。 | `MainLayout` default props。 | 否。 |
| `isRail` | 桌面側欄是否收合。 | `App.vue``v-model:is-rail` 綁定 `menuStore.isRail`store 會寫入 localStorage。 | 否。 |
| `favoritesBarVisible` | 常用功能列是否顯示。 | `App.vue``v-model:favorites-bar-visible` 綁定 `favoritesStore`。 | 否。 |
| `breadcrumbBarVisible` | Breadcrumb 是否顯示。 | `App.vue``v-model:breadcrumb-bar-visible` 綁定 `favoritesStore`。 | 否。 |
## 事件契約
| 事件 | 觸發時機 | 外層目前處理 |
|---|---|---|
| `select(item)` | 選取側邊選單、常用功能或搜尋結果延伸選取時。 | `App.vue` 呼叫 `router.push(item.path)`。 |
| `search(keyword)` | 搜尋列按 Enter 或搜尋按鈕。 | `App.vue` 以已載入的合併選單做前端搜尋並顯示 dialog。 |
| `action(type)` | 點擊通知、訊息、說明等工具列 action。 | `messages` 會開啟訊息 dialog;其他目前無處理。 |
| `logout` | 點擊登出按鈕。 | `App.vue` 清除 auth、tabs,導回 login。 |
| `toggle-sidebar(payload)` | 點擊 drawer 收合/展開按鈕。 | 目前外層未綁定。 |
| `toggle-theme(themeName)` | 切換主題成功。 | 目前外層未綁定。 |
| `add-favorite` | 點擊常用功能新增按鈕。 | 目前外層未綁定。 |
| `remove-favorite(item)` | 點擊常用 chip close。 | `App.vue` 從本機常用清單切換移除。 |
| `update:isRail(value)` | 受控模式下更新側欄 rail 狀態。 | `v-model:is-rail` 寫回 `menuStore.isRail`。 |
| `update:favoritesBarVisible(value)` | 更新常用列顯示狀態。 | `v-model:favorites-bar-visible` 寫回 `favoritesStore`。 |
| `update:breadcrumbBarVisible(value)` | 更新 breadcrumb 顯示狀態。 | `v-model:breadcrumb-bar-visible` 寫回 `favoritesStore`。 |
+29 -2
View File
@@ -1,5 +1,5 @@
<template> <template>
<v-form @submit.prevent="$emit('submit', { username, password, rememberMe })"> <v-form @submit.prevent="handleSubmit">
<v-text-field <v-text-field
v-model="username" v-model="username"
bg-color="surface" bg-color="surface"
@@ -27,8 +27,12 @@
<slot name="verify"></slot> <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-checkbox
v-if="props.withRememberAccount"
v-model="rememberMe" v-model="rememberMe"
color="primary" color="primary"
density="compact" density="compact"
@@ -36,6 +40,7 @@
:label="props.rememberMeLabel" :label="props.rememberMeLabel"
></v-checkbox> ></v-checkbox>
<a <a
v-if="props.withForgotPassword"
class="text-body-2 text-primary text-decoration-none" class="text-body-2 text-primary text-decoration-none"
:href="props.forgotPasswordHref || '#'" :href="props.forgotPasswordHref || '#'"
:target="props.forgotPasswordTarget" :target="props.forgotPasswordTarget"
@@ -100,11 +105,21 @@ const props = defineProps({
type: String, type: String,
default: 'sklogin.remember.username', default: 'sklogin.remember.username',
}, },
withRememberAccount: {
type: Boolean,
default: true,
},
withForgotPassword: {
type: Boolean,
default: true,
},
}) })
const emit = defineEmits(['submit', 'forgot-password']) const emit = defineEmits(['submit', 'forgot-password'])
onMounted(() => { onMounted(() => {
if (!props.withRememberAccount) return
const saved = localStorage.getItem(props.rememberStorageKey) const saved = localStorage.getItem(props.rememberStorageKey)
if (saved) { if (saved) {
username.value = saved username.value = saved
@@ -113,6 +128,8 @@ onMounted(() => {
}) })
watch([rememberMe, username], ([nextRemember, nextUsername]) => { watch([rememberMe, username], ([nextRemember, nextUsername]) => {
if (!props.withRememberAccount) return
if (!nextRemember) { if (!nextRemember) {
localStorage.removeItem(props.rememberStorageKey) localStorage.removeItem(props.rememberStorageKey)
return return
@@ -126,7 +143,17 @@ watch([rememberMe, username], ([nextRemember, nextUsername]) => {
localStorage.setItem(props.rememberStorageKey, 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) { function handleForgotPasswordClick(e: MouseEvent) {
if (!props.withForgotPassword) return
emit('forgot-password', e) emit('forgot-password', e)
if (!props.forgotPasswordHref) { if (!props.forgotPasswordHref) {
e.preventDefault() e.preventDefault()
+26 -20
View File
@@ -2,14 +2,14 @@
<div class="d-flex flex-column"> <div class="d-flex flex-column">
<v-card variant="flat"> <v-card variant="flat">
<v-card-title class="d-flex flex-wrap align-center py-0 ga-2"> <v-card-title class="d-flex flex-wrap align-center py-0 ga-2">
<span class="text-h6">可編輯表格維護示範</span> <span class="text-h6">{{ title }}</span>
<v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal"> <v-chip :color="hasAnyChange ? 'warning' : 'success'" variant="tonal">
{{ hasAnyChange ? '有未儲存變更' : '已同步' }} {{ hasAnyChange ? '有未儲存變更' : '已同步' }}
</v-chip> </v-chip>
<v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip> <v-chip color="info" variant="tonal">筆數 {{ filteredStudents.length }}</v-chip>
<v-spacer /> <v-spacer />
<v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible" <v-btn flat :prepend-icon="mdiMagnify" @click="isSearchVisible = !isSearchVisible"
>條件搜尋</v-btn >顯示條件搜尋</v-btn
> >
<v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" /> <v-switch v-model="isBulkEditEnabled" color="primary" hide-details label="啟用編輯" />
</v-card-title> </v-card-title>
@@ -19,37 +19,31 @@
<v-card-text class="pb-0 pt-2"> <v-card-text class="pb-0 pt-2">
<v-row v-if="isSearchVisible" class="mb-2" density="compact"> <v-row v-if="isSearchVisible" class="mb-2" density="compact">
<v-col cols="12" md="3"> <v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">學號</div> <BaseFormTextField
<v-text-field
v-model="search.studentId" v-model="search.studentId"
clearable clearable
density="compact" label="學號"
hide-details :label-char-count="2"
placeholder="例如:S2024001" placeholder="例如:S2024001"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="3"> <v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">姓名</div> <BaseFormTextField
<v-text-field
v-model="search.name" v-model="search.name"
clearable clearable
density="compact" label="姓名"
hide-details :label-char-count="2"
placeholder="例如:王小明" placeholder="例如:王小明"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="3"> <v-col cols="12" md="3">
<div class="text-body-1 text-medium-emphasis pl-2">系所</div> <BaseFormSelect
<v-select
v-model="search.department" v-model="search.department"
:class="{ 'select-hide-arrow': !isBulkEditEnabled }" :class="{ 'select-hide-arrow': !isBulkEditEnabled }"
clearable clearable
density="compact" label="系所"
hide-details :label-char-count="2"
:items="departments" :items="departments"
variant="outlined"
/> />
</v-col> </v-col>
</v-row> </v-row>
@@ -374,9 +368,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js' import { mdiContentSave, mdiDelete, mdiMagnify, mdiRestore } from '@mdi/js'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue' import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid' import { useEditableStudentGrid } from '@/composables/maint/useEditableStudentGrid'
withDefaults(
defineProps<{
title?: string
}>(),
{
title: '可編輯表格維護示範',
}
)
const { const {
departments, departments,
enrollYears, enrollYears,
@@ -405,7 +410,9 @@ const {
const itemsPerPage = 10 const itemsPerPage = 10
const currentPage = ref(1) const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(filteredStudents.value.length / itemsPerPage))) const pageCount = computed(() =>
Math.max(1, Math.ceil(filteredStudents.value.length / itemsPerPage))
)
const pageSummary = computed(() => { const pageSummary = computed(() => {
const total = filteredStudents.value.length const total = filteredStudents.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆' if (total === 0) return '第 0-0 筆 / 共 0 筆'
@@ -434,8 +441,7 @@ const singleDeleteMessage = computed(() => {
}) })
const selectedDeleteMessage = computed( const selectedDeleteMessage = computed(
() => () => `確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
`確定要刪除目前選取的 ${selectedRowIds.value.length} 筆資料嗎?此操作會在儲存後正式生效。`
) )
watch(pageCount, (value) => { watch(pageCount, (value) => {
@@ -8,7 +8,7 @@
:icon="mdAndUp ? false : mdiMagnify" :icon="mdAndUp ? false : mdiMagnify"
:prepend-icon="mdAndUp ? mdiMagnify : undefined" :prepend-icon="mdAndUp ? mdiMagnify : undefined"
size="small" size="small"
:text="mdAndUp ? '搜尋條件' : false" :text="mdAndUp ? '顯示搜尋條件' : false"
variant="text" variant="text"
@click="$emit('toggle-search')" @click="$emit('toggle-search')"
> >
+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 流程
+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 onAfterDelete?: (deletedId: number) => void
} }
interface UseMaintenanceCrudFlowResult<T extends { id: number }> { export interface UseMaintenanceCrudFlowResult<T extends { id: number }> {
confirmCloseVisible: Ref<boolean> confirmCloseVisible: Ref<boolean>
confirmSaveVisible: Ref<boolean> confirmSaveVisible: Ref<boolean>
confirmDeleteVisible: Ref<boolean> confirmDeleteVisible: Ref<boolean>
+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, toValue, watch, type MaybeRefOrGetter } from 'vue'
import { computed, ref, watch } from 'vue'
export interface LoginAnnouncementItem { export interface LoginAnnouncementItem {
id: string | number id: string | number
@@ -25,6 +24,10 @@ export interface LoginMobileAnnouncementItem {
createdAt?: string createdAt?: string
} }
interface UseLoginAnnouncementsOptions {
enabled: MaybeRefOrGetter<boolean>
}
const storageKey = 'sk_playground_login_announcements' const storageKey = 'sk_playground_login_announcements'
const defaultItems: LoginAnnouncementItem[] = [ const defaultItems: LoginAnnouncementItem[] = [
@@ -110,10 +113,11 @@ async function mockFetchMobileAnnouncementsApi(): Promise<LoginMobileAnnouncemen
] ]
} }
export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () => { export function useLoginAnnouncements(options: UseLoginAnnouncementsOptions) {
const items = ref<LoginAnnouncementItem[]>(readItems()) const items = ref<LoginAnnouncementItem[]>([])
const selectedId = ref<string | number | null>(null) const selectedId = ref<string | number | null>(null)
const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([]) const mobileAnnouncements = ref<LoginMobileAnnouncementItem[]>([])
const enabled = computed(() => toValue(options.enabled))
const listItems = computed<LoginAnnouncementListItem[]>(() => const listItems = computed<LoginAnnouncementListItem[]>(() =>
items.value.map((item) => ({ items.value.map((item) => ({
@@ -132,17 +136,19 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
{ label: '國中', value: 'junior' }, { label: '國中', value: 'junior' },
{ label: '高中', value: 'senior' }, { label: '高中', value: 'senior' },
], ],
items: listItems.value, items: enabled.value ? listItems.value : [],
systemAnnouncements: mobileAnnouncements.value, systemAnnouncements: enabled.value ? mobileAnnouncements.value : [],
itemsPerPage: 5, itemsPerPage: 5,
dateHeader: '公告時間', dateHeader: '公告時間',
schoolHeader: '公告學校', schoolHeader: '公告學校',
titleHeader: '公告標題', titleHeader: '公告標題',
paginationLabel: '總筆數:', paginationLabel: '總筆數:',
allTabLabel: '全部',
emptyText: '目前沒有公告資料',
})) }))
const selectedAnnouncement = computed(() => { 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 return items.value.find((item) => item.id === selectedId.value) ?? null
}) })
@@ -151,59 +157,54 @@ export const useLoginAnnouncementsStore = defineStore('loginAnnouncements', () =
}) })
const mobileAnnouncementConfig = computed(() => ({ const mobileAnnouncementConfig = computed(() => ({
items: mobileAnnouncements.value, items: enabled.value ? mobileAnnouncements.value : [],
show: mobileAnnouncements.value.length > 0, show: enabled.value && mobileAnnouncements.value.length > 0,
viewAllText: '查看全部', viewAllText: '查看全部',
listTitle: '系統公告', listTitle: '系統公告',
closeText: '關閉', closeText: '關閉',
emptyText: '目前沒有公告', emptyText: '目前沒有公告',
})) }))
const hydrate = () => { function hydrate() {
if (!enabled.value) return
items.value = readItems() items.value = readItems()
} }
const replaceAll = (nextItems: LoginAnnouncementItem[]) => { async function fetchMobileAnnouncements() {
items.value = Array.isArray(nextItems) ? nextItems : [] if (!enabled.value) return
}
const selectById = (id: string | number) => {
selectedId.value = id
}
const clearSelection = () => {
selectedId.value = null
}
const fetchMobileAnnouncements = async () => {
const result = await mockFetchMobileAnnouncementsApi() const result = await mockFetchMobileAnnouncementsApi()
mobileAnnouncements.value = Array.isArray(result) ? result : [] mobileAnnouncements.value = Array.isArray(result) ? result : []
} }
const fetchMobileAnnouncement = async () => { async function load() {
hydrate()
await fetchMobileAnnouncements() await fetchMobileAnnouncements()
} }
function selectById(id: string | number) {
if (!enabled.value) return
selectedId.value = id
}
watch( watch(
items, items,
(val) => { (val) => {
if (!enabled.value) return
writeItems(val) writeItems(val)
}, },
{ deep: true } { deep: true }
) )
return { return {
items,
listItems,
boardConfig, boardConfig,
mobileAnnouncementConfig, mobileAnnouncementConfig,
selectedAnnouncement, selectedAnnouncement,
selectedAnnouncementDetail, selectedAnnouncementDetail,
hydrate, load,
replaceAll,
selectById, 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 時,同步清理不再使用的文案。
- 不把大型靜態資料塞進語系檔;語系檔只放文案。
+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
}
+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: '/', path: '/',
name: 'home', name: 'home',
component: () => import('@/views/Home.vue'), component: () => import('@/views/Home.vue'),
meta: { layout: 'default', requiresAuth: true }, meta: { layout: 'default', requiresAuth: false },
}, },
{ {
path: '/settings', path: '/settings',
@@ -17,7 +17,7 @@ export const routes: RouteRecordRaw[] = [
path: '/login', path: '/login',
name: 'login', name: 'login',
component: () => import('@/views/Login.vue'), component: () => import('@/views/Login.vue'),
meta: { layout: 'none', guestOnly: true }, meta: { layout: 'none', guestOnly: false },
}, },
{ {
path: '/single-record-maintenance', path: '/single-record-maintenance',
@@ -49,6 +49,22 @@ export const routes: RouteRecordRaw[] = [
component: () => import('@/views/maint/EditableGrid.vue'), component: () => import('@/views/maint/EditableGrid.vue'),
meta: { layout: 'default' }, 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})', path: '/:fncId([0-9A-Z]{5,6})',
name: 'fnc-page', 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。
+6
View File
@@ -84,3 +84,9 @@ token 由 `tokenService` 作為單一來源:
## 請求取消 ## 請求取消
需要取消請求時,由 store 或 composable 建立 `AbortController`service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。 需要取消請求時,由 store 或 composable 建立 `AbortController`service module 只接收 `signal`。不要讓 service module 持有 controller 或 UI 狀態。
建議做法:
- 在 store/composable 以 key 管理同類請求(例如 `auth/login``menu/get-menu`)。
- 發新請求前先取消同 key 舊請求,避免競態與多餘流量。
- 請求結束後於 `finally` 清理該 key;離開流程(如 `clear``logout`)時清理全部 key。
+10 -2
View File
@@ -1,4 +1,4 @@
import type { CaptchaResponse } from '@/types/api' import type { CaptchaResponse, LoginRequestBody } from '@/types/api'
import { httpClient } from '../client' import { httpClient } from '../client'
export interface RequestOptions { export interface RequestOptions {
@@ -10,7 +10,7 @@ export const authApi = {
getCaptcha: async () => ({ getCaptcha: async () => ({
data: await httpClient.get('Auth/get-captcha').json<CaptchaResponse>(), data: await httpClient.get('Auth/get-captcha').json<CaptchaResponse>(),
}), }),
login: async (payload: FormData, options?: RequestOptions) => ({ loginWithFormData: async (payload: FormData, options?: RequestOptions) => ({
data: await httpClient data: await httpClient
.post('Auth/login', { .post('Auth/login', {
body: payload, body: payload,
@@ -18,4 +18,12 @@ export const authApi = {
}) })
.json<unknown>(), .json<unknown>(),
}), }),
loginWithJson: async (payload: LoginRequestBody, options?: RequestOptions) => ({
data: await httpClient
.post('Auth/login', {
json: payload,
signal: options?.signal,
})
.json<unknown>(),
}),
} }
+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。
+55 -53
View File
@@ -1,10 +1,51 @@
import type { CaptchaResponse, LoginPayload, User } from '@/types/api' import type { LoginPayload, LoginRequestBody, LoginRequestFormat, User } from '@/types/api'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { normalizeError } from '@/services/error' import { normalizeError } from '@/services/error'
import { authApi } from '@/services/modules/auth' import { authApi } from '@/services/modules/auth'
import { tokenService } from '@/services/token' import { tokenService } from '@/services/token'
import { useMenuStore } from '@/stores/menu' import { useMenuStore } from '@/stores/menu'
import { createRequestControllerManager } from '@/stores/request-controller'
const defaultLoginRequestFormat: LoginRequestFormat = 'formData'
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 // - 只在 store 管理登入狀態:user/token/loading/error
// - Component 不直接呼叫 API,避免狀態散落 // - Component 不直接呼叫 API,避免狀態散落
@@ -16,61 +57,24 @@ export const useAuthStore = defineStore('auth', () => {
const token = tokenService.token const token = tokenService.token
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const captcha = ref<CaptchaResponse | null>(null) const requestControllerManager = createRequestControllerManager()
const captchaLoading = ref(false)
const captchaErrorMessage = ref<string | null>(null)
// 只針對 login 取消重複請求,避免競態與重複提交
const loginController = ref<AbortController | null>(null)
const isAuthenticated = computed(() => !!token.value) const isAuthenticated = computed(() => !!token.value)
const roles = computed(() => (user.value?.role ? [user.value.role] : [])) const roles = computed(() => (user.value?.role ? [user.value.role] : []))
const getCaptcha = async () => { const login = async (payload: LoginPayload, options: LoginOptions = {}) => {
captchaLoading.value = true const signal = requestControllerManager.replace('auth/login')
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 loading.value = true
error.value = null error.value = null
try { try {
if (!captcha.value?.dntCaptchaTextValue || !captcha.value?.dntCaptchaTokenValue) { const requestBody = createLoginRequestBody(payload)
throw new Error('驗證碼資料缺失,請先刷新驗證碼') const requestFormat = options.requestFormat ?? defaultLoginRequestFormat
} const requestOptions = { signal }
const { data } =
const requestPayload = { requestFormat === 'json'
UserID: payload.UserID, ? await authApi.loginWithJson(requestBody, requestOptions)
Password: payload.Password, : await authApi.loginWithFormData(createLoginFormData(requestBody), requestOptions)
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 => { const parseUser = (val: unknown): User | undefined => {
if (!val || typeof val !== 'object') return if (!val || typeof val !== 'object') return
@@ -110,6 +114,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
user.value = result.user ?? null user.value = result.user ?? null
// 使用者token寫入
tokenService.setToken(result.accessToken) tokenService.setToken(result.accessToken)
} catch (error_) { } catch (error_) {
const normalizedError = normalizeError(error_) const normalizedError = normalizeError(error_)
@@ -119,21 +124,18 @@ export const useAuthStore = defineStore('auth', () => {
throw normalizedError throw normalizedError
} finally { } finally {
loading.value = false loading.value = false
loginController.value = null requestControllerManager.clear('auth/login')
} }
} }
const logout = () => { const logout = () => {
requestControllerManager.clearAll()
user.value = null user.value = null
tokenService.clearToken() tokenService.clearToken()
useMenuStore().clear() useMenuStore().clear()
} }
return { return {
getCaptcha,
captcha,
captchaLoading,
captchaErrorMessage,
user, user,
token, token,
loading, loading,
+10 -2
View File
@@ -2,10 +2,12 @@ import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { normalizeError } from '@/services/error' import { normalizeError } from '@/services/error'
import { menuApi, type MenuNode } from '@/services/modules/menu' import { menuApi, type MenuNode } from '@/services/modules/menu'
import { createRequestControllerManager } from '@/stores/request-controller'
export interface LayoutMenuItem { export interface LayoutMenuItem {
title: string title: string
path?: string path?: string
icon?: string
navigable?: boolean navigable?: boolean
subItems?: LayoutMenuItem[] subItems?: LayoutMenuItem[]
} }
@@ -16,6 +18,7 @@ export const useMenuStore = defineStore('menu', () => {
const isRail = ref(false) const isRail = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const loading = ref(false) const loading = ref(false)
const requestControllerManager = createRequestControllerManager()
const menuStorageKey = 'sk_playground_menu' const menuStorageKey = 'sk_playground_menu'
const favoriteStorageKey = 'sk_playground_favorite' const favoriteStorageKey = 'sk_playground_favorite'
@@ -179,6 +182,7 @@ export const useMenuStore = defineStore('menu', () => {
}) })
const clear = () => { const clear = () => {
requestControllerManager.clearAll()
menu.value = [] menu.value = []
favorite.value = [] favorite.value = []
isRail.value = false isRail.value = false
@@ -189,9 +193,10 @@ export const useMenuStore = defineStore('menu', () => {
} }
const getMenu = async (id: string) => { const getMenu = async (id: string) => {
const signal = requestControllerManager.replace('menu/get-menu')
try { try {
loading.value = true loading.value = true
const res = await menuApi.getMenu({ userID: id }) const res = await menuApi.getMenu({ userID: id }, { signal })
menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : [] menu.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) { } catch (error_) {
const normalizedError = normalizeError(error_) const normalizedError = normalizeError(error_)
@@ -201,13 +206,15 @@ export const useMenuStore = defineStore('menu', () => {
throw normalizedError throw normalizedError
} finally { } finally {
loading.value = false loading.value = false
requestControllerManager.clear('menu/get-menu')
} }
} }
const getFavorite = async (id: string) => { const getFavorite = async (id: string) => {
const signal = requestControllerManager.replace('menu/get-favorite')
try { try {
loading.value = true loading.value = true
const res = await menuApi.getFavorite({ userID: id }) const res = await menuApi.getFavorite({ userID: id }, { signal })
favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : [] favorite.value = Array.isArray(res.data.data) ? (res.data.data as MenuNode[]) : []
} catch (error_) { } catch (error_) {
const normalizedError = normalizeError(error_) const normalizedError = normalizeError(error_)
@@ -217,6 +224,7 @@ export const useMenuStore = defineStore('menu', () => {
throw normalizedError throw normalizedError
} finally { } finally {
loading.value = false loading.value = false
requestControllerManager.clear('menu/get-favorite')
} }
} }
+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,
}
}
+2 -13
View File
@@ -1,19 +1,8 @@
import type { StudentRecord } from '@/models/student'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
export interface StudentRecord { export type { StudentRecord } from '@/models/student'
id: number
studentId: string
name: string
department: string
grade: number
enrollYear: number
credits: number
advisor: string
email: string
phone: string
status: string
}
const seedStudents: StudentRecord[] = [ const seedStudents: StudentRecord[] = [
{ {
+10
View File
@@ -0,0 +1,10 @@
# Types Guide
`types` 放跨模組共用的 TypeScript 型別定義,例如 API 回應格式(`ApiError`)、認證相關型別(`User``LoginPayload``CaptchaResponse`)。
## 規則
- 只放純型別(interface / type),不放邏輯、class、常數或 runtime code。
- 型別應小而聚焦,避免一個大雜燴檔。
-`models/` 的區別:`types/` 偏向 API 協定層(request/response、錯誤格式),`models/` 偏向領域實體與 page model。
- 不 import component、view、store、composable。
+17 -1
View File
@@ -16,8 +16,24 @@ export interface CaptchaResponse {
dntCaptchaTextValue: string dntCaptchaTextValue: string
} }
export interface LoginCaptchaPayload {
DNTCaptchaInputText: string
DNTCaptchaText: string
DNTCaptchaToken: string
}
export interface LoginPayload { export interface LoginPayload {
UserID: string UserID: string
Password: string Password: string
DNTCaptchaInputText: string captcha?: LoginCaptchaPayload
} }
export interface LoginRequestBody {
UserID: string
Password: string
DNTCaptchaInputText?: string
DNTCaptchaText?: string
DNTCaptchaToken?: string
}
export type LoginRequestFormat = 'formData' | 'json'
+9 -7
View File
@@ -1,13 +1,15 @@
<template>
<v-sheet height="100%" width="100%">
{{ fncId }}
</v-sheet>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
const route = useRoute() const route = useRoute()
const fncId = computed(() => String(route.params.fncId ?? '')) const pageModel = computed(() => ({
fncId: String(route.params.fncId ?? ''),
}))
</script> </script>
<template>
<v-sheet height="100%" width="100%">
{{ pageModel.fncId }}
</v-sheet>
</template>
+61
View File
@@ -0,0 +1,61 @@
# Views Guide
`views` 是 route entry。View 自含 page model 組裝與頁面 UI,若邏輯複雜才抽到 page driver composable。
## 規則
- 使用 `<script setup lang="ts">`
- 直接 route component 放在 `src/views``src/views/<feature>`
- route params/query 的解析可在 view 做簡單轉換;超過簡單轉換時放進 page driver。
- 不直接 import 或包住 `MainLayout.vue`
- 複雜 UI 拆到 `components/sections/*``components/items/*`
## 建議形狀
簡單頁面:直接在 view 組裝 page model 與 template。
```vue
<script setup lang="ts">
import { computed } from 'vue'
const pageModel = computed(() => ({ title: '我的頁面' }))
</script>
<template>
<v-card>{{ pageModel.title }}</v-card>
</template>
```
複雜頁面:透過 page driver composable 協調多個資料來源。
```vue
<script setup lang="ts">
import MaintShell from '@/components/maint/MaintShell.vue'
import { useXxxPage } from '@/composables/page-drivers/useXxxPage'
const { pageModel, search, handleSave, ... } = useXxxPage()
</script>
<template>
<MaintShell :title="pageModel.title" @create="handleCreate">
<template #table>...</template>
</MaintShell>
</template>
```
以 destructure 方式取用 composable 回傳值,模板不寫 `.value`
## Login.vue 開關
`Login.vue` 是登入頁的完整入口,登入頁功能開關集中在 view 內宣告,透過 composable 往下傳遞,不在子元件各自決定是否啟用。
- `withCaptcha`:控制驗證碼 UI、captcha API 載入/刷新,以及登入 payload 是否帶 captcha 資料。關閉時不應發出 captcha API,也不應檢查或送出 captcha 欄位。
- `withAnnouncement`:控制公告 UI、公告 mock data/composable 資料流與公告詳情互動。關閉時公告板、手機公告列與公告對話框資料來源都應停用。
- `withForgotPassword`:控制忘記密碼連結與事件。關閉時 UI 不顯示,也不應觸發忘記密碼事件。
- `withRememberAccount`:控制記住帳號 UI 與 localStorage 讀寫。關閉時不顯示 checkbox、不讀寫記住帳號 storage,送出資料固定視為未記住帳號。
新增登入頁選配功能時,優先維持同樣模式:view 宣告開關、composable 負責資料流與 side effect、form component 只依 props 呈現 UI 與發出事件。
## 子目錄
- `views/demos` 是一般頁面與 section 使用方式的 demo route entry,仍需維持薄 view。
- `views/maint` 是 maintenance demo route entry。詳見 `src/views/maint/GUIDE.md`
- `views/errors` 是錯誤頁入口,通常使用 `meta.layout = 'none'`。每個錯誤頁(`Forbidden.vue``ServerError.vue``NotFound.vue` 等)只傳入 props 給共用的 `ErrorShell.vue`,不再各自重複佈局邏輯。`ErrorShell.vue` 提供標題、圖示、顏色、描述、後端訊息、操作按鈕(返回上頁 / 回首頁 / 前往登入)等 slots。
+123 -75
View File
@@ -1,83 +1,131 @@
<template>
<page-index
:is-news-dialog-open="isNewsDialogOpen"
:news-items="newsItems"
:quick-items="quickItems"
:selected-news="selectedNews"
@message-center="handleMessageCenter"
@news="handleNews"
@quick="handleQuick"
@update:is-news-dialog-open="isNewsDialogOpen = $event"
/>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { mdiEyeOutline, mdiFolderOutline } from '@mdi/js'
import PageIndex from '@/components/PageIndex.vue' import { useHomePage } from '@/composables/page-drivers/useHomePage'
import { useMessageStore } from '@/stores/messages'
import { useSnackbarStore } from '@/stores/snackbar'
const snackbar = useSnackbarStore() const { handleMessageCenter, handleNews, handleQuick, isNewsDialogOpen, pageModel, selectedNews } =
const messageStore = useMessageStore() useHomePage()
const newsItems = [ interface NewsItem {
{ id: number
id: 1, date: string
date: '29', month: string
month: '1月', title: string
title: '113學年度第2學期加退選開始', desc: string
desc: '加退選時間為1月29日至2月9日止,請同學把握時間完成選課作業。', dept: string
dept: '教務處', views: string
views: '1,234', isNew: boolean
isNew: true,
},
{
id: 2,
date: '27',
month: '1月',
title: '場地借用系統維護通知',
desc: '系統將於本週六凌晨01:00-05:00進行維護,期間暫停服務。',
dept: '總務處',
views: '856',
isNew: false,
},
{
id: 3,
date: '25',
month: '1月',
title: '112學年度第1學期期末成績已開放查詢',
desc: '同學可至「查詢 > 教務資訊查詢 > 學期成績查詢」查看本學期成績。',
dept: '教務處',
views: '3,567',
isNew: false,
},
]
type NewsItem = (typeof newsItems)[number]
const quickItems = [
{ icon: '', title: '線上加選' },
{ icon: '', title: '線上退選' },
{ icon: '📊', title: '成績查詢' },
{ icon: '📅', title: '個人課表' },
{ icon: '📝', title: '網路請假' },
{ icon: '🏢', title: '場地借用' },
]
const selectedNews = ref<NewsItem | null>(null)
const isNewsDialogOpen = ref(false)
function handleNews(item: NewsItem) {
selectedNews.value = item
isNewsDialogOpen.value = true
} }
// 點擊首頁「訊息中心」卡片,開啟共用的訊息清單 dialog function resolveNewsItem(wrapped: unknown): NewsItem {
function handleMessageCenter() { if (wrapped && typeof wrapped === 'object' && 'raw' in wrapped) {
messageStore.open() return (wrapped as { raw: NewsItem }).raw
} }
return wrapped as NewsItem
function handleQuick(item: (typeof quickItems)[number]) {
snackbar.show({ message: `前往:${item.title}`, color: 'info' })
} }
</script> </script>
<template>
<v-sheet>
<v-container fluid class="pa-0 px-2">
<v-card variant="flat">
<v-card-title> 歡迎使用校務資訊系統 </v-card-title>
<v-card-text class="text-grey">
使用頂部搜尋框快速找到功能或從左側選單瀏覽所有系統模組
</v-card-text>
</v-card>
<v-card variant="flat" border="thin primary" class="pa-4">
<v-card-title class="mb-4"> 最新消息 </v-card-title>
<v-data-iterator item-key="id" :items="pageModel.newsItems" :items-per-page="-1">
<template #default="{ items }">
<v-row density="compact">
<v-col v-for="wrapped in items" :key="resolveNewsItem(wrapped).id" cols="12">
<v-card @click="handleNews(resolveNewsItem(wrapped))">
<div class="d-flex flex-no-wrap">
<v-avatar rounded="0" size="64" class="flex-column bg-primary">
<div>
{{ resolveNewsItem(wrapped).date }}
</div>
<div>
{{ resolveNewsItem(wrapped).month }}
</div>
</v-avatar>
<div class="flex-fill">
<v-card-title>
{{ resolveNewsItem(wrapped).title }}
<v-chip
v-if="resolveNewsItem(wrapped).isNew"
class="ml-2"
color="yellow"
size="x-small"
variant="flat"
>
NEW
</v-chip>
</v-card-title>
<v-card-text class="pa-4">
{{ resolveNewsItem(wrapped).desc }}
</v-card-text>
<v-card-text class="pt-0">
<v-row align="center">
<v-icon size="14" :icon="mdiFolderOutline" />
<v-col cols="1"> {{ resolveNewsItem(wrapped).dept }}</v-col>
<v-icon size="14" :icon="mdiEyeOutline" />
<v-col>{{ resolveNewsItem(wrapped).views }} 次瀏覽</v-col>
</v-row>
</v-card-text>
</div>
</div>
</v-card>
</v-col>
</v-row>
</template>
</v-data-iterator>
</v-card>
<v-card class="pa-4 mt-4" @click="handleMessageCenter">
<v-card-title class="mb-4"> 訊息中心 </v-card-title>
<v-card-text class="text-body-large text-secondary">
{{ Math.floor(Math.random() * 10) }} 筆未讀
</v-card-text>
</v-card>
<v-card variant="flat" border="thin primary" class="pa-4 mt-4">
<v-row density="compact" align="center">
<v-card-title> 快速存取 </v-card-title>
<v-col v-for="item in pageModel.quickItems" :key="item.title">
<v-card
class="d-flex flex-column align-center ga-1 text-center py-3 px-2"
color="primary-variant"
variant="tonal"
@click="handleQuick(item)"
>
<div class="text-h5">{{ item.icon }}</div>
<div class="text-body-medium font-weight-medium">{{ item.title }}</div>
</v-card>
</v-col>
</v-row>
</v-card>
</v-container>
<v-dialog v-model="isNewsDialogOpen" max-width="640">
<v-card v-if="selectedNews">
<v-card-title class="text-h6 font-weight-bold bg-primary-variant pa-4">
{{ selectedNews.title }}
</v-card-title>
<v-card-subtitle class="text-body-2 pt-4 text-medium-emphasis">
{{ selectedNews.month }} {{ selectedNews.date }} · {{ selectedNews.dept }} ·
{{ selectedNews.views }} 次瀏覽
</v-card-subtitle>
<v-card-text class="pt-4">
{{ selectedNews.desc }}
</v-card-text>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="isNewsDialogOpen = false"> 關閉 </v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-sheet>
</template>
<style scoped></style>
+480 -217
View File
@@ -1,21 +1,440 @@
<script setup lang="ts">
import { mdiBullhornVariantOutline } from '@mdi/js'
import { computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import HyakkaouAcademyImage from '@/assets/logo.png'
import LoginAnnouncementBoard from '@/components/login/LoginAnnouncementBoard.vue'
import LoginBrand from '@/components/login/LoginBrand.vue'
import LoginForm from '@/components/login/LoginForm.vue'
import LoginHeader from '@/components/login/LoginHeader.vue'
import LoginToolBar from '@/components/login/LoginToolBar.vue'
import LoginVerify from '@/components/login/LoginVerify.vue'
import {
type LoginAnnouncementListItem,
useLoginAnnouncements,
} from '@/composables/useLoginAnnouncements'
import { useLoginCaptcha } from '@/composables/useLoginCaptcha'
import { useAuthStore } from '@/stores/auth'
import { useMenuStore } from '@/stores/menu'
import { useSnackbarStore } from '@/stores/snackbar'
type LayoutType = 'side-left' | 'side-right' | 'card'
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const menuStore = useMenuStore()
const snackbarStore = useSnackbarStore()
const locales = ['zh-TW', 'en-US']
const illustrationImage = ref(HyakkaouAcademyImage)
const formPositionLayout = ref<LayoutType>('side-left')
const withAnnouncement = ref(true)
const withForgotPassword = ref(true)
const withRememberAccount = ref(true)
const withCaptcha = ref(true)
const loginCaptcha = useLoginCaptcha({ enabled: withCaptcha })
const loginAnnouncements = useLoginAnnouncements({ enabled: withAnnouncement })
const {
boardConfig: announcementBoard,
mobileAnnouncementConfig: mobileAnnouncement,
selectedAnnouncement,
selectedAnnouncementDetail,
} = loginAnnouncements
const title = computed(() => t('pages.login.title'))
const organization = computed(() => t('pages.login.organization'))
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
const passwPlaceholder = computed(() => t('pages.login.passwPlaceholder'))
const illustrationTitle = computed(() => t('pages.login.illustrationTitle'))
const illustrationDescription = computed(() => t('pages.login.illustrationDescription'))
const welcomeText = computed(() => t('pages.login.welcomeText'))
const welcomeDescription = computed(() => t('pages.login.welcomeDescription'))
const rememberMeLabel = computed(() => t('pages.login.rememberMeLabel'))
const forgotPasswordText = computed(() => t('pages.login.forgotPasswordText'))
const submitText = computed(() => t('pages.login.submitText'))
const verifyText = computed(() => t('pages.login.verifyText'))
const captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
const forgotPasswordHref = ref('/forgot-password')
const forgotPasswordTarget = ref<string | undefined>(undefined)
const rememberStorageKey = ref('login.remember.username')
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMessage = ref('')
const announcementDialogVisible = ref(false)
const branding = computed(() => ({
title: title.value,
organization: organization.value,
}))
const illustration = computed(() => ({
image: illustrationImage.value,
title: illustrationTitle.value,
description: illustrationDescription.value,
}))
const header = computed(() => ({
welcomeText: welcomeText.value,
welcomeDescription: welcomeDescription.value,
}))
const form = computed(() => ({
accPlaceholder: accPlaceholder.value,
passwPlaceholder: passwPlaceholder.value,
rememberMeLabel: rememberMeLabel.value,
submitText: submitText.value,
verifyText: verifyText.value,
captchaPlaceholder: captchaPlaceholder.value,
refreshTitle: refreshTitle.value,
rememberStorageKey: rememberStorageKey.value,
withForgotPassword: withForgotPassword.value,
withRememberAccount: withRememberAccount.value,
withCaptcha: withCaptcha.value,
captcha: loginCaptcha.formCaptcha.value,
captchaValue: loginCaptcha.captchaValue.value,
captchaLoading: loginCaptcha.captchaLoading.value,
captchaErrorMessage: loginCaptcha.captchaErrorMessage.value ?? '',
captchaVerified: false,
forgotPassword: {
text: forgotPasswordText.value,
href: forgotPasswordHref.value,
target: forgotPasswordTarget.value,
},
}))
const toolbar = computed(() => ({
show: true,
locale: locale.value,
locales,
}))
const mobileAnnouncementSheetVisible = ref(false)
const mobileAnnouncementItems = computed(() => mobileAnnouncement.value.items ?? [])
const showMobileAnnouncementBanner = computed(() => {
if (!withAnnouncement.value) return false
if (mobileAnnouncement.value.show === false) return false
return mobileAnnouncementItems.value.length > 0
})
const mobileAnnouncementBannerText = computed(() => {
return mobileAnnouncementItems.value[0]?.content ?? ''
})
const layoutClass = computed(() => `layout-${formPositionLayout.value}`)
function handleForgotPassword(e: MouseEvent) {
if (!withForgotPassword.value) return
console.log('Forgot Password Click:', e)
}
function handleChangeLocale(nextLocale: string) {
locale.value = nextLocale
localStorage.setItem('locale', nextLocale)
}
async function handleCaptchaRefresh() {
await loginCaptcha.refreshCaptcha().catch(() => undefined)
}
function handleCaptchaChange(value: string) {
loginCaptcha.setCaptchaValue(value)
}
function handleToggleLayout() {
const layoutOrder: LayoutType[] = ['side-left', 'side-right', 'card']
const currentIndex = layoutOrder.indexOf(formPositionLayout.value)
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % layoutOrder.length
formPositionLayout.value = layoutOrder[nextIndex] ?? 'side-left'
}
function handleSelectAnnouncement(item: LoginAnnouncementListItem) {
loginAnnouncements.selectById(item.id)
announcementDialogVisible.value = true
}
async function onLogin(data: Record<string, unknown>) {
if (withCaptcha.value && !loginCaptcha.captchaValue.value) {
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.verifyRequired')
dialogVisible.value = true
return
}
try {
dialogTitle.value = t('common.notice')
const inputUserId = String(data.username ?? '').trim()
const inputPassword = String(data.password ?? '').trim()
const isDev = import.meta.env.DEV
const devDefaultUserId = String(import.meta.env.VITE_DEV_DEFAULT_USER_ID ?? '').trim()
const devDefaultPassword = String(import.meta.env.VITE_DEV_DEFAULT_PASSWORD ?? '').trim()
const userId = isDev && !inputUserId ? devDefaultUserId : inputUserId
const password = isDev && !inputPassword ? devDefaultPassword : inputPassword
await authStore.login({
UserID: userId,
Password: password,
captcha: loginCaptcha.getLoginCaptchaPayload(),
})
menuStore.getMenu(authStore.user?.id ?? '')
snackbarStore.show({
message: t('pages.login.alert.loginSuccess'),
color: 'success',
timeout: 2000,
location: 'top right',
variant: 'flat',
})
const redirect = (route.query.redirect as string) || '/'
await router.push(redirect.startsWith('/') ? redirect : '/')
} catch (error) {
console.error('Login error:', error)
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.loginFailed')
dialogVisible.value = true
}
}
onMounted(() => {
void loginAnnouncements.load()
void loginCaptcha.loadCaptcha().catch(() => undefined)
})
</script>
<template> <template>
<page-login <v-sheet class="bg-surface" :class="layoutClass" height="100%">
:announcement-board="announcementBoard" <v-row
:branding="branding" v-if="formPositionLayout !== 'card'"
:form="form" class="fill-height"
:header="header" :class="{ 'flex-row-reverse': formPositionLayout === 'side-right' }"
:illustration="illustration" no-gutters
:layout="formPositionLayout" >
:mobile-announcement="mobileAnnouncement" <v-col
:toolbar="toolbar" class="illustration-panel d-none d-sm-flex align-center justify-center position-relative flex-grow-1"
@captcha-change="handleCaptchaChange" cols="12"
@captcha-refresh="handleCaptchaRefresh" lg="8"
@change-locale="handleChangeLocale" sm="6"
@forgot-password="handleForgotPassword" >
<div class="brand-header position-absolute top-0 left-0 px-2 pt-4 pa-lg-4">
<LoginBrand :title="branding.title" />
</div>
<v-sheet
v-if="withAnnouncement"
class="board-wrapper pa-2 pa-lg-0"
color="rgba(var(--v-theme-surface), 0.8)"
elevation="0"
max-width="680"
rounded="lg"
width="100%"
>
<LoginAnnouncementBoard
:all-tab-label="announcementBoard.allTabLabel"
:date-header="announcementBoard.dateHeader"
:empty-text="announcementBoard.emptyText"
:items="announcementBoard.items"
:items-per-page="announcementBoard.itemsPerPage"
:pagination-label="announcementBoard.paginationLabel"
:school-header="announcementBoard.schoolHeader"
:system-announcements="announcementBoard.systemAnnouncements"
:tabs="announcementBoard.tabs"
:title="announcementBoard.title"
:title-header="announcementBoard.titleHeader"
@select-announcement="handleSelectAnnouncement" @select-announcement="handleSelectAnnouncement"
@submit="onLogin" />
</v-sheet>
</v-col>
<v-col
class="bg-background d-flex flex-column flex-grow-1 px-4 py-2 py-md-0"
cols="12"
lg="4"
sm="6"
>
<div v-if="showMobileAnnouncementBanner" class="banner-wrapper px-3">
<v-banner
class="d-sm-none mb-2"
density="comfortable"
lines="one"
:mobile="false"
:stacked="false"
>
<template #prepend>
<v-slide-x-transition appear>
<div class="mobile-banner-icon-wrap d-flex align-center">
<v-icon
class="mobile-banner-icon"
color="primary"
size="small"
:icon="mdiBullhornVariantOutline"
/>
</div>
</v-slide-x-transition>
</template>
<v-banner-text>{{ mobileAnnouncementBannerText }}</v-banner-text>
<template #actions>
<v-btn
class="text-none"
color="primary"
size="small"
variant="text"
@click="mobileAnnouncementSheetVisible = true"
>
{{ mobileAnnouncement.viewAllText }}
</v-btn>
</template>
</v-banner>
</div>
<LoginToolBar
v-if="toolbar.show"
:locale="toolbar.locale"
:locales="toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout" @toggle-layout="handleToggleLayout"
/> />
<div
class="login-form-wrapper d-flex flex-column justify-center py-0 py-sm-4 py-lg-8 px-4 px-sm-6 px-lg-12 flex-grow-1"
>
<div class="login-header-height d-flex d-sm-none justify-center align-start">
<LoginBrand :title="branding.title" />
</div>
<LoginHeader
class="d-none d-sm-block"
:welcome-description="header.welcomeDescription"
:welcome-text="header.welcomeText"
/>
<LoginForm
:acc-placeholder="form.accPlaceholder"
:forgot-password-href="form.forgotPassword.href"
:forgot-password-target="form.forgotPassword.target"
:forgot-password-text="form.forgotPassword.text"
:passw-placeholder="form.passwPlaceholder"
:remember-me-label="form.rememberMeLabel"
:remember-storage-key="form.rememberStorageKey"
:submit-text="form.submitText"
:with-forgot-password="form.withForgotPassword"
:with-remember-account="form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="onLogin"
>
<template v-if="form.withCaptcha" #verify>
<LoginVerify
:captcha="form.captcha"
:captcha-placeholder="form.captchaPlaceholder"
:error-message="form.captchaErrorMessage"
:loading="form.captchaLoading"
:model-value="form.captchaValue"
:refresh-title="form.refreshTitle"
:verified="form.captchaVerified"
:verify-text="form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-auto py-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
</div>
</div>
</v-col>
</v-row>
<v-row
v-else
class="fill-height align-center justify-center bg-background pa-4 pa-md-0"
no-gutters
>
<v-card
class="rounded-lg"
:class="toolbar.show ? 'px-8 pt-0 pb-4' : 'pa-8'"
elevation="10"
max-width="450"
width="100%"
>
<LoginToolBar
v-if="toolbar.show"
:locale="toolbar.locale"
:locales="toolbar.locales"
@change-locale="handleChangeLocale"
@toggle-layout="handleToggleLayout"
/>
<div class="d-flex justify-center mb-6 mb-md-4">
<LoginBrand :title="branding.title" />
</div>
<LoginHeader
class="d-none d-md-block"
:welcome-description="header.welcomeDescription"
:welcome-text="header.welcomeText"
/>
<LoginForm
:acc-placeholder="form.accPlaceholder"
:forgot-password-href="form.forgotPassword.href"
:forgot-password-target="form.forgotPassword.target"
:forgot-password-text="form.forgotPassword.text"
:passw-placeholder="form.passwPlaceholder"
:remember-me-label="form.rememberMeLabel"
:remember-storage-key="form.rememberStorageKey"
:submit-text="form.submitText"
:with-forgot-password="form.withForgotPassword"
:with-remember-account="form.withRememberAccount"
@forgot-password="handleForgotPassword"
@submit="onLogin"
>
<template v-if="form.withCaptcha" #verify>
<LoginVerify
:captcha="form.captcha"
:captcha-placeholder="form.captchaPlaceholder"
:error-message="form.captchaErrorMessage"
:loading="form.captchaLoading"
:model-value="form.captchaValue"
:refresh-title="form.refreshTitle"
:verified="form.captchaVerified"
:verify-text="form.verifyText"
@refresh="handleCaptchaRefresh"
@update:model-value="handleCaptchaChange"
/>
</template>
</LoginForm>
<div class="mt-8 text-center text-caption text-grey-darken-2">
Copyright © {{ new Date().getFullYear() }} {{ branding.organization }}
</div>
</v-card>
</v-row>
<v-bottom-sheet
v-if="withAnnouncement"
v-model="mobileAnnouncementSheetVisible"
class="d-sm-none"
>
<v-card rounded="t-xl">
<v-card-title class="text-subtitle-1 font-weight-bold">
{{ mobileAnnouncement.listTitle }}
</v-card-title>
<v-list lines="two">
<v-list-item v-for="item in mobileAnnouncementItems" :key="item.id">
<v-list-item-title>{{ item.content }}</v-list-item-title>
<v-list-item-subtitle v-if="item.title || item.createdAt">
{{ item.title }}<span v-if="item.title && item.createdAt"> </span
>{{ item.createdAt }}
</v-list-item-subtitle>
</v-list-item>
<v-list-item v-if="mobileAnnouncementItems.length === 0">
<v-list-item-title>{{ mobileAnnouncement.emptyText }}</v-list-item-title>
</v-list-item>
</v-list>
<v-card-actions class="justify-end">
<v-btn color="primary" variant="text" @click="mobileAnnouncementSheetVisible = false">
{{ mobileAnnouncement.closeText }}
</v-btn>
</v-card-actions>
</v-card>
</v-bottom-sheet>
</v-sheet>
<v-dialog v-model="dialogVisible" width="360"> <v-dialog v-model="dialogVisible" width="360">
<v-card> <v-card>
@@ -51,217 +470,61 @@
</v-dialog> </v-dialog>
</template> </template>
<script setup lang="ts"> <style scoped>
import { storeToRefs } from 'pinia' :deep(.v-banner__prepend) {
import { computed, onMounted, ref } from 'vue' align-self: center;
import { useI18n } from 'vue-i18n' margin-inline-end: 16px;
import { useRoute, useRouter } from 'vue-router'
import HyakkaouAcademyImage from '@/assets/logo.png'
import PageLogin from '@/components/PageLogin.vue'
import { useAuthStore } from '@/stores/auth'
import {
type LoginAnnouncementListItem,
useLoginAnnouncementsStore,
} from '@/stores/loginAnnouncements'
import { useMenuStore } from '@/stores/menu'
import { useSnackbarStore } from '@/stores/snackbar'
type LayoutType = 'side-left' | 'side-right' | 'card'
// i18n
const { t, locale } = useI18n()
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
const loginAnnouncementsStore = useLoginAnnouncementsStore()
const menuStore = useMenuStore()
const snackbarStore = useSnackbarStore()
const {
boardConfig: announcementBoard,
mobileAnnouncementConfig: mobileAnnouncement,
selectedAnnouncement,
selectedAnnouncementDetail,
} = storeToRefs(loginAnnouncementsStore)
// 語系選項
const locales = ['zh-TW', 'en-US']
// 插圖圖片來源
const illustrationImage = ref(HyakkaouAcademyImage)
// 功能開關與版型
const formPositionLayout = ref<LayoutType>('side-left')
// 功能開關:是否啟用驗證碼
const withCaptcha = ref(true)
// 文字內容(i18n
const title = computed(() => t('pages.login.title'))
const organization = computed(() => t('pages.login.organization'))
const accPlaceholder = computed(() => t('pages.login.accPlaceholder'))
const passwPlaceholder = computed(() => t('pages.login.passwPlaceholder'))
const illustrationTitle = computed(() => t('pages.login.illustrationTitle'))
const illustrationDescription = computed(() => t('pages.login.illustrationDescription'))
const welcomeText = computed(() => t('pages.login.welcomeText'))
const welcomeDescription = computed(() => t('pages.login.welcomeDescription'))
const rememberMeLabel = computed(() => t('pages.login.rememberMeLabel'))
const forgotPasswordText = computed(() => t('pages.login.forgotPasswordText'))
const submitText = computed(() => t('pages.login.submitText'))
const verifyText = computed(() => t('pages.login.verifyText'))
const captchaPlaceholder = computed(() => t('pages.login.captchaPlaceholder'))
const refreshTitle = computed(() => t('pages.login.refreshTitle'))
// 連結與儲存設定
// 忘記密碼連結(由 form.forgotPassword 設定)
const forgotPasswordHref = ref('/forgot-password')
const forgotPasswordTarget = ref<string | undefined>(undefined)
// 記住帳號的 localStorage key
const rememberStorageKey = ref('login.remember.username')
// 驗證碼 API
const captchaValue = ref('')
// 驗證與對話框狀態
const dialogVisible = ref(false)
const dialogTitle = ref('')
const dialogMessage = ref('')
const announcementDialogVisible = ref(false)
// 內容組合(傳入 PageLogin
const branding = computed(() => ({
title: title.value,
organization: organization.value,
}))
const illustration = computed(() => ({
image: illustrationImage.value,
title: illustrationTitle.value,
description: illustrationDescription.value,
}))
const header = computed(() => ({
welcomeText: welcomeText.value,
welcomeDescription: welcomeDescription.value,
}))
// 表單區塊設定(含功能開關)
const form = computed(() => ({
accPlaceholder: accPlaceholder.value,
passwPlaceholder: passwPlaceholder.value,
rememberMeLabel: rememberMeLabel.value,
submitText: submitText.value,
verifyText: verifyText.value,
captchaPlaceholder: captchaPlaceholder.value,
refreshTitle: refreshTitle.value,
rememberStorageKey: rememberStorageKey.value,
// 功能開關:是否顯示驗證碼
withCaptcha: withCaptcha.value,
captcha: authStore.captcha
? {
imgUrl: authStore.captcha.dntCaptchaImgUrl,
id: authStore.captcha.dntCaptchaId,
tokenValue: authStore.captcha.dntCaptchaTokenValue,
}
: undefined,
captchaValue: captchaValue.value,
captchaLoading: authStore.captchaLoading,
captchaErrorMessage: authStore.captchaErrorMessage ?? '',
captchaVerified: false,
forgotPassword: {
text: forgotPasswordText.value,
href: forgotPasswordHref.value,
target: forgotPasswordTarget.value,
},
}))
// 右上工具列設定(含顯示開關)
const toolbar = computed(() => ({
// 功能開關:是否顯示語系切換工具列
show: true,
locale: locale.value,
locales,
}))
// 事件處理
function handleForgotPassword(e: MouseEvent) {
console.log('Forgot Password Click:', e)
} }
function handleChangeLocale(nextLocale: string) { :deep(.v-banner-actions) {
locale.value = nextLocale align-self: center;
localStorage.setItem('locale', nextLocale)
} }
async function handleCaptchaRefresh() { .mobile-banner-icon {
captchaValue.value = '' animation: mobile-banner-breathe 2.8s ease-in-out infinite;
await authStore.getCaptcha() transform-origin: center;
} }
function handleCaptchaChange(value: string) { @keyframes mobile-banner-breathe {
captchaValue.value = value 0%,
100% {
opacity: 0.9;
transform: scale(1);
} }
function handleToggleLayout() { 50% {
const layoutOrder: LayoutType[] = ['side-left', 'side-right', 'card'] opacity: 1;
const currentIndex = layoutOrder.indexOf(formPositionLayout.value) transform: scale(1.08);
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % layoutOrder.length
formPositionLayout.value = layoutOrder[nextIndex] ?? 'side-left'
}
function handleSelectAnnouncement(item: LoginAnnouncementListItem) {
loginAnnouncementsStore.selectById(item.id)
announcementDialogVisible.value = true
}
async function onLogin(data: Record<string, unknown>) {
if (withCaptcha.value && !captchaValue.value) {
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.verifyRequired')
dialogVisible.value = true
return
}
try {
dialogTitle.value = t('common.notice')
const inputUserId = String(data.username ?? '').trim()
const inputPassword = String(data.password ?? '').trim()
const isDev = import.meta.env.DEV
const devDefaultUserId = String(import.meta.env.VITE_DEV_DEFAULT_USER_ID ?? '').trim()
const devDefaultPassword = String(import.meta.env.VITE_DEV_DEFAULT_PASSWORD ?? '').trim()
const userId = isDev && !inputUserId ? devDefaultUserId : inputUserId
const password = isDev && !inputPassword ? devDefaultPassword : inputPassword
await authStore.login({
UserID: userId,
Password: password,
DNTCaptchaInputText: captchaValue.value,
})
menuStore.getMenu(authStore.user?.id ?? '')
// menuStore.getFavorite(authStore.user?.id ?? '')
snackbarStore.show({
message: t('pages.login.alert.loginSuccess'),
color: 'success',
timeout: 2000,
location: 'top right',
variant: 'flat',
})
const redirect = (route.query.redirect as string) || '/'
await router.push(redirect.startsWith('/') ? redirect : '/')
} catch (error) {
console.error('Login error:', error)
dialogTitle.value = t('common.notice')
dialogMessage.value = t('pages.login.alert.loginFailed')
dialogVisible.value = true
} }
} }
onMounted(() => { @media (prefers-reduced-motion: reduce) {
loginAnnouncementsStore.hydrate() .mobile-banner-icon {
loginAnnouncementsStore.fetchMobileAnnouncements() animation: none;
authStore.getCaptcha() }
}) }
</script>
.illustration-panel {
background: linear-gradient(
135deg,
rgb(var(--v-theme-background)) 0%,
rgb(var(--v-theme-surface)) 100%
);
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
.login-form-wrapper {
max-width: 450px;
margin: 0 auto;
width: 100%;
}
.login-header-height {
height: 120px;
}
.layout-side-right .illustration-panel {
border-right: none;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.05);
}
</style>
+10 -4
View File
@@ -1,5 +1,11 @@
<template> <script setup lang="ts">
<div>設定頁面</div> import { computed } from 'vue'
</template>
<script setup lang="ts"></script> const pageModel = computed(() => ({
title: '設定頁面',
}))
</script>
<template>
<div>{{ pageModel.title }}</div>
</template>
+75
View File
@@ -0,0 +1,75 @@
<script setup lang="ts">
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import SectionFormPage from '@/components/sections/SectionFormPage.vue'
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
const { demoForm, handleFormBack, handleFormSubmit, pageModel, resetDemoForm } =
useSectionsDemoPage()
</script>
<template>
<SectionFormPage
reset-label="清除"
submit-label="送出"
:message="pageModel.formMessage"
title="SectionFormPage 報表申請"
@back="handleFormBack"
@reset="resetDemoForm"
@submit="handleFormSubmit"
>
<template #fields>
<v-row density="compact">
<v-col cols="12" md="6">
<BaseFormTextField v-model="demoForm.title" label="標題" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect v-model="demoForm.owner" label="單位" :items="pageModel.ownerOptions" />
</v-col>
<v-col cols="12" md="3">
<BaseFormSelect
v-model="demoForm.category"
label="類型"
:items="pageModel.categoryOptions"
/>
</v-col>
<v-col cols="12">
<BaseFormTextField v-model="demoForm.description" label="說明" />
</v-col>
</v-row>
</template>
<template #sections>
<v-card class="mb-2">
<v-card-title class="text-title-medium font-weight-bold">明細</v-card-title>
<v-card-text>
<v-table density="compact">
<thead>
<tr>
<th>欄位</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>單位</td>
<td>{{ demoForm.owner }}</td>
</tr>
<tr>
<td>類型</td>
<td>{{ demoForm.category }}</td>
</tr>
</tbody>
</v-table>
</v-card-text>
</v-card>
</template>
<template #notices>
<v-list class="bg-yellow-lighten-5" density="compact">
<v-list-item>送出前確認標題與單位</v-list-item>
<v-list-item>表單狀態由 page driver 管理</v-list-item>
</v-list>
</template>
</SectionFormPage>
</template>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import SectionQueryPage from '@/components/sections/SectionQueryPage.vue'
import { useSectionsDemoPage } from '@/composables/page-drivers/useSectionsDemoPage'
const { handleQueryBack, handleQuerySearch, pageModel, queryFilters } = useSectionsDemoPage()
</script>
<template>
<SectionQueryPage title="查詢頁DEMO" @back="handleQueryBack" @search="handleQuerySearch">
<template #filters>
<v-col cols="12" md="4">
<BaseFormTextField v-model="queryFilters.keyword" label="關鍵字" />
</v-col>
<v-col cols="12" md="4">
<BaseFormSelect v-model="queryFilters.owner" label="單位" :items="pageModel.ownerOptions" />
</v-col>
</template>
<template #results>
<v-alert v-if="pageModel.queryMessage" class="mb-3" type="success" variant="tonal">
{{ pageModel.queryMessage }}
</v-alert>
<v-table density="compact">
<thead class="bg-primary">
<tr>
<th>名稱</th>
<th>單位</th>
<th>狀態</th>
<th>更新日</th>
</tr>
</thead>
<tbody>
<tr v-if="pageModel.reports.length === 0">
<td class="text-center" colspan="4">尚無查詢結果</td>
</tr>
<tr v-for="row in pageModel.reports" :key="row.id">
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
<td>{{ row.status }}</td>
<td>{{ row.updatedAt }}</td>
</tr>
</tbody>
</v-table>
</template>
</SectionQueryPage>
</template>
+16 -4
View File
@@ -1,7 +1,19 @@
<template>
<EditableStudentGrid />
</template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'
import EditableStudentGrid from '@/components/maint/EditableGrid.vue' import EditableStudentGrid from '@/components/maint/EditableGrid.vue'
import type { MaintenancePageModel } from '@/models/page'
import { useStudentStore } from '@/stores/students'
const studentStore = useStudentStore()
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '可編輯表格維護示範',
records: studentStore.students,
loading: false,
error: null,
}))
</script> </script>
<template>
<EditableStudentGrid :title="pageModel.title" />
</template>
+21
View File
@@ -0,0 +1,21 @@
# Maintenance Views Guide
`views/maint` 是維護頁 demo。所有檔案都是自含的 route entry,UI 與流程直接在 view 中組合 `components/maint/MaintShell``components/sections``components/items` 與 composable。
## 目前範本
- `SingleRecord.vue`:單主檔 CRUD + dialog(使用 page driver composable)。
- `EditableGrid.vue`:可編輯表格。
- `MasterDetailA.vue`:主檔 + 側邊明細 panel(使用 page driver composable)。
- `MasterDetailB.vue`:主檔 + collapse / full-height 明細。
- `MasterDetailC.vue`:主檔 + 簡化明細清單。
## 複製規則
複製維護頁時同步調整:
- `router/routes.ts``path``name``component``meta.layout`
- 頁面標題、查詢欄位、表格欄位、form 型別、驗證規則
- store、service、model、語系、menu/favorites/breadcrumb 相關資料
正式 domain 不應長期塞在 `maint`,複製後優先移到自己的 feature 目錄。
File diff suppressed because it is too large Load Diff
+32 -34
View File
@@ -1,76 +1,63 @@
<template> <template>
<page-maint <!-- Page component 組合 MaintShell 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<maint-shell
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
:title="`主從資料維護示範B`" :title="pageModel.title"
@create="openAddDialog" @create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
> >
<!-- 搜尋欄位放在 MaintShell search-fields slot讓外殼固定欄位由頁面決定 -->
<template #search-fields> <template #search-fields>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div> <BaseFormTextField
<v-text-field
id="search-student-id" id="search-student-id"
v-model="search.studentId" v-model="search.studentId"
aria-labelledby="search-student-id-label" label="學號"
density="compact" :label-char-count="2"
hide-details
name="searchStudentId" name="searchStudentId"
placeholder="例如:S2024001" placeholder="例如:S2024001"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div> <BaseFormTextField
<v-text-field
id="search-name" id="search-name"
v-model="search.name" v-model="search.name"
aria-labelledby="search-name-label" label="姓名"
density="compact" :label-char-count="2"
hide-details
name="searchName" name="searchName"
placeholder="例如:王小明" placeholder="例如:王小明"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div> <BaseFormSelect
<v-select
id="search-department" id="search-department"
v-model="search.department" v-model="search.department"
aria-labelledby="search-department-label" label="系所"
density="compact" :label-char-count="2"
hide-details
:items="departments" :items="departments"
name="searchDepartment" name="searchDepartment"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div> <BaseFormSelect
<v-select
id="search-grade" id="search-grade"
v-model="search.grade" v-model="search.grade"
aria-labelledby="search-grade-label" label="年級"
density="compact" :label-char-count="2"
hide-details
item-title="title" item-title="title"
item-value="value" item-value="value"
:items="gradeOptions" :items="gradeOptions"
name="searchGrade" name="searchGrade"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div> <BaseFormSelect
<v-select
id="search-status" id="search-status"
v-model="search.status" v-model="search.status"
aria-labelledby="search-status-label" label="狀態"
density="compact" :label-char-count="2"
hide-details
:items="statuses" :items="statuses"
name="searchStatus" name="searchStatus"
variant="outlined"
/> />
</v-col> </v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto"> <v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
@@ -78,6 +65,7 @@
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn> <v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col> </v-col>
</template> </template>
<!-- table slot 放主檔表格與列操作操作事件再交給頁面流程函式處理 -->
<template #table> <template #table>
<v-data-table <v-data-table
v-model:page="currentPage" v-model:page="currentPage"
@@ -170,7 +158,7 @@
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
</page-maint> </maint-shell>
<!-- 主從式維護視窗 --> <!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 --> <!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
@@ -502,17 +490,20 @@ import { mdiBookPlus, mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue' import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import DetailCollapseGropus from '@/components/maint/master-detail/DetailCollapseGropus.vue' import DetailCollapseGropus from '@/components/maint/master-detail/DetailCollapseGropus.vue'
import DetailFullHeightPanel from '@/components/maint/master-detail/DetailFullHeightPanel.vue' import DetailFullHeightPanel from '@/components/maint/master-detail/DetailFullHeightPanel.vue'
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue' import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue' import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue' import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/maint/MaintShell.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow' import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm' import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters' import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters'
import { type StudentRecord, useStudentStore } from '@/stores/students' import { type StudentRecord, useStudentStore } from '@/stores/students'
import type { MaintenancePageModel } from '@/models/page'
// 下拉選項:系所/年級/入學年度/狀態 // 下拉選項:系所/年級/入學年度/狀態
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融'] const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
@@ -578,6 +569,13 @@ const searchPanelOpen = ref(false)
const studentStore = useStudentStore() const studentStore = useStudentStore()
const semesterStore = useSemesterStore() const semesterStore = useSemesterStore()
const students = computed(() => studentStore.students) const students = computed(() => studentStore.students)
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範B',
records: students.value,
loading: false,
error: null,
}))
type StudentPayload = Omit<StudentRecord, 'id'> type StudentPayload = Omit<StudentRecord, 'id'>
const itemsPerPage = 10 const itemsPerPage = 10
const currentPage = ref(1) const currentPage = ref(1)
+32 -34
View File
@@ -1,76 +1,63 @@
<template> <template>
<page-maint <!-- Page component 組合 MaintShell 外殼主檔表格子檔區與 dialog流程狀態集中在本頁 script -->
<maint-shell
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
:title="`主從資料維護示範C`" :title="pageModel.title"
@create="openAddDialog" @create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
> >
<!-- 搜尋欄位放在 MaintShell search-fields slot讓外殼固定欄位由頁面決定 -->
<template #search-fields> <template #search-fields>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div> <BaseFormTextField
<v-text-field
id="search-student-id" id="search-student-id"
v-model="search.studentId" v-model="search.studentId"
aria-labelledby="search-student-id-label" label="學號"
density="compact" :label-char-count="2"
hide-details
name="searchStudentId" name="searchStudentId"
placeholder="例如:S2024001" placeholder="例如:S2024001"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div> <BaseFormTextField
<v-text-field
id="search-name" id="search-name"
v-model="search.name" v-model="search.name"
aria-labelledby="search-name-label" label="姓名"
density="compact" :label-char-count="2"
hide-details
name="searchName" name="searchName"
placeholder="例如:王小明" placeholder="例如:王小明"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div> <BaseFormSelect
<v-select
id="search-department" id="search-department"
v-model="search.department" v-model="search.department"
aria-labelledby="search-department-label" label="系所"
density="compact" :label-char-count="2"
hide-details
:items="departments" :items="departments"
name="searchDepartment" name="searchDepartment"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div> <BaseFormSelect
<v-select
id="search-grade" id="search-grade"
v-model="search.grade" v-model="search.grade"
aria-labelledby="search-grade-label" label="年級"
density="compact" :label-char-count="2"
hide-details
item-title="title" item-title="title"
item-value="value" item-value="value"
:items="gradeOptions" :items="gradeOptions"
name="searchGrade" name="searchGrade"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div> <BaseFormSelect
<v-select
id="search-status" id="search-status"
v-model="search.status" v-model="search.status"
aria-labelledby="search-status-label" label="狀態"
density="compact" :label-char-count="2"
hide-details
:items="statuses" :items="statuses"
name="searchStatus" name="searchStatus"
variant="outlined"
/> />
</v-col> </v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto"> <v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
@@ -78,6 +65,7 @@
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn> <v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn>
</v-col> </v-col>
</template> </template>
<!-- table slot 放主檔表格與列操作操作事件再交給頁面流程函式處理 -->
<template #table> <template #table>
<v-data-table <v-data-table
v-model:page="currentPage" v-model:page="currentPage"
@@ -170,7 +158,7 @@
</template> </template>
</v-data-table> </v-data-table>
</template> </template>
</page-maint> </maint-shell>
<!-- 主從式維護視窗 --> <!-- 主從式維護視窗 -->
<!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 --> <!-- 說明包含主檔 (學生資料) 與明細檔 (學期成績) 的維護介面 -->
@@ -489,17 +477,20 @@ import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil, mdiSchool } from '@
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue' import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import MasterDetailCCourseMobilePanel from '@/components/maint/master-detail/CourseMobilePanel.vue' import MasterDetailCCourseMobilePanel from '@/components/maint/master-detail/CourseMobilePanel.vue'
import DetailSimpleList from '@/components/maint/master-detail/DetailSimpleList.vue' import DetailSimpleList from '@/components/maint/master-detail/DetailSimpleList.vue'
import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue' import MasterFileFormFields from '@/components/maint/MasterFileFormFields.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue' import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue' import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue' import MaintShell from '@/components/maint/MaintShell.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow' import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm' import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters' import { type CourseRecord, type SemesterRecord, useSemesterStore } from '@/stores/semesters'
import { type StudentRecord, useStudentStore } from '@/stores/students' import { type StudentRecord, useStudentStore } from '@/stores/students'
import type { MaintenancePageModel } from '@/models/page'
// 下拉選項:系所/年級/入學年度/狀態 // 下拉選項:系所/年級/入學年度/狀態
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融'] const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
@@ -565,6 +556,13 @@ const searchPanelOpen = ref(false)
const studentStore = useStudentStore() const studentStore = useStudentStore()
const semesterStore = useSemesterStore() const semesterStore = useSemesterStore()
const students = computed(() => studentStore.students) const students = computed(() => studentStore.students)
const pageModel = computed<MaintenancePageModel>(() => ({
type: 'maintenance',
title: '主從資料維護示範C',
records: students.value,
loading: false,
error: null,
}))
type StudentPayload = Omit<StudentRecord, 'id'> type StudentPayload = Omit<StudentRecord, 'id'>
const itemsPerPage = 10 const itemsPerPage = 10
const currentPage = ref(1) const currentPage = ref(1)
+50 -863
View File
@@ -1,921 +1,108 @@
<script setup lang="ts">
import BaseFormSelect from '@/components/base/BaseFormSelect.vue'
import BaseFormTextField from '@/components/base/BaseFormTextField.vue'
import MaintShell from '@/components/maint/MaintShell.vue'
import SectionDataTable from '@/components/sections/SectionDataTable.vue'
import SectionFormPanel from '@/components/sections/SectionFormPanel.vue'
import { useSingleRecordMaintenancePage } from '@/composables/page-drivers/useSingleRecordMaintenancePage'
const {
commands, currentPage, departments, flow, formPanelEvents, formPanelProps,
formState, gradeOptions, itemsPerPage, pageCount, pageModel, pageSummary,
resetSearch, search, searchPanelOpen, snackbarVisible,
statuses, students, tableHeaders,
} = useSingleRecordMaintenancePage()
</script>
<template> <template>
<page-maint <MaintShell
:title="pageModel.title"
:search-panel-open="searchPanelOpen" :search-panel-open="searchPanelOpen"
:title="`單筆資料維護示範`"
@create="openAddDialog"
@toggle-search="searchPanelOpen = !searchPanelOpen" @toggle-search="searchPanelOpen = !searchPanelOpen"
@create="commands.openAddDialog"
> >
<template #search-fields> <template #search-fields>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-student-id-label" class="text-body-2 text-medium-emphasis pl-2">學號</div> <BaseFormTextField
<v-text-field
id="search-student-id" id="search-student-id"
v-model="search.studentId" v-model="search.studentId"
aria-labelledby="search-student-id-label" label="學號"
density="compact" :label-char-count="2"
hide-details
name="searchStudentId" name="searchStudentId"
placeholder="例如:S2024001" placeholder="例如:S2024001"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-name-label" class="text-body-2 text-medium-emphasis pl-2">姓名</div> <BaseFormTextField
<v-text-field
id="search-name" id="search-name"
v-model="search.name" v-model="search.name"
aria-labelledby="search-name-label" label="姓名"
density="compact" :label-char-count="2"
hide-details
name="searchName" name="searchName"
placeholder="例如:王小明" placeholder="例如:王小明"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-department-label" class="text-body-2 text-medium-emphasis pl-2">系所</div> <BaseFormSelect
<v-select
id="search-department" id="search-department"
v-model="search.department" v-model="search.department"
aria-labelledby="search-department-label" label="系所"
density="compact" :label-char-count="2"
hide-details
:items="departments" :items="departments"
name="searchDepartment" name="searchDepartment"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-grade-label" class="text-body-2 text-medium-emphasis pl-2">年級</div> <BaseFormSelect
<v-select
id="search-grade" id="search-grade"
v-model="search.grade" v-model="search.grade"
aria-labelledby="search-grade-label" label="年級"
density="compact" :label-char-count="2"
hide-details
item-title="title" item-title="title"
item-value="value" item-value="value"
:items="gradeOptions" :items="gradeOptions"
name="searchGrade" name="searchGrade"
variant="outlined"
/> />
</v-col> </v-col>
<v-col cols="12" md="2"> <v-col cols="12" md="2">
<div id="search-status-label" class="text-body-2 text-medium-emphasis pl-2">狀態</div> <BaseFormSelect
<v-select
id="search-status" id="search-status"
v-model="search.status" v-model="search.status"
aria-labelledby="search-status-label" label="狀態"
density="compact" :label-char-count="2"
hide-details
:items="statuses" :items="statuses"
name="searchStatus" name="searchStatus"
variant="outlined"
/> />
</v-col> </v-col>
<v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto"> <v-col class="d-flex justify-end align-end flex-grow-1 ga-2" cols="12" md="auto">
<v-btn :prepend-icon="mdiBroom" variant="text" @click="resetSearch">清除</v-btn> <v-btn variant="text" @click="resetSearch">清除</v-btn>
<v-btn color="primary" disabled :prepend-icon="mdiMagnify" variant="tonal">查詢</v-btn> <v-btn color="primary" disabled variant="tonal">查詢</v-btn>
</v-col> </v-col>
</template> </template>
<template #table> <template #table>
<v-data-table <SectionDataTable
v-model:page="currentPage" v-model:current-page="currentPage"
class="student-table" :grade-label="formState.gradeLabel"
density="compact"
fixed-header
:headers="tableHeaders" :headers="tableHeaders"
height="100%"
hide-default-footer
:items="students" :items="students"
:items-per-page="itemsPerPage" :items-per-page="itemsPerPage"
:row-props="rowProps" :page-count="pageCount"
> :page-summary="pageSummary"
<template #[`item.grade`]="{ item }"> :row-props="formState.rowProps"
{{ gradeLabel(item.grade) }} :status-color="formState.statusColor"
</template> @delete="flow.requestDeleteConfirmation"
<template #[`item.status`]="{ item }"> @edit="commands.openEditDialog"
<v-chip :color="statusColor(item.status)" size="small" variant="tonal"> @view="commands.openViewDialog"
{{ item.status }}
</v-chip>
</template>
<template #[`item.actions`]="{ item }">
<div class="d-flex ga-2">
<v-btn
color="info"
:prepend-icon="mdiEye"
size="small"
variant="text"
@click="openViewDialog(item)"
>
檢視
</v-btn>
<v-btn
color="primary"
:prepend-icon="mdiPencil"
size="small"
variant="text"
@click="openEditDialog(item)"
>
修改
</v-btn>
<v-btn
color="error"
:prepend-icon="mdiDelete"
size="small"
variant="text"
@click="requestDeleteConfirmation(item)"
>
刪除
</v-btn>
</div>
</template>
<template #bottom>
<div class="d-flex align-center justify-space-between px-4 py-3">
<div class="text-body-2 text-medium-emphasis">
{{ pageSummary }}
</div>
<div class="d-flex align-center ga-2">
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="currentPage = 1"
>
第一頁
</v-btn>
<v-btn
:disabled="currentPage <= 1"
size="small"
variant="text"
@click="currentPage -= 1"
>
上一頁
</v-btn>
<span class="text-body-2">{{ currentPage }} / {{ pageCount }}</span>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="currentPage += 1"
>
下一頁
</v-btn>
<v-btn
:disabled="currentPage >= pageCount"
size="small"
variant="text"
@click="currentPage = pageCount"
>
最後頁
</v-btn>
</div>
</div>
</template>
</v-data-table>
</template>
</page-maint>
<!-- 新增 / 編輯 / 檢視側邊欄 -->
<teleport to="body">
<!-- 包成元件需要傳高度寬度給dialog-panel -->
<v-overlay
class="dialog-overlay"
:close-on-content-click="false"
:model-value="dialogVisible"
scrim="rgba(0, 0, 0, 0.45)"
scroll-strategy="block"
@update:model-value="handleDialogVisibility"
>
<div class="dialog-panel">
<mnt-dialog-card
content-class="pa-2 flex-grow-1 overflow-y-auto"
:dialog-subtitle="dialogSubtitle"
:dialog-title="dialogTitle"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
width="100%"
>
<template #toolbar>
<mnt-record-nav-toolbar
edit-label="進入編輯"
first-label="第一筆"
:has-next-record="hasNextRecord"
:has-prev-record="hasPrevRecord"
:is-edit-mode="isEditMode"
:is-view-mode="isViewMode"
last-label="最後一筆"
view-label="回到檢視"
@first="openEdgeRecord('first')"
@last="openEdgeRecord('last')"
@next="openAdjacentRecord('next')"
@prev="openAdjacentRecord('prev')"
@switch-to-edit="switchToEditMode"
@switch-to-view="switchToViewMode"
/> />
</template> </template>
<template #content> </MaintShell>
<!-- 儲存前驗證錯誤摘要 -->
<v-alert
v-if="errorSummary.length > 0 && !isLoading"
class="mb-4"
type="error"
variant="tonal"
>
<div class="text-subtitle-2 mb-2">請先修正以下問題</div>
<div class="d-flex flex-column ga-1">
<v-btn
v-for="error in errorSummary"
:key="error.field"
color="error"
size="small"
variant="text"
@click="scrollToField(error.field)"
>
{{ error.message }}
</v-btn>
</div>
</v-alert>
<!-- 編輯/檢視載入中骨架 --> <SectionFormPanel
<v-skeleton-loader v-bind="formPanelProps"
v-if="isLoading" v-on="formPanelEvents"
class="mt-4"
type="subtitle,paragraph"
width="100%"
/> />
<!-- 表單檢視模式使用 readonly避免 focus 狀態 -->
<v-form
v-else
:class="{ 'form-readonly': isFormReadonly }"
@submit.prevent="requestSaveConfirmation"
>
<v-row density="compact">
<v-col cols="12" md="6">
<v-text-field
id="field-studentId"
v-model="form.studentId"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.studentId"
label="學號"
placeholder="例如:S2024008"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('studentId')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-name"
v-model="form.name"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.name"
label="姓名"
placeholder="例如:陳怡君"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('name')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-department"
v-model="form.department"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.department"
:items="departments"
label="系所"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('department')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-grade"
v-model="form.grade"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.grade"
item-title="title"
item-value="value"
:items="gradeOptions"
label="年級"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('grade')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-enrollYear"
v-model="form.enrollYear"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.enrollYear"
:items="enrollYears"
label="入學年度"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('enrollYear')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-credits"
v-model.number="form.credits"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.credits"
label="已修學分"
min="0"
:readonly="isFormReadonly"
type="number"
variant="outlined"
@update:model-value="clearFieldError('credits')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-advisor"
v-model="form.advisor"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.advisor"
label="指導老師"
placeholder="例如:林教授"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('advisor')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-email"
v-model="form.email"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.email"
label="Email"
placeholder="name@school.edu"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('email')"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
id="field-phone"
v-model="form.phone"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.phone"
label="電話"
placeholder="例如:02-2345-6789"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('phone')"
/>
</v-col>
<v-col cols="12" md="6">
<v-select
id="field-status"
v-model="form.status"
density="comfortable"
:disabled="isFormLocked"
:error-messages="fieldErrors.status"
:items="statuses"
label="狀態"
:readonly="isFormReadonly"
variant="outlined"
@update:model-value="clearFieldError('status')"
/>
</v-col>
</v-row>
</v-form>
</template>
<template #actions>
<v-spacer />
<v-btn :disabled="isSaving" variant="text" @click="requestCloseDialog">取消</v-btn>
<v-btn
v-if="isEditMode"
color="error"
:disabled="isSaving"
variant="tonal"
@click="requestDeleteCurrent"
>
刪除
</v-btn>
<v-btn
v-if="!isViewMode"
color="primary"
:disabled="!isDirty || isLoading"
:loading="isSaving"
variant="flat"
@click="requestSaveConfirmation"
>
儲存
</v-btn>
<v-btn v-else color="primary" variant="flat" @click="requestCloseDialog">關閉</v-btn>
</template>
</mnt-dialog-card>
</div>
</v-overlay>
</teleport>
<ConfirmDialog
v-model="confirmCloseVisible"
confirm-color="error"
confirm-text="關閉不儲存"
message="目前有尚未儲存的內容,確定要關閉嗎?"
title="未儲存變更"
@confirm="confirmClose"
/>
<ConfirmDialog
v-model="confirmSaveVisible"
:confirm-loading="isSaving"
confirm-text="確認儲存"
max-width="520"
title="確認儲存變更"
@confirm="confirmSave"
>
<div v-if="saveSummary.length > 0" class="d-flex flex-column ga-2">
<div v-for="item in saveSummary" :key="item.label" class="d-flex flex-column">
<div class="text-caption text-medium-emphasis">{{ item.label }}</div>
<div v-if="item.before !== null" class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.before === '—' }">
{{ item.before }}
</span>
</div>
<div class="text-body-2">
<span class="text-medium-emphasis"></span>
<span :class="{ 'text-medium-emphasis': item.after === '—' }">
{{ item.after }}
</span>
</div>
</div>
</div>
<div v-else class="text-body-2">目前沒有可儲存的變更</div>
</ConfirmDialog>
<ConfirmDialog
v-model="confirmDeleteVisible"
confirm-color="error"
confirm-text="確定刪除"
:message="`確定要刪除 ${pendingDeleteLabel} 嗎?此操作無法復原。`"
title="確認刪除"
@confirm="confirmDelete"
/>
<ConfirmDialog
v-model="confirmSwitchVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換為檢視模式將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmSwitch"
/>
<ConfirmDialog
v-model="confirmNavigateVisible"
confirm-text="確定切換"
max-width="480"
message="目前有尚未儲存的內容,切換到其他資料將會捨棄變更,確定要切換嗎?"
title="未儲存變更"
@confirm="confirmNavigate"
/>
<!-- 成功提示 -->
<v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200"> <v-snackbar v-model="snackbarVisible" color="success" location="bottom right" :timeout="2200">
儲存成功 儲存成功
</v-snackbar> </v-snackbar>
</template> </template>
<script setup lang="ts">
import { mdiBroom, mdiDelete, mdiEye, mdiMagnify, mdiPencil } from '@mdi/js'
import { computed, nextTick, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import ConfirmDialog from '@/components/maint/CommonConfirmDialog.vue'
import MntDialogCard from '@/components/maint/MntDialogCard.vue'
import MntRecordNavToolbar from '@/components/maint/MntRecordNavToolbar.vue'
import PageMaint from '@/components/PageMaint.vue'
import { useMaintenanceCrudFlow } from '@/composables/maint/useMaintenanceCrudFlow'
import { useStudentMaintenanceForm } from '@/composables/maint/useStudentMaintenanceForm'
import { type StudentRecord, useStudentStore } from '@/stores/students'
// 下拉選項:系所/年級/入學年度/狀態
const departments = ['資訊工程', '企業管理', '應用外語', '視覺設計', '財務金融']
const gradeOptions = [
{ title: '大一', value: 1 },
{ title: '大二', value: 2 },
{ title: '大三', value: 3 },
{ title: '大四', value: 4 },
{ title: '研究所', value: 5 },
]
const enrollYears = [2024, 2023, 2022, 2021, 2020, 2019]
const statuses = ['在學', '休學', '畢業']
const { smAndUp } = useDisplay()
// 表格欄位設定(含固定欄與排序)
const tableHeaders = computed(() => [
{
title: '學號',
key: 'studentId',
sortable: true,
fixed: smAndUp.value && ('start' as const),
width: 120,
},
{
title: '姓名',
key: 'name',
sortable: true,
fixed: smAndUp.value && ('start' as const),
width: 100,
},
{ title: '系所', key: 'department', sortable: true, width: 140 },
{ title: '年級', key: 'grade', sortable: true, width: 90 },
{ title: '入學年度', key: 'enrollYear', sortable: true, width: 110 },
{ title: '已修學分', key: 'credits', sortable: true, width: 110 },
{ title: 'Email', key: 'email', sortable: true, width: 200 },
{ title: '電話', key: 'phone', sortable: true, width: 140 },
{ title: '指導老師', key: 'advisor', sortable: true, width: 110 },
{ title: '狀態', key: 'status', sortable: true, width: 90 },
{
title: '操作',
key: 'actions',
sortable: false,
fixed: smAndUp.value && ('end' as const),
width: 'auto',
cellProps: { class: 'px-0 bg-background' },
},
])
// 查詢條件(示意用,未接 API)
const search = ref({
studentId: '',
name: '',
department: '',
grade: null as number | null,
status: '',
})
// 查詢區塊是否展開
const searchPanelOpen = ref(false)
// 透過 store 管理 Demo 資料與 CRUD
const studentStore = useStudentStore()
const students = computed(() => studentStore.students)
type StudentPayload = Omit<StudentRecord, 'id'>
const itemsPerPage = 10
const currentPage = ref(1)
const pageCount = computed(() => Math.max(1, Math.ceil(students.value.length / itemsPerPage)))
const pageSummary = computed(() => {
const total = students.value.length
if (total === 0) return '第 0-0 筆 / 共 0 筆'
const start = (currentPage.value - 1) * itemsPerPage + 1
const end = Math.min(currentPage.value * itemsPerPage, total)
return `${start}-${end} 筆 / 共 ${total}`
})
// 彈窗狀態與流程控制
const dialogVisible = ref(false)
const editingId = ref<number | null>(null)
const dialogMode = ref<'create' | 'edit' | 'view'>('create')
const isLoading = ref(false)
const isSaving = ref(false)
// UI 回饋
const snackbarVisible = ref(false)
const highlightedId = ref<number | null>(null)
// 防止快速切換導致的異步覆蓋
const loadSequence = ref(0)
const {
errorSummary,
fieldErrors,
form,
isDirty,
saveSummary,
clearAllErrors,
clearFieldError,
gradeLabel,
resetForm,
rowProps,
setForm,
statusColor,
syncInitialForm,
validateForm,
} = useStudentMaintenanceForm({
departments,
gradeOptions,
enrollYears,
statuses,
students,
editingId,
highlightedId,
})
// 表單欄位簡單排版(無分組)
// 彈窗標題/副標題
const dialogTitle = computed(() => {
if (dialogMode.value === 'view') return '檢視資料示範'
if (dialogMode.value === 'edit') return '修改資料示範'
return '新增資料示範'
})
const dialogSubtitle = computed(() => {
if (!editingId.value) return ''
return `${form.value.studentId || '未填學號'}${form.value.name || '未填姓名'}`
})
// 是否有修改(用於啟用儲存與提示)
// 載入/儲存時鎖定、檢視模式 readonly
const isFormLocked = computed(() => isLoading.value || isSaving.value)
const {
confirmClose,
confirmCloseVisible,
confirmDelete,
confirmDeleteVisible,
confirmNavigate,
confirmNavigateVisible,
confirmSaveVisible,
confirmSwitch,
confirmSwitchVisible,
handleDialogVisibility,
hasNextRecord,
hasPrevRecord,
isEditMode,
isViewMode,
openAdjacentRecord,
openEdgeRecord,
pendingDeleteLabel,
requestCloseDialog,
requestDeleteConfirmation,
requestDeleteCurrent,
switchToEditMode,
switchToViewMode,
} = useMaintenanceCrudFlow<StudentRecord>({
records: students,
editingId,
dialogMode,
dialogVisible,
isLoading,
isSaving,
isDirty,
clearAllErrors,
resetForm,
openEditDialog,
openViewDialog,
removeRecord: (id) => {
studentStore.removeStudent(id)
},
describeRecord: (student) => `${student.studentId} ${student.name}`,
})
const isFormReadonly = computed(() => isViewMode.value)
// 重設查詢條件
function resetSearch() {
search.value = {
studentId: '',
name: '',
department: '',
grade: null,
status: '',
}
}
watch(pageCount, (value) => {
if (currentPage.value > value) {
currentPage.value = value
}
})
// 新增:開啟彈窗,使用預設值
function openAddDialog() {
loadSequence.value += 1
dialogMode.value = 'create'
editingId.value = null
resetForm()
isLoading.value = false
dialogVisible.value = true
}
// 編輯:先開彈窗,資料載入後填入
function openEditDialog(student: StudentRecord) {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = 'edit'
editingId.value = student.id
dialogVisible.value = true
isLoading.value = true
clearAllErrors()
setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
setForm({
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
})
syncInitialForm()
isLoading.value = false
}, 350)
}
// 檢視:只讀模式並預設展開所有分組
function openViewDialog(student: StudentRecord) {
loadSequence.value += 1
const sequence = loadSequence.value
dialogMode.value = 'view'
editingId.value = student.id
dialogVisible.value = true
isLoading.value = true
clearAllErrors()
setTimeout(() => {
if (sequence !== loadSequence.value || !dialogVisible.value) return
setForm({
studentId: student.studentId,
name: student.name,
department: student.department,
grade: student.grade,
enrollYear: student.enrollYear,
credits: student.credits,
advisor: student.advisor,
email: student.email,
phone: student.phone,
status: student.status,
})
syncInitialForm()
isLoading.value = false
}, 350)
}
// 先檢核再提示儲存確認
async function requestSaveConfirmation() {
if (isSaving.value || isLoading.value || !isDirty.value || isViewMode.value) return
clearAllErrors()
const errors = validateForm()
if (errors.length > 0) {
for (const error of errors) {
fieldErrors.value[error.field] = [error.message]
}
await nextTick()
const firstError = errors[0]
if (firstError) scrollToField(firstError.field)
return
}
confirmSaveVisible.value = true
}
// 儲存確認後才真正送出
function confirmSave() {
confirmSaveVisible.value = false
saveStudent()
}
// 寫入資料(Demo:直接更新列表)
async function saveStudent() {
if (isSaving.value || isLoading.value) return
isSaving.value = true
await new Promise((resolve) => setTimeout(resolve, 450))
const payload = {
studentId: form.value.studentId.trim(),
name: form.value.name.trim(),
department: form.value.department,
grade: form.value.grade,
enrollYear: form.value.enrollYear,
credits: form.value.credits,
advisor: form.value.advisor.trim(),
email: form.value.email.trim(),
phone: form.value.phone.trim(),
status: form.value.status,
} as StudentPayload
if (editingId.value) {
const updated = studentStore.updateStudent(editingId.value, payload)
if (updated) highlightedId.value = editingId.value
} else {
const createdId = studentStore.addStudent(payload)
highlightedId.value = createdId
}
syncInitialForm()
dialogVisible.value = false
snackbarVisible.value = true
isSaving.value = false
window.setTimeout(() => {
highlightedId.value = null
}, 1600)
}
function scrollToField(field: string) {
const target = document.getElementById(`field-${field}`)
if (!target) return
target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
</script>
<style scoped>
.dialog-overlay :deep(.v-overlay__content) {
height: 100vh;
display: flex;
justify-content: flex-end;
width: 100vw;
max-width: 100vw;
}
.dialog-panel {
width: 760px;
max-width: 100%;
height: 100vh;
background: rgb(var(--v-theme-surface));
padding: 12px;
box-shadow: -12px 0 24px rgba(0, 0, 0, 0.18);
display: flex;
}
.dialog-actions {
position: sticky;
bottom: 0;
background: rgba(var(--v-theme-surface), 0.95);
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 1;
}
.dialog-title {
position: sticky;
top: 0;
background: rgba(var(--v-theme-surface), 0.95);
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
z-index: 2;
}
.student-table {
overflow: auto;
}
.student-table :deep(table) {
min-width: 1400px;
}
.student-table :deep(th),
.student-table :deep(td) {
white-space: nowrap;
}
.student-table :deep(.v-data-table-column--fixed),
.student-table :deep(.v-data-table-column--fixed-end) {
background: rgb(var(--v-theme-surface));
}
.student-table :deep(.v-data-table-column--fixed-last-start)::after {
content: '';
position: absolute;
top: 0;
right: -5px;
bottom: 0;
width: 5px;
box-shadow: inset 10px 0 8px -8px rgba(0, 0, 0, 0.15);
}
.student-table :deep(.v-data-table-footer) {
padding: 4px 0 0;
}
/* 直線 */
/* .student-table :deep(.v-data-table-column--first-fixed-end),
.student-table :deep(.v-data-table-column--last-fixed) {
border-left: none !important;
border-right: none !important;
} */
.form-readonly :deep(.v-field) {
pointer-events: none;
}
tbody tr.is-highlighted {
animation: row-highlight 1.6s ease-out;
}
@keyframes row-highlight {
0% {
background-color: rgba(var(--v-theme-primary), 0.18);
}
100% {
background-color: transparent;
}
}
</style>
-18
View File
@@ -1,18 +0,0 @@
import { test, expect } from '@playwright/test';
test('has title', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Expect a title "to contain" a substring.
await expect(page).toHaveTitle(/Playwright/);
});
test('get started link', async ({ page }) => {
await page.goto('https://playwright.dev/');
// Click the get started link.
await page.getByRole('link', { name: 'Get started' }).click();
// Expects page to have a heading with the name of Installation.
await expect(page.getByRole('heading', { name: 'Installation' })).toBeVisible();
});
+1 -2
View File
@@ -4,8 +4,7 @@
"vite.config.*", "vite.config.*",
"vitest.config.*", "vitest.config.*",
"cypress.config.*", "cypress.config.*",
"nightwatch.conf.*", "nightwatch.conf.*"
"playwright.config.*"
], ],
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
+7 -4
View File
@@ -1,10 +1,13 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from 'node:url'
import Vue from '@vitejs/plugin-vue' import Vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify' import Vuetify, { transformAssetUrls } from 'vite-plugin-vuetify'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [ plugins: [
Vue({ Vue({
template: { transformAssetUrls }, template: { transformAssetUrls },
@@ -36,7 +39,7 @@ export default defineConfig({
port: 3700, port: 3700,
proxy:{ proxy:{
"/service/": { "/service/": {
target: "http://192.168.89.54:9002", target: env.VITE_PROXY_TARGET || "http://localhost:8080",
changeOrigin: true, changeOrigin: true,
}, },
} }
@@ -47,4 +50,4 @@ export default defineConfig({
}, },
}, },
}, },
}) }})