diff --git a/data/page_status.json b/data/page_status.json index 32559d7..faaae02 100644 --- a/data/page_status.json +++ b/data/page_status.json @@ -8,7 +8,9 @@ { "route": "/wip-overview", "name": "WIP 即時概況", - "status": "released" + "status": "released", + "drawer_id": "reports", + "order": 1 }, { "route": "/wip-detail", @@ -23,37 +25,65 @@ { "route": "/resource-history", "name": "設備歷史績效", - "status": "released" + "status": "released", + "drawer_id": "reports", + "order": 3 }, { "route": "/tables", "name": "表格總覽", - "status": "dev" + "status": "dev", + "drawer_id": "dev-tools", + "order": 1 }, { "route": "/resource", "name": "機台狀態", - "status": "released" + "status": "released", + "drawer_id": "reports", + "order": 2 }, { "route": "/excel-query", "name": "Excel 批次查詢", - "status": "dev" + "status": "dev", + "drawer_id": "dev-tools", + "order": 2 }, { "route": "/job-query", "name": "設備維修查詢", - "status": "released" + "status": "released", + "drawer_id": "queries", + "order": 3 }, { "route": "/query-tool", "name": "批次追蹤工具", - "status": "released" + "status": "released", + "drawer_id": "queries", + "order": 4 }, { "route": "/tmtt-defect", "name": "TMTT印字腳型不良分析", - "status": "released" + "status": "released", + "drawer_id": "queries", + "order": 5 + }, + { + "route": "/admin/pages", + "name": "頁面管理", + "status": "dev", + "drawer_id": "dev-tools", + "order": 1 + }, + { + "route": "/admin/performance", + "name": "效能監控", + "status": "dev", + "drawer_id": "dev-tools", + "order": 2 } ], "api_public": true, @@ -62,5 +92,25 @@ "updated_at": "2026-01-29 13:49:59", "object_count": 19, "source": "tools/query_table_schema.py" - } + }, + "drawers": [ + { + "id": "reports", + "name": "報表類", + "order": 1, + "admin_only": false + }, + { + "id": "queries", + "name": "查詢類", + "order": 2, + "admin_only": false + }, + { + "id": "dev-tools", + "name": "開發工具", + "order": 3, + "admin_only": true + } + ] } \ No newline at end of file diff --git a/frontend/src/portal/main.js b/frontend/src/portal/main.js index ebeb23c..d9d2f9c 100644 --- a/frontend/src/portal/main.js +++ b/frontend/src/portal/main.js @@ -168,7 +168,7 @@ import './portal.css'; }); if (sidebarItems.length > 0) { - activateTab(sidebarItems[0].dataset.target); + activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null); } window.toggleHealthPopup = toggleHealthPopup; diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/.openspec.yaml b/openspec/changes/archive/2026-02-09-dynamic-nav-management/.openspec.yaml new file mode 100644 index 0000000..9bc4ae2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-09 diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/design.md b/openspec/changes/archive/2026-02-09-dynamic-nav-management/design.md new file mode 100644 index 0000000..5c33da6 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/design.md @@ -0,0 +1,112 @@ +## Context + +Portal sidebar 導航結構目前寫死在 `portal.html` (lines 356-421),包含三個固定分類與十個固定頁面按鈕。頁面的 `released`/`dev` 狀態已經由 `data/page_status.json` + `page_registry.py` 動態管理,但抽屜分類與頁面歸屬仍需改程式碼。 + +現有架構: +- `data/page_status.json`: 頁面狀態持久化(JSON file, atomic write, thread-safe cache) +- `page_registry.py`: 服務層(CRUD + cache + lock) +- `admin_routes.py` (lines 629-667): Admin API(GET/PUT pages) +- `admin/pages.html`: Admin UI(頁面狀態切換表格) +- `portal.html`: Jinja2 模板,`{% if can_view_page() %}` 控制可見性 + +關鍵約束: +- 專案使用 Jinja2 shell + Vite JS 混合架構,所有 11 個頁面一致 +- 無資料庫,所有配置用 JSON file 持久化 +- 需要向下相容:現有 `page_status.json` 的 page entries 不可遺失 + +## Goals / Non-Goals + +**Goals:** +- Admin 可透過 UI 對抽屜進行 CRUD(新增、改名、刪除、排序) +- Admin 可透過 UI 指定頁面歸屬的抽屜與排序 +- Portal sidebar 根據 JSON 配置動態渲染(Jinja2 for loop) +- 向下相容:首次載入時自動從現有 hardcoded 結構產生初始配置 +- 為未來全面 Vite SPA 化鋪路:API 設計可直接被前端 fetch 使用 + +**Non-Goals:** +- 不做全面 Vite SPA 化(本次只做 Jinja2 動態渲染) +- 不做頁面路由的動態新增(頁面路由仍在 `app.py` 中定義) +- 不改變 `released`/`dev` 狀態的邏輯 +- 不做拖拉排序(使用上下箭頭或數字排序即可) +- 不做多使用者即時同步(Admin 改完,其他使用者刷新頁面即可看到) + +## Decisions + +### 1. 資料結構:擴展 `page_status.json` + +**選擇**: 在現有 JSON 中新增 `drawers` 頂層欄位,並在 page 中加入 `drawer_id` 和 `order`。 + +```json +{ + "drawers": [ + { "id": "reports", "name": "報表類", "order": 1 }, + { "id": "queries", "name": "查詢類", "order": 2 }, + { "id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": true } + ], + "pages": [ + { + "route": "/wip-overview", + "name": "WIP 即時概況", + "status": "released", + "drawer_id": "reports", + "order": 1 + } + ], + "api_public": true +} +``` + +**替代方案**: 獨立 `navigation.json` 檔案。 +**放棄原因**: 頁面狀態和歸屬是同一份資料的不同面向,拆成兩個檔案增加同步複雜度。現有的 atomic write + lock 機制可以直接沿用。 + +### 2. 向下相容:自動遷移策略 + +**選擇**: `page_registry.py` 的 `_load()` 函式在讀取時檢測是否存在 `drawers` 欄位。若不存在,自動注入預設的三個抽屜定義並根據目前 portal.html 的 hardcoded 映射填充 `drawer_id`。遷移後立即 `_save()` 持久化。 + +**理由**: 零手動操作部署。首次啟動即完成遷移。 + +### 3. iframe 策略:維持現行邏輯 + +**選擇**: 一般頁面每個獨立 iframe,admin 工具頁面共用 `toolFrame`。動態渲染時根據 `drawer.admin_only` 判斷是否使用共用 iframe。 + +**理由**: 最小變動。iframe 行為和 `portal.js` 的 `activateTab()` 邏輯不需改動。 + +**具體做法**: 每個 page 的 iframe id 由 route 推導(如 `/wip-overview` → `wipOverviewFrame`),`admin_only` 抽屜下的頁面共用 `toolFrame` 並使用 `data-tool-src` 切換。 + +### 4. Admin API 設計 + +**選擇**: 在現有 `admin_routes.py` 中擴展,新增 drawer endpoints。 + +| Endpoint | Method | 用途 | +|---|---|---| +| `GET /admin/api/drawers` | GET | 取得所有抽屜 | +| `POST /admin/api/drawers` | POST | 新增抽屜 | +| `PUT /admin/api/drawers/` | PUT | 更新抽屜(改名、排序) | +| `DELETE /admin/api/drawers/` | DELETE | 刪除抽屜(需先移走其下頁面) | +| `PUT /admin/api/pages/` | PUT | 擴展現有 endpoint,支援 `drawer_id` 和 `order` | + +**替代方案**: RESTful nested resource `/admin/api/drawers//pages`。 +**放棄原因**: 頁面已經有獨立的 PUT endpoint,加上 `drawer_id` 欄位更簡單。 + +### 5. Admin UI:擴展 `/admin/pages` + +**選擇**: 在現有 `admin/pages.html` 上方加入抽屜管理區塊,下方頁面列表加入抽屜歸屬下拉選單和排序控制。 + +**理由**: 使用者已經知道去哪裡管理頁面,擴展比新頁面更自然。 + +### 6. Portal 模板動態化 + +**選擇**: `app.py` 的 portal route 讀取 drawers + pages 配置,組裝成結構化資料傳入 Jinja2 context。`portal.html` 用 `{% for %}` 渲染 sidebar 和 iframes。 + +**理由**: 維持現有 Jinja2 shell 架構一致性,`can_view_page()` server-side 過濾不需改動。 + +## Risks / Trade-offs + +- **[Risk] JSON 並發寫入** → 現有 `_lock` + atomic write 已處理。單行程 Flask 部署下無問題。如果未來 multi-worker 部署,需考慮 file lock(但這是既有風險,非本次引入)。 +- **[Risk] 刪除抽屜時頁面孤立** → API 層強制檢查:抽屜下仍有頁面時禁止刪除,回傳 409 Conflict。 +- **[Risk] 首次遷移時 hardcoded 映射不正確** → 遷移邏輯使用明確的 route-to-drawer 映射表,與現有 portal.html 一一對應。 +- **[Trade-off] 未歸屬抽屜的頁面不會出現在 sidebar** → 這是刻意設計。子頁面(如 `/wip-detail`, `/hold-detail`)不需出現在 sidebar 中,它們沒有 `drawer_id` 即可。 + +## Open Questions + +- 無。探索階段已充分討論並確認方向。 diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/proposal.md b/openspec/changes/archive/2026-02-09-dynamic-nav-management/proposal.md new file mode 100644 index 0000000..f394fc2 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/proposal.md @@ -0,0 +1,30 @@ +## Why + +Portal sidebar 的抽屜分類(報表類/查詢類/開發工具)與頁面歸屬目前寫死在 `portal.html` Jinja2 模板中,任何導航結構的變更都需要改程式碼並重新部署。Admin 應能透過現有的「頁面管理」UI 動態管理抽屜與頁面分配,無需開發介入。 + +## What Changes + +- 擴展 `data/page_status.json` 資料結構,加入 `drawers` 陣列定義抽屜(名稱、排序、可見性),並在每個 page 中加入 `drawer_id` 和 `order` 欄位 +- 擴展 `page_registry.py` 服務層,新增 drawer CRUD 函式與 page-to-drawer 指派函式 +- 新增 admin API endpoints:drawer 的增刪改查、頁面的抽屜指派與排序 +- **BREAKING**: `portal.html` sidebar 從寫死 HTML 改為 `{% for drawer in drawers %}` 動態渲染 +- 擴展現有 `/admin/pages` UI,加入抽屜管理區塊與頁面拖拉/下拉分配功能 +- 頁面的 `released`/`dev` 狀態邏輯與 `can_view_page()` 權限檢查保持不變 + +## Capabilities + +### New Capabilities +- `drawer-management`: Admin 可透過 API 與 UI 對抽屜進行 CRUD 操作(新增、刪除、改名、排序) +- `page-drawer-assignment`: Admin 可透過 API 與 UI 將頁面指派到不同抽屜,並控制頁面在抽屜內的排序 + +### Modified Capabilities +- `portal-drawer-navigation`: 導航結構從寫死改為從 JSON 配置動態渲染,抽屜分類與頁面歸屬由資料驅動 + +## Impact + +- **資料層**: `data/page_status.json` 結構擴展(向下相容,新增欄位有預設值) +- **服務層**: `page_registry.py` 新增 drawer 相關函式 +- **路由層**: `admin_routes.py` 新增 drawer API endpoints +- **模板層**: `portal.html` sidebar 區塊重寫為動態渲染 +- **前端層**: `admin/pages.html` UI 擴展 +- **無影響**: 各頁面本身的路由、模板、Vite 模組均不需變更 diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/drawer-management/spec.md b/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/drawer-management/spec.md new file mode 100644 index 0000000..f33c1ea --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/drawer-management/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Admin SHALL be able to create drawers +The system SHALL allow admin users to create new navigation drawers via API, specifying a name, an order value, and an optional `admin_only` flag. + +#### Scenario: Create a new drawer +- **WHEN** admin sends POST `/admin/api/drawers` with `{"name": "自訂分類", "order": 4}` +- **THEN** the system SHALL create a new drawer with a generated kebab-case id and persist it to `page_status.json` + +#### Scenario: Create drawer with duplicate name +- **WHEN** admin sends POST `/admin/api/drawers` with a name that already exists +- **THEN** the system SHALL return 409 Conflict with an error message + +### Requirement: Admin SHALL be able to rename drawers +The system SHALL allow admin users to update a drawer's name via API. + +#### Scenario: Rename a drawer +- **WHEN** admin sends PUT `/admin/api/drawers/` with `{"name": "新名稱"}` +- **THEN** the system SHALL update the drawer name and persist the change + +### Requirement: Admin SHALL be able to reorder drawers +The system SHALL allow admin users to change a drawer's sort order via API. + +#### Scenario: Change drawer order +- **WHEN** admin sends PUT `/admin/api/drawers/` with `{"order": 2}` +- **THEN** the system SHALL update the drawer order and the sidebar SHALL reflect the new order on next page load + +### Requirement: Admin SHALL be able to delete empty drawers +The system SHALL allow admin users to delete a drawer only when no pages are assigned to it. + +#### Scenario: Delete an empty drawer +- **WHEN** admin sends DELETE `/admin/api/drawers/` and the drawer has no assigned pages +- **THEN** the system SHALL remove the drawer from the configuration + +#### Scenario: Attempt to delete a drawer with assigned pages +- **WHEN** admin sends DELETE `/admin/api/drawers/` and the drawer still has assigned pages +- **THEN** the system SHALL return 409 Conflict with an error listing the assigned pages + +### Requirement: Admin SHALL be able to list all drawers +The system SHALL provide an API to retrieve all drawers with their metadata. + +#### Scenario: List all drawers +- **WHEN** admin sends GET `/admin/api/drawers` +- **THEN** the system SHALL return all drawers sorted by their `order` field + +### Requirement: Drawer management SHALL be accessible from the admin UI +The existing `/admin/pages` page SHALL include a drawer management section where admin can create, rename, reorder, and delete drawers. + +#### Scenario: Admin opens page management +- **WHEN** admin navigates to the page management UI +- **THEN** the UI SHALL display a drawer list with controls for add, rename, reorder, and delete diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/page-drawer-assignment/spec.md b/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/page-drawer-assignment/spec.md new file mode 100644 index 0000000..e6f3502 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/page-drawer-assignment/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Admin SHALL be able to assign a page to a drawer +The system SHALL allow admin users to assign a page to a specific drawer by setting its `drawer_id` via the existing page update API. + +#### Scenario: Assign page to a drawer +- **WHEN** admin sends PUT `/admin/api/pages/` with `{"drawer_id": "queries"}` +- **THEN** the page SHALL be associated with the specified drawer + +#### Scenario: Assign page to non-existent drawer +- **WHEN** admin sends PUT `/admin/api/pages/` with a `drawer_id` that does not exist +- **THEN** the system SHALL return 400 Bad Request with an error message + +### Requirement: Admin SHALL be able to set page order within a drawer +The system SHALL allow admin users to control the display order of pages within a drawer. + +#### Scenario: Set page order +- **WHEN** admin sends PUT `/admin/api/pages/` with `{"order": 3}` +- **THEN** the page SHALL be displayed at position 3 within its drawer on next portal load + +### Requirement: Pages without a drawer assignment SHALL NOT appear in the sidebar +Pages that have no `drawer_id` (e.g., sub-pages like `/wip-detail`, `/hold-detail`) SHALL NOT be rendered in the portal sidebar, but SHALL remain accessible via their direct routes. + +#### Scenario: Sub-page without drawer assignment +- **WHEN** a page exists in `page_status.json` without a `drawer_id` +- **THEN** the page SHALL NOT appear in any sidebar drawer +- **THEN** the page SHALL still be accessible via its direct URL + +### Requirement: Page drawer assignment SHALL be configurable from the admin UI +The existing `/admin/pages` page table SHALL include a drawer assignment dropdown and order controls for each page. + +#### Scenario: Admin changes page drawer via UI +- **WHEN** admin selects a different drawer from the dropdown for a page +- **THEN** the UI SHALL call the page update API with the new `drawer_id` + +#### Scenario: Admin clears drawer assignment via UI +- **WHEN** admin selects "未分類" (unassigned) from the dropdown +- **THEN** the page's `drawer_id` SHALL be removed and the page SHALL no longer appear in the sidebar diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/portal-drawer-navigation/spec.md b/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/portal-drawer-navigation/spec.md new file mode 100644 index 0000000..2defb83 --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/specs/portal-drawer-navigation/spec.md @@ -0,0 +1,43 @@ +## MODIFIED Requirements + +### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers +The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML. + +#### Scenario: Drawer grouping visibility +- **WHEN** users open the portal +- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field +- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field + +#### Scenario: Admin-only drawer visibility +- **WHEN** a drawer has `admin_only: true` and the current user is not admin +- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar + +#### Scenario: Empty drawer visibility +- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`) +- **THEN** the drawer group title SHALL NOT be rendered + +### Requirement: Existing Page Behavior SHALL Remain Compatible +The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames. + +#### Scenario: Route continuity +- **WHEN** a user selects an existing page entry from a dynamically rendered drawer +- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior + +#### Scenario: Iframe lazy-load continuity +- **WHEN** a sidebar item is clicked for the first time +- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior + +## ADDED Requirements + +### Requirement: First-run migration SHALL populate drawer configuration automatically +When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers. + +#### Scenario: First startup after deployment +- **WHEN** the application starts and `page_status.json` has no `drawers` field +- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具) +- **THEN** the system SHALL assign each existing page to its historically correct drawer +- **THEN** the system SHALL persist the updated configuration immediately + +#### Scenario: Subsequent startup +- **WHEN** the application starts and `page_status.json` already contains a `drawers` field +- **THEN** the system SHALL NOT modify the existing drawer configuration diff --git a/openspec/changes/archive/2026-02-09-dynamic-nav-management/tasks.md b/openspec/changes/archive/2026-02-09-dynamic-nav-management/tasks.md new file mode 100644 index 0000000..6ed93cd --- /dev/null +++ b/openspec/changes/archive/2026-02-09-dynamic-nav-management/tasks.md @@ -0,0 +1,36 @@ +## 1. 資料層:擴展 page_status.json 與 page_registry.py + +- [x] 1.1 在 `page_registry.py` 的 `_load()` 中加入自動遷移邏輯:當 `drawers` 欄位不存在時,注入預設三個抽屜並根據 hardcoded 映射表填充每個 page 的 `drawer_id` 和 `order`,然後 `_save()` +- [x] 1.2 新增 drawer CRUD 函式:`get_all_drawers()`, `create_drawer(name, order, admin_only)`, `update_drawer(id, ...)`, `delete_drawer(id)` +- [x] 1.3 擴展 `set_page_status()` 支援 `drawer_id` 和 `order` 參數 +- [x] 1.4 新增 `get_navigation_config()` 函式:回傳按 drawer order 排序的巢狀結構(drawers → pages),供 portal route 使用 + +## 2. API 層:擴展 admin_routes.py + +- [x] 2.1 新增 `GET /admin/api/drawers` endpoint,回傳所有抽屜(sorted by order) +- [x] 2.2 新增 `POST /admin/api/drawers` endpoint,建立新抽屜(驗證名稱不重複) +- [x] 2.3 新增 `PUT /admin/api/drawers/` endpoint,更新抽屜名稱/排序/admin_only +- [x] 2.4 新增 `DELETE /admin/api/drawers/` endpoint,刪除空抽屜(有頁面時回傳 409) +- [x] 2.5 擴展現有 `PUT /admin/api/pages/` endpoint,接受 `drawer_id` 和 `order` 欄位(驗證 drawer_id 存在) + +## 3. 模板層:portal.html 動態渲染 + +- [x] 3.1 修改 `app.py` 的 portal route,呼叫 `get_navigation_config()` 取得結構化導航資料,傳入 Jinja2 context +- [x] 3.2 將 `portal.html` sidebar 區塊(lines 356-392)改為 `{% for drawer in drawers %}` 動態渲染,保留 `can_view_page()` 過濾與 `admin_only` 判斷 +- [x] 3.3 將 `portal.html` iframe 區塊(lines 394-421)改為動態渲染,根據配置產生 iframe elements +- [x] 3.4 確認 `portal.js` 的 `activateTab()` 與 lazy-load 邏輯在動態 DOM 下正常運作(iframe id 命名規則需一致) + +## 4. Admin UI:擴展 /admin/pages + +- [x] 4.1 在 `admin/pages.html` 上方加入「抽屜管理」區塊:抽屜列表 + 新增/改名/刪除/排序控制 +- [x] 4.2 在頁面列表每一列加入 drawer 歸屬下拉選單(含「未分類」選項)和排序輸入 +- [x] 4.3 實作前端 JS:drawer CRUD 操作(呼叫 API → 更新 UI) +- [x] 4.4 實作前端 JS:頁面 drawer 指派操作(下拉變更 → PUT API) + +## 5. 驗證 + +- [x] 5.1 驗證首次啟動遷移:刪除 `page_status.json` 中的 `drawers` 欄位,重啟後確認自動產生正確的預設配置 +- [x] 5.2 驗證 portal sidebar 動態渲染:新增/刪除抽屜後刷新 portal,確認 sidebar 正確反映 +- [x] 5.3 驗證頁面歸屬變更:改變頁面的 drawer_id 後刷新 portal,確認頁面出現在正確的抽屜中 +- [x] 5.4 驗證權限邏輯不變:非 admin 使用者看不到 dev 頁面、看不到 admin_only 抽屜 +- [x] 5.5 驗證安全性:非 admin 使用者無法存取 drawer API endpoints diff --git a/openspec/specs/drawer-management/spec.md b/openspec/specs/drawer-management/spec.md new file mode 100644 index 0000000..fa8109b --- /dev/null +++ b/openspec/specs/drawer-management/spec.md @@ -0,0 +1,55 @@ +## Purpose +Define stable requirements for drawer-management. + +## Requirements + + +### Requirement: Admin SHALL be able to create drawers +The system SHALL allow admin users to create new navigation drawers via API, specifying a name, an order value, and an optional `admin_only` flag. + +#### Scenario: Create a new drawer +- **WHEN** admin sends POST `/admin/api/drawers` with `{"name": "自訂分類", "order": 4}` +- **THEN** the system SHALL create a new drawer with a generated kebab-case id and persist it to `page_status.json` + +#### Scenario: Create drawer with duplicate name +- **WHEN** admin sends POST `/admin/api/drawers` with a name that already exists +- **THEN** the system SHALL return 409 Conflict with an error message + +### Requirement: Admin SHALL be able to rename drawers +The system SHALL allow admin users to update a drawer's name via API. + +#### Scenario: Rename a drawer +- **WHEN** admin sends PUT `/admin/api/drawers/` with `{"name": "新名稱"}` +- **THEN** the system SHALL update the drawer name and persist the change + +### Requirement: Admin SHALL be able to reorder drawers +The system SHALL allow admin users to change a drawer's sort order via API. + +#### Scenario: Change drawer order +- **WHEN** admin sends PUT `/admin/api/drawers/` with `{"order": 2}` +- **THEN** the system SHALL update the drawer order and the sidebar SHALL reflect the new order on next page load + +### Requirement: Admin SHALL be able to delete empty drawers +The system SHALL allow admin users to delete a drawer only when no pages are assigned to it. + +#### Scenario: Delete an empty drawer +- **WHEN** admin sends DELETE `/admin/api/drawers/` and the drawer has no assigned pages +- **THEN** the system SHALL remove the drawer from the configuration + +#### Scenario: Attempt to delete a drawer with assigned pages +- **WHEN** admin sends DELETE `/admin/api/drawers/` and the drawer still has assigned pages +- **THEN** the system SHALL return 409 Conflict with an error listing the assigned pages + +### Requirement: Admin SHALL be able to list all drawers +The system SHALL provide an API to retrieve all drawers with their metadata. + +#### Scenario: List all drawers +- **WHEN** admin sends GET `/admin/api/drawers` +- **THEN** the system SHALL return all drawers sorted by their `order` field + +### Requirement: Drawer management SHALL be accessible from the admin UI +The existing `/admin/pages` page SHALL include a drawer management section where admin can create, rename, reorder, and delete drawers. + +#### Scenario: Admin opens page management +- **WHEN** admin navigates to the page management UI +- **THEN** the UI SHALL display a drawer list with controls for add, rename, reorder, and delete diff --git a/openspec/specs/page-drawer-assignment/spec.md b/openspec/specs/page-drawer-assignment/spec.md new file mode 100644 index 0000000..ef8f41e --- /dev/null +++ b/openspec/specs/page-drawer-assignment/spec.md @@ -0,0 +1,42 @@ +## Purpose +Define stable requirements for page-drawer-assignment. + +## Requirements + + +### Requirement: Admin SHALL be able to assign a page to a drawer +The system SHALL allow admin users to assign a page to a specific drawer by setting its `drawer_id` via the existing page update API. + +#### Scenario: Assign page to a drawer +- **WHEN** admin sends PUT `/admin/api/pages/` with `{"drawer_id": "queries"}` +- **THEN** the page SHALL be associated with the specified drawer + +#### Scenario: Assign page to non-existent drawer +- **WHEN** admin sends PUT `/admin/api/pages/` with a `drawer_id` that does not exist +- **THEN** the system SHALL return 400 Bad Request with an error message + +### Requirement: Admin SHALL be able to set page order within a drawer +The system SHALL allow admin users to control the display order of pages within a drawer. + +#### Scenario: Set page order +- **WHEN** admin sends PUT `/admin/api/pages/` with `{"order": 3}` +- **THEN** the page SHALL be displayed at position 3 within its drawer on next portal load + +### Requirement: Pages without a drawer assignment SHALL NOT appear in the sidebar +Pages that have no `drawer_id` (e.g., sub-pages like `/wip-detail`, `/hold-detail`) SHALL NOT be rendered in the portal sidebar, but SHALL remain accessible via their direct routes. + +#### Scenario: Sub-page without drawer assignment +- **WHEN** a page exists in `page_status.json` without a `drawer_id` +- **THEN** the page SHALL NOT appear in any sidebar drawer +- **THEN** the page SHALL still be accessible via its direct URL + +### Requirement: Page drawer assignment SHALL be configurable from the admin UI +The existing `/admin/pages` page table SHALL include a drawer assignment dropdown and order controls for each page. + +#### Scenario: Admin changes page drawer via UI +- **WHEN** admin selects a different drawer from the dropdown for a page +- **THEN** the UI SHALL call the page update API with the new `drawer_id` + +#### Scenario: Admin clears drawer assignment via UI +- **WHEN** admin selects "未分類" (unassigned) from the dropdown +- **THEN** the page's `drawer_id` SHALL be removed and the page SHALL no longer appear in the sidebar diff --git a/openspec/specs/portal-drawer-navigation/spec.md b/openspec/specs/portal-drawer-navigation/spec.md index d392571..aae7748 100644 --- a/openspec/specs/portal-drawer-navigation/spec.md +++ b/openspec/specs/portal-drawer-navigation/spec.md @@ -5,15 +5,41 @@ Define stable requirements for portal-drawer-navigation. ### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers -The portal SHALL group navigation entries into functional drawers: reports, queries, and development tools. +The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML. #### Scenario: Drawer grouping visibility - **WHEN** users open the portal -- **THEN** report pages and query pages SHALL appear in separate drawer groups +- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field +- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field + +#### Scenario: Admin-only drawer visibility +- **WHEN** a drawer has `admin_only: true` and the current user is not admin +- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar + +#### Scenario: Empty drawer visibility +- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`) +- **THEN** the drawer group title SHALL NOT be rendered ### Requirement: Existing Page Behavior SHALL Remain Compatible The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames. #### Scenario: Route continuity -- **WHEN** a user selects an existing page entry from the new drawer +- **WHEN** a user selects an existing page entry from a dynamically rendered drawer - **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior + +#### Scenario: Iframe lazy-load continuity +- **WHEN** a sidebar item is clicked for the first time +- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior + +### Requirement: First-run migration SHALL populate drawer configuration automatically +When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers. + +#### Scenario: First startup after deployment +- **WHEN** the application starts and `page_status.json` has no `drawers` field +- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具) +- **THEN** the system SHALL assign each existing page to its historically correct drawer +- **THEN** the system SHALL persist the updated configuration immediately + +#### Scenario: Subsequent startup +- **WHEN** the application starts and `page_status.json` already contains a `drawers` field +- **THEN** the system SHALL NOT modify the existing drawer configuration diff --git a/src/mes_dashboard/app.py b/src/mes_dashboard/app.py index ce9c7c5..e1a38e8 100644 --- a/src/mes_dashboard/app.py +++ b/src/mes_dashboard/app.py @@ -33,7 +33,11 @@ from mes_dashboard.routes import register_routes from mes_dashboard.routes.auth_routes import auth_bp from mes_dashboard.routes.admin_routes import admin_bp from mes_dashboard.routes.health_routes import health_bp -from mes_dashboard.services.page_registry import get_page_status, is_api_public +from mes_dashboard.services.page_registry import ( + get_navigation_config, + get_page_status, + is_api_public, +) from mes_dashboard.core.cache_updater import start_cache_updater, stop_cache_updater from mes_dashboard.services.realtime_equipment_cache import ( init_realtime_equipment_cache, @@ -367,7 +371,7 @@ def create_app(config_name: str | None = None) -> Flask: @app.route('/') def portal_index(): """Portal home with tabs.""" - return render_template('portal.html') + return render_template('portal.html', drawers=get_navigation_config()) @app.route('/favicon.ico') def favicon(): diff --git a/src/mes_dashboard/routes/admin_routes.py b/src/mes_dashboard/routes/admin_routes.py index 50f726f..95f6a7a 100644 --- a/src/mes_dashboard/routes/admin_routes.py +++ b/src/mes_dashboard/routes/admin_routes.py @@ -31,7 +31,17 @@ from mes_dashboard.core.worker_recovery_policy import ( extract_restart_history, load_restart_state, ) -from mes_dashboard.services.page_registry import get_all_pages, set_page_status +from mes_dashboard.services.page_registry import ( + DrawerConflictError, + DrawerNotFoundError, + create_drawer, + delete_drawer, + get_all_drawers, + get_all_pages, + get_page_status, + set_page_status, + update_drawer, +) admin_bp = Blueprint("admin", __name__, url_prefix="/admin") logger = logging.getLogger("mes_dashboard.admin") @@ -637,30 +647,127 @@ def pages(): return render_template("admin/pages.html") -@admin_bp.route("/api/pages", methods=["GET"]) -@admin_required -def api_get_pages(): - """API: Get all page configurations.""" - return jsonify({"success": True, "pages": get_all_pages()}) - - -@admin_bp.route("/api/pages/", methods=["PUT"]) -@admin_required -def api_update_page(route: str): - """API: Update page status.""" - data = request.get_json() - status = data.get("status") - name = data.get("name") - - if status not in ("released", "dev"): - return jsonify({"success": False, "error": "Invalid status"}), 400 - - # Ensure route starts with / - if not route.startswith("/"): - route = "/" + route - - try: - set_page_status(route, status, name) - return jsonify({"success": True}) - except Exception as e: - return jsonify({"success": False, "error": str(e)}), 500 +@admin_bp.route("/api/pages", methods=["GET"]) +@admin_required +def api_get_pages(): + """API: Get all page configurations.""" + return jsonify({"success": True, "pages": get_all_pages()}) + + +@admin_bp.route("/api/drawers", methods=["GET"]) +@admin_required +def api_get_drawers(): + """API: Get all drawer configurations.""" + return jsonify({"success": True, "drawers": get_all_drawers()}) + + +@admin_bp.route("/api/drawers", methods=["POST"]) +@admin_required +def api_create_drawer(): + """API: Create a new drawer.""" + payload = request.get_json(silent=True) or {} + name = payload.get("name") + order = payload.get("order") + admin_only = bool(payload.get("admin_only", False)) + + try: + drawer = create_drawer(name=name, order=order, admin_only=admin_only) + except DrawerConflictError as exc: + return jsonify({"success": False, "error": str(exc)}), 409 + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: # pragma: no cover - defensive fallback + logger.exception("Failed to create drawer") + return jsonify({"success": False, "error": str(exc)}), 500 + + return jsonify({"success": True, "drawer": drawer}), 201 + + +@admin_bp.route("/api/drawers/", methods=["PUT"]) +@admin_required +def api_update_drawer(drawer_id: str): + """API: Update drawer name/order/admin_only.""" + payload = request.get_json(silent=True) or {} + updates: dict[str, Any] = {} + + if "name" in payload: + updates["name"] = payload.get("name") + if "order" in payload: + updates["order"] = payload.get("order") + if "admin_only" in payload: + updates["admin_only"] = bool(payload.get("admin_only")) + + if not updates: + return jsonify({"success": False, "error": "No drawer fields to update"}), 400 + + try: + drawer = update_drawer(drawer_id, **updates) + except DrawerNotFoundError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except DrawerConflictError as exc: + return jsonify({"success": False, "error": str(exc)}), 409 + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: # pragma: no cover - defensive fallback + logger.exception("Failed to update drawer %s", drawer_id) + return jsonify({"success": False, "error": str(exc)}), 500 + + return jsonify({"success": True, "drawer": drawer}) + + +@admin_bp.route("/api/drawers/", methods=["DELETE"]) +@admin_required +def api_delete_drawer(drawer_id: str): + """API: Delete a drawer if no pages are assigned to it.""" + try: + delete_drawer(drawer_id) + except DrawerNotFoundError as exc: + return jsonify({"success": False, "error": str(exc)}), 404 + except DrawerConflictError as exc: + return jsonify({"success": False, "error": str(exc)}), 409 + except Exception as exc: # pragma: no cover - defensive fallback + logger.exception("Failed to delete drawer %s", drawer_id) + return jsonify({"success": False, "error": str(exc)}), 500 + + return jsonify({"success": True}) + + +@admin_bp.route("/api/pages/", methods=["PUT"]) +@admin_required +def api_update_page(route: str): + """API: Update page status/name/drawer assignment/order.""" + data = request.get_json(silent=True) or {} + updatable_fields = {"status", "name", "drawer_id", "order"} + if not any(field in data for field in updatable_fields): + return jsonify({"success": False, "error": "No page fields to update"}), 400 + + # Ensure route starts with / + if not route.startswith("/"): + route = "/" + route + + status = data.get("status") + if "status" in data and status not in ("released", "dev"): + return jsonify({"success": False, "error": "Invalid status"}), 400 + if "status" not in data: + status = get_page_status(route) + if status is None: + return jsonify({ + "success": False, + "error": "Status is required for unregistered pages", + }), 400 + + update_kwargs: dict[str, Any] = {} + if "name" in data: + update_kwargs["name"] = data.get("name") + if "drawer_id" in data: + update_kwargs["drawer_id"] = data.get("drawer_id") + if "order" in data: + update_kwargs["order"] = data.get("order") + + try: + set_page_status(route, status, **update_kwargs) + return jsonify({"success": True}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as e: + return jsonify({"success": False, "error": str(e)}), 500 diff --git a/src/mes_dashboard/services/page_registry.py b/src/mes_dashboard/services/page_registry.py index 8d50f0d..eb05814 100644 --- a/src/mes_dashboard/services/page_registry.py +++ b/src/mes_dashboard/services/page_registry.py @@ -1,44 +1,120 @@ -# -*- coding: utf-8 -*- -"""Page registry service for managing page access status.""" - +# -*- coding: utf-8 -*- +"""Page registry service for managing page access status.""" + from __future__ import annotations import json import logging import os +import re import tempfile from pathlib import Path from threading import Lock - -logger = logging.getLogger(__name__) - -# Data file path (relative to project root) -# Path: src/mes_dashboard/services/page_registry.py -> project root/data/ -DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json" -_lock = Lock() -_cache: dict | None = None - - -def _load() -> dict: - """Load page status configuration.""" - global _cache - if _cache is None: - if DATA_FILE.exists(): - try: - _cache = json.loads(DATA_FILE.read_text(encoding="utf-8")) - logger.debug("Loaded page status from %s", DATA_FILE) - except (json.JSONDecodeError, OSError) as e: - logger.warning("Failed to load page status: %s", e) - _cache = {"pages": [], "api_public": True} - else: - logger.info("Page status file not found, using defaults") - _cache = {"pages": [], "api_public": True} - return _cache - - + +logger = logging.getLogger(__name__) + +# Data file path (relative to project root) +# Path: src/mes_dashboard/services/page_registry.py -> project root/data/ +DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json" +_lock = Lock() +_cache: dict | None = None +_cache_mtime: float = 0.0 +_UNSET = object() + +DEFAULT_DRAWERS = [ + {"id": "reports", "name": "報表類", "order": 1, "admin_only": False}, + {"id": "queries", "name": "查詢類", "order": 2, "admin_only": False}, + {"id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": True}, +] + +LEGACY_NAV_ASSIGNMENTS = { + "/wip-overview": {"drawer_id": "reports", "order": 1}, + "/resource": {"drawer_id": "reports", "order": 2}, + "/resource-history": {"drawer_id": "reports", "order": 3}, + "/tables": {"drawer_id": "queries", "order": 1}, + "/excel-query": {"drawer_id": "queries", "order": 2}, + "/job-query": {"drawer_id": "queries", "order": 3}, + "/query-tool": {"drawer_id": "queries", "order": 4}, + "/tmtt-defect": {"drawer_id": "queries", "order": 5}, + "/admin/pages": { + "drawer_id": "dev-tools", + "order": 1, + "status": "dev", + "name": "頁面管理", + }, + "/admin/performance": { + "drawer_id": "dev-tools", + "order": 2, + "status": "dev", + "name": "效能監控", + }, +} + +LEGACY_FRAME_ID_MAP = { + "/wip-overview": "wipOverviewFrame", + "/resource": "resourceFrame", + "/tables": "tableFrame", + "/excel-query": "excelQueryFrame", + "/resource-history": "resourceHistoryFrame", + "/job-query": "jobQueryFrame", + "/query-tool": "queryToolFrame", + "/tmtt-defect": "tmttDefectFrame", +} + + +class DrawerError(Exception): + """Base drawer management error.""" + + +class DrawerNotFoundError(DrawerError): + """Raised when drawer cannot be found.""" + + +class DrawerConflictError(DrawerError): + """Raised when drawer operation conflicts with existing data.""" + + +def _load() -> dict: + """Load page status configuration. + + Detects file changes across gunicorn workers by comparing mtime, + so writes from one worker are visible to reads in another. + """ + global _cache, _cache_mtime + + # Check if another worker has written a newer version to disk. + if _cache is not None: + try: + disk_mtime = DATA_FILE.stat().st_mtime + except OSError: + disk_mtime = 0.0 + if disk_mtime > _cache_mtime: + _cache = None # Invalidate so we re-read below. + + if _cache is None: + if DATA_FILE.exists(): + try: + _cache = json.loads(DATA_FILE.read_text(encoding="utf-8")) + _cache_mtime = DATA_FILE.stat().st_mtime + logger.debug("Loaded page status from %s", DATA_FILE) + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load page status: %s", e) + _cache = {"pages": [], "api_public": True} + _cache_mtime = 0.0 + else: + logger.info("Page status file not found, using defaults") + _cache = {"pages": [], "api_public": True} + _cache_mtime = 0.0 + + if _migrate_navigation_schema(_cache): + _save(_cache) + logger.info("Migrated page status config to drawers schema") + return _cache + + def _save(data: dict) -> None: """Save page status configuration.""" - global _cache + global _cache, _cache_mtime tmp_path: Path | None = None try: DATA_FILE.parent.mkdir(parents=True, exist_ok=True) @@ -59,6 +135,10 @@ def _save(data: dict) -> None: tmp_path = Path(tmp.name) os.replace(tmp_path, DATA_FILE) _cache = data + try: + _cache_mtime = DATA_FILE.stat().st_mtime + except OSError: + _cache_mtime = 0.0 logger.debug("Saved page status to %s", DATA_FILE) except OSError as e: if tmp_path is not None: @@ -68,96 +148,378 @@ def _save(data: dict) -> None: pass logger.error("Failed to save page status: %s", e) raise - - -def get_page_status(route: str) -> str | None: - """Get page status ('released' or 'dev'). - - Args: - route: Page route path (e.g., '/wip-overview') - - Returns: - 'released', 'dev', or None if page is not registered. - """ - with _lock: - data = _load() - for page in data.get("pages", []): - if page["route"] == route: - return page.get("status", "dev") - return None # Not registered - let Flask handle it - - -def is_page_registered(route: str) -> bool: - """Check if a page is registered in the page registry. - - Args: - route: Page route path (e.g., '/wip-overview') - - Returns: - True if page is registered, False otherwise. - """ - return get_page_status(route) is not None - - -def set_page_status(route: str, status: str, name: str | None = None) -> None: - """Set page status. - - Args: - route: Page route path - status: 'released' or 'dev' - name: Optional page display name - """ - if status not in ("released", "dev"): - raise ValueError(f"Invalid status: {status}") - - with _lock: - data = _load() - pages = data.setdefault("pages", []) - - # Update existing page - for page in pages: - if page["route"] == route: - page["status"] = status - if name: - page["name"] = name - _save(data) - logger.info("Updated page status: %s -> %s", route, status) - return - - # Add new page - pages.append({ - "route": route, - "name": name or route, - "status": status - }) - _save(data) - logger.info("Added new page: %s (%s)", route, status) - - -def get_all_pages() -> list[dict]: - """Get all page configurations. - - Returns: - List of page dicts: [{route, name, status}, ...] - """ - with _lock: - return _load().get("pages", []) - - -def is_api_public() -> bool: - """Check if API endpoints are publicly accessible. - - Returns: - True if API endpoints bypass permission checks - """ - with _lock: - return _load().get("api_public", True) - - -def reload_cache() -> None: - """Force reload of page status from disk.""" - global _cache - with _lock: - _cache = None - _load() - logger.info("Reloaded page status cache") + + +def _migrate_navigation_schema(data: dict) -> bool: + """Migrate legacy schema to drawers schema when needed.""" + if "drawers" in data: + return False + + data["drawers"] = [drawer.copy() for drawer in DEFAULT_DRAWERS] + pages = data.setdefault("pages", []) + pages_by_route = {page.get("route"): page for page in pages if page.get("route")} + + for route, assignment in LEGACY_NAV_ASSIGNMENTS.items(): + page = pages_by_route.get(route) + if page is None and route.startswith("/admin/"): + page = { + "route": route, + "name": assignment.get("name", route), + "status": assignment.get("status", "dev"), + } + pages.append(page) + pages_by_route[route] = page + + if page is None: + continue + + page.setdefault("drawer_id", assignment["drawer_id"]) + page.setdefault("order", assignment["order"]) + if assignment.get("name"): + page.setdefault("name", assignment["name"]) + if assignment.get("status"): + page.setdefault("status", assignment["status"]) + + return True + + +def _safe_int(value: object, default: int) -> int: + try: + return int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return default + + +def _as_positive_int(value: object, *, field: str) -> int: + try: + parsed = int(value) # type: ignore[arg-type] + except (TypeError, ValueError) as exc: + raise ValueError(f"{field} must be an integer") from exc + + if parsed < 1: + raise ValueError(f"{field} must be >= 1") + return parsed + + +def _drawer_exists(data: dict, drawer_id: str) -> bool: + return any(drawer.get("id") == drawer_id for drawer in data.get("drawers", [])) + + +def _apply_page_drawer(data: dict, page: dict, drawer_id: str | None) -> None: + if drawer_id in (None, ""): + page.pop("drawer_id", None) + return + + drawer_id = str(drawer_id) + if not _drawer_exists(data, drawer_id): + raise ValueError(f"Drawer not found: {drawer_id}") + page["drawer_id"] = drawer_id + + +def _apply_page_order(page: dict, order: object) -> None: + if order in (None, ""): + page.pop("order", None) + return + page["order"] = _as_positive_int(order, field="order") + + +def _slugify_drawer_id(name: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") + return slug or "drawer" + + +def _generate_drawer_id(name: str, existing_ids: set[str]) -> str: + base = _slugify_drawer_id(name) + candidate = base + suffix = 2 + while candidate in existing_ids: + candidate = f"{base}-{suffix}" + suffix += 1 + return candidate + + +def _route_to_frame_id(route: str) -> str: + if route in LEGACY_FRAME_ID_MAP: + return LEGACY_FRAME_ID_MAP[route] + + parts = [part for part in re.split(r"[\/_-]+", route.strip("/")) if part] + if not parts: + return "homeFrame" + camel = parts[0].lower() + "".join(part.capitalize() for part in parts[1:]) + return f"{camel}Frame" + + +def _sorted_drawers(drawers: list[dict]) -> list[dict]: + return sorted( + drawers, + key=lambda drawer: ( + _safe_int(drawer.get("order"), 9999), + str(drawer.get("name", "")), + ), + ) + + +def _sorted_pages(pages: list[dict]) -> list[dict]: + return sorted( + pages, + key=lambda page: ( + _safe_int(page.get("order"), 9999), + str(page.get("name") or page.get("route", "")), + ), + ) + + +def get_page_status(route: str) -> str | None: + """Get page status ('released' or 'dev'). + + Args: + route: Page route path (e.g., '/wip-overview') + + Returns: + 'released', 'dev', or None if page is not registered. + """ + with _lock: + data = _load() + for page in data.get("pages", []): + if page["route"] == route: + return page.get("status", "dev") + return None # Not registered - let Flask handle it + + +def is_page_registered(route: str) -> bool: + """Check if a page is registered in the page registry. + + Args: + route: Page route path (e.g., '/wip-overview') + + Returns: + True if page is registered, False otherwise. + """ + return get_page_status(route) is not None + + +def set_page_status( + route: str, + status: str, + name: str | None = None, + drawer_id: str | None | object = _UNSET, + order: int | None | object = _UNSET, +) -> None: + """Set page status. + + Args: + route: Page route path + status: 'released' or 'dev' + name: Optional page display name + drawer_id: Optional drawer assignment (None/'': clear assignment) + order: Optional page order within drawer (None/'': clear order) + """ + if status not in ("released", "dev"): + raise ValueError(f"Invalid status: {status}") + + with _lock: + data = _load() + pages = data.setdefault("pages", []) + + # Update existing page + for page in pages: + if page["route"] == route: + page["status"] = status + if name: + page["name"] = name + if drawer_id is not _UNSET: + _apply_page_drawer(data, page, drawer_id) + if order is not _UNSET: + _apply_page_order(page, order) + _save(data) + logger.info("Updated page status: %s -> %s", route, status) + return + + # Add new page + new_page = { + "route": route, + "name": name or route, + "status": status, + } + if drawer_id is not _UNSET: + _apply_page_drawer(data, new_page, drawer_id) + if order is not _UNSET: + _apply_page_order(new_page, order) + + pages.append(new_page) + _save(data) + logger.info("Added new page: %s (%s)", route, status) + + +def get_all_pages() -> list[dict]: + """Get all page configurations. + + Returns: + List of page dicts: [{route, name, status, drawer_id?, order?}, ...] + """ + with _lock: + return _load().get("pages", []) + + +def get_all_drawers() -> list[dict]: + """Get all drawer configurations sorted by order.""" + with _lock: + data = _load() + drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS]) + return [dict(drawer) for drawer in _sorted_drawers(drawers)] + + +def create_drawer(name: str, order: int | None = None, admin_only: bool = False) -> dict: + """Create a new drawer and persist it.""" + normalized_name = (name or "").strip() + if not normalized_name: + raise ValueError("Drawer name is required") + + with _lock: + data = _load() + drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS]) + lower_names = {str(drawer.get("name", "")).strip().casefold() for drawer in drawers} + if normalized_name.casefold() in lower_names: + raise DrawerConflictError(f"Drawer name already exists: {normalized_name}") + + existing_ids = {str(drawer.get("id", "")) for drawer in drawers} + drawer_id = _generate_drawer_id(normalized_name, existing_ids) + if order is None: + order = max((_safe_int(drawer.get("order"), 0) for drawer in drawers), default=0) + 1 + else: + order = _as_positive_int(order, field="order") + + created = { + "id": drawer_id, + "name": normalized_name, + "order": order, + "admin_only": bool(admin_only), + } + drawers.append(created) + _save(data) + logger.info("Created drawer: %s", drawer_id) + return dict(created) + + +def update_drawer( + drawer_id: str, + *, + name: str | None = None, + order: int | None = None, + admin_only: bool | None = None, +) -> dict: + """Update drawer fields and persist it.""" + with _lock: + data = _load() + drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS]) + target = next((drawer for drawer in drawers if drawer.get("id") == drawer_id), None) + if target is None: + raise DrawerNotFoundError(f"Drawer not found: {drawer_id}") + + if name is not None: + normalized_name = name.strip() + if not normalized_name: + raise ValueError("Drawer name is required") + for drawer in drawers: + if drawer is target: + continue + if str(drawer.get("name", "")).strip().casefold() == normalized_name.casefold(): + raise DrawerConflictError(f"Drawer name already exists: {normalized_name}") + target["name"] = normalized_name + + if order is not None: + target["order"] = _as_positive_int(order, field="order") + + if admin_only is not None: + target["admin_only"] = bool(admin_only) + + _save(data) + logger.info("Updated drawer: %s", drawer_id) + return dict(target) + + +def delete_drawer(drawer_id: str) -> None: + """Delete a drawer when no pages are assigned to it.""" + with _lock: + data = _load() + drawers = data.setdefault("drawers", [drawer.copy() for drawer in DEFAULT_DRAWERS]) + target = next((drawer for drawer in drawers if drawer.get("id") == drawer_id), None) + if target is None: + raise DrawerNotFoundError(f"Drawer not found: {drawer_id}") + + assigned_pages = [ + page.get("route") + for page in data.get("pages", []) + if page.get("drawer_id") == drawer_id + ] + if assigned_pages: + assigned = ", ".join(str(route) for route in assigned_pages if route) + raise DrawerConflictError(f"Drawer has assigned pages: {assigned}") + + data["drawers"] = [drawer for drawer in drawers if drawer.get("id") != drawer_id] + _save(data) + logger.info("Deleted drawer: %s", drawer_id) + + +def get_navigation_config() -> list[dict]: + """Get drawers with nested pages sorted for portal sidebar rendering.""" + with _lock: + data = _load() + drawers = _sorted_drawers(data.get("drawers", [])) + grouped: dict[str, dict] = {} + ordered_drawers: list[dict] = [] + + for drawer in drawers: + normalized_drawer = { + "id": str(drawer.get("id")), + "name": drawer.get("name") or str(drawer.get("id")), + "order": _safe_int(drawer.get("order"), 9999), + "admin_only": bool(drawer.get("admin_only", False)), + "pages": [], + } + grouped[normalized_drawer["id"]] = normalized_drawer + ordered_drawers.append(normalized_drawer) + + for page in data.get("pages", []): + route = page.get("route") + drawer_id = page.get("drawer_id") + if not route or not drawer_id or drawer_id not in grouped: + continue + + drawer = grouped[drawer_id] + use_tool_frame = bool(drawer["admin_only"]) + drawer["pages"].append( + { + "route": route, + "name": page.get("name") or route, + "status": page.get("status", "dev"), + "order": _safe_int(page.get("order"), 9999), + "frame_id": "toolFrame" if use_tool_frame else _route_to_frame_id(route), + "tool_src": route if use_tool_frame else None, + } + ) + + for drawer in ordered_drawers: + drawer["pages"] = _sorted_pages(drawer["pages"]) + + return ordered_drawers + + +def is_api_public() -> bool: + """Check if API endpoints are publicly accessible. + + Returns: + True if API endpoints bypass permission checks + """ + with _lock: + return _load().get("api_public", True) + + +def reload_cache() -> None: + """Force reload of page status from disk.""" + global _cache, _cache_mtime + with _lock: + _cache = None + _cache_mtime = 0.0 + _load() + logger.info("Reloaded page status cache") diff --git a/src/mes_dashboard/templates/admin/pages.html b/src/mes_dashboard/templates/admin/pages.html index ee307e8..803ca06 100644 --- a/src/mes_dashboard/templates/admin/pages.html +++ b/src/mes_dashboard/templates/admin/pages.html @@ -1,301 +1,669 @@ -{% extends "_base.html" %} - -{% block title %}頁面管理 - MES Dashboard{% endblock %} - -{% block head_extra %} - -{% endblock %} - -{% block content %} -
-
-
-

頁面管理

-

設定頁面存取權限:Released(所有人可見)/ Dev(僅管理員可見)

-
-
- {% if admin_user %} -
-
{{ admin_user.displayName }}
-
{{ admin_user.mail }}
-
- {% endif %} - 登出 -
-
- -
-
-

所有頁面

-
-
- - - - - - - - - - - - - -
路由名稱狀態
載入中...
-
-
- - 返回首頁 -
-{% endblock %} - -{% block scripts %} +{% extends "_base.html" %} + +{% block title %}頁面管理 - MES Dashboard{% endblock %} + +{% block head_extra %} + +{% endblock %} + +{% block content %} +
+
+
+

頁面管理

+

管理頁面狀態、抽屜分類與排序(Released:所有人可見 / Dev:僅管理員可見)

+
+
+ {% if admin_user %} +
+
{{ admin_user.displayName }}
+
{{ admin_user.mail }}
+
+ {% endif %} + 登出 +
+
+ +
+
+
+

抽屜管理

+
可新增、改名、排序、設定 admin-only,空抽屜才能刪除
+
+
+
+ + + + +
+
+ + + + + + + + + + + + + + + +
ID名稱排序可見性操作
載入中...
+
+
+ +
+
+
+

所有頁面

+
可切換 Released/Dev,並設定抽屜歸屬與抽屜內排序
+
+
+
+ + + + + + + + + + + + + + + +
路由名稱狀態抽屜歸屬排序
載入中...
+
+
+ + 返回首頁 +
+{% endblock %} + +{% block scripts %} -{% endblock %} + window.MES?.toast?.success?.(`已新增抽屜:${name}`); + newDrawerNameInput.value = ''; + newDrawerOrderInput.value = ''; + newDrawerAdminOnlyInput.checked = false; + await refreshAll(); + } catch (error) { + window.MES?.toast?.error?.(`新增抽屜失敗: ${error.message}`); + } + } + + async function saveDrawerFromRow(row) { + const drawerId = row.dataset.drawerId; + const name = row.querySelector('.drawer-name-input')?.value.trim() || ''; + const orderRaw = row.querySelector('.drawer-order-input')?.value.trim() || ''; + const adminOnly = row.querySelector('.drawer-admin-only-input')?.checked || false; + + if (!drawerId) { + return; + } + + const payload = { name, admin_only: adminOnly }; + if (orderRaw !== '') { + payload.order = Number(orderRaw); + } + + try { + await apiFetch(`/admin/api/drawers/${encodeURIComponent(drawerId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + window.MES?.toast?.success?.(`已更新抽屜:${drawerId}`); + await refreshAll(); + } catch (error) { + window.MES?.toast?.error?.(`更新抽屜失敗: ${error.message}`); + } + } + + async function deleteDrawerById(drawerId) { + if (!drawerId) { + return; + } + if (!window.confirm(`確定刪除抽屜「${drawerId}」?`)) { + return; + } + + try { + await apiFetch(`/admin/api/drawers/${encodeURIComponent(drawerId)}`, { + method: 'DELETE', + }); + window.MES?.toast?.success?.(`已刪除抽屜:${drawerId}`); + await refreshAll(); + } catch (error) { + window.MES?.toast?.error?.(`刪除抽屜失敗: ${error.message}`); + } + } + + async function updatePage(route, payload, successMessage) { + try { + await apiFetch(`/admin/api/pages${route}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (successMessage) { + window.MES?.toast?.success?.(successMessage); + } + await loadPages(); + } catch (error) { + window.MES?.toast?.error?.(`更新頁面失敗: ${error.message}`); + await loadPages(); + } + } + + async function togglePageStatus(route, currentStatus) { + const nextStatus = currentStatus === 'released' ? 'dev' : 'released'; + await updatePage(route, { status: nextStatus }, `已更新: ${route} → ${nextStatus}`); + } + + pagesTbody.addEventListener('click', async (event) => { + const button = event.target.closest('button[data-action="toggle-status"]'); + if (!button) { + return; + } + const row = button.closest('tr[data-route]'); + if (!row) { + return; + } + const route = row.dataset.route; + const currentStatus = button.classList.contains('status-released') ? 'released' : 'dev'; + if (route) { + await togglePageStatus(route, currentStatus); + } + }); + + pagesTbody.addEventListener('change', async (event) => { + const row = event.target.closest('tr[data-route]'); + if (!row) { + return; + } + const route = row.dataset.route; + if (!route) { + return; + } + + if (event.target.classList.contains('drawer-select')) { + const drawerId = event.target.value || null; + await updatePage(route, { drawer_id: drawerId }, `已更新抽屜歸屬: ${route}`); + return; + } + + if (event.target.classList.contains('page-order-input')) { + const value = event.target.value.trim(); + const payload = { order: value === '' ? null : Number(value) }; + await updatePage(route, payload, `已更新排序: ${route}`); + } + }); + + drawersTbody.addEventListener('click', async (event) => { + const button = event.target.closest('button[data-action]'); + if (!button) { + return; + } + + const row = button.closest('tr[data-drawer-id]'); + if (!row) { + return; + } + + const drawerId = row.dataset.drawerId; + if (button.dataset.action === 'save-drawer') { + await saveDrawerFromRow(row); + return; + } + if (button.dataset.action === 'delete-drawer') { + await deleteDrawerById(drawerId); + } + }); + + createDrawerBtn.addEventListener('click', createDrawer); + newDrawerNameInput.addEventListener('keydown', (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + createDrawer(); + } + }); + + refreshAll().catch((error) => { + pagesTbody.innerHTML = `載入失敗: ${escapeHtml(error.message)}`; + drawersTbody.innerHTML = `載入失敗: ${escapeHtml(error.message)}`; + window.MES?.toast?.error?.(`載入失敗: ${error.message}`); + }); + +{% endblock %} diff --git a/src/mes_dashboard/templates/portal.html b/src/mes_dashboard/templates/portal.html index ccb56d1..30750d0 100644 --- a/src/mes_dashboard/templates/portal.html +++ b/src/mes_dashboard/templates/portal.html @@ -342,8 +342,6 @@
{% if is_admin %} {{ admin_user.displayName }} - 頁面管理 - 效能監控 登出 {% else %} 管理員登入 @@ -354,69 +352,49 @@
- {% if can_view_page('/wip-overview') %} - - {% endif %} - {% if can_view_page('/resource') %} - - {% endif %} - {% if can_view_page('/tables') %} - - {% endif %} - {% if can_view_page('/excel-query') %} - - {% endif %} - {% if can_view_page('/resource-history') %} - - {% endif %} - {% if can_view_page('/job-query') %} - - {% endif %} - {% if can_view_page('/query-tool') %} - - {% endif %} - {% if can_view_page('/tmtt-defect') %} - - {% endif %} - {% if is_admin %} + {% set frame_ns = namespace(has_tool_pages=false) %} + {% for drawer in drawers | default([]) %} + {% if not drawer.admin_only or is_admin %} + {% for page in drawer.pages %} + {% if can_view_page(page.route) %} + {% if page.tool_src %} + {% set frame_ns.has_tool_pages = true %} + {% else %} + + {% endif %} + {% endif %} + {% endfor %} + {% endif %} + {% endfor %} + {% if is_admin and frame_ns.has_tool_pages %} {% endif %}
@@ -432,7 +410,6 @@