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>
5.4 KiB
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。
{
"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
- 無。探索階段已充分討論並確認方向。