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

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

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

View File

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

View File

@@ -0,0 +1,112 @@
## Context
Portal sidebar 導航結構目前寫死在 `portal.html` (lines 356-421),包含三個固定分類與十個固定頁面按鈕。頁面的 `released`/`dev` 狀態已經由 `data/page_status.json` + `page_registry.py` 動態管理,但抽屜分類與頁面歸屬仍需改程式碼。
現有架構:
- `data/page_status.json`: 頁面狀態持久化JSON file, atomic write, thread-safe cache
- `page_registry.py`: 服務層CRUD + cache + lock
- `admin_routes.py` (lines 629-667): Admin APIGET/PUT pages
- `admin/pages.html`: Admin UI頁面狀態切換表格
- `portal.html`: Jinja2 模板,`{% if can_view_page() %}` 控制可見性
關鍵約束:
- 專案使用 Jinja2 shell + Vite JS 混合架構,所有 11 個頁面一致
- 無資料庫,所有配置用 JSON file 持久化
- 需要向下相容:現有 `page_status.json` 的 page entries 不可遺失
## Goals / Non-Goals
**Goals:**
- Admin 可透過 UI 對抽屜進行 CRUD新增、改名、刪除、排序
- Admin 可透過 UI 指定頁面歸屬的抽屜與排序
- Portal sidebar 根據 JSON 配置動態渲染Jinja2 for loop
- 向下相容:首次載入時自動從現有 hardcoded 結構產生初始配置
- 為未來全面 Vite SPA 化鋪路API 設計可直接被前端 fetch 使用
**Non-Goals:**
- 不做全面 Vite SPA 化(本次只做 Jinja2 動態渲染)
- 不做頁面路由的動態新增(頁面路由仍在 `app.py` 中定義)
- 不改變 `released`/`dev` 狀態的邏輯
- 不做拖拉排序(使用上下箭頭或數字排序即可)
- 不做多使用者即時同步Admin 改完,其他使用者刷新頁面即可看到)
## Decisions
### 1. 資料結構:擴展 `page_status.json`
**選擇**: 在現有 JSON 中新增 `drawers` 頂層欄位,並在 page 中加入 `drawer_id``order`
```json
{
"drawers": [
{ "id": "reports", "name": "報表類", "order": 1 },
{ "id": "queries", "name": "查詢類", "order": 2 },
{ "id": "dev-tools", "name": "開發工具", "order": 3, "admin_only": true }
],
"pages": [
{
"route": "/wip-overview",
"name": "WIP 即時概況",
"status": "released",
"drawer_id": "reports",
"order": 1
}
],
"api_public": true
}
```
**替代方案**: 獨立 `navigation.json` 檔案。
**放棄原因**: 頁面狀態和歸屬是同一份資料的不同面向,拆成兩個檔案增加同步複雜度。現有的 atomic write + lock 機制可以直接沿用。
### 2. 向下相容:自動遷移策略
**選擇**: `page_registry.py``_load()` 函式在讀取時檢測是否存在 `drawers` 欄位。若不存在,自動注入預設的三個抽屜定義並根據目前 portal.html 的 hardcoded 映射填充 `drawer_id`。遷移後立即 `_save()` 持久化。
**理由**: 零手動操作部署。首次啟動即完成遷移。
### 3. iframe 策略:維持現行邏輯
**選擇**: 一般頁面每個獨立 iframeadmin 工具頁面共用 `toolFrame`。動態渲染時根據 `drawer.admin_only` 判斷是否使用共用 iframe。
**理由**: 最小變動。iframe 行為和 `portal.js``activateTab()` 邏輯不需改動。
**具體做法**: 每個 page 的 iframe id 由 route 推導(如 `/wip-overview``wipOverviewFrame``admin_only` 抽屜下的頁面共用 `toolFrame` 並使用 `data-tool-src` 切換。
### 4. Admin API 設計
**選擇**: 在現有 `admin_routes.py` 中擴展,新增 drawer endpoints。
| Endpoint | Method | 用途 |
|---|---|---|
| `GET /admin/api/drawers` | GET | 取得所有抽屜 |
| `POST /admin/api/drawers` | POST | 新增抽屜 |
| `PUT /admin/api/drawers/<id>` | PUT | 更新抽屜(改名、排序) |
| `DELETE /admin/api/drawers/<id>` | DELETE | 刪除抽屜(需先移走其下頁面) |
| `PUT /admin/api/pages/<route>` | PUT | 擴展現有 endpoint支援 `drawer_id``order` |
**替代方案**: RESTful nested resource `/admin/api/drawers/<id>/pages`
**放棄原因**: 頁面已經有獨立的 PUT endpoint加上 `drawer_id` 欄位更簡單。
### 5. Admin UI擴展 `/admin/pages`
**選擇**: 在現有 `admin/pages.html` 上方加入抽屜管理區塊,下方頁面列表加入抽屜歸屬下拉選單和排序控制。
**理由**: 使用者已經知道去哪裡管理頁面,擴展比新頁面更自然。
### 6. Portal 模板動態化
**選擇**: `app.py` 的 portal route 讀取 drawers + pages 配置,組裝成結構化資料傳入 Jinja2 context。`portal.html``{% for %}` 渲染 sidebar 和 iframes。
**理由**: 維持現有 Jinja2 shell 架構一致性,`can_view_page()` server-side 過濾不需改動。
## Risks / Trade-offs
- **[Risk] JSON 並發寫入** → 現有 `_lock` + atomic write 已處理。單行程 Flask 部署下無問題。如果未來 multi-worker 部署,需考慮 file lock但這是既有風險非本次引入
- **[Risk] 刪除抽屜時頁面孤立** → API 層強制檢查:抽屜下仍有頁面時禁止刪除,回傳 409 Conflict。
- **[Risk] 首次遷移時 hardcoded 映射不正確** → 遷移邏輯使用明確的 route-to-drawer 映射表,與現有 portal.html 一一對應。
- **[Trade-off] 未歸屬抽屜的頁面不會出現在 sidebar** → 這是刻意設計。子頁面(如 `/wip-detail`, `/hold-detail`)不需出現在 sidebar 中,它們沒有 `drawer_id` 即可。
## Open Questions
- 無。探索階段已充分討論並確認方向。

View File

@@ -0,0 +1,30 @@
## Why
Portal sidebar 的抽屜分類(報表類/查詢類/開發工具)與頁面歸屬目前寫死在 `portal.html` Jinja2 模板中任何導航結構的變更都需要改程式碼並重新部署。Admin 應能透過現有的「頁面管理」UI 動態管理抽屜與頁面分配,無需開發介入。
## What Changes
- 擴展 `data/page_status.json` 資料結構,加入 `drawers` 陣列定義抽屜(名稱、排序、可見性),並在每個 page 中加入 `drawer_id``order` 欄位
- 擴展 `page_registry.py` 服務層,新增 drawer CRUD 函式與 page-to-drawer 指派函式
- 新增 admin API endpointsdrawer 的增刪改查、頁面的抽屜指派與排序
- **BREAKING**: `portal.html` sidebar 從寫死 HTML 改為 `{% for drawer in drawers %}` 動態渲染
- 擴展現有 `/admin/pages` UI加入抽屜管理區塊與頁面拖拉/下拉分配功能
- 頁面的 `released`/`dev` 狀態邏輯與 `can_view_page()` 權限檢查保持不變
## Capabilities
### New Capabilities
- `drawer-management`: Admin 可透過 API 與 UI 對抽屜進行 CRUD 操作(新增、刪除、改名、排序)
- `page-drawer-assignment`: Admin 可透過 API 與 UI 將頁面指派到不同抽屜,並控制頁面在抽屜內的排序
### Modified Capabilities
- `portal-drawer-navigation`: 導航結構從寫死改為從 JSON 配置動態渲染,抽屜分類與頁面歸屬由資料驅動
## Impact
- **資料層**: `data/page_status.json` 結構擴展(向下相容,新增欄位有預設值)
- **服務層**: `page_registry.py` 新增 drawer 相關函式
- **路由層**: `admin_routes.py` 新增 drawer API endpoints
- **模板層**: `portal.html` sidebar 區塊重寫為動態渲染
- **前端層**: `admin/pages.html` UI 擴展
- **無影響**: 各頁面本身的路由、模板、Vite 模組均不需變更

View File

@@ -0,0 +1,51 @@
## ADDED Requirements
### Requirement: Admin SHALL be able to create drawers
The system SHALL allow admin users to create new navigation drawers via API, specifying a name, an order value, and an optional `admin_only` flag.
#### Scenario: Create a new drawer
- **WHEN** admin sends POST `/admin/api/drawers` with `{"name": "自訂分類", "order": 4}`
- **THEN** the system SHALL create a new drawer with a generated kebab-case id and persist it to `page_status.json`
#### Scenario: Create drawer with duplicate name
- **WHEN** admin sends POST `/admin/api/drawers` with a name that already exists
- **THEN** the system SHALL return 409 Conflict with an error message
### Requirement: Admin SHALL be able to rename drawers
The system SHALL allow admin users to update a drawer's name via API.
#### Scenario: Rename a drawer
- **WHEN** admin sends PUT `/admin/api/drawers/<id>` with `{"name": "新名稱"}`
- **THEN** the system SHALL update the drawer name and persist the change
### Requirement: Admin SHALL be able to reorder drawers
The system SHALL allow admin users to change a drawer's sort order via API.
#### Scenario: Change drawer order
- **WHEN** admin sends PUT `/admin/api/drawers/<id>` with `{"order": 2}`
- **THEN** the system SHALL update the drawer order and the sidebar SHALL reflect the new order on next page load
### Requirement: Admin SHALL be able to delete empty drawers
The system SHALL allow admin users to delete a drawer only when no pages are assigned to it.
#### Scenario: Delete an empty drawer
- **WHEN** admin sends DELETE `/admin/api/drawers/<id>` and the drawer has no assigned pages
- **THEN** the system SHALL remove the drawer from the configuration
#### Scenario: Attempt to delete a drawer with assigned pages
- **WHEN** admin sends DELETE `/admin/api/drawers/<id>` and the drawer still has assigned pages
- **THEN** the system SHALL return 409 Conflict with an error listing the assigned pages
### Requirement: Admin SHALL be able to list all drawers
The system SHALL provide an API to retrieve all drawers with their metadata.
#### Scenario: List all drawers
- **WHEN** admin sends GET `/admin/api/drawers`
- **THEN** the system SHALL return all drawers sorted by their `order` field
### Requirement: Drawer management SHALL be accessible from the admin UI
The existing `/admin/pages` page SHALL include a drawer management section where admin can create, rename, reorder, and delete drawers.
#### Scenario: Admin opens page management
- **WHEN** admin navigates to the page management UI
- **THEN** the UI SHALL display a drawer list with controls for add, rename, reorder, and delete

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Admin SHALL be able to assign a page to a drawer
The system SHALL allow admin users to assign a page to a specific drawer by setting its `drawer_id` via the existing page update API.
#### Scenario: Assign page to a drawer
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with `{"drawer_id": "queries"}`
- **THEN** the page SHALL be associated with the specified drawer
#### Scenario: Assign page to non-existent drawer
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with a `drawer_id` that does not exist
- **THEN** the system SHALL return 400 Bad Request with an error message
### Requirement: Admin SHALL be able to set page order within a drawer
The system SHALL allow admin users to control the display order of pages within a drawer.
#### Scenario: Set page order
- **WHEN** admin sends PUT `/admin/api/pages/<route>` with `{"order": 3}`
- **THEN** the page SHALL be displayed at position 3 within its drawer on next portal load
### Requirement: Pages without a drawer assignment SHALL NOT appear in the sidebar
Pages that have no `drawer_id` (e.g., sub-pages like `/wip-detail`, `/hold-detail`) SHALL NOT be rendered in the portal sidebar, but SHALL remain accessible via their direct routes.
#### Scenario: Sub-page without drawer assignment
- **WHEN** a page exists in `page_status.json` without a `drawer_id`
- **THEN** the page SHALL NOT appear in any sidebar drawer
- **THEN** the page SHALL still be accessible via its direct URL
### Requirement: Page drawer assignment SHALL be configurable from the admin UI
The existing `/admin/pages` page table SHALL include a drawer assignment dropdown and order controls for each page.
#### Scenario: Admin changes page drawer via UI
- **WHEN** admin selects a different drawer from the dropdown for a page
- **THEN** the UI SHALL call the page update API with the new `drawer_id`
#### Scenario: Admin clears drawer assignment via UI
- **WHEN** admin selects "未分類" (unassigned) from the dropdown
- **THEN** the page's `drawer_id` SHALL be removed and the page SHALL no longer appear in the sidebar

View File

@@ -0,0 +1,43 @@
## MODIFIED Requirements
### Requirement: Portal Navigation SHALL Group Entries by Functional Drawers
The portal SHALL group navigation entries into functional drawers as defined in the `drawers` configuration of `page_status.json`, rendered dynamically via Jinja2 loop instead of hardcoded HTML.
#### Scenario: Drawer grouping visibility
- **WHEN** users open the portal
- **THEN** the sidebar SHALL display drawers in the order defined by each drawer's `order` field
- **THEN** each drawer SHALL show only the pages assigned to it via `drawer_id`, sorted by each page's `order` field
#### Scenario: Admin-only drawer visibility
- **WHEN** a drawer has `admin_only: true` and the current user is not admin
- **THEN** the drawer and all its pages SHALL NOT be rendered in the sidebar
#### Scenario: Empty drawer visibility
- **WHEN** a drawer has no visible pages (all filtered out by `can_view_page()`)
- **THEN** the drawer group title SHALL NOT be rendered
### Requirement: Existing Page Behavior SHALL Remain Compatible
The portal navigation refactor SHALL preserve existing target routes and lazy-load behavior for content frames.
#### Scenario: Route continuity
- **WHEN** a user selects an existing page entry from a dynamically rendered drawer
- **THEN** the corresponding original route SHALL be loaded without changing page business logic behavior
#### Scenario: Iframe lazy-load continuity
- **WHEN** a sidebar item is clicked for the first time
- **THEN** the iframe SHALL lazy-load its content from the page's route, consistent with current behavior
## ADDED Requirements
### Requirement: First-run migration SHALL populate drawer configuration automatically
When `page_status.json` does not contain a `drawers` field, the system SHALL automatically create the default drawer structure matching the current hardcoded layout and assign existing pages to their corresponding drawers.
#### Scenario: First startup after deployment
- **WHEN** the application starts and `page_status.json` has no `drawers` field
- **THEN** the system SHALL create three default drawers (報表類, 查詢類, 開發工具)
- **THEN** the system SHALL assign each existing page to its historically correct drawer
- **THEN** the system SHALL persist the updated configuration immediately
#### Scenario: Subsequent startup
- **WHEN** the application starts and `page_status.json` already contains a `drawers` field
- **THEN** the system SHALL NOT modify the existing drawer configuration

View File

@@ -0,0 +1,36 @@
## 1. 資料層:擴展 page_status.json 與 page_registry.py
- [x] 1.1 在 `page_registry.py``_load()` 中加入自動遷移邏輯:當 `drawers` 欄位不存在時,注入預設三個抽屜並根據 hardcoded 映射表填充每個 page 的 `drawer_id``order`,然後 `_save()`
- [x] 1.2 新增 drawer CRUD 函式:`get_all_drawers()`, `create_drawer(name, order, admin_only)`, `update_drawer(id, ...)`, `delete_drawer(id)`
- [x] 1.3 擴展 `set_page_status()` 支援 `drawer_id``order` 參數
- [x] 1.4 新增 `get_navigation_config()` 函式:回傳按 drawer order 排序的巢狀結構drawers → pages供 portal route 使用
## 2. API 層:擴展 admin_routes.py
- [x] 2.1 新增 `GET /admin/api/drawers` endpoint回傳所有抽屜sorted by order
- [x] 2.2 新增 `POST /admin/api/drawers` endpoint建立新抽屜驗證名稱不重複
- [x] 2.3 新增 `PUT /admin/api/drawers/<id>` endpoint更新抽屜名稱/排序/admin_only
- [x] 2.4 新增 `DELETE /admin/api/drawers/<id>` endpoint刪除空抽屜有頁面時回傳 409
- [x] 2.5 擴展現有 `PUT /admin/api/pages/<route>` endpoint接受 `drawer_id``order` 欄位(驗證 drawer_id 存在)
## 3. 模板層portal.html 動態渲染
- [x] 3.1 修改 `app.py` 的 portal route呼叫 `get_navigation_config()` 取得結構化導航資料,傳入 Jinja2 context
- [x] 3.2 將 `portal.html` sidebar 區塊lines 356-392改為 `{% for drawer in drawers %}` 動態渲染,保留 `can_view_page()` 過濾與 `admin_only` 判斷
- [x] 3.3 將 `portal.html` iframe 區塊lines 394-421改為動態渲染根據配置產生 iframe elements
- [x] 3.4 確認 `portal.js``activateTab()` 與 lazy-load 邏輯在動態 DOM 下正常運作iframe id 命名規則需一致)
## 4. Admin UI擴展 /admin/pages
- [x] 4.1 在 `admin/pages.html` 上方加入「抽屜管理」區塊:抽屜列表 + 新增/改名/刪除/排序控制
- [x] 4.2 在頁面列表每一列加入 drawer 歸屬下拉選單(含「未分類」選項)和排序輸入
- [x] 4.3 實作前端 JSdrawer CRUD 操作(呼叫 API → 更新 UI
- [x] 4.4 實作前端 JS頁面 drawer 指派操作(下拉變更 → PUT API
## 5. 驗證
- [x] 5.1 驗證首次啟動遷移:刪除 `page_status.json` 中的 `drawers` 欄位,重啟後確認自動產生正確的預設配置
- [x] 5.2 驗證 portal sidebar 動態渲染:新增/刪除抽屜後刷新 portal確認 sidebar 正確反映
- [x] 5.3 驗證頁面歸屬變更:改變頁面的 drawer_id 後刷新 portal確認頁面出現在正確的抽屜中
- [x] 5.4 驗證權限邏輯不變:非 admin 使用者看不到 dev 頁面、看不到 admin_only 抽屜
- [x] 5.5 驗證安全性:非 admin 使用者無法存取 drawer API endpoints