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:
@@ -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
|
||||
Reference in New Issue
Block a user