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", "route": "/wip-overview",
"name": "WIP 即時概況", "name": "WIP 即時概況",
"status": "released" "status": "released",
"drawer_id": "reports",
"order": 1
}, },
{ {
"route": "/wip-detail", "route": "/wip-detail",
@@ -23,37 +25,65 @@
{ {
"route": "/resource-history", "route": "/resource-history",
"name": "設備歷史績效", "name": "設備歷史績效",
"status": "released" "status": "released",
"drawer_id": "reports",
"order": 3
}, },
{ {
"route": "/tables", "route": "/tables",
"name": "表格總覽", "name": "表格總覽",
"status": "dev" "status": "dev",
"drawer_id": "dev-tools",
"order": 1
}, },
{ {
"route": "/resource", "route": "/resource",
"name": "機台狀態", "name": "機台狀態",
"status": "released" "status": "released",
"drawer_id": "reports",
"order": 2
}, },
{ {
"route": "/excel-query", "route": "/excel-query",
"name": "Excel 批次查詢", "name": "Excel 批次查詢",
"status": "dev" "status": "dev",
"drawer_id": "dev-tools",
"order": 2
}, },
{ {
"route": "/job-query", "route": "/job-query",
"name": "設備維修查詢", "name": "設備維修查詢",
"status": "released" "status": "released",
"drawer_id": "queries",
"order": 3
}, },
{ {
"route": "/query-tool", "route": "/query-tool",
"name": "批次追蹤工具", "name": "批次追蹤工具",
"status": "released" "status": "released",
"drawer_id": "queries",
"order": 4
}, },
{ {
"route": "/tmtt-defect", "route": "/tmtt-defect",
"name": "TMTT印字腳型不良分析", "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, "api_public": true,
@@ -62,5 +92,25 @@
"updated_at": "2026-01-29 13:49:59", "updated_at": "2026-01-29 13:49:59",
"object_count": 19, "object_count": 19,
"source": "tools/query_table_schema.py" "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) { if (sidebarItems.length > 0) {
activateTab(sidebarItems[0].dataset.target); activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
} }
window.toggleHealthPopup = toggleHealthPopup; 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 ### 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 #### Scenario: Drawer grouping visibility
- **WHEN** users open the portal - **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 ### Requirement: Existing Page Behavior SHALL Remain Compatible
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames. The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
#### Scenario: Route continuity #### 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 - **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.auth_routes import auth_bp
from mes_dashboard.routes.admin_routes import admin_bp from mes_dashboard.routes.admin_routes import admin_bp
from mes_dashboard.routes.health_routes import health_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.core.cache_updater import start_cache_updater, stop_cache_updater
from mes_dashboard.services.realtime_equipment_cache import ( from mes_dashboard.services.realtime_equipment_cache import (
init_realtime_equipment_cache, init_realtime_equipment_cache,
@@ -367,7 +371,7 @@ def create_app(config_name: str | None = None) -> Flask:
@app.route('/') @app.route('/')
def portal_index(): def portal_index():
"""Portal home with tabs.""" """Portal home with tabs."""
return render_template('portal.html') return render_template('portal.html', drawers=get_navigation_config())
@app.route('/favicon.ico') @app.route('/favicon.ico')
def favicon(): def favicon():

View File

@@ -31,7 +31,17 @@ from mes_dashboard.core.worker_recovery_policy import (
extract_restart_history, extract_restart_history,
load_restart_state, 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") admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
logger = logging.getLogger("mes_dashboard.admin") logger = logging.getLogger("mes_dashboard.admin")
@@ -637,30 +647,127 @@ def pages():
return render_template("admin/pages.html") return render_template("admin/pages.html")
@admin_bp.route("/api/pages", methods=["GET"]) @admin_bp.route("/api/pages", methods=["GET"])
@admin_required @admin_required
def api_get_pages(): def api_get_pages():
"""API: Get all page configurations.""" """API: Get all page configurations."""
return jsonify({"success": True, "pages": get_all_pages()}) return jsonify({"success": True, "pages": get_all_pages()})
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"]) @admin_bp.route("/api/drawers", methods=["GET"])
@admin_required @admin_required
def api_update_page(route: str): def api_get_drawers():
"""API: Update page status.""" """API: Get all drawer configurations."""
data = request.get_json() return jsonify({"success": True, "drawers": get_all_drawers()})
status = data.get("status")
name = data.get("name")
@admin_bp.route("/api/drawers", methods=["POST"])
if status not in ("released", "dev"): @admin_required
return jsonify({"success": False, "error": "Invalid status"}), 400 def api_create_drawer():
"""API: Create a new drawer."""
# Ensure route starts with / payload = request.get_json(silent=True) or {}
if not route.startswith("/"): name = payload.get("name")
route = "/" + route order = payload.get("order")
admin_only = bool(payload.get("admin_only", False))
try:
set_page_status(route, status, name) try:
return jsonify({"success": True}) drawer = create_drawer(name=name, order=order, admin_only=admin_only)
except Exception as e: except DrawerConflictError as exc:
return jsonify({"success": False, "error": str(e)}), 500 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 -*- # -*- coding: utf-8 -*-
"""Page registry service for managing page access status.""" """Page registry service for managing page access status."""
from __future__ import annotations from __future__ import annotations
import json import json
import logging import logging
import os import os
import re
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from threading import Lock from threading import Lock
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Data file path (relative to project root) # Data file path (relative to project root)
# Path: src/mes_dashboard/services/page_registry.py -> project root/data/ # Path: src/mes_dashboard/services/page_registry.py -> project root/data/
DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json" DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json"
_lock = Lock() _lock = Lock()
_cache: dict | None = None _cache: dict | None = None
_cache_mtime: float = 0.0
_UNSET = object()
def _load() -> dict:
"""Load page status configuration.""" DEFAULT_DRAWERS = [
global _cache {"id": "reports", "name": "報表類", "order": 1, "admin_only": False},
if _cache is None: {"id": "queries", "name": "查詢類", "order": 2, "admin_only": False},
if DATA_FILE.exists(): {"id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": True},
try: ]
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
logger.debug("Loaded page status from %s", DATA_FILE) LEGACY_NAV_ASSIGNMENTS = {
except (json.JSONDecodeError, OSError) as e: "/wip-overview": {"drawer_id": "reports", "order": 1},
logger.warning("Failed to load page status: %s", e) "/resource": {"drawer_id": "reports", "order": 2},
_cache = {"pages": [], "api_public": True} "/resource-history": {"drawer_id": "reports", "order": 3},
else: "/tables": {"drawer_id": "queries", "order": 1},
logger.info("Page status file not found, using defaults") "/excel-query": {"drawer_id": "queries", "order": 2},
_cache = {"pages": [], "api_public": True} "/job-query": {"drawer_id": "queries", "order": 3},
return _cache "/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: def _save(data: dict) -> None:
"""Save page status configuration.""" """Save page status configuration."""
global _cache global _cache, _cache_mtime
tmp_path: Path | None = None tmp_path: Path | None = None
try: try:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True) DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -59,6 +135,10 @@ def _save(data: dict) -> None:
tmp_path = Path(tmp.name) tmp_path = Path(tmp.name)
os.replace(tmp_path, DATA_FILE) os.replace(tmp_path, DATA_FILE)
_cache = data _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) logger.debug("Saved page status to %s", DATA_FILE)
except OSError as e: except OSError as e:
if tmp_path is not None: if tmp_path is not None:
@@ -68,96 +148,378 @@ def _save(data: dict) -> None:
pass pass
logger.error("Failed to save page status: %s", e) logger.error("Failed to save page status: %s", e)
raise raise
def get_page_status(route: str) -> str | None: def _migrate_navigation_schema(data: dict) -> bool:
"""Get page status ('released' or 'dev'). """Migrate legacy schema to drawers schema when needed."""
if "drawers" in data:
Args: return False
route: Page route path (e.g., '/wip-overview')
data["drawers"] = [drawer.copy() for drawer in DEFAULT_DRAWERS]
Returns: pages = data.setdefault("pages", [])
'released', 'dev', or None if page is not registered. pages_by_route = {page.get("route"): page for page in pages if page.get("route")}
"""
with _lock: for route, assignment in LEGACY_NAV_ASSIGNMENTS.items():
data = _load() page = pages_by_route.get(route)
for page in data.get("pages", []): if page is None and route.startswith("/admin/"):
if page["route"] == route: page = {
return page.get("status", "dev") "route": route,
return None # Not registered - let Flask handle it "name": assignment.get("name", route),
"status": assignment.get("status", "dev"),
}
def is_page_registered(route: str) -> bool: pages.append(page)
"""Check if a page is registered in the page registry. pages_by_route[route] = page
Args: if page is None:
route: Page route path (e.g., '/wip-overview') continue
Returns: page.setdefault("drawer_id", assignment["drawer_id"])
True if page is registered, False otherwise. page.setdefault("order", assignment["order"])
""" if assignment.get("name"):
return get_page_status(route) is not None page.setdefault("name", assignment["name"])
if assignment.get("status"):
page.setdefault("status", assignment["status"])
def set_page_status(route: str, status: str, name: str | None = None) -> None:
"""Set page status. return True
Args:
route: Page route path def _safe_int(value: object, default: int) -> int:
status: 'released' or 'dev' try:
name: Optional page display name return int(value) # type: ignore[arg-type]
""" except (TypeError, ValueError):
if status not in ("released", "dev"): return default
raise ValueError(f"Invalid status: {status}")
with _lock: def _as_positive_int(value: object, *, field: str) -> int:
data = _load() try:
pages = data.setdefault("pages", []) parsed = int(value) # type: ignore[arg-type]
except (TypeError, ValueError) as exc:
# Update existing page raise ValueError(f"{field} must be an integer") from exc
for page in pages:
if page["route"] == route: if parsed < 1:
page["status"] = status raise ValueError(f"{field} must be >= 1")
if name: return parsed
page["name"] = name
_save(data)
logger.info("Updated page status: %s -> %s", route, status) def _drawer_exists(data: dict, drawer_id: str) -> bool:
return return any(drawer.get("id") == drawer_id for drawer in data.get("drawers", []))
# Add new page
pages.append({ def _apply_page_drawer(data: dict, page: dict, drawer_id: str | None) -> None:
"route": route, if drawer_id in (None, ""):
"name": name or route, page.pop("drawer_id", None)
"status": status return
})
_save(data) drawer_id = str(drawer_id)
logger.info("Added new page: %s (%s)", route, status) if not _drawer_exists(data, drawer_id):
raise ValueError(f"Drawer not found: {drawer_id}")
page["drawer_id"] = drawer_id
def get_all_pages() -> list[dict]:
"""Get all page configurations.
def _apply_page_order(page: dict, order: object) -> None:
Returns: if order in (None, ""):
List of page dicts: [{route, name, status}, ...] page.pop("order", None)
""" return
with _lock: page["order"] = _as_positive_int(order, field="order")
return _load().get("pages", [])
def _slugify_drawer_id(name: str) -> str:
def is_api_public() -> bool: slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
"""Check if API endpoints are publicly accessible. return slug or "drawer"
Returns:
True if API endpoints bypass permission checks def _generate_drawer_id(name: str, existing_ids: set[str]) -> str:
""" base = _slugify_drawer_id(name)
with _lock: candidate = base
return _load().get("api_public", True) suffix = 2
while candidate in existing_ids:
candidate = f"{base}-{suffix}"
def reload_cache() -> None: suffix += 1
"""Force reload of page status from disk.""" return candidate
global _cache
with _lock:
_cache = None def _route_to_frame_id(route: str) -> str:
_load() if route in LEGACY_FRAME_ID_MAP:
logger.info("Reloaded page status cache") 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" %} {% extends "_base.html" %}
{% block title %}頁面管理 - MES Dashboard{% endblock %} {% block title %}頁面管理 - MES Dashboard{% endblock %}
{% block head_extra %} {% block head_extra %}
<style> <style>
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
} }
body { body {
font-family: 'Microsoft JhengHei', Arial, sans-serif; font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #f5f7fa; background: #f5f7fa;
color: #222; color: #222;
} }
.shell { .shell {
max-width: 1200px; max-width: 1320px;
margin: 0 auto; margin: 0 auto;
padding: 20px; padding: 20px;
} }
.header { .header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white; color: white;
padding: 24px; padding: 24px;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-bottom: 20px; margin-bottom: 20px;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
} }
.header-left h1 { .header-left h1 {
font-size: 24px; font-size: 24px;
margin-bottom: 4px; margin-bottom: 4px;
} }
.header-left p { .header-left p {
font-size: 14px; font-size: 14px;
opacity: 0.9; opacity: 0.9;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.admin-info { .admin-info {
text-align: right; text-align: right;
font-size: 14px; font-size: 14px;
} }
.admin-info .name { .admin-info .name {
font-weight: 600; font-weight: 600;
} }
.logout-btn { .logout-btn {
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
color: white; color: white;
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px; padding: 8px 16px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
text-decoration: none; text-decoration: none;
transition: background 0.2s ease; transition: background 0.2s ease;
} }
.logout-btn:hover { .logout-btn:hover {
background: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.3);
} }
.panel { .panel {
background: white; background: white;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
overflow: hidden; overflow: hidden;
} margin-bottom: 16px;
}
.panel-header {
padding: 16px 20px; .panel-header {
border-bottom: 1px solid #eee; padding: 16px 20px;
display: flex; border-bottom: 1px solid #eee;
justify-content: space-between; display: flex;
align-items: center; justify-content: space-between;
} align-items: center;
gap: 16px;
.panel-header h2 { }
font-size: 18px;
font-weight: 600; .panel-header h2 {
} font-size: 18px;
font-weight: 600;
.table-container { }
overflow-x: auto;
} .panel-subtitle {
font-size: 13px;
table { color: #64748b;
width: 100%; }
border-collapse: collapse;
} .table-container {
overflow-x: auto;
th, td { }
padding: 14px 20px;
text-align: left; table {
border-bottom: 1px solid #eee; width: 100%;
} border-collapse: collapse;
}
th {
background: #f8f9fa; th, td {
font-weight: 600; padding: 14px 16px;
font-size: 14px; text-align: left;
color: #555; border-bottom: 1px solid #eee;
} vertical-align: middle;
}
td {
font-size: 14px; th {
} background: #f8f9fa;
font-weight: 600;
tr:hover { font-size: 14px;
background: #fafbfc; color: #555;
} }
.route-cell { td {
font-family: 'Consolas', 'Monaco', monospace; font-size: 14px;
color: #555; }
}
tr:hover {
.status-badge { background: #fafbfc;
display: inline-flex; }
align-items: center;
gap: 6px; .route-cell {
padding: 6px 12px; font-family: 'Consolas', 'Monaco', monospace;
border-radius: 20px; color: #555;
font-size: 13px; }
font-weight: 500;
cursor: pointer; .status-badge {
transition: all 0.2s ease; display: inline-flex;
} align-items: center;
gap: 6px;
.status-released { padding: 6px 12px;
background: #e8f5e9; border-radius: 20px;
color: #2e7d32; font-size: 13px;
} font-weight: 500;
cursor: pointer;
.status-dev { transition: all 0.2s ease;
background: #fff3e0; border: none;
color: #e65100; }
}
.status-released {
.status-badge:hover { background: #e8f5e9;
transform: scale(1.05); color: #2e7d32;
} }
.loading { .status-dev {
text-align: center; background: #fff3e0;
padding: 40px; color: #e65100;
color: #666; }
}
.status-badge:hover {
.back-link { transform: scale(1.05);
display: inline-block; }
margin-top: 16px;
color: #667eea; .input {
text-decoration: none; width: 100%;
font-size: 14px; border: 1px solid #cbd5e1;
} border-radius: 6px;
padding: 6px 8px;
.back-link:hover { font-size: 13px;
text-decoration: underline; background: #fff;
} color: #1e293b;
</style> }
{% endblock %}
.input:focus {
{% block content %} outline: none;
<div class="shell"> border-color: #667eea;
<div class="header"> box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
<div class="header-left"> }
<h1>頁面管理</h1>
<p>設定頁面存取權限Released所有人可見/ Dev僅管理員可見</p> .order-input {
</div> width: 96px;
<div class="header-right"> }
{% if admin_user %}
<div class="admin-info"> .drawer-create {
<div class="name">{{ admin_user.displayName }}</div> padding: 14px 20px;
<div>{{ admin_user.mail }}</div> border-bottom: 1px solid #eee;
</div> display: flex;
{% endif %} gap: 10px;
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a> flex-wrap: wrap;
</div> align-items: center;
</div> background: #f8fafc;
}
<div class="panel">
<div class="panel-header"> .checkbox-label {
<h2>所有頁面</h2> display: inline-flex;
</div> align-items: center;
<div class="table-container"> gap: 6px;
<table> color: #475569;
<thead> font-size: 13px;
<tr> white-space: nowrap;
<th>路由</th> }
<th>名稱</th>
<th>狀態</th> .action-btn {
</tr> border: 1px solid #d0d7e3;
</thead> border-radius: 6px;
<tbody id="pages-tbody"> padding: 6px 10px;
<tr> background: #fff;
<td colspan="3" class="loading">載入中...</td> color: #334155;
</tr> cursor: pointer;
</tbody> font-size: 12px;
</table> transition: all 0.2s ease;
</div> }
</div>
.action-btn:hover {
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a> border-color: #94a3b8;
</div> background: #f8fafc;
{% endblock %} }
{% block scripts %} .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> <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 || ''; const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content || '';
let drawersCache = [];
let pagesCache = [];
function withCsrfHeaders(headers = {}) { function withCsrfHeaders(headers = {}) {
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers; return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
} }
async function loadPages() { function escapeHtml(value) {
try { const div = document.createElement('div');
const response = await fetch('/admin/api/pages'); div.textContent = value ?? '';
const data = await response.json(); return div.innerHTML;
}
if (!data.success) {
throw new Error(data.error || 'Failed to load pages'); function escapeAttr(value) {
} return escapeHtml(String(value ?? '')).replace(/\"/g, '&quot;');
}
renderPages(data.pages);
} catch (error) { async function parseJsonResponse(response) {
console.error('Error loading pages:', error); try {
tbody.innerHTML = `<tr><td colspan="3" class="loading">載入失敗: ${error.message}</td></tr>`; return await response.json();
} } catch (error) {
} if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
function renderPages(pages) { }
if (pages.length === 0) { throw error;
tbody.innerHTML = '<tr><td colspan="3" class="loading">尚無頁面設定</td></tr>'; }
return; }
}
async function apiFetch(url, options = {}) {
tbody.innerHTML = pages.map(page => ` const config = { ...options };
<tr> config.headers = withCsrfHeaders(config.headers || {});
<td class="route-cell">${escapeHtml(page.route)}</td> const response = await fetch(url, config);
<td>${escapeHtml(page.name)}</td> const data = await parseJsonResponse(response);
<td> if (!response.ok || data.success === false) {
<span class="status-badge status-${page.status}" throw new Error(data.error || `HTTP ${response.status}`);
onclick="toggleStatus('${escapeHtml(page.route)}', '${page.status}')"> }
${page.status === 'released' ? 'Released' : 'Dev'} return data;
</span> }
</td>
</tr> function drawerOptionsHtml(selectedDrawerId) {
`).join(''); const normalized = selectedDrawerId || '';
} const options = ['<option value="">未分類</option>'];
drawersCache.forEach((drawer) => {
async function toggleStatus(route, currentStatus) { const selected = drawer.id === normalized ? 'selected' : '';
const newStatus = currentStatus === 'released' ? 'dev' : 'released'; options.push(
`<option value="${escapeAttr(drawer.id)}" ${selected}>${escapeHtml(drawer.name)}</option>`
try { );
const response = await fetch(`/admin/api/pages${route}`, { });
method: 'PUT', return options.join('');
headers: withCsrfHeaders({ 'Content-Type': 'application/json' }), }
body: JSON.stringify({ status: newStatus })
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),
}); });
window.MES?.toast?.success?.(`已新增抽屜:${name}`);
const data = await response.json(); newDrawerNameInput.value = '';
newDrawerOrderInput.value = '';
if (!data.success) { newDrawerAdminOnlyInput.checked = false;
throw new Error(data.error || 'Failed to update status'); await refreshAll();
} } catch (error) {
window.MES?.toast?.error?.(`新增抽屜失敗: ${error.message}`);
window.MES?.toast?.success?.(`已更新: ${route}${newStatus}`); }
loadPages(); }
} catch (error) {
console.error('Error updating status:', error); async function saveDrawerFromRow(row) {
window.MES?.toast?.error?.(`更新失敗: ${error.message}`); 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;
function escapeHtml(str) {
const div = document.createElement('div'); if (!drawerId) {
div.textContent = str; return;
return div.innerHTML; }
}
const payload = { name, admin_only: adminOnly };
// Load pages on page load if (orderRaw !== '') {
loadPages(); payload.order = Number(orderRaw);
</script> }
{% endblock %}
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"> <div class="admin-status">
{% if is_admin %} {% if is_admin %}
<span class="admin-name">{{ admin_user.displayName }}</span> <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> <a href="{{ url_for('auth.logout') }}">登出</a>
{% else %} {% else %}
<a href="{{ url_for('auth.login') }}">管理員登入</a> <a href="{{ url_for('auth.login') }}">管理員登入</a>
@@ -354,69 +352,49 @@
<div class="main-layout"> <div class="main-layout">
<nav class="sidebar"> <nav class="sidebar">
<div class="sidebar-group-title">報表類</div> {% for drawer in drawers | default([]) %}
{% if can_view_page('/wip-overview') %} {% if not drawer.admin_only or is_admin %}
<button class="sidebar-item" data-target="wipOverviewFrame">WIP 即時概況</button> {% set nav_ns = namespace(has_visible_pages=false) %}
{% endif %} {% for page in drawer.pages %}
{% if can_view_page('/resource') %} {% if can_view_page(page.route) %}
<button class="sidebar-item" data-target="resourceFrame">設備即時概況</button> {% set nav_ns.has_visible_pages = true %}
{% endif %} {% endif %}
{% if can_view_page('/resource-history') %} {% endfor %}
<button class="sidebar-item" data-target="resourceHistoryFrame">設備歷史績效</button> {% if nav_ns.has_visible_pages %}
{% endif %} <div class="sidebar-group-title">{{ drawer.name }}</div>
{% for page in drawer.pages %}
<div class="sidebar-group-title">查詢類</div> {% if can_view_page(page.route) %}
{% if can_view_page('/tables') %} <button
<button class="sidebar-item" data-target="tableFrame">數據表查詢工具</button> class="sidebar-item"
{% endif %} data-target="{{ page.frame_id }}"
{% if can_view_page('/excel-query') %} {% if page.tool_src %}data-tool-src="{{ page.tool_src }}"{% endif %}
<button class="sidebar-item" data-target="excelQueryFrame">Excel 批次查詢</button> >{{ page.name }}</button>
{% endif %} {% endif %}
{% if can_view_page('/job-query') %} {% endfor %}
<button class="sidebar-item" data-target="jobQueryFrame">設備維修查詢</button> {% endif %}
{% endif %} {% endif %}
{% if can_view_page('/query-tool') %} {% endfor %}
<button class="sidebar-item" data-target="queryToolFrame">批次追蹤工具</button> {% if not is_admin %}
{% 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 %}
<a class="sidebar-item" href="{{ url_for('auth.login') }}">管理員登入</a> <a class="sidebar-item" href="{{ url_for('auth.login') }}">管理員登入</a>
{% endif %} {% endif %}
</nav> </nav>
<div class="panel"> <div class="panel">
{% if can_view_page('/wip-overview') %} {% set frame_ns = namespace(has_tool_pages=false) %}
<iframe id="wipOverviewFrame" data-src="/wip-overview" title="WIP 即時概況"></iframe> {% for drawer in drawers | default([]) %}
{% endif %} {% if not drawer.admin_only or is_admin %}
{% if can_view_page('/resource') %} {% for page in drawer.pages %}
<iframe id="resourceFrame" data-src="/resource" title="設備即時概況"></iframe> {% if can_view_page(page.route) %}
{% endif %} {% if page.tool_src %}
{% if can_view_page('/tables') %} {% set frame_ns.has_tool_pages = true %}
<iframe id="tableFrame" data-src="/tables" title="數據表查詢工具"></iframe> {% else %}
{% endif %} <iframe id="{{ page.frame_id }}" data-src="{{ page.route }}" title="{{ page.name }}"></iframe>
{% if can_view_page('/excel-query') %} {% endif %}
<iframe id="excelQueryFrame" data-src="/excel-query" title="Excel 批次查詢"></iframe> {% endif %}
{% endif %} {% endfor %}
{% if can_view_page('/resource-history') %} {% endif %}
<iframe id="resourceHistoryFrame" data-src="/resource-history" title="設備歷史績效"></iframe> {% endfor %}
{% endif %} {% if is_admin and frame_ns.has_tool_pages %}
{% 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 %}
<iframe id="toolFrame" title="開發工具"></iframe> <iframe id="toolFrame" title="開發工具"></iframe>
{% endif %} {% endif %}
</div> </div>
@@ -432,7 +410,6 @@
<script> <script>
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]'); const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]');
const frames = document.querySelectorAll('iframe'); const frames = document.querySelectorAll('iframe');
const toolFrame = document.getElementById('toolFrame');
function setFrameHeight() { function setFrameHeight() {
const headerHeight = document.querySelector('.header').offsetHeight; const headerHeight = document.querySelector('.header').offsetHeight;
@@ -476,7 +453,7 @@
// Auto-activate first available item // Auto-activate first available item
if (sidebarItems.length > 0) { if (sidebarItems.length > 0) {
activateTab(sidebarItems[0].dataset.target); activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
} }
window.addEventListener('resize', setFrameHeight); window.addEventListener('resize', setFrameHeight);

View File

@@ -280,8 +280,8 @@ class TestPermissionMiddleware:
assert response.status_code == 200 assert response.status_code == 200
class TestAdminAPI: class TestAdminAPI:
"""Tests for admin API endpoints.""" """Tests for admin API endpoints."""
def test_get_pages_without_login(self, client): def test_get_pages_without_login(self, client):
"""Test get pages API requires login.""" """Test get pages API requires login."""
@@ -289,22 +289,64 @@ class TestAdminAPI:
# Should redirect # Should redirect
assert response.status_code == 302 assert response.status_code == 302
def test_get_pages_with_login(self, client): def test_get_pages_with_login(self, client):
"""Test get pages API with login.""" """Test get pages API with login."""
with client.session_transaction() as sess: with client.session_transaction() as sess:
sess["admin"] = {"username": "admin"} sess["admin"] = {"username": "admin"}
response = client.get("/admin/api/pages") response = client.get("/admin/api/pages")
assert response.status_code == 200 assert response.status_code == 200
data = json.loads(response.data) data = json.loads(response.data)
assert data["success"] is True assert data["success"] is True
assert "pages" in data assert "pages" in data
def test_update_page_status(self, client, temp_page_status): def test_get_drawers_without_login(self, client):
"""Test updating page status via API.""" """Test drawer API requires login."""
with client.session_transaction() as sess: response = client.get("/admin/api/drawers", follow_redirects=False)
sess["admin"] = {"username": "admin"} 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( response = client.put(
"/admin/api/pages/wip-overview", "/admin/api/pages/wip-overview",
@@ -329,10 +371,48 @@ class TestAdminAPI:
"/admin/api/pages/wip-overview", "/admin/api/pages/wip-overview",
data=json.dumps({"status": "invalid"}), data=json.dumps({"status": "invalid"}),
content_type="application/json" 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: class TestContextProcessor:
"""Tests for template context processor.""" """Tests for template context processor."""

View File

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