feat(portal): implement dynamic drawer/page navigation management

Replace hardcoded sidebar drawer configuration with admin-manageable
dynamic system. Extend page_status.json with drawer definitions and
page assignments, add drawer CRUD API endpoints, render portal sidebar
via Jinja2 loops, and extend /admin/pages UI with drawer management.
Fix multi-worker cache invalidation via mtime-based staleness detection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-02-09 11:34:04 +08:00
parent 706c8ba52c
commit 9b1d2edc52
20 changed files with 2269 additions and 735 deletions

View File

@@ -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
}
]
}

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-02-09

View File

@@ -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 APIGET/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 策略:維持現行邏輯
**選擇**: 一般頁面每個獨立 iframeadmin 工具頁面共用 `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/<id>` | PUT | 更新抽屜(改名、排序) |
| `DELETE /admin/api/drawers/<id>` | DELETE | 刪除抽屜(需先移走其下頁面) |
| `PUT /admin/api/pages/<route>` | PUT | 擴展現有 endpoint支援 `drawer_id``order` |
**替代方案**: RESTful nested resource `/admin/api/drawers/<id>/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
- 無。探索階段已充分討論並確認方向。

View File

@@ -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 endpointsdrawer 的增刪改查、頁面的抽屜指派與排序
- **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 模組均不需變更

View File

@@ -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/<id>` 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/<id>` 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/<id>` 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/<id>` 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

View File

@@ -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/<route>` 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/<route>` 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/<route>` 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

View File

@@ -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

View File

@@ -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/<id>` endpoint更新抽屜名稱/排序/admin_only
- [x] 2.4 新增 `DELETE /admin/api/drawers/<id>` endpoint刪除空抽屜有頁面時回傳 409
- [x] 2.5 擴展現有 `PUT /admin/api/pages/<route>` 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 實作前端 JSdrawer 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

View File

@@ -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/<id>` 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/<id>` 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/<id>` 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/<id>` 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

View File

@@ -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/<route>` 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/<route>` 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/<route>` 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

View File

@@ -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

View File

@@ -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():

View File

@@ -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/<path:route>", 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/<drawer_id>", 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/<drawer_id>", 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/<path:route>", 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

View File

@@ -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")

View File

@@ -1,301 +1,669 @@
{% extends "_base.html" %}
{% block title %}頁面管理 - MES Dashboard{% endblock %}
{% block head_extra %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #f5f7fa;
color: #222;
}
.shell {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 24px;
margin-bottom: 4px;
}
.header-left p {
font-size: 14px;
opacity: 0.9;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.admin-info {
text-align: right;
font-size: 14px;
}
.admin-info .name {
font-weight: 600;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
transition: background 0.2s ease;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.panel {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
overflow: hidden;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h2 {
font-size: 18px;
font-weight: 600;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 14px 20px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
font-weight: 600;
font-size: 14px;
color: #555;
}
td {
font-size: 14px;
}
tr:hover {
background: #fafbfc;
}
.route-cell {
font-family: 'Consolas', 'Monaco', monospace;
color: #555;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.status-released {
background: #e8f5e9;
color: #2e7d32;
}
.status-dev {
background: #fff3e0;
color: #e65100;
}
.status-badge:hover {
transform: scale(1.05);
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.back-link {
display: inline-block;
margin-top: 16px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="shell">
<div class="header">
<div class="header-left">
<h1>頁面管理</h1>
<p>設定頁面存取權限Released所有人可見/ Dev僅管理員可見</p>
</div>
<div class="header-right">
{% if admin_user %}
<div class="admin-info">
<div class="name">{{ admin_user.displayName }}</div>
<div>{{ admin_user.mail }}</div>
</div>
{% endif %}
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>所有頁面</h2>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>路由</th>
<th>名稱</th>
<th>狀態</th>
</tr>
</thead>
<tbody id="pages-tbody">
<tr>
<td colspan="3" class="loading">載入中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
</div>
{% endblock %}
{% block scripts %}
{% extends "_base.html" %}
{% block title %}頁面管理 - MES Dashboard{% endblock %}
{% block head_extra %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #f5f7fa;
color: #222;
}
.shell {
max-width: 1320px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 24px;
margin-bottom: 4px;
}
.header-left p {
font-size: 14px;
opacity: 0.9;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.admin-info {
text-align: right;
font-size: 14px;
}
.admin-info .name {
font-weight: 600;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
transition: background 0.2s ease;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.panel {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
overflow: hidden;
margin-bottom: 16px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
}
.panel-header h2 {
font-size: 18px;
font-weight: 600;
}
.panel-subtitle {
font-size: 13px;
color: #64748b;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid #eee;
vertical-align: middle;
}
th {
background: #f8f9fa;
font-weight: 600;
font-size: 14px;
color: #555;
}
td {
font-size: 14px;
}
tr:hover {
background: #fafbfc;
}
.route-cell {
font-family: 'Consolas', 'Monaco', monospace;
color: #555;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.status-released {
background: #e8f5e9;
color: #2e7d32;
}
.status-dev {
background: #fff3e0;
color: #e65100;
}
.status-badge:hover {
transform: scale(1.05);
}
.input {
width: 100%;
border: 1px solid #cbd5e1;
border-radius: 6px;
padding: 6px 8px;
font-size: 13px;
background: #fff;
color: #1e293b;
}
.input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.order-input {
width: 96px;
}
.drawer-create {
padding: 14px 20px;
border-bottom: 1px solid #eee;
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
background: #f8fafc;
}
.checkbox-label {
display: inline-flex;
align-items: center;
gap: 6px;
color: #475569;
font-size: 13px;
white-space: nowrap;
}
.action-btn {
border: 1px solid #d0d7e3;
border-radius: 6px;
padding: 6px 10px;
background: #fff;
color: #334155;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.action-btn:hover {
border-color: #94a3b8;
background: #f8fafc;
}
.action-btn.primary {
border-color: #667eea;
background: #667eea;
color: #fff;
}
.action-btn.primary:hover {
background: #5668d8;
border-color: #5668d8;
}
.action-btn.danger {
border-color: #ef4444;
color: #b91c1c;
background: #fff5f5;
}
.action-btn.danger:hover {
background: #fee2e2;
}
.actions-cell {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.back-link {
display: inline-block;
margin-top: 16px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="shell">
<div class="header">
<div class="header-left">
<h1>頁面管理</h1>
<p>管理頁面狀態、抽屜分類與排序Released所有人可見 / Dev僅管理員可見</p>
</div>
<div class="header-right">
{% if admin_user %}
<div class="admin-info">
<div class="name">{{ admin_user.displayName }}</div>
<div>{{ admin_user.mail }}</div>
</div>
{% endif %}
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a>
</div>
</div>
<div class="panel">
<div class="panel-header">
<div>
<h2>抽屜管理</h2>
<div class="panel-subtitle">可新增、改名、排序、設定 admin-only空抽屜才能刪除</div>
</div>
</div>
<div class="drawer-create">
<input id="new-drawer-name" class="input" type="text" placeholder="新抽屜名稱(例如:自訂分類)">
<input id="new-drawer-order" class="input order-input" type="number" min="1" placeholder="排序">
<label class="checkbox-label">
<input id="new-drawer-admin-only" type="checkbox">
僅管理員可見
</label>
<button id="create-drawer-btn" class="action-btn primary">新增抽屜</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>ID</th>
<th>名稱</th>
<th>排序</th>
<th>可見性</th>
<th>操作</th>
</tr>
</thead>
<tbody id="drawers-tbody">
<tr>
<td colspan="5" class="loading">載入中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="panel">
<div class="panel-header">
<div>
<h2>所有頁面</h2>
<div class="panel-subtitle">可切換 Released/Dev並設定抽屜歸屬與抽屜內排序</div>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>路由</th>
<th>名稱</th>
<th>狀態</th>
<th>抽屜歸屬</th>
<th>排序</th>
</tr>
</thead>
<tbody id="pages-tbody">
<tr>
<td colspan="5" class="loading">載入中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
</div>
{% endblock %}
{% block scripts %}
<script>
const tbody = document.getElementById('pages-tbody');
const pagesTbody = document.getElementById('pages-tbody');
const drawersTbody = document.getElementById('drawers-tbody');
const createDrawerBtn = document.getElementById('create-drawer-btn');
const newDrawerNameInput = document.getElementById('new-drawer-name');
const newDrawerOrderInput = document.getElementById('new-drawer-order');
const newDrawerAdminOnlyInput = document.getElementById('new-drawer-admin-only');
const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content || '';
let drawersCache = [];
let pagesCache = [];
function withCsrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
}
async function loadPages() {
try {
const response = await fetch('/admin/api/pages');
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to load pages');
}
renderPages(data.pages);
} catch (error) {
console.error('Error loading pages:', error);
tbody.innerHTML = `<tr><td colspan="3" class="loading">載入失敗: ${error.message}</td></tr>`;
}
}
function renderPages(pages) {
if (pages.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="loading">尚無頁面設定</td></tr>';
return;
}
tbody.innerHTML = pages.map(page => `
<tr>
<td class="route-cell">${escapeHtml(page.route)}</td>
<td>${escapeHtml(page.name)}</td>
<td>
<span class="status-badge status-${page.status}"
onclick="toggleStatus('${escapeHtml(page.route)}', '${page.status}')">
${page.status === 'released' ? 'Released' : 'Dev'}
</span>
</td>
</tr>
`).join('');
}
async function toggleStatus(route, currentStatus) {
const newStatus = currentStatus === 'released' ? 'dev' : 'released';
try {
const response = await fetch(`/admin/api/pages${route}`, {
method: 'PUT',
headers: withCsrfHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ status: newStatus })
function escapeHtml(value) {
const div = document.createElement('div');
div.textContent = value ?? '';
return div.innerHTML;
}
function escapeAttr(value) {
return escapeHtml(String(value ?? '')).replace(/\"/g, '&quot;');
}
async function parseJsonResponse(response) {
try {
return await response.json();
} catch (error) {
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
throw error;
}
}
async function apiFetch(url, options = {}) {
const config = { ...options };
config.headers = withCsrfHeaders(config.headers || {});
const response = await fetch(url, config);
const data = await parseJsonResponse(response);
if (!response.ok || data.success === false) {
throw new Error(data.error || `HTTP ${response.status}`);
}
return data;
}
function drawerOptionsHtml(selectedDrawerId) {
const normalized = selectedDrawerId || '';
const options = ['<option value="">未分類</option>'];
drawersCache.forEach((drawer) => {
const selected = drawer.id === normalized ? 'selected' : '';
options.push(
`<option value="${escapeAttr(drawer.id)}" ${selected}>${escapeHtml(drawer.name)}</option>`
);
});
return options.join('');
}
function renderDrawers(drawers) {
if (!drawers || drawers.length === 0) {
drawersTbody.innerHTML = '<tr><td colspan="5" class="loading">尚無抽屜</td></tr>';
return;
}
drawersTbody.innerHTML = drawers.map((drawer) => `
<tr data-drawer-id="${escapeAttr(drawer.id)}">
<td class="route-cell">${escapeHtml(drawer.id)}</td>
<td><input class="input drawer-name-input" type="text" value="${escapeAttr(drawer.name)}"></td>
<td><input class="input order-input drawer-order-input" type="number" min="1" value="${escapeAttr(String(drawer.order ?? ''))}"></td>
<td>
<label class="checkbox-label">
<input class="drawer-admin-only-input" type="checkbox" ${drawer.admin_only ? 'checked' : ''}>
僅管理員
</label>
</td>
<td class="actions-cell">
<button class="action-btn" data-action="save-drawer">儲存</button>
<button class="action-btn danger" data-action="delete-drawer">刪除</button>
</td>
</tr>
`).join('');
}
function renderPages(pages) {
if (!pages || pages.length === 0) {
pagesTbody.innerHTML = '<tr><td colspan="5" class="loading">尚無頁面設定</td></tr>';
return;
}
pagesTbody.innerHTML = pages.map((page) => `
<tr data-route="${escapeAttr(page.route)}">
<td class="route-cell">${escapeHtml(page.route)}</td>
<td>${escapeHtml(page.name)}</td>
<td>
<button class="status-badge status-${page.status}" data-action="toggle-status">
${page.status === 'released' ? 'Released' : 'Dev'}
</button>
</td>
<td>
<select class="input drawer-select">
${drawerOptionsHtml(page.drawer_id)}
</select>
</td>
<td>
<input
class="input order-input page-order-input"
type="number"
min="1"
value="${escapeAttr(String(page.order ?? ''))}"
placeholder="未設定"
>
</td>
</tr>
`).join('');
}
async function loadDrawers() {
const data = await apiFetch('/admin/api/drawers');
drawersCache = data.drawers || [];
renderDrawers(drawersCache);
renderPages(pagesCache);
}
async function loadPages() {
const data = await apiFetch('/admin/api/pages');
pagesCache = data.pages || [];
renderPages(pagesCache);
}
async function refreshAll() {
await loadDrawers();
await loadPages();
}
async function createDrawer() {
const name = newDrawerNameInput.value.trim();
const orderRaw = newDrawerOrderInput.value.trim();
const adminOnly = newDrawerAdminOnlyInput.checked;
if (!name) {
window.MES?.toast?.error?.('請輸入抽屜名稱');
return;
}
const payload = {
name,
admin_only: adminOnly,
};
if (orderRaw !== '') {
payload.order = Number(orderRaw);
}
try {
await apiFetch('/admin/api/drawers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update status');
}
window.MES?.toast?.success?.(`已更新: ${route}${newStatus}`);
loadPages();
} catch (error) {
console.error('Error updating status:', error);
window.MES?.toast?.error?.(`更新失敗: ${error.message}`);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Load pages on page load
loadPages();
</script>
{% 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 = `<tr><td colspan="5" class="loading">載入失敗: ${escapeHtml(error.message)}</td></tr>`;
drawersTbody.innerHTML = `<tr><td colspan="5" class="loading">載入失敗: ${escapeHtml(error.message)}</td></tr>`;
window.MES?.toast?.error?.(`載入失敗: ${error.message}`);
});
</script>
{% endblock %}

View File

@@ -342,8 +342,6 @@
<div class="admin-status">
{% if is_admin %}
<span class="admin-name">{{ admin_user.displayName }}</span>
<a href="{{ url_for('admin.pages') }}">頁面管理</a>
<a href="{{ url_for('admin.performance') }}">效能監控</a>
<a href="{{ url_for('auth.logout') }}">登出</a>
{% else %}
<a href="{{ url_for('auth.login') }}">管理員登入</a>
@@ -354,69 +352,49 @@
<div class="main-layout">
<nav class="sidebar">
<div class="sidebar-group-title">報表類</div>
{% if can_view_page('/wip-overview') %}
<button class="sidebar-item" data-target="wipOverviewFrame">WIP 即時概況</button>
{% endif %}
{% if can_view_page('/resource') %}
<button class="sidebar-item" data-target="resourceFrame">設備即時概況</button>
{% endif %}
{% if can_view_page('/resource-history') %}
<button class="sidebar-item" data-target="resourceHistoryFrame">設備歷史績效</button>
{% endif %}
<div class="sidebar-group-title">查詢類</div>
{% if can_view_page('/tables') %}
<button class="sidebar-item" data-target="tableFrame">數據表查詢工具</button>
{% endif %}
{% if can_view_page('/excel-query') %}
<button class="sidebar-item" data-target="excelQueryFrame">Excel 批次查詢</button>
{% endif %}
{% if can_view_page('/job-query') %}
<button class="sidebar-item" data-target="jobQueryFrame">設備維修查詢</button>
{% endif %}
{% if can_view_page('/query-tool') %}
<button class="sidebar-item" data-target="queryToolFrame">批次追蹤工具</button>
{% endif %}
{% if can_view_page('/tmtt-defect') %}
<button class="sidebar-item" data-target="tmttDefectFrame">TMTT不良分析</button>
{% endif %}
<div class="sidebar-group-title">開發工具</div>
{% if is_admin %}
<button class="sidebar-item" data-target="toolFrame" data-tool-src="/admin/pages">頁面管理</button>
<button class="sidebar-item" data-target="toolFrame" data-tool-src="/admin/performance">效能監控</button>
{% else %}
{% for drawer in drawers | default([]) %}
{% if not drawer.admin_only or is_admin %}
{% set nav_ns = namespace(has_visible_pages=false) %}
{% for page in drawer.pages %}
{% if can_view_page(page.route) %}
{% set nav_ns.has_visible_pages = true %}
{% endif %}
{% endfor %}
{% if nav_ns.has_visible_pages %}
<div class="sidebar-group-title">{{ drawer.name }}</div>
{% for page in drawer.pages %}
{% if can_view_page(page.route) %}
<button
class="sidebar-item"
data-target="{{ page.frame_id }}"
{% if page.tool_src %}data-tool-src="{{ page.tool_src }}"{% endif %}
>{{ page.name }}</button>
{% endif %}
{% endfor %}
{% endif %}
{% endif %}
{% endfor %}
{% if not is_admin %}
<a class="sidebar-item" href="{{ url_for('auth.login') }}">管理員登入</a>
{% endif %}
</nav>
<div class="panel">
{% if can_view_page('/wip-overview') %}
<iframe id="wipOverviewFrame" data-src="/wip-overview" title="WIP 即時概況"></iframe>
{% endif %}
{% if can_view_page('/resource') %}
<iframe id="resourceFrame" data-src="/resource" title="設備即時概況"></iframe>
{% endif %}
{% if can_view_page('/tables') %}
<iframe id="tableFrame" data-src="/tables" title="數據表查詢工具"></iframe>
{% endif %}
{% if can_view_page('/excel-query') %}
<iframe id="excelQueryFrame" data-src="/excel-query" title="Excel 批次查詢"></iframe>
{% endif %}
{% if can_view_page('/resource-history') %}
<iframe id="resourceHistoryFrame" data-src="/resource-history" title="設備歷史績效"></iframe>
{% endif %}
{% if can_view_page('/job-query') %}
<iframe id="jobQueryFrame" data-src="/job-query" title="設備維修查詢"></iframe>
{% endif %}
{% if can_view_page('/query-tool') %}
<iframe id="queryToolFrame" data-src="/query-tool" title="批次追蹤工具"></iframe>
{% endif %}
{% if can_view_page('/tmtt-defect') %}
<iframe id="tmttDefectFrame" data-src="/tmtt-defect" title="TMTT不良分析"></iframe>
{% 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 %}
<iframe id="{{ page.frame_id }}" data-src="{{ page.route }}" title="{{ page.name }}"></iframe>
{% endif %}
{% endif %}
{% endfor %}
{% endif %}
{% endfor %}
{% if is_admin and frame_ns.has_tool_pages %}
<iframe id="toolFrame" title="開發工具"></iframe>
{% endif %}
</div>
@@ -432,7 +410,6 @@
<script>
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]');
const frames = document.querySelectorAll('iframe');
const toolFrame = document.getElementById('toolFrame');
function setFrameHeight() {
const headerHeight = document.querySelector('.header').offsetHeight;
@@ -476,7 +453,7 @@
// Auto-activate first available item
if (sidebarItems.length > 0) {
activateTab(sidebarItems[0].dataset.target);
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
}
window.addEventListener('resize', setFrameHeight);

View File

@@ -280,8 +280,8 @@ class TestPermissionMiddleware:
assert response.status_code == 200
class TestAdminAPI:
"""Tests for admin API endpoints."""
class TestAdminAPI:
"""Tests for admin API endpoints."""
def test_get_pages_without_login(self, client):
"""Test get pages API requires login."""
@@ -289,22 +289,64 @@ class TestAdminAPI:
# Should redirect
assert response.status_code == 302
def test_get_pages_with_login(self, client):
"""Test get pages API with login."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
def test_get_pages_with_login(self, client):
"""Test get pages API with login."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.get("/admin/api/pages")
assert response.status_code == 200
data = json.loads(response.data)
assert data["success"] is True
assert "pages" in data
def test_update_page_status(self, client, temp_page_status):
"""Test updating page status via API."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
data = json.loads(response.data)
assert data["success"] is True
assert "pages" in data
def test_get_drawers_without_login(self, client):
"""Test drawer API requires login."""
response = client.get("/admin/api/drawers", follow_redirects=False)
assert response.status_code == 302
def test_mutate_drawers_without_login(self, client):
"""Test drawer mutations require login."""
response = client.post(
"/admin/api/drawers",
data=json.dumps({"name": "Unauthorized Drawer"}),
content_type="application/json",
follow_redirects=False,
)
assert response.status_code in (302, 401)
response = client.delete("/admin/api/drawers/reports", follow_redirects=False)
assert response.status_code == 302
def test_get_drawers_with_login(self, client):
"""Test list drawers API with login."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.get("/admin/api/drawers")
assert response.status_code == 200
data = json.loads(response.data)
assert data["success"] is True
assert "drawers" in data
assert any(drawer["id"] == "reports" for drawer in data["drawers"])
def test_create_drawer_duplicate_name_conflict(self, client):
"""Test creating duplicate drawer name returns 409."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.post(
"/admin/api/drawers",
data=json.dumps({"name": "報表類", "order": 99}),
content_type="application/json",
)
assert response.status_code == 409
def test_update_page_status(self, client, temp_page_status):
"""Test updating page status via API."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.put(
"/admin/api/pages/wip-overview",
@@ -329,10 +371,48 @@ class TestAdminAPI:
"/admin/api/pages/wip-overview",
data=json.dumps({"status": "invalid"}),
content_type="application/json"
)
assert response.status_code == 400
)
assert response.status_code == 400
def test_update_page_drawer_assignment(self, client):
"""Test assigning page drawer via page update API."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.put(
"/admin/api/pages/wip-overview",
data=json.dumps({"drawer_id": "queries", "order": 3}),
content_type="application/json",
)
assert response.status_code == 200
page_registry._cache = None
pages = page_registry.get_all_pages()
page = next(item for item in pages if item["route"] == "/wip-overview")
assert page["drawer_id"] == "queries"
assert page["order"] == 3
def test_update_page_invalid_drawer_assignment(self, client):
"""Test assigning a non-existent drawer returns bad request."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.put(
"/admin/api/pages/wip-overview",
data=json.dumps({"drawer_id": "missing-drawer"}),
content_type="application/json",
)
assert response.status_code == 400
def test_delete_drawer_with_assigned_pages_conflict(self, client):
"""Test deleting a non-empty drawer returns conflict."""
with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"}
response = client.delete("/admin/api/drawers/reports")
assert response.status_code == 409
class TestContextProcessor:
"""Tests for template context processor."""

View File

@@ -1,194 +1,243 @@
# -*- coding: utf-8 -*-
"""Unit tests for page_registry module."""
import json
import pytest
import tempfile
from pathlib import Path
from unittest.mock import patch
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from mes_dashboard.services import page_registry
@pytest.fixture
def temp_data_file(tmp_path):
"""Create a temporary data file for testing."""
data_file = tmp_path / "page_status.json"
initial_data = {
"pages": [
{"route": "/", "name": "Home", "status": "released"},
{"route": "/dev-page", "name": "Dev Page", "status": "dev"},
],
"api_public": True
}
data_file.write_text(json.dumps(initial_data), encoding="utf-8")
return data_file
@pytest.fixture
def mock_registry(temp_data_file):
"""Mock page_registry to use temp file."""
original_data_file = page_registry.DATA_FILE
original_cache = page_registry._cache
page_registry.DATA_FILE = temp_data_file
page_registry._cache = None # Clear cache
yield temp_data_file
# Restore original
page_registry.DATA_FILE = original_data_file
page_registry._cache = original_cache
class TestGetPageStatus:
"""Tests for get_page_status function."""
def test_get_released_page_status(self, mock_registry):
"""Test getting status of released page."""
status = page_registry.get_page_status("/")
assert status == "released"
def test_get_dev_page_status(self, mock_registry):
"""Test getting status of dev page."""
status = page_registry.get_page_status("/dev-page")
assert status == "dev"
def test_get_unregistered_page_status(self, mock_registry):
"""Test getting status of unregistered page returns None."""
status = page_registry.get_page_status("/not-registered")
assert status is None
class TestIsPageRegistered:
"""Tests for is_page_registered function."""
def test_registered_page(self, mock_registry):
"""Test checking registered page."""
assert page_registry.is_page_registered("/") is True
def test_unregistered_page(self, mock_registry):
"""Test checking unregistered page."""
assert page_registry.is_page_registered("/not-here") is False
class TestSetPageStatus:
"""Tests for set_page_status function."""
def test_update_existing_page(self, mock_registry):
"""Test updating existing page status."""
page_registry.set_page_status("/", "dev")
assert page_registry.get_page_status("/") == "dev"
def test_add_new_page(self, mock_registry):
"""Test adding new page."""
page_registry.set_page_status("/new-page", "released", "New Page")
assert page_registry.get_page_status("/new-page") == "released"
def test_invalid_status_raises_error(self, mock_registry):
"""Test setting invalid status raises ValueError."""
with pytest.raises(ValueError, match="Invalid status"):
page_registry.set_page_status("/", "invalid")
def test_update_page_name(self, mock_registry):
"""Test updating page name."""
page_registry.set_page_status("/", "released", "New Name")
pages = page_registry.get_all_pages()
home = next(p for p in pages if p["route"] == "/")
assert home["name"] == "New Name"
class TestGetAllPages:
"""Tests for get_all_pages function."""
def test_get_all_pages(self, mock_registry):
"""Test getting all pages."""
pages = page_registry.get_all_pages()
assert len(pages) == 2
routes = [p["route"] for p in pages]
assert "/" in routes
assert "/dev-page" in routes
class TestIsApiPublic:
"""Tests for is_api_public function."""
def test_api_public_true(self, mock_registry):
"""Test API public flag when true."""
assert page_registry.is_api_public() is True
def test_api_public_false(self, mock_registry, temp_data_file):
"""Test API public flag when false."""
data = json.loads(temp_data_file.read_text())
data["api_public"] = False
temp_data_file.write_text(json.dumps(data))
page_registry._cache = None # Clear cache
assert page_registry.is_api_public() is False
class TestReloadCache:
"""Tests for reload_cache function."""
def test_reload_cache(self, mock_registry, temp_data_file):
"""Test reloading cache from disk."""
# First load
assert page_registry.get_page_status("/") == "released"
# Modify file directly
data = json.loads(temp_data_file.read_text())
data["pages"][0]["status"] = "dev"
temp_data_file.write_text(json.dumps(data))
# Cache still has old value
assert page_registry.get_page_status("/") == "released"
# After reload, should have new value
page_registry.reload_cache()
assert page_registry.get_page_status("/") == "dev"
class TestConcurrency:
"""Tests for thread safety."""
def test_concurrent_access(self, mock_registry):
"""Test concurrent read/write operations."""
import threading
errors = []
def reader():
try:
for _ in range(100):
page_registry.get_page_status("/")
except Exception as e:
errors.append(e)
def writer():
try:
for i in range(100):
status = "released" if i % 2 == 0 else "dev"
page_registry.set_page_status("/", status)
except Exception as e:
errors.append(e)
threads = [
threading.Thread(target=reader) for _ in range(3)
] + [
threading.Thread(target=writer) for _ in range(2)
]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0, f"Errors occurred: {errors}"
if __name__ == "__main__":
pytest.main([__file__, "-v"])
# -*- coding: utf-8 -*-
"""Unit tests for page_registry module."""
import json
import os
import sys
import threading
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
from mes_dashboard.services import page_registry
@pytest.fixture
def temp_data_file(tmp_path):
"""Create a temporary legacy data file for migration tests."""
data_file = tmp_path / "page_status.json"
initial_data = {
"pages": [
{"route": "/", "name": "Home", "status": "released"},
{"route": "/wip-overview", "name": "WIP Overview", "status": "released"},
{"route": "/tables", "name": "Tables", "status": "dev"},
{"route": "/dev-page", "name": "Dev Page", "status": "dev"},
],
"api_public": True,
}
data_file.write_text(json.dumps(initial_data), encoding="utf-8")
return data_file
@pytest.fixture
def mock_registry(temp_data_file):
"""Mock page_registry to use temp file."""
original_data_file = page_registry.DATA_FILE
original_cache = page_registry._cache
page_registry.DATA_FILE = temp_data_file
page_registry._cache = None
yield temp_data_file
page_registry.DATA_FILE = original_data_file
page_registry._cache = original_cache
class TestSchemaMigration:
"""Tests for first-run drawers migration."""
def test_migration_adds_drawers_and_assignments(self, mock_registry):
drawers = page_registry.get_all_drawers()
drawer_ids = [drawer["id"] for drawer in drawers]
assert drawer_ids == ["reports", "queries", "dev-tools"]
pages = page_registry.get_all_pages()
page_by_route = {page["route"]: page for page in pages}
assert page_by_route["/wip-overview"]["drawer_id"] == "reports"
assert page_by_route["/wip-overview"]["order"] == 1
assert page_by_route["/tables"]["drawer_id"] == "queries"
assert page_by_route["/tables"]["order"] == 1
# Admin tools should be backfilled from legacy hardcoded sidebar mapping.
assert page_by_route["/admin/pages"]["drawer_id"] == "dev-tools"
assert page_by_route["/admin/performance"]["drawer_id"] == "dev-tools"
def test_subsequent_load_does_not_reset_drawers(self, mock_registry):
page_registry.get_all_drawers()
page_registry.create_drawer("custom", order=10, admin_only=False)
page_registry.reload_cache()
drawers = page_registry.get_all_drawers()
assert any(drawer["id"] == "custom" for drawer in drawers)
class TestGetPageStatus:
"""Tests for get_page_status function."""
def test_get_released_page_status(self, mock_registry):
status = page_registry.get_page_status("/")
assert status == "released"
def test_get_dev_page_status(self, mock_registry):
status = page_registry.get_page_status("/dev-page")
assert status == "dev"
def test_get_unregistered_page_status(self, mock_registry):
status = page_registry.get_page_status("/not-registered")
assert status is None
class TestSetPageStatus:
"""Tests for set_page_status function."""
def test_update_existing_page_status(self, mock_registry):
page_registry.set_page_status("/dev-page", "released")
assert page_registry.get_page_status("/dev-page") == "released"
def test_set_page_drawer_and_order(self, mock_registry):
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
pages = page_registry.get_all_pages()
dev_page = next(page for page in pages if page["route"] == "/dev-page")
assert dev_page["drawer_id"] == "queries"
assert dev_page["order"] == 9
def test_clear_page_drawer_and_order(self, mock_registry):
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
page_registry.set_page_status("/dev-page", "dev", drawer_id=None, order=None)
pages = page_registry.get_all_pages()
dev_page = next(page for page in pages if page["route"] == "/dev-page")
assert "drawer_id" not in dev_page
assert "order" not in dev_page
def test_set_invalid_drawer_raises_error(self, mock_registry):
with pytest.raises(ValueError, match="Drawer not found"):
page_registry.set_page_status("/dev-page", "dev", drawer_id="not-exists")
def test_invalid_status_raises_error(self, mock_registry):
with pytest.raises(ValueError, match="Invalid status"):
page_registry.set_page_status("/", "invalid")
class TestDrawerCrud:
"""Tests for drawer CRUD functions."""
def test_create_drawer(self, mock_registry):
created = page_registry.create_drawer("Custom Drawer", order=4, admin_only=True)
assert created["name"] == "Custom Drawer"
assert created["order"] == 4
assert created["admin_only"] is True
def test_create_duplicate_drawer_name_raises_conflict(self, mock_registry):
with pytest.raises(page_registry.DrawerConflictError):
page_registry.create_drawer("報表類", order=4)
def test_update_drawer(self, mock_registry):
updated = page_registry.update_drawer(
"reports",
name="報表中心",
order=7,
admin_only=True,
)
assert updated["name"] == "報表中心"
assert updated["order"] == 7
assert updated["admin_only"] is True
def test_delete_drawer_rejects_assigned_pages(self, mock_registry):
with pytest.raises(page_registry.DrawerConflictError, match="assigned pages"):
page_registry.delete_drawer("reports")
def test_delete_empty_drawer(self, mock_registry):
created = page_registry.create_drawer("Temporary", order=8)
page_registry.delete_drawer(created["id"])
drawers = page_registry.get_all_drawers()
assert all(drawer["id"] != created["id"] for drawer in drawers)
class TestNavigationConfig:
"""Tests for navigation config generation."""
def test_navigation_config_grouped_and_sorted(self, mock_registry):
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=5)
nav = page_registry.get_navigation_config()
assert [drawer["id"] for drawer in nav] == ["reports", "queries", "dev-tools"]
reports = next(drawer for drawer in nav if drawer["id"] == "reports")
assert [page["route"] for page in reports["pages"]] == ["/wip-overview"]
assert reports["pages"][0]["frame_id"] == "wipOverviewFrame"
assert reports["pages"][0]["tool_src"] is None
queries = next(drawer for drawer in nav if drawer["id"] == "queries")
assert queries["pages"][0]["route"] == "/tables"
assert queries["pages"][-1]["route"] == "/dev-page"
dev_tools = next(drawer for drawer in nav if drawer["id"] == "dev-tools")
assert all(page["frame_id"] == "toolFrame" for page in dev_tools["pages"])
assert dev_tools["pages"][0]["tool_src"] == "/admin/pages"
class TestIsApiPublic:
"""Tests for is_api_public function."""
def test_api_public_true(self, mock_registry):
assert page_registry.is_api_public() is True
def test_api_public_false(self, mock_registry, temp_data_file):
data = json.loads(temp_data_file.read_text())
data["api_public"] = False
temp_data_file.write_text(json.dumps(data))
page_registry._cache = None
assert page_registry.is_api_public() is False
class TestReloadCache:
"""Tests for reload_cache function."""
def test_reload_cache(self, mock_registry, temp_data_file):
assert page_registry.get_page_status("/") == "released"
data = json.loads(temp_data_file.read_text())
home = next(page for page in data["pages"] if page["route"] == "/")
home["status"] = "dev"
temp_data_file.write_text(json.dumps(data))
assert page_registry.get_page_status("/") == "released"
page_registry.reload_cache()
assert page_registry.get_page_status("/") == "dev"
class TestConcurrency:
"""Tests for thread safety."""
def test_concurrent_access(self, mock_registry):
errors = []
def reader():
try:
for _ in range(100):
page_registry.get_page_status("/")
except Exception as exc: # pragma: no cover - defensive
errors.append(exc)
def writer():
try:
for index in range(100):
status = "released" if index % 2 == 0 else "dev"
page_registry.set_page_status("/", status)
except Exception as exc: # pragma: no cover - defensive
errors.append(exc)
threads = [threading.Thread(target=reader) for _ in range(3)] + [
threading.Thread(target=writer) for _ in range(2)
]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
assert len(errors) == 0, f"Errors occurred: {errors}"

View File

@@ -100,6 +100,108 @@ class TestTemplateIntegration(unittest.TestCase):
self.assertIn('mes-toast-container', html)
class TestPortalDynamicDrawerRendering(unittest.TestCase):
"""Test dynamic portal drawer rendering."""
def setUp(self):
db._ENGINE = None
self.app = create_app('testing')
self.app.config['TESTING'] = True
self.client = self.app.test_client()
_login_as_admin(self.client)
def test_portal_uses_navigation_config_for_sidebar_and_iframes(self):
drawers = [
{
"id": "custom",
"name": "自訂分類",
"order": 1,
"admin_only": False,
"pages": [
{
"route": "/wip-overview",
"name": "自訂首頁",
"status": "released",
"order": 1,
"frame_id": "customFrame",
"tool_src": None,
}
],
},
{
"id": "dev-tools",
"name": "開發工具",
"order": 2,
"admin_only": True,
"pages": [
{
"route": "/admin/pages",
"name": "頁面管理",
"status": "dev",
"order": 1,
"frame_id": "toolFrame",
"tool_src": "/admin/pages",
}
],
},
]
with patch("mes_dashboard.app.get_navigation_config", return_value=drawers):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
html = response.data.decode("utf-8")
self.assertIn("自訂分類", html)
self.assertIn('data-target="customFrame"', html)
self.assertIn('id="customFrame"', html)
self.assertIn('data-tool-src="/admin/pages"', html)
self.assertIn('id="toolFrame"', html)
def test_portal_hides_admin_only_drawer_for_non_admin(self):
client = self.app.test_client()
drawers = [
{
"id": "custom",
"name": "自訂分類",
"order": 1,
"admin_only": False,
"pages": [
{
"route": "/wip-overview",
"name": "自訂首頁",
"status": "released",
"order": 1,
"frame_id": "customFrame",
"tool_src": None,
}
],
},
{
"id": "dev-tools",
"name": "開發工具",
"order": 2,
"admin_only": True,
"pages": [
{
"route": "/admin/pages",
"name": "頁面管理",
"status": "dev",
"order": 1,
"frame_id": "toolFrame",
"tool_src": "/admin/pages",
}
],
},
]
with patch("mes_dashboard.app.get_navigation_config", return_value=drawers):
response = client.get("/")
self.assertEqual(response.status_code, 200)
html = response.data.decode("utf-8")
self.assertIn("自訂分類", html)
self.assertNotIn("開發工具", html)
self.assertNotIn('data-tool-src="/admin/pages"', html)
class TestToastCSSIntegration(unittest.TestCase):
"""Test that Toast CSS styles are included in pages."""