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:
@@ -8,7 +8,9 @@
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released"
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/wip-detail",
|
||||
@@ -23,37 +25,65 @@
|
||||
{
|
||||
"route": "/resource-history",
|
||||
"name": "設備歷史績效",
|
||||
"status": "released"
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/tables",
|
||||
"name": "表格總覽",
|
||||
"status": "dev"
|
||||
"status": "dev",
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/resource",
|
||||
"name": "機台狀態",
|
||||
"status": "released"
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/excel-query",
|
||||
"name": "Excel 批次查詢",
|
||||
"status": "dev"
|
||||
"status": "dev",
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 2
|
||||
},
|
||||
{
|
||||
"route": "/job-query",
|
||||
"name": "設備維修查詢",
|
||||
"status": "released"
|
||||
"status": "released",
|
||||
"drawer_id": "queries",
|
||||
"order": 3
|
||||
},
|
||||
{
|
||||
"route": "/query-tool",
|
||||
"name": "批次追蹤工具",
|
||||
"status": "released"
|
||||
"status": "released",
|
||||
"drawer_id": "queries",
|
||||
"order": 4
|
||||
},
|
||||
{
|
||||
"route": "/tmtt-defect",
|
||||
"name": "TMTT印字腳型不良分析",
|
||||
"status": "released"
|
||||
"status": "released",
|
||||
"drawer_id": "queries",
|
||||
"order": 5
|
||||
},
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 1
|
||||
},
|
||||
{
|
||||
"route": "/admin/performance",
|
||||
"name": "效能監控",
|
||||
"status": "dev",
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 2
|
||||
}
|
||||
],
|
||||
"api_public": true,
|
||||
@@ -62,5 +92,25 @@
|
||||
"updated_at": "2026-01-29 13:49:59",
|
||||
"object_count": 19,
|
||||
"source": "tools/query_table_schema.py"
|
||||
}
|
||||
},
|
||||
"drawers": [
|
||||
{
|
||||
"id": "reports",
|
||||
"name": "報表類",
|
||||
"order": 1,
|
||||
"admin_only": false
|
||||
},
|
||||
{
|
||||
"id": "queries",
|
||||
"name": "查詢類",
|
||||
"order": 2,
|
||||
"admin_only": false
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"name": "開發工具",
|
||||
"order": 3,
|
||||
"admin_only": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -168,7 +168,7 @@ import './portal.css';
|
||||
});
|
||||
|
||||
if (sidebarItems.length > 0) {
|
||||
activateTab(sidebarItems[0].dataset.target);
|
||||
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
|
||||
}
|
||||
|
||||
window.toggleHealthPopup = toggleHealthPopup;
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-09
|
||||
@@ -0,0 +1,112 @@
|
||||
## Context
|
||||
|
||||
Portal sidebar 導航結構目前寫死在 `portal.html` (lines 356-421),包含三個固定分類與十個固定頁面按鈕。頁面的 `released`/`dev` 狀態已經由 `data/page_status.json` + `page_registry.py` 動態管理,但抽屜分類與頁面歸屬仍需改程式碼。
|
||||
|
||||
現有架構:
|
||||
- `data/page_status.json`: 頁面狀態持久化(JSON file, atomic write, thread-safe cache)
|
||||
- `page_registry.py`: 服務層(CRUD + cache + lock)
|
||||
- `admin_routes.py` (lines 629-667): Admin API(GET/PUT pages)
|
||||
- `admin/pages.html`: Admin UI(頁面狀態切換表格)
|
||||
- `portal.html`: Jinja2 模板,`{% if can_view_page() %}` 控制可見性
|
||||
|
||||
關鍵約束:
|
||||
- 專案使用 Jinja2 shell + Vite JS 混合架構,所有 11 個頁面一致
|
||||
- 無資料庫,所有配置用 JSON file 持久化
|
||||
- 需要向下相容:現有 `page_status.json` 的 page entries 不可遺失
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Admin 可透過 UI 對抽屜進行 CRUD(新增、改名、刪除、排序)
|
||||
- Admin 可透過 UI 指定頁面歸屬的抽屜與排序
|
||||
- Portal sidebar 根據 JSON 配置動態渲染(Jinja2 for loop)
|
||||
- 向下相容:首次載入時自動從現有 hardcoded 結構產生初始配置
|
||||
- 為未來全面 Vite SPA 化鋪路:API 設計可直接被前端 fetch 使用
|
||||
|
||||
**Non-Goals:**
|
||||
- 不做全面 Vite SPA 化(本次只做 Jinja2 動態渲染)
|
||||
- 不做頁面路由的動態新增(頁面路由仍在 `app.py` 中定義)
|
||||
- 不改變 `released`/`dev` 狀態的邏輯
|
||||
- 不做拖拉排序(使用上下箭頭或數字排序即可)
|
||||
- 不做多使用者即時同步(Admin 改完,其他使用者刷新頁面即可看到)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. 資料結構:擴展 `page_status.json`
|
||||
|
||||
**選擇**: 在現有 JSON 中新增 `drawers` 頂層欄位,並在 page 中加入 `drawer_id` 和 `order`。
|
||||
|
||||
```json
|
||||
{
|
||||
"drawers": [
|
||||
{ "id": "reports", "name": "報表類", "order": 1 },
|
||||
{ "id": "queries", "name": "查詢類", "order": 2 },
|
||||
{ "id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": true }
|
||||
],
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "WIP 即時概況",
|
||||
"status": "released",
|
||||
"drawer_id": "reports",
|
||||
"order": 1
|
||||
}
|
||||
],
|
||||
"api_public": true
|
||||
}
|
||||
```
|
||||
|
||||
**替代方案**: 獨立 `navigation.json` 檔案。
|
||||
**放棄原因**: 頁面狀態和歸屬是同一份資料的不同面向,拆成兩個檔案增加同步複雜度。現有的 atomic write + lock 機制可以直接沿用。
|
||||
|
||||
### 2. 向下相容:自動遷移策略
|
||||
|
||||
**選擇**: `page_registry.py` 的 `_load()` 函式在讀取時檢測是否存在 `drawers` 欄位。若不存在,自動注入預設的三個抽屜定義並根據目前 portal.html 的 hardcoded 映射填充 `drawer_id`。遷移後立即 `_save()` 持久化。
|
||||
|
||||
**理由**: 零手動操作部署。首次啟動即完成遷移。
|
||||
|
||||
### 3. iframe 策略:維持現行邏輯
|
||||
|
||||
**選擇**: 一般頁面每個獨立 iframe,admin 工具頁面共用 `toolFrame`。動態渲染時根據 `drawer.admin_only` 判斷是否使用共用 iframe。
|
||||
|
||||
**理由**: 最小變動。iframe 行為和 `portal.js` 的 `activateTab()` 邏輯不需改動。
|
||||
|
||||
**具體做法**: 每個 page 的 iframe id 由 route 推導(如 `/wip-overview` → `wipOverviewFrame`),`admin_only` 抽屜下的頁面共用 `toolFrame` 並使用 `data-tool-src` 切換。
|
||||
|
||||
### 4. Admin API 設計
|
||||
|
||||
**選擇**: 在現有 `admin_routes.py` 中擴展,新增 drawer endpoints。
|
||||
|
||||
| Endpoint | Method | 用途 |
|
||||
|---|---|---|
|
||||
| `GET /admin/api/drawers` | GET | 取得所有抽屜 |
|
||||
| `POST /admin/api/drawers` | POST | 新增抽屜 |
|
||||
| `PUT /admin/api/drawers/<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
|
||||
|
||||
- 無。探索階段已充分討論並確認方向。
|
||||
@@ -0,0 +1,30 @@
|
||||
## Why
|
||||
|
||||
Portal sidebar 的抽屜分類(報表類/查詢類/開發工具)與頁面歸屬目前寫死在 `portal.html` Jinja2 模板中,任何導航結構的變更都需要改程式碼並重新部署。Admin 應能透過現有的「頁面管理」UI 動態管理抽屜與頁面分配,無需開發介入。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 擴展 `data/page_status.json` 資料結構,加入 `drawers` 陣列定義抽屜(名稱、排序、可見性),並在每個 page 中加入 `drawer_id` 和 `order` 欄位
|
||||
- 擴展 `page_registry.py` 服務層,新增 drawer CRUD 函式與 page-to-drawer 指派函式
|
||||
- 新增 admin API endpoints:drawer 的增刪改查、頁面的抽屜指派與排序
|
||||
- **BREAKING**: `portal.html` sidebar 從寫死 HTML 改為 `{% for drawer in drawers %}` 動態渲染
|
||||
- 擴展現有 `/admin/pages` UI,加入抽屜管理區塊與頁面拖拉/下拉分配功能
|
||||
- 頁面的 `released`/`dev` 狀態邏輯與 `can_view_page()` 權限檢查保持不變
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `drawer-management`: Admin 可透過 API 與 UI 對抽屜進行 CRUD 操作(新增、刪除、改名、排序)
|
||||
- `page-drawer-assignment`: Admin 可透過 API 與 UI 將頁面指派到不同抽屜,並控制頁面在抽屜內的排序
|
||||
|
||||
### Modified Capabilities
|
||||
- `portal-drawer-navigation`: 導航結構從寫死改為從 JSON 配置動態渲染,抽屜分類與頁面歸屬由資料驅動
|
||||
|
||||
## Impact
|
||||
|
||||
- **資料層**: `data/page_status.json` 結構擴展(向下相容,新增欄位有預設值)
|
||||
- **服務層**: `page_registry.py` 新增 drawer 相關函式
|
||||
- **路由層**: `admin_routes.py` 新增 drawer API endpoints
|
||||
- **模板層**: `portal.html` sidebar 區塊重寫為動態渲染
|
||||
- **前端層**: `admin/pages.html` UI 擴展
|
||||
- **無影響**: 各頁面本身的路由、模板、Vite 模組均不需變更
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 實作前端 JS:drawer CRUD 操作(呼叫 API → 更新 UI)
|
||||
- [x] 4.4 實作前端 JS:頁面 drawer 指派操作(下拉變更 → PUT API)
|
||||
|
||||
## 5. 驗證
|
||||
|
||||
- [x] 5.1 驗證首次啟動遷移:刪除 `page_status.json` 中的 `drawers` 欄位,重啟後確認自動產生正確的預設配置
|
||||
- [x] 5.2 驗證 portal sidebar 動態渲染:新增/刪除抽屜後刷新 portal,確認 sidebar 正確反映
|
||||
- [x] 5.3 驗證頁面歸屬變更:改變頁面的 drawer_id 後刷新 portal,確認頁面出現在正確的抽屜中
|
||||
- [x] 5.4 驗證權限邏輯不變:非 admin 使用者看不到 dev 頁面、看不到 admin_only 抽屜
|
||||
- [x] 5.5 驗證安全性:非 admin 使用者無法存取 drawer API endpoints
|
||||
55
openspec/specs/drawer-management/spec.md
Normal file
55
openspec/specs/drawer-management/spec.md
Normal 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
|
||||
42
openspec/specs/page-drawer-assignment/spec.md
Normal file
42
openspec/specs/page-drawer-assignment/spec.md
Normal 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
|
||||
@@ -5,15 +5,41 @@ Define stable requirements for portal-drawer-navigation.
|
||||
|
||||
|
||||
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
|
||||
The portal SHALL group navigation entries into functional drawers: reports, queries, and development tools.
|
||||
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML.
|
||||
|
||||
#### Scenario: Drawer grouping visibility
|
||||
- **WHEN** users open the portal
|
||||
- **THEN** report pages and query pages SHALL appear in separate drawer groups
|
||||
- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field
|
||||
- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field
|
||||
|
||||
#### Scenario: Admin-only drawer visibility
|
||||
- **WHEN** a drawer has `admin_only: true` and the current user is not admin
|
||||
- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
|
||||
|
||||
#### Scenario: Empty drawer visibility
|
||||
- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`)
|
||||
- **THEN** the drawer group title SHALL NOT be rendered
|
||||
|
||||
### Requirement: Existing Page Behavior SHALL Remain Compatible
|
||||
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
|
||||
|
||||
#### Scenario: Route continuity
|
||||
- **WHEN** a user selects an existing page entry from the new drawer
|
||||
- **WHEN** a user selects an existing page entry from a dynamically rendered drawer
|
||||
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
|
||||
|
||||
#### Scenario: Iframe lazy-load continuity
|
||||
- **WHEN** a sidebar item is clicked for the first time
|
||||
- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior
|
||||
|
||||
### Requirement: First-run migration SHALL populate drawer configuration automatically
|
||||
When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers.
|
||||
|
||||
#### Scenario: First startup after deployment
|
||||
- **WHEN** the application starts and `page_status.json` has no `drawers` field
|
||||
- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具)
|
||||
- **THEN** the system SHALL assign each existing page to its historically correct drawer
|
||||
- **THEN** the system SHALL persist the updated configuration immediately
|
||||
|
||||
#### Scenario: Subsequent startup
|
||||
- **WHEN** the application starts and `page_status.json` already contains a `drawers` field
|
||||
- **THEN** the system SHALL NOT modify the existing drawer configuration
|
||||
|
||||
@@ -33,7 +33,11 @@ from mes_dashboard.routes import register_routes
|
||||
from mes_dashboard.routes.auth_routes import auth_bp
|
||||
from mes_dashboard.routes.admin_routes import admin_bp
|
||||
from mes_dashboard.routes.health_routes import health_bp
|
||||
from mes_dashboard.services.page_registry import get_page_status, is_api_public
|
||||
from mes_dashboard.services.page_registry import (
|
||||
get_navigation_config,
|
||||
get_page_status,
|
||||
is_api_public,
|
||||
)
|
||||
from mes_dashboard.core.cache_updater import start_cache_updater, stop_cache_updater
|
||||
from mes_dashboard.services.realtime_equipment_cache import (
|
||||
init_realtime_equipment_cache,
|
||||
@@ -367,7 +371,7 @@ def create_app(config_name: str | None = None) -> Flask:
|
||||
@app.route('/')
|
||||
def portal_index():
|
||||
"""Portal home with tabs."""
|
||||
return render_template('portal.html')
|
||||
return render_template('portal.html', drawers=get_navigation_config())
|
||||
|
||||
@app.route('/favicon.ico')
|
||||
def favicon():
|
||||
|
||||
@@ -31,7 +31,17 @@ from mes_dashboard.core.worker_recovery_policy import (
|
||||
extract_restart_history,
|
||||
load_restart_state,
|
||||
)
|
||||
from mes_dashboard.services.page_registry import get_all_pages, set_page_status
|
||||
from mes_dashboard.services.page_registry import (
|
||||
DrawerConflictError,
|
||||
DrawerNotFoundError,
|
||||
create_drawer,
|
||||
delete_drawer,
|
||||
get_all_drawers,
|
||||
get_all_pages,
|
||||
get_page_status,
|
||||
set_page_status,
|
||||
update_drawer,
|
||||
)
|
||||
|
||||
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
|
||||
logger = logging.getLogger("mes_dashboard.admin")
|
||||
@@ -644,23 +654,120 @@ def api_get_pages():
|
||||
return jsonify({"success": True, "pages": get_all_pages()})
|
||||
|
||||
|
||||
@admin_bp.route("/api/drawers", methods=["GET"])
|
||||
@admin_required
|
||||
def api_get_drawers():
|
||||
"""API: Get all drawer configurations."""
|
||||
return jsonify({"success": True, "drawers": get_all_drawers()})
|
||||
|
||||
|
||||
@admin_bp.route("/api/drawers", methods=["POST"])
|
||||
@admin_required
|
||||
def api_create_drawer():
|
||||
"""API: Create a new drawer."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
name = payload.get("name")
|
||||
order = payload.get("order")
|
||||
admin_only = bool(payload.get("admin_only", False))
|
||||
|
||||
try:
|
||||
drawer = create_drawer(name=name, order=order, admin_only=admin_only)
|
||||
except DrawerConflictError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 409
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.exception("Failed to create drawer")
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
return jsonify({"success": True, "drawer": drawer}), 201
|
||||
|
||||
|
||||
@admin_bp.route("/api/drawers/<drawer_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
def api_update_drawer(drawer_id: str):
|
||||
"""API: Update drawer name/order/admin_only."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
updates: dict[str, Any] = {}
|
||||
|
||||
if "name" in payload:
|
||||
updates["name"] = payload.get("name")
|
||||
if "order" in payload:
|
||||
updates["order"] = payload.get("order")
|
||||
if "admin_only" in payload:
|
||||
updates["admin_only"] = bool(payload.get("admin_only"))
|
||||
|
||||
if not updates:
|
||||
return jsonify({"success": False, "error": "No drawer fields to update"}), 400
|
||||
|
||||
try:
|
||||
drawer = update_drawer(drawer_id, **updates)
|
||||
except DrawerNotFoundError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 404
|
||||
except DrawerConflictError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 409
|
||||
except ValueError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 400
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.exception("Failed to update drawer %s", drawer_id)
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
return jsonify({"success": True, "drawer": drawer})
|
||||
|
||||
|
||||
@admin_bp.route("/api/drawers/<drawer_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
def api_delete_drawer(drawer_id: str):
|
||||
"""API: Delete a drawer if no pages are assigned to it."""
|
||||
try:
|
||||
delete_drawer(drawer_id)
|
||||
except DrawerNotFoundError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 404
|
||||
except DrawerConflictError as exc:
|
||||
return jsonify({"success": False, "error": str(exc)}), 409
|
||||
except Exception as exc: # pragma: no cover - defensive fallback
|
||||
logger.exception("Failed to delete drawer %s", drawer_id)
|
||||
return jsonify({"success": False, "error": str(exc)}), 500
|
||||
|
||||
return jsonify({"success": True})
|
||||
|
||||
|
||||
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"])
|
||||
@admin_required
|
||||
def api_update_page(route: str):
|
||||
"""API: Update page status."""
|
||||
data = request.get_json()
|
||||
status = data.get("status")
|
||||
name = data.get("name")
|
||||
|
||||
if status not in ("released", "dev"):
|
||||
return jsonify({"success": False, "error": "Invalid status"}), 400
|
||||
"""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, name)
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
@@ -17,28 +18,103 @@ logger = logging.getLogger(__name__)
|
||||
DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json"
|
||||
_lock = Lock()
|
||||
_cache: dict | None = None
|
||||
_cache_mtime: float = 0.0
|
||||
_UNSET = object()
|
||||
|
||||
DEFAULT_DRAWERS = [
|
||||
{"id": "reports", "name": "報表類", "order": 1, "admin_only": False},
|
||||
{"id": "queries", "name": "查詢類", "order": 2, "admin_only": False},
|
||||
{"id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": True},
|
||||
]
|
||||
|
||||
LEGACY_NAV_ASSIGNMENTS = {
|
||||
"/wip-overview": {"drawer_id": "reports", "order": 1},
|
||||
"/resource": {"drawer_id": "reports", "order": 2},
|
||||
"/resource-history": {"drawer_id": "reports", "order": 3},
|
||||
"/tables": {"drawer_id": "queries", "order": 1},
|
||||
"/excel-query": {"drawer_id": "queries", "order": 2},
|
||||
"/job-query": {"drawer_id": "queries", "order": 3},
|
||||
"/query-tool": {"drawer_id": "queries", "order": 4},
|
||||
"/tmtt-defect": {"drawer_id": "queries", "order": 5},
|
||||
"/admin/pages": {
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 1,
|
||||
"status": "dev",
|
||||
"name": "頁面管理",
|
||||
},
|
||||
"/admin/performance": {
|
||||
"drawer_id": "dev-tools",
|
||||
"order": 2,
|
||||
"status": "dev",
|
||||
"name": "效能監控",
|
||||
},
|
||||
}
|
||||
|
||||
LEGACY_FRAME_ID_MAP = {
|
||||
"/wip-overview": "wipOverviewFrame",
|
||||
"/resource": "resourceFrame",
|
||||
"/tables": "tableFrame",
|
||||
"/excel-query": "excelQueryFrame",
|
||||
"/resource-history": "resourceHistoryFrame",
|
||||
"/job-query": "jobQueryFrame",
|
||||
"/query-tool": "queryToolFrame",
|
||||
"/tmtt-defect": "tmttDefectFrame",
|
||||
}
|
||||
|
||||
|
||||
class DrawerError(Exception):
|
||||
"""Base drawer management error."""
|
||||
|
||||
|
||||
class DrawerNotFoundError(DrawerError):
|
||||
"""Raised when drawer cannot be found."""
|
||||
|
||||
|
||||
class DrawerConflictError(DrawerError):
|
||||
"""Raised when drawer operation conflicts with existing data."""
|
||||
|
||||
|
||||
def _load() -> dict:
|
||||
"""Load page status configuration."""
|
||||
global _cache
|
||||
"""Load page status configuration.
|
||||
|
||||
Detects file changes across gunicorn workers by comparing mtime,
|
||||
so writes from one worker are visible to reads in another.
|
||||
"""
|
||||
global _cache, _cache_mtime
|
||||
|
||||
# Check if another worker has written a newer version to disk.
|
||||
if _cache is not None:
|
||||
try:
|
||||
disk_mtime = DATA_FILE.stat().st_mtime
|
||||
except OSError:
|
||||
disk_mtime = 0.0
|
||||
if disk_mtime > _cache_mtime:
|
||||
_cache = None # Invalidate so we re-read below.
|
||||
|
||||
if _cache is None:
|
||||
if DATA_FILE.exists():
|
||||
try:
|
||||
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
|
||||
_cache_mtime = DATA_FILE.stat().st_mtime
|
||||
logger.debug("Loaded page status from %s", DATA_FILE)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
logger.warning("Failed to load page status: %s", e)
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
_cache_mtime = 0.0
|
||||
else:
|
||||
logger.info("Page status file not found, using defaults")
|
||||
_cache = {"pages": [], "api_public": True}
|
||||
_cache_mtime = 0.0
|
||||
|
||||
if _migrate_navigation_schema(_cache):
|
||||
_save(_cache)
|
||||
logger.info("Migrated page status config to drawers schema")
|
||||
return _cache
|
||||
|
||||
|
||||
def _save(data: dict) -> None:
|
||||
"""Save page status configuration."""
|
||||
global _cache
|
||||
global _cache, _cache_mtime
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
@@ -59,6 +135,10 @@ def _save(data: dict) -> None:
|
||||
tmp_path = Path(tmp.name)
|
||||
os.replace(tmp_path, DATA_FILE)
|
||||
_cache = data
|
||||
try:
|
||||
_cache_mtime = DATA_FILE.stat().st_mtime
|
||||
except OSError:
|
||||
_cache_mtime = 0.0
|
||||
logger.debug("Saved page status to %s", DATA_FILE)
|
||||
except OSError as e:
|
||||
if tmp_path is not None:
|
||||
@@ -70,6 +150,125 @@ def _save(data: dict) -> None:
|
||||
raise
|
||||
|
||||
|
||||
def _migrate_navigation_schema(data: dict) -> bool:
|
||||
"""Migrate legacy schema to drawers schema when needed."""
|
||||
if "drawers" in data:
|
||||
return False
|
||||
|
||||
data["drawers"] = [drawer.copy() for drawer in DEFAULT_DRAWERS]
|
||||
pages = data.setdefault("pages", [])
|
||||
pages_by_route = {page.get("route"): page for page in pages if page.get("route")}
|
||||
|
||||
for route, assignment in LEGACY_NAV_ASSIGNMENTS.items():
|
||||
page = pages_by_route.get(route)
|
||||
if page is None and route.startswith("/admin/"):
|
||||
page = {
|
||||
"route": route,
|
||||
"name": assignment.get("name", route),
|
||||
"status": assignment.get("status", "dev"),
|
||||
}
|
||||
pages.append(page)
|
||||
pages_by_route[route] = page
|
||||
|
||||
if page is None:
|
||||
continue
|
||||
|
||||
page.setdefault("drawer_id", assignment["drawer_id"])
|
||||
page.setdefault("order", assignment["order"])
|
||||
if assignment.get("name"):
|
||||
page.setdefault("name", assignment["name"])
|
||||
if assignment.get("status"):
|
||||
page.setdefault("status", assignment["status"])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _safe_int(value: object, default: int) -> int:
|
||||
try:
|
||||
return int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_positive_int(value: object, *, field: str) -> int:
|
||||
try:
|
||||
parsed = int(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError(f"{field} must be an integer") from exc
|
||||
|
||||
if parsed < 1:
|
||||
raise ValueError(f"{field} must be >= 1")
|
||||
return parsed
|
||||
|
||||
|
||||
def _drawer_exists(data: dict, drawer_id: str) -> bool:
|
||||
return any(drawer.get("id") == drawer_id for drawer in data.get("drawers", []))
|
||||
|
||||
|
||||
def _apply_page_drawer(data: dict, page: dict, drawer_id: str | None) -> None:
|
||||
if drawer_id in (None, ""):
|
||||
page.pop("drawer_id", None)
|
||||
return
|
||||
|
||||
drawer_id = str(drawer_id)
|
||||
if not _drawer_exists(data, drawer_id):
|
||||
raise ValueError(f"Drawer not found: {drawer_id}")
|
||||
page["drawer_id"] = drawer_id
|
||||
|
||||
|
||||
def _apply_page_order(page: dict, order: object) -> None:
|
||||
if order in (None, ""):
|
||||
page.pop("order", None)
|
||||
return
|
||||
page["order"] = _as_positive_int(order, field="order")
|
||||
|
||||
|
||||
def _slugify_drawer_id(name: str) -> str:
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return slug or "drawer"
|
||||
|
||||
|
||||
def _generate_drawer_id(name: str, existing_ids: set[str]) -> str:
|
||||
base = _slugify_drawer_id(name)
|
||||
candidate = base
|
||||
suffix = 2
|
||||
while candidate in existing_ids:
|
||||
candidate = f"{base}-{suffix}"
|
||||
suffix += 1
|
||||
return candidate
|
||||
|
||||
|
||||
def _route_to_frame_id(route: str) -> str:
|
||||
if route in LEGACY_FRAME_ID_MAP:
|
||||
return LEGACY_FRAME_ID_MAP[route]
|
||||
|
||||
parts = [part for part in re.split(r"[\/_-]+", route.strip("/")) if part]
|
||||
if not parts:
|
||||
return "homeFrame"
|
||||
camel = parts[0].lower() + "".join(part.capitalize() for part in parts[1:])
|
||||
return f"{camel}Frame"
|
||||
|
||||
|
||||
def _sorted_drawers(drawers: list[dict]) -> list[dict]:
|
||||
return sorted(
|
||||
drawers,
|
||||
key=lambda drawer: (
|
||||
_safe_int(drawer.get("order"), 9999),
|
||||
str(drawer.get("name", "")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _sorted_pages(pages: list[dict]) -> list[dict]:
|
||||
return sorted(
|
||||
pages,
|
||||
key=lambda page: (
|
||||
_safe_int(page.get("order"), 9999),
|
||||
str(page.get("name") or page.get("route", "")),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_page_status(route: str) -> str | None:
|
||||
"""Get page status ('released' or 'dev').
|
||||
|
||||
@@ -99,13 +298,21 @@ def is_page_registered(route: str) -> bool:
|
||||
return get_page_status(route) is not None
|
||||
|
||||
|
||||
def set_page_status(route: str, status: str, name: str | None = None) -> 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}")
|
||||
@@ -120,16 +327,26 @@ def set_page_status(route: str, status: str, name: str | None = None) -> None:
|
||||
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
|
||||
pages.append({
|
||||
new_page = {
|
||||
"route": route,
|
||||
"name": name or route,
|
||||
"status": status
|
||||
})
|
||||
"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)
|
||||
|
||||
@@ -138,12 +355,156 @@ def get_all_pages() -> list[dict]:
|
||||
"""Get all page configurations.
|
||||
|
||||
Returns:
|
||||
List of page dicts: [{route, name, status}, ...]
|
||||
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.
|
||||
|
||||
@@ -156,8 +517,9 @@ def is_api_public() -> bool:
|
||||
|
||||
def reload_cache() -> None:
|
||||
"""Force reload of page status from disk."""
|
||||
global _cache
|
||||
global _cache, _cache_mtime
|
||||
with _lock:
|
||||
_cache = None
|
||||
_cache_mtime = 0.0
|
||||
_load()
|
||||
logger.info("Reloaded page status cache")
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
}
|
||||
|
||||
.shell {
|
||||
max-width: 1200px;
|
||||
max-width: 1320px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
@@ -78,8 +78,9 @@
|
||||
.panel {
|
||||
background: white;
|
||||
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;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -88,6 +89,7 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
@@ -95,6 +97,11 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.panel-subtitle {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
@@ -105,9 +112,10 @@
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 14px 20px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #eee;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -140,6 +148,7 @@
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.status-released {
|
||||
@@ -156,6 +165,88 @@
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.order-input {
|
||||
width: 96px;
|
||||
}
|
||||
|
||||
.drawer-create {
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
border: 1px solid #d0d7e3;
|
||||
border-radius: 6px;
|
||||
padding: 6px 10px;
|
||||
background: #fff;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: #94a3b8;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
border-color: #667eea;
|
||||
background: #667eea;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: #5668d8;
|
||||
border-color: #5668d8;
|
||||
}
|
||||
|
||||
.action-btn.danger {
|
||||
border-color: #ef4444;
|
||||
color: #b91c1c;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.action-btn.danger:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
@@ -181,7 +272,7 @@
|
||||
<div class="header">
|
||||
<div class="header-left">
|
||||
<h1>頁面管理</h1>
|
||||
<p>設定頁面存取權限:Released(所有人可見)/ Dev(僅管理員可見)</p>
|
||||
<p>管理頁面狀態、抽屜分類與排序(Released:所有人可見 / Dev:僅管理員可見)</p>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{% if admin_user %}
|
||||
@@ -196,7 +287,46 @@
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>所有頁面</h2>
|
||||
<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>
|
||||
@@ -205,11 +335,13 @@
|
||||
<th>路由</th>
|
||||
<th>名稱</th>
|
||||
<th>狀態</th>
|
||||
<th>抽屜歸屬</th>
|
||||
<th>排序</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pages-tbody">
|
||||
<tr>
|
||||
<td colspan="3" class="loading">載入中...</td>
|
||||
<td colspan="5" class="loading">載入中...</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -222,80 +354,316 @@
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
const tbody = document.getElementById('pages-tbody');
|
||||
const pagesTbody = document.getElementById('pages-tbody');
|
||||
const drawersTbody = document.getElementById('drawers-tbody');
|
||||
const createDrawerBtn = document.getElementById('create-drawer-btn');
|
||||
const newDrawerNameInput = document.getElementById('new-drawer-name');
|
||||
const newDrawerOrderInput = document.getElementById('new-drawer-order');
|
||||
const newDrawerAdminOnlyInput = document.getElementById('new-drawer-admin-only');
|
||||
const csrfToken = document.querySelector('meta[name=\"csrf-token\"]')?.content || '';
|
||||
|
||||
let drawersCache = [];
|
||||
let pagesCache = [];
|
||||
|
||||
function withCsrfHeaders(headers = {}) {
|
||||
return csrfToken ? { ...headers, 'X-CSRF-Token': csrfToken } : headers;
|
||||
}
|
||||
|
||||
async function loadPages() {
|
||||
function escapeHtml(value) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value ?? '';
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function escapeAttr(value) {
|
||||
return escapeHtml(String(value ?? '')).replace(/\"/g, '"');
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response) {
|
||||
try {
|
||||
const response = await fetch('/admin/api/pages');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to load pages');
|
||||
}
|
||||
|
||||
renderPages(data.pages);
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error loading pages:', error);
|
||||
tbody.innerHTML = `<tr><td colspan="3" class="loading">載入失敗: ${error.message}</td></tr>`;
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function renderPages(pages) {
|
||||
if (pages.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="3" class="loading">尚無頁面設定</td></tr>';
|
||||
async function apiFetch(url, options = {}) {
|
||||
const config = { ...options };
|
||||
config.headers = withCsrfHeaders(config.headers || {});
|
||||
const response = await fetch(url, config);
|
||||
const data = await parseJsonResponse(response);
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function drawerOptionsHtml(selectedDrawerId) {
|
||||
const normalized = selectedDrawerId || '';
|
||||
const options = ['<option value="">未分類</option>'];
|
||||
drawersCache.forEach((drawer) => {
|
||||
const selected = drawer.id === normalized ? 'selected' : '';
|
||||
options.push(
|
||||
`<option value="${escapeAttr(drawer.id)}" ${selected}>${escapeHtml(drawer.name)}</option>`
|
||||
);
|
||||
});
|
||||
return options.join('');
|
||||
}
|
||||
|
||||
function renderDrawers(drawers) {
|
||||
if (!drawers || drawers.length === 0) {
|
||||
drawersTbody.innerHTML = '<tr><td colspan="5" class="loading">尚無抽屜</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = pages.map(page => `
|
||||
<tr>
|
||||
<td class="route-cell">${escapeHtml(page.route)}</td>
|
||||
<td>${escapeHtml(page.name)}</td>
|
||||
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>
|
||||
<span class="status-badge status-${page.status}"
|
||||
onclick="toggleStatus('${escapeHtml(page.route)}', '${page.status}')">
|
||||
${page.status === 'released' ? 'Released' : 'Dev'}
|
||||
</span>
|
||||
<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('');
|
||||
}
|
||||
|
||||
async function toggleStatus(route, currentStatus) {
|
||||
const newStatus = currentStatus === 'released' ? 'dev' : 'released';
|
||||
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 {
|
||||
const response = await fetch(`/admin/api/pages${route}`, {
|
||||
method: 'PUT',
|
||||
headers: withCsrfHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ status: newStatus })
|
||||
await apiFetch('/admin/api/drawers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.error || 'Failed to update status');
|
||||
}
|
||||
|
||||
window.MES?.toast?.success?.(`已更新: ${route} → ${newStatus}`);
|
||||
loadPages();
|
||||
window.MES?.toast?.success?.(`已新增抽屜:${name}`);
|
||||
newDrawerNameInput.value = '';
|
||||
newDrawerOrderInput.value = '';
|
||||
newDrawerAdminOnlyInput.checked = false;
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
console.error('Error updating status:', error);
|
||||
window.MES?.toast?.error?.(`更新失敗: ${error.message}`);
|
||||
window.MES?.toast?.error?.(`新增抽屜失敗: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
async function saveDrawerFromRow(row) {
|
||||
const drawerId = row.dataset.drawerId;
|
||||
const name = row.querySelector('.drawer-name-input')?.value.trim() || '';
|
||||
const orderRaw = row.querySelector('.drawer-order-input')?.value.trim() || '';
|
||||
const adminOnly = row.querySelector('.drawer-admin-only-input')?.checked || false;
|
||||
|
||||
if (!drawerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = { name, admin_only: adminOnly };
|
||||
if (orderRaw !== '') {
|
||||
payload.order = Number(orderRaw);
|
||||
}
|
||||
|
||||
try {
|
||||
await apiFetch(`/admin/api/drawers/${encodeURIComponent(drawerId)}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
window.MES?.toast?.success?.(`已更新抽屜:${drawerId}`);
|
||||
await refreshAll();
|
||||
} catch (error) {
|
||||
window.MES?.toast?.error?.(`更新抽屜失敗: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load pages on page load
|
||||
loadPages();
|
||||
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 %}
|
||||
|
||||
@@ -342,8 +342,6 @@
|
||||
<div class="admin-status">
|
||||
{% if is_admin %}
|
||||
<span class="admin-name">{{ admin_user.displayName }}</span>
|
||||
<a href="{{ url_for('admin.pages') }}">頁面管理</a>
|
||||
<a href="{{ url_for('admin.performance') }}">效能監控</a>
|
||||
<a href="{{ url_for('auth.logout') }}">登出</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.login') }}">管理員登入</a>
|
||||
@@ -354,69 +352,49 @@
|
||||
|
||||
<div class="main-layout">
|
||||
<nav class="sidebar">
|
||||
<div class="sidebar-group-title">報表類</div>
|
||||
{% if can_view_page('/wip-overview') %}
|
||||
<button class="sidebar-item" data-target="wipOverviewFrame">WIP 即時概況</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/resource') %}
|
||||
<button class="sidebar-item" data-target="resourceFrame">設備即時概況</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/resource-history') %}
|
||||
<button class="sidebar-item" data-target="resourceHistoryFrame">設備歷史績效</button>
|
||||
{% endif %}
|
||||
|
||||
<div class="sidebar-group-title">查詢類</div>
|
||||
{% if can_view_page('/tables') %}
|
||||
<button class="sidebar-item" data-target="tableFrame">數據表查詢工具</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/excel-query') %}
|
||||
<button class="sidebar-item" data-target="excelQueryFrame">Excel 批次查詢</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/job-query') %}
|
||||
<button class="sidebar-item" data-target="jobQueryFrame">設備維修查詢</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/query-tool') %}
|
||||
<button class="sidebar-item" data-target="queryToolFrame">批次追蹤工具</button>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tmtt-defect') %}
|
||||
<button class="sidebar-item" data-target="tmttDefectFrame">TMTT不良分析</button>
|
||||
{% endif %}
|
||||
|
||||
<div class="sidebar-group-title">開發工具</div>
|
||||
{% if is_admin %}
|
||||
<button class="sidebar-item" data-target="toolFrame" data-tool-src="/admin/pages">頁面管理</button>
|
||||
<button class="sidebar-item" data-target="toolFrame" data-tool-src="/admin/performance">效能監控</button>
|
||||
{% else %}
|
||||
{% for drawer in drawers | default([]) %}
|
||||
{% if not drawer.admin_only or is_admin %}
|
||||
{% set nav_ns = namespace(has_visible_pages=false) %}
|
||||
{% for page in drawer.pages %}
|
||||
{% if can_view_page(page.route) %}
|
||||
{% set nav_ns.has_visible_pages = true %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if nav_ns.has_visible_pages %}
|
||||
<div class="sidebar-group-title">{{ drawer.name }}</div>
|
||||
{% for page in drawer.pages %}
|
||||
{% if can_view_page(page.route) %}
|
||||
<button
|
||||
class="sidebar-item"
|
||||
data-target="{{ page.frame_id }}"
|
||||
{% if page.tool_src %}data-tool-src="{{ page.tool_src }}"{% endif %}
|
||||
>{{ page.name }}</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if not is_admin %}
|
||||
<a class="sidebar-item" href="{{ url_for('auth.login') }}">管理員登入</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<div class="panel">
|
||||
{% if can_view_page('/wip-overview') %}
|
||||
<iframe id="wipOverviewFrame" data-src="/wip-overview" title="WIP 即時概況"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/resource') %}
|
||||
<iframe id="resourceFrame" data-src="/resource" title="設備即時概況"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tables') %}
|
||||
<iframe id="tableFrame" data-src="/tables" title="數據表查詢工具"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/excel-query') %}
|
||||
<iframe id="excelQueryFrame" data-src="/excel-query" title="Excel 批次查詢"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/resource-history') %}
|
||||
<iframe id="resourceHistoryFrame" data-src="/resource-history" title="設備歷史績效"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/job-query') %}
|
||||
<iframe id="jobQueryFrame" data-src="/job-query" title="設備維修查詢"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/query-tool') %}
|
||||
<iframe id="queryToolFrame" data-src="/query-tool" title="批次追蹤工具"></iframe>
|
||||
{% endif %}
|
||||
{% if can_view_page('/tmtt-defect') %}
|
||||
<iframe id="tmttDefectFrame" data-src="/tmtt-defect" title="TMTT不良分析"></iframe>
|
||||
{% endif %}
|
||||
{% if is_admin %}
|
||||
{% set frame_ns = namespace(has_tool_pages=false) %}
|
||||
{% for drawer in drawers | default([]) %}
|
||||
{% if not drawer.admin_only or is_admin %}
|
||||
{% for page in drawer.pages %}
|
||||
{% if can_view_page(page.route) %}
|
||||
{% if page.tool_src %}
|
||||
{% set frame_ns.has_tool_pages = true %}
|
||||
{% else %}
|
||||
<iframe id="{{ page.frame_id }}" data-src="{{ page.route }}" title="{{ page.name }}"></iframe>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if is_admin and frame_ns.has_tool_pages %}
|
||||
<iframe id="toolFrame" title="開發工具"></iframe>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -432,7 +410,6 @@
|
||||
<script>
|
||||
const sidebarItems = document.querySelectorAll('.sidebar-item[data-target]');
|
||||
const frames = document.querySelectorAll('iframe');
|
||||
const toolFrame = document.getElementById('toolFrame');
|
||||
|
||||
function setFrameHeight() {
|
||||
const headerHeight = document.querySelector('.header').offsetHeight;
|
||||
@@ -476,7 +453,7 @@
|
||||
|
||||
// Auto-activate first available item
|
||||
if (sidebarItems.length > 0) {
|
||||
activateTab(sidebarItems[0].dataset.target);
|
||||
activateTab(sidebarItems[0].dataset.target, sidebarItems[0].dataset.toolSrc || null);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', setFrameHeight);
|
||||
|
||||
@@ -301,6 +301,48 @@ class TestAdminAPI:
|
||||
assert data["success"] is True
|
||||
assert "pages" in data
|
||||
|
||||
def test_get_drawers_without_login(self, client):
|
||||
"""Test drawer API requires login."""
|
||||
response = client.get("/admin/api/drawers", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_mutate_drawers_without_login(self, client):
|
||||
"""Test drawer mutations require login."""
|
||||
response = client.post(
|
||||
"/admin/api/drawers",
|
||||
data=json.dumps({"name": "Unauthorized Drawer"}),
|
||||
content_type="application/json",
|
||||
follow_redirects=False,
|
||||
)
|
||||
assert response.status_code in (302, 401)
|
||||
|
||||
response = client.delete("/admin/api/drawers/reports", follow_redirects=False)
|
||||
assert response.status_code == 302
|
||||
|
||||
def test_get_drawers_with_login(self, client):
|
||||
"""Test list drawers API with login."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"username": "admin"}
|
||||
|
||||
response = client.get("/admin/api/drawers")
|
||||
assert response.status_code == 200
|
||||
data = json.loads(response.data)
|
||||
assert data["success"] is True
|
||||
assert "drawers" in data
|
||||
assert any(drawer["id"] == "reports" for drawer in data["drawers"])
|
||||
|
||||
def test_create_drawer_duplicate_name_conflict(self, client):
|
||||
"""Test creating duplicate drawer name returns 409."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"username": "admin"}
|
||||
|
||||
response = client.post(
|
||||
"/admin/api/drawers",
|
||||
data=json.dumps({"name": "報表類", "order": 99}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_update_page_status(self, client, temp_page_status):
|
||||
"""Test updating page status via API."""
|
||||
with client.session_transaction() as sess:
|
||||
@@ -333,6 +375,44 @@ class TestAdminAPI:
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_update_page_drawer_assignment(self, client):
|
||||
"""Test assigning page drawer via page update API."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"username": "admin"}
|
||||
|
||||
response = client.put(
|
||||
"/admin/api/pages/wip-overview",
|
||||
data=json.dumps({"drawer_id": "queries", "order": 3}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
page_registry._cache = None
|
||||
pages = page_registry.get_all_pages()
|
||||
page = next(item for item in pages if item["route"] == "/wip-overview")
|
||||
assert page["drawer_id"] == "queries"
|
||||
assert page["order"] == 3
|
||||
|
||||
def test_update_page_invalid_drawer_assignment(self, client):
|
||||
"""Test assigning a non-existent drawer returns bad request."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"username": "admin"}
|
||||
|
||||
response = client.put(
|
||||
"/admin/api/pages/wip-overview",
|
||||
data=json.dumps({"drawer_id": "missing-drawer"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_delete_drawer_with_assigned_pages_conflict(self, client):
|
||||
"""Test deleting a non-empty drawer returns conflict."""
|
||||
with client.session_transaction() as sess:
|
||||
sess["admin"] = {"username": "admin"}
|
||||
|
||||
response = client.delete("/admin/api/drawers/reports")
|
||||
assert response.status_code == 409
|
||||
|
||||
|
||||
class TestContextProcessor:
|
||||
"""Tests for template context processor."""
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"""Unit tests for page_registry module."""
|
||||
|
||||
import json
|
||||
import pytest
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import sys
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'src'))
|
||||
|
||||
from mes_dashboard.services import page_registry
|
||||
@@ -16,14 +15,16 @@ from mes_dashboard.services import page_registry
|
||||
|
||||
@pytest.fixture
|
||||
def temp_data_file(tmp_path):
|
||||
"""Create a temporary data file for testing."""
|
||||
"""Create a temporary legacy data file for migration tests."""
|
||||
data_file = tmp_path / "page_status.json"
|
||||
initial_data = {
|
||||
"pages": [
|
||||
{"route": "/", "name": "Home", "status": "released"},
|
||||
{"route": "/wip-overview", "name": "WIP Overview", "status": "released"},
|
||||
{"route": "/tables", "name": "Tables", "status": "dev"},
|
||||
{"route": "/dev-page", "name": "Dev Page", "status": "dev"},
|
||||
],
|
||||
"api_public": True
|
||||
"api_public": True,
|
||||
}
|
||||
data_file.write_text(json.dumps(initial_data), encoding="utf-8")
|
||||
return data_file
|
||||
@@ -36,97 +37,159 @@ def mock_registry(temp_data_file):
|
||||
original_cache = page_registry._cache
|
||||
|
||||
page_registry.DATA_FILE = temp_data_file
|
||||
page_registry._cache = None # Clear cache
|
||||
page_registry._cache = None
|
||||
|
||||
yield temp_data_file
|
||||
|
||||
# Restore original
|
||||
page_registry.DATA_FILE = original_data_file
|
||||
page_registry._cache = original_cache
|
||||
|
||||
|
||||
class TestSchemaMigration:
|
||||
"""Tests for first-run drawers migration."""
|
||||
|
||||
def test_migration_adds_drawers_and_assignments(self, mock_registry):
|
||||
drawers = page_registry.get_all_drawers()
|
||||
drawer_ids = [drawer["id"] for drawer in drawers]
|
||||
assert drawer_ids == ["reports", "queries", "dev-tools"]
|
||||
|
||||
pages = page_registry.get_all_pages()
|
||||
page_by_route = {page["route"]: page for page in pages}
|
||||
|
||||
assert page_by_route["/wip-overview"]["drawer_id"] == "reports"
|
||||
assert page_by_route["/wip-overview"]["order"] == 1
|
||||
assert page_by_route["/tables"]["drawer_id"] == "queries"
|
||||
assert page_by_route["/tables"]["order"] == 1
|
||||
|
||||
# Admin tools should be backfilled from legacy hardcoded sidebar mapping.
|
||||
assert page_by_route["/admin/pages"]["drawer_id"] == "dev-tools"
|
||||
assert page_by_route["/admin/performance"]["drawer_id"] == "dev-tools"
|
||||
|
||||
def test_subsequent_load_does_not_reset_drawers(self, mock_registry):
|
||||
page_registry.get_all_drawers()
|
||||
page_registry.create_drawer("custom", order=10, admin_only=False)
|
||||
|
||||
page_registry.reload_cache()
|
||||
drawers = page_registry.get_all_drawers()
|
||||
assert any(drawer["id"] == "custom" for drawer in drawers)
|
||||
|
||||
|
||||
class TestGetPageStatus:
|
||||
"""Tests for get_page_status function."""
|
||||
|
||||
def test_get_released_page_status(self, mock_registry):
|
||||
"""Test getting status of released page."""
|
||||
status = page_registry.get_page_status("/")
|
||||
assert status == "released"
|
||||
|
||||
def test_get_dev_page_status(self, mock_registry):
|
||||
"""Test getting status of dev page."""
|
||||
status = page_registry.get_page_status("/dev-page")
|
||||
assert status == "dev"
|
||||
|
||||
def test_get_unregistered_page_status(self, mock_registry):
|
||||
"""Test getting status of unregistered page returns None."""
|
||||
status = page_registry.get_page_status("/not-registered")
|
||||
assert status is None
|
||||
|
||||
|
||||
class TestIsPageRegistered:
|
||||
"""Tests for is_page_registered function."""
|
||||
|
||||
def test_registered_page(self, mock_registry):
|
||||
"""Test checking registered page."""
|
||||
assert page_registry.is_page_registered("/") is True
|
||||
|
||||
def test_unregistered_page(self, mock_registry):
|
||||
"""Test checking unregistered page."""
|
||||
assert page_registry.is_page_registered("/not-here") is False
|
||||
|
||||
|
||||
class TestSetPageStatus:
|
||||
"""Tests for set_page_status function."""
|
||||
|
||||
def test_update_existing_page(self, mock_registry):
|
||||
"""Test updating existing page status."""
|
||||
page_registry.set_page_status("/", "dev")
|
||||
assert page_registry.get_page_status("/") == "dev"
|
||||
def test_update_existing_page_status(self, mock_registry):
|
||||
page_registry.set_page_status("/dev-page", "released")
|
||||
assert page_registry.get_page_status("/dev-page") == "released"
|
||||
|
||||
def test_add_new_page(self, mock_registry):
|
||||
"""Test adding new page."""
|
||||
page_registry.set_page_status("/new-page", "released", "New Page")
|
||||
assert page_registry.get_page_status("/new-page") == "released"
|
||||
def test_set_page_drawer_and_order(self, mock_registry):
|
||||
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
|
||||
pages = page_registry.get_all_pages()
|
||||
dev_page = next(page for page in pages if page["route"] == "/dev-page")
|
||||
assert dev_page["drawer_id"] == "queries"
|
||||
assert dev_page["order"] == 9
|
||||
|
||||
def test_clear_page_drawer_and_order(self, mock_registry):
|
||||
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=9)
|
||||
page_registry.set_page_status("/dev-page", "dev", drawer_id=None, order=None)
|
||||
pages = page_registry.get_all_pages()
|
||||
dev_page = next(page for page in pages if page["route"] == "/dev-page")
|
||||
assert "drawer_id" not in dev_page
|
||||
assert "order" not in dev_page
|
||||
|
||||
def test_set_invalid_drawer_raises_error(self, mock_registry):
|
||||
with pytest.raises(ValueError, match="Drawer not found"):
|
||||
page_registry.set_page_status("/dev-page", "dev", drawer_id="not-exists")
|
||||
|
||||
def test_invalid_status_raises_error(self, mock_registry):
|
||||
"""Test setting invalid status raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Invalid status"):
|
||||
page_registry.set_page_status("/", "invalid")
|
||||
|
||||
def test_update_page_name(self, mock_registry):
|
||||
"""Test updating page name."""
|
||||
page_registry.set_page_status("/", "released", "New Name")
|
||||
pages = page_registry.get_all_pages()
|
||||
home = next(p for p in pages if p["route"] == "/")
|
||||
assert home["name"] == "New Name"
|
||||
|
||||
class TestDrawerCrud:
|
||||
"""Tests for drawer CRUD functions."""
|
||||
|
||||
def test_create_drawer(self, mock_registry):
|
||||
created = page_registry.create_drawer("Custom Drawer", order=4, admin_only=True)
|
||||
assert created["name"] == "Custom Drawer"
|
||||
assert created["order"] == 4
|
||||
assert created["admin_only"] is True
|
||||
|
||||
def test_create_duplicate_drawer_name_raises_conflict(self, mock_registry):
|
||||
with pytest.raises(page_registry.DrawerConflictError):
|
||||
page_registry.create_drawer("報表類", order=4)
|
||||
|
||||
def test_update_drawer(self, mock_registry):
|
||||
updated = page_registry.update_drawer(
|
||||
"reports",
|
||||
name="報表中心",
|
||||
order=7,
|
||||
admin_only=True,
|
||||
)
|
||||
assert updated["name"] == "報表中心"
|
||||
assert updated["order"] == 7
|
||||
assert updated["admin_only"] is True
|
||||
|
||||
def test_delete_drawer_rejects_assigned_pages(self, mock_registry):
|
||||
with pytest.raises(page_registry.DrawerConflictError, match="assigned pages"):
|
||||
page_registry.delete_drawer("reports")
|
||||
|
||||
def test_delete_empty_drawer(self, mock_registry):
|
||||
created = page_registry.create_drawer("Temporary", order=8)
|
||||
page_registry.delete_drawer(created["id"])
|
||||
drawers = page_registry.get_all_drawers()
|
||||
assert all(drawer["id"] != created["id"] for drawer in drawers)
|
||||
|
||||
|
||||
class TestGetAllPages:
|
||||
"""Tests for get_all_pages function."""
|
||||
class TestNavigationConfig:
|
||||
"""Tests for navigation config generation."""
|
||||
|
||||
def test_get_all_pages(self, mock_registry):
|
||||
"""Test getting all pages."""
|
||||
pages = page_registry.get_all_pages()
|
||||
assert len(pages) == 2
|
||||
routes = [p["route"] for p in pages]
|
||||
assert "/" in routes
|
||||
assert "/dev-page" in routes
|
||||
def test_navigation_config_grouped_and_sorted(self, mock_registry):
|
||||
page_registry.set_page_status("/dev-page", "dev", drawer_id="queries", order=5)
|
||||
nav = page_registry.get_navigation_config()
|
||||
|
||||
assert [drawer["id"] for drawer in nav] == ["reports", "queries", "dev-tools"]
|
||||
|
||||
reports = next(drawer for drawer in nav if drawer["id"] == "reports")
|
||||
assert [page["route"] for page in reports["pages"]] == ["/wip-overview"]
|
||||
assert reports["pages"][0]["frame_id"] == "wipOverviewFrame"
|
||||
assert reports["pages"][0]["tool_src"] is None
|
||||
|
||||
queries = next(drawer for drawer in nav if drawer["id"] == "queries")
|
||||
assert queries["pages"][0]["route"] == "/tables"
|
||||
assert queries["pages"][-1]["route"] == "/dev-page"
|
||||
|
||||
dev_tools = next(drawer for drawer in nav if drawer["id"] == "dev-tools")
|
||||
assert all(page["frame_id"] == "toolFrame" for page in dev_tools["pages"])
|
||||
assert dev_tools["pages"][0]["tool_src"] == "/admin/pages"
|
||||
|
||||
|
||||
class TestIsApiPublic:
|
||||
"""Tests for is_api_public function."""
|
||||
|
||||
def test_api_public_true(self, mock_registry):
|
||||
"""Test API public flag when true."""
|
||||
assert page_registry.is_api_public() is True
|
||||
|
||||
def test_api_public_false(self, mock_registry, temp_data_file):
|
||||
"""Test API public flag when false."""
|
||||
data = json.loads(temp_data_file.read_text())
|
||||
data["api_public"] = False
|
||||
temp_data_file.write_text(json.dumps(data))
|
||||
page_registry._cache = None # Clear cache
|
||||
page_registry._cache = None
|
||||
|
||||
assert page_registry.is_api_public() is False
|
||||
|
||||
@@ -135,19 +198,14 @@ class TestReloadCache:
|
||||
"""Tests for reload_cache function."""
|
||||
|
||||
def test_reload_cache(self, mock_registry, temp_data_file):
|
||||
"""Test reloading cache from disk."""
|
||||
# First load
|
||||
assert page_registry.get_page_status("/") == "released"
|
||||
|
||||
# Modify file directly
|
||||
data = json.loads(temp_data_file.read_text())
|
||||
data["pages"][0]["status"] = "dev"
|
||||
home = next(page for page in data["pages"] if page["route"] == "/")
|
||||
home["status"] = "dev"
|
||||
temp_data_file.write_text(json.dumps(data))
|
||||
|
||||
# Cache still has old value
|
||||
assert page_registry.get_page_status("/") == "released"
|
||||
|
||||
# After reload, should have new value
|
||||
page_registry.reload_cache()
|
||||
assert page_registry.get_page_status("/") == "dev"
|
||||
|
||||
@@ -156,39 +214,30 @@ class TestConcurrency:
|
||||
"""Tests for thread safety."""
|
||||
|
||||
def test_concurrent_access(self, mock_registry):
|
||||
"""Test concurrent read/write operations."""
|
||||
import threading
|
||||
|
||||
errors = []
|
||||
|
||||
def reader():
|
||||
try:
|
||||
for _ in range(100):
|
||||
page_registry.get_page_status("/")
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
errors.append(exc)
|
||||
|
||||
def writer():
|
||||
try:
|
||||
for i in range(100):
|
||||
status = "released" if i % 2 == 0 else "dev"
|
||||
for index in range(100):
|
||||
status = "released" if index % 2 == 0 else "dev"
|
||||
page_registry.set_page_status("/", status)
|
||||
except Exception as e:
|
||||
errors.append(e)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
errors.append(exc)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=reader) for _ in range(3)
|
||||
] + [
|
||||
threads = [threading.Thread(target=reader) for _ in range(3)] + [
|
||||
threading.Thread(target=writer) for _ in range(2)
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
for t in threads:
|
||||
t.join()
|
||||
for thread in threads:
|
||||
thread.start()
|
||||
for thread in threads:
|
||||
thread.join()
|
||||
|
||||
assert len(errors) == 0, f"Errors occurred: {errors}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
|
||||
@@ -100,6 +100,108 @@ class TestTemplateIntegration(unittest.TestCase):
|
||||
self.assertIn('mes-toast-container', html)
|
||||
|
||||
|
||||
class TestPortalDynamicDrawerRendering(unittest.TestCase):
|
||||
"""Test dynamic portal drawer rendering."""
|
||||
|
||||
def setUp(self):
|
||||
db._ENGINE = None
|
||||
self.app = create_app('testing')
|
||||
self.app.config['TESTING'] = True
|
||||
self.client = self.app.test_client()
|
||||
_login_as_admin(self.client)
|
||||
|
||||
def test_portal_uses_navigation_config_for_sidebar_and_iframes(self):
|
||||
drawers = [
|
||||
{
|
||||
"id": "custom",
|
||||
"name": "自訂分類",
|
||||
"order": 1,
|
||||
"admin_only": False,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "自訂首頁",
|
||||
"status": "released",
|
||||
"order": 1,
|
||||
"frame_id": "customFrame",
|
||||
"tool_src": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"name": "開發工具",
|
||||
"order": 2,
|
||||
"admin_only": True,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"order": 1,
|
||||
"frame_id": "toolFrame",
|
||||
"tool_src": "/admin/pages",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
with patch("mes_dashboard.app.get_navigation_config", return_value=drawers):
|
||||
response = self.client.get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode("utf-8")
|
||||
self.assertIn("自訂分類", html)
|
||||
self.assertIn('data-target="customFrame"', html)
|
||||
self.assertIn('id="customFrame"', html)
|
||||
self.assertIn('data-tool-src="/admin/pages"', html)
|
||||
self.assertIn('id="toolFrame"', html)
|
||||
|
||||
def test_portal_hides_admin_only_drawer_for_non_admin(self):
|
||||
client = self.app.test_client()
|
||||
drawers = [
|
||||
{
|
||||
"id": "custom",
|
||||
"name": "自訂分類",
|
||||
"order": 1,
|
||||
"admin_only": False,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/wip-overview",
|
||||
"name": "自訂首頁",
|
||||
"status": "released",
|
||||
"order": 1,
|
||||
"frame_id": "customFrame",
|
||||
"tool_src": None,
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dev-tools",
|
||||
"name": "開發工具",
|
||||
"order": 2,
|
||||
"admin_only": True,
|
||||
"pages": [
|
||||
{
|
||||
"route": "/admin/pages",
|
||||
"name": "頁面管理",
|
||||
"status": "dev",
|
||||
"order": 1,
|
||||
"frame_id": "toolFrame",
|
||||
"tool_src": "/admin/pages",
|
||||
}
|
||||
],
|
||||
},
|
||||
]
|
||||
with patch("mes_dashboard.app.get_navigation_config", return_value=drawers):
|
||||
response = client.get("/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
html = response.data.decode("utf-8")
|
||||
self.assertIn("自訂分類", html)
|
||||
self.assertNotIn("開發工具", html)
|
||||
self.assertNotIn('data-tool-src="/admin/pages"', html)
|
||||
|
||||
|
||||
class TestToastCSSIntegration(unittest.TestCase):
|
||||
"""Test that Toast CSS styles are included in pages."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user