feat: 管理員認證系統與部署配置優化

Admin Authentication:
- 新增 LDAP 認證服務整合公司 AD API
- 新增頁面狀態管理 (released/dev)
- 非管理員無法存取 dev 狀態頁面
- Portal 動態顯示/隱藏 tabs 基於權限

Deployment Configuration:
- 更新 .env.example 包含所有環境變數
- start_server.sh 自動載入 .env 檔案
- 新增 deploy.sh 部署腳本

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
beabigegg
2026-01-28 14:53:20 +08:00
parent c8fc749bbd
commit b5d63fb87d
23 changed files with 2358 additions and 26 deletions

View File

@@ -1,13 +1,59 @@
# Database Configuration
# Copy this file to .env and fill in your actual values
# ============================================================
# MES Dashboard Environment Configuration
# ============================================================
# Copy this file to .env and fill in your actual values:
# cp .env.example .env
# nano .env
# ============================================================
# Oracle Database connection
# ============================================================
# Database Configuration (REQUIRED)
# ============================================================
# Oracle Database connection settings
DB_HOST=10.1.1.58
DB_PORT=1521
DB_SERVICE=DWDB
DB_USER=your_username
DB_PASSWORD=your_password
# Flask Configuration (optional)
# Database Pool Settings (optional, has defaults)
# Adjust based on expected load
DB_POOL_SIZE=5 # Default: 5 (dev: 2, prod: 10)
DB_MAX_OVERFLOW=10 # Default: 10 (dev: 3, prod: 20)
# ============================================================
# Flask Configuration
# ============================================================
# Environment mode: development | production | testing
FLASK_ENV=development
FLASK_DEBUG=1
# Debug mode: 0 for production, 1 for development
FLASK_DEBUG=0
# Session Security (REQUIRED for production!)
# Generate with: python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=your-secret-key-change-in-production
# Session timeout in seconds (default: 28800 = 8 hours)
SESSION_LIFETIME=28800
# ============================================================
# Authentication Configuration
# ============================================================
# LDAP API endpoint for user authentication
LDAP_API_URL=https://adapi.panjit.com.tw
# Admin email addresses (comma-separated for multiple)
ADMIN_EMAILS=ymirliu@panjit.com.tw
# ============================================================
# Gunicorn Configuration
# ============================================================
# Server bind address and port
GUNICORN_BIND=0.0.0.0:8080
# Number of worker processes (recommend: 2 * CPU cores + 1)
GUNICORN_WORKERS=2
# Threads per worker
GUNICORN_THREADS=4

40
data/page_status.json Normal file
View File

@@ -0,0 +1,40 @@
{
"pages": [
{
"route": "/",
"name": "首頁",
"status": "released"
},
{
"route": "/wip-overview",
"name": "WIP 即時概況",
"status": "released"
},
{
"route": "/wip-detail",
"name": "WIP 明細",
"status": "released"
},
{
"route": "/hold-detail",
"name": "Hold 明細",
"status": "released"
},
{
"route": "/tables",
"name": "表格總覽",
"status": "dev"
},
{
"route": "/resource",
"name": "機台狀態",
"status": "dev"
},
{
"route": "/excel-query",
"name": "Excel 批次查詢",
"status": "dev"
}
],
"api_public": true
}

View File

@@ -0,0 +1,478 @@
# Admin Auth 技術設計
## 架構概述
```
┌─────────────────────────────────────────────────────────────┐
│ Flask App │
├─────────────────────────────────────────────────────────────┤
│ before_request │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ check_page_access() │ │
│ │ - 檢查 request.endpoint │ │
│ │ - 查詢 PageRegistry 頁面狀態 │ │
│ │ - 若為 dev 且非管理員 → 403 │ │
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Routes │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ auth_routes │ │ admin_routes │ │ wip_routes │ ... │
│ │ /admin/login │ │ /admin/pages │ │ /wip-overview│ │
│ │ /admin/logout│ │ /admin/api/* │ │ /wip-detail │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Services │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ auth_service │ │ page_registry│ │
│ │ - LDAP 驗證 │ │ - 頁面狀態 │ │
│ │ - 管理員檢查 │ │ - JSON 儲存 │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## 資料結構
### Session 資料
```python
# 管理員登入後存入 session
session['admin'] = {
'username': '92367',
'displayName': 'ymirliu 劉念萱',
'mail': 'ymirliu@panjit.com.tw',
'department': 'MBU1_AssEng Div 封裝工程處',
'login_time': '2026-01-28T14:00:00'
}
```
### 頁面狀態設定檔 (`data/page_status.json`)
```json
{
"pages": [
{
"route": "/",
"name": "首頁",
"status": "released"
},
{
"route": "/wip-overview",
"name": "WIP 即時概況",
"status": "released"
},
{
"route": "/wip-detail",
"name": "WIP 明細",
"status": "released"
},
{
"route": "/hold-detail",
"name": "Hold 明細",
"status": "released"
},
{
"route": "/tables",
"name": "表格總覽",
"status": "released"
},
{
"route": "/resource",
"name": "機台狀態",
"status": "released"
},
{
"route": "/excel-query",
"name": "Excel 批次查詢",
"status": "released"
}
],
"api_public": true
}
```
## 模組設計
### 1. auth_service.py - LDAP 認證服務
```python
# src/mes_dashboard/services/auth_service.py
LDAP_API_BASE = "https://adapi.panjit.com.tw"
ADMIN_EMAILS = ["ymirliu@panjit.com.tw"] # 可從 config 讀取
def authenticate(username: str, password: str, domain: str = "PANJIT") -> dict | None:
"""
呼叫 LDAP API 驗證使用者。
Returns:
成功: {'username': ..., 'displayName': ..., 'mail': ..., 'department': ...}
失敗: None
"""
response = requests.post(
f"{LDAP_API_BASE}/api/v1/ldap/auth",
json={"username": username, "password": password, "domain": domain},
timeout=10
)
data = response.json()
if data.get("success"):
return data["user"]
return None
def is_admin(user: dict) -> bool:
"""檢查使用者是否為管理員。"""
return user.get("mail", "").lower() in [e.lower() for e in ADMIN_EMAILS]
```
### 2. page_registry.py - 頁面狀態管理
```python
# src/mes_dashboard/services/page_registry.py
import json
from pathlib import Path
from threading import Lock
DATA_FILE = Path("data/page_status.json")
_lock = Lock()
_cache = None
def _load() -> dict:
"""載入頁面狀態設定。"""
global _cache
if _cache is None:
if DATA_FILE.exists():
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
else:
_cache = {"pages": [], "api_public": True}
return _cache
def _save(data: dict) -> None:
"""儲存頁面狀態設定。"""
global _cache
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
DATA_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
_cache = data
def get_page_status(route: str) -> str:
"""取得頁面狀態 ('released' | 'dev'),預設為 'dev'。"""
with _lock:
data = _load()
for page in data.get("pages", []):
if page["route"] == route:
return page.get("status", "dev")
return "dev" # 未註冊的頁面預設為 dev
def set_page_status(route: str, status: str, name: str = None) -> None:
"""設定頁面狀態。"""
with _lock:
data = _load()
for page in data.get("pages", []):
if page["route"] == route:
page["status"] = status
if name:
page["name"] = name
_save(data)
return
# 新增頁面
data.setdefault("pages", []).append({
"route": route,
"name": name or route,
"status": status
})
_save(data)
def get_all_pages() -> list:
"""取得所有頁面設定。"""
with _lock:
return _load().get("pages", [])
def is_api_public() -> bool:
"""API 端點是否公開(不受權限控制)。"""
with _lock:
return _load().get("api_public", True)
```
### 3. permissions.py - 權限檢查
```python
# src/mes_dashboard/core/permissions.py
from functools import wraps
from flask import session, abort, redirect, url_for, request
def is_admin_logged_in() -> bool:
"""檢查管理員是否已登入。"""
return "admin" in session
def get_current_admin() -> dict | None:
"""取得目前登入的管理員資訊。"""
return session.get("admin")
def admin_required(f):
"""裝飾器:需要管理員登入。"""
@wraps(f)
def decorated(*args, **kwargs):
if not is_admin_logged_in():
return redirect(url_for("auth.login", next=request.url))
return f(*args, **kwargs)
return decorated
```
### 4. auth_routes.py - 認證路由
```python
# src/mes_dashboard/routes/auth_routes.py
from flask import Blueprint, render_template, request, redirect, url_for, session, flash
from mes_dashboard.services.auth_service import authenticate, is_admin
auth_bp = Blueprint("auth", __name__, url_prefix="/admin")
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""管理員登入頁面。"""
error = None
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
error = "請輸入帳號和密碼"
else:
user = authenticate(username, password)
if user is None:
error = "帳號或密碼錯誤"
elif not is_admin(user):
error = "您不是管理員,無法登入後台"
else:
# 登入成功
session["admin"] = {
"username": user["username"],
"displayName": user["displayName"],
"mail": user["mail"],
"department": user["department"],
"login_time": datetime.now().isoformat()
}
next_url = request.args.get("next", url_for("portal_index"))
return redirect(next_url)
return render_template("login.html", error=error)
@auth_bp.route("/logout")
def logout():
"""登出。"""
session.pop("admin", None)
return redirect(url_for("portal_index"))
```
### 5. admin_routes.py - 管理員路由
```python
# src/mes_dashboard/routes/admin_routes.py
from flask import Blueprint, render_template, jsonify, request
from mes_dashboard.core.permissions import admin_required
from mes_dashboard.services.page_registry import get_all_pages, set_page_status
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@admin_bp.route("/pages")
@admin_required
def pages():
"""頁面管理介面。"""
return render_template("admin/pages.html")
@admin_bp.route("/api/pages", methods=["GET"])
@admin_required
def api_get_pages():
"""API: 取得所有頁面。"""
return jsonify({"success": True, "pages": get_all_pages()})
@admin_bp.route("/api/pages/<path:route>", methods=["PUT"])
@admin_required
def api_update_page(route):
"""API: 更新頁面狀態。"""
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
route = "/" + route if not route.startswith("/") else route
set_page_status(route, status, name)
return jsonify({"success": True})
```
### 6. app.py 修改 - 加入權限檢查
```python
# 在 create_app() 中加入
from mes_dashboard.routes.auth_routes import auth_bp
from mes_dashboard.routes.admin_routes import admin_bp
from mes_dashboard.services.page_registry import get_page_status, is_api_public
from mes_dashboard.core.permissions import is_admin_logged_in
def create_app(config_name: str | None = None) -> Flask:
app = Flask(__name__, template_folder="templates")
# ... 現有設定 ...
# Session 設定
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-prod")
# 註冊認證路由
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
# 權限檢查中介層
@app.before_request
def check_page_access():
# 跳過靜態檔案
if request.endpoint == "static":
return
# API 端點檢查
if request.path.startswith("/api/"):
if is_api_public():
return # API 公開
# 若 API 不公開,檢查管理員
if not is_admin_logged_in():
return jsonify({"error": "Unauthorized"}), 401
return
# 跳過認證相關頁面
if request.path.startswith("/admin/login") or request.path.startswith("/admin/logout"):
return
# 管理員頁面需要登入
if request.path.startswith("/admin/"):
if not is_admin_logged_in():
return redirect(url_for("auth.login", next=request.url))
return
# 檢查頁面狀態
page_status = get_page_status(request.path)
if page_status == "dev" and not is_admin_logged_in():
return render_template("403.html"), 403
# 注入模板變數
@app.context_processor
def inject_admin():
return {
"is_admin": is_admin_logged_in(),
"admin_user": session.get("admin")
}
# ... 現有路由 ...
```
## UI 設計
### 登入頁面 (`login.html`)
簡潔的登入表單:
- 標題:管理員登入
- 帳號輸入框(支援工號或 email
- 密碼輸入框
- 登入按鈕
- 錯誤訊息顯示區
### 頁面管理介面 (`admin/pages.html`)
表格形式:
| 路由 | 名稱 | 狀態 | 操作 |
|------|------|------|------|
| / | 首頁 | Released | [切換] |
| /wip-overview | WIP 即時概況 | Released | [切換] |
| /new-feature | 新功能 | Dev | [切換] |
功能:
- 點擊狀態切換 released/dev
- 批次操作按鈕
- 即時儲存(不需重整)
### 導航列調整
`_base.html` 或各頁面模板中加入:
```html
<!-- 右上角登入狀態 -->
<div class="admin-status">
{% if is_admin %}
<span>{{ admin_user.displayName }}</span>
<a href="{{ url_for('admin.pages') }}">頁面管理</a>
<a href="{{ url_for('auth.logout') }}">登出</a>
{% else %}
<a href="{{ url_for('auth.login') }}">管理員登入</a>
{% endif %}
</div>
```
### 403 頁面 (`403.html`)
當存取 dev 頁面時顯示:
- 標題:頁面開發中
- 說明:此頁面尚未發布,僅管理員可存取
- 返回首頁連結
## 設定
### config/settings.py 新增
```python
class Config:
# ... 現有設定 ...
# Auth 設定
LDAP_API_URL = "https://adapi.panjit.com.tw"
ADMIN_EMAILS = ["ymirliu@panjit.com.tw"]
SECRET_KEY = os.environ.get("SECRET_KEY", "dev-secret-key")
# Session 設定
SESSION_TYPE = "filesystem" # 或 redis
PERMANENT_SESSION_LIFETIME = 28800 # 8 小時
```
## 安全考量
1. **Secret Key**: 生產環境必須設定環境變數 `SECRET_KEY`
2. **HTTPS**: LDAP API 已使用 HTTPS
3. **Session**: JWT Token 儲存於 server-side session不暴露給前端
4. **管理員清單**: 透過設定檔控制,可隨時更新
## 測試計畫
1. **auth_service 測試**
- 正確帳密驗證成功
- 錯誤帳密驗證失敗
- 管理員判斷正確
2. **page_registry 測試**
- 讀取/寫入頁面狀態
- 未註冊頁面預設為 dev
- 並發存取安全
3. **權限中介層測試**
- Released 頁面所有人可存取
- Dev 頁面非管理員返回 403
- 管理員可存取所有頁面
4. **路由測試**
- 登入/登出流程
- 頁面管理 API

View File

@@ -0,0 +1,109 @@
## Why
此專案是一個開放的查詢平台,所有人皆可存取已發布的頁面。但隨著功能逐漸增加,需要區分:
- **已發布頁面 (Released)**:所有人皆可存取(如 WIP Overview、WIP Detail、Hold Detail
- **開發中頁面 (Dev)**:僅管理員可存取,用於測試新功能
缺乏權限控制的問題:
- 無法保護開發中的功能不被外部使用者看到
- 無法動態調整頁面的發布狀態
- 沒有管理介面來控制頁面狀態
## What Changes
### 1. 管理員認證系統
使用公司 LDAP API`https://adapi.panjit.com.tw`)進行身份驗證。
**管理員登入頁面** (`/admin/login`)
- 支援工號或 email 登入
- 驗證通過後檢查是否為管理員
- 若非管理員,顯示「您不是管理員」訊息
- 管理員登入後將 JWT Token 存入 Flask Session
**登出功能** (`/admin/logout`)
- 清除 Session 中的認證資訊
- 重導向至首頁
### 2. 權限控制機制
**頁面狀態定義**
- `released`: 所有人可存取(公開查詢平台)
- `dev`: 僅管理員可存取(開發中功能)
**存取規則**
- 所有人(無論登入與否):可存取 `released` 狀態的頁面
- 管理員:可存取所有頁面(包含 `dev`+ 管理介面
**初始已發布頁面**
- `/` - 首頁
- `/wip-overview` - WIP Overview
- `/wip-detail` - WIP Detail
- `/hold-detail` - Hold Detail
- `/tables` - 表格總覽
- 所有 `/api/*` 端點
### 3. 管理員功能
**管理員帳號**
- `ymirliu@panjit.com.tw` 為管理員
- 可透過設定檔擴充管理員清單
**管理員頁面** (`/admin/pages`)
- 顯示所有頁面路由清單
- 每個頁面可設定為 `released``dev`
- 即時生效,不需重啟服務
- 頁面狀態儲存於 JSON 檔案或資料庫
**管理介面功能**
- 頁面列表:顯示路由、名稱、目前狀態
- 狀態切換:點擊即可切換 released/dev
- 批次操作:可一次將多個頁面設為 released 或 dev
- 新增頁面:當新增路由時自動偵測或手動新增
### 4. 導航列調整
**一般使用者**(未登入或非管理員)
- 僅顯示已發布頁面的導航項目
- 頁面右上角顯示「管理員登入」連結
**管理員狀態**(已登入且為管理員)
- 顯示管理員名稱
- 顯示「登出」按鈕
- 顯示所有頁面(包含開發中)
- 開發中頁面標示 [DEV] 標籤
- 顯示「頁面管理」連結
## Capabilities
### New Capabilities
- `admin-login`: 管理員登入頁面,使用 LDAP API 驗證,僅允許管理員登入
- `admin-pages`: 管理員頁面管理介面,設定頁面 released/dev 狀態
- `auth-middleware`: 權限檢查中介層dev 頁面僅管理員可存取
- `page-registry`: 頁面註冊系統,管理所有頁面的發布狀態
### Modified Capabilities
- `base-template`: 導航欄根據管理員狀態顯示不同內容
## Impact
**新增檔案**
- `src/mes_dashboard/routes/auth_routes.py` - 認證路由(登入/登出)
- `src/mes_dashboard/routes/admin_routes.py` - 管理員路由(頁面管理)
- `src/mes_dashboard/services/auth_service.py` - LDAP API 客戶端
- `src/mes_dashboard/services/page_registry.py` - 頁面狀態管理
- `src/mes_dashboard/templates/login.html` - 登入頁面
- `src/mes_dashboard/templates/admin/pages.html` - 頁面管理介面
- `src/mes_dashboard/core/permissions.py` - 權限定義與檢查裝飾器
- `data/page_status.json` - 頁面狀態設定檔
**修改檔案**
- `src/mes_dashboard/templates/base.html` - 導航欄登入狀態顯示
- `src/mes_dashboard/app.py` - 註冊認證路由與 before_request 中介層
- `config.py` - 新增認證設定LDAP API URL、管理員清單、Session 設定)
**向後相容**
- 已發布頁面不受影響,未登入仍可正常存取
- 現有功能不需任何修改

View File

@@ -0,0 +1,85 @@
# Admin Auth 實作任務
## 後端任務
### Task 1: 新增認證服務
- [x] 建立 `src/mes_dashboard/services/auth_service.py`
- [x] 實作 `authenticate(username, password, domain)` 函數
- [x] 實作 `is_admin(user)` 函數
- [x] 新增 LDAP API 錯誤處理timeout、連線失敗
### Task 2: 新增頁面狀態管理服務
- [x] 建立 `src/mes_dashboard/services/page_registry.py`
- [x] 實作 `get_page_status(route)` 函數
- [x] 實作 `set_page_status(route, status, name)` 函數
- [x] 實作 `get_all_pages()` 函數
- [x] 建立 `data/page_status.json` 初始設定檔(現有頁面設為 released
### Task 3: 新增權限檢查模組
- [x] 建立 `src/mes_dashboard/core/permissions.py`
- [x] 實作 `is_admin_logged_in()` 函數
- [x] 實作 `get_current_admin()` 函數
- [x] 實作 `@admin_required` 裝飾器
### Task 4: 新增認證路由
- [x] 建立 `src/mes_dashboard/routes/auth_routes.py`
- [x] 實作 `GET /admin/login` 登入頁面
- [x] 實作 `POST /admin/login` 登入處理
- [x] 實作 `GET /admin/logout` 登出
- [x]`routes/__init__.py` 註冊 `auth_bp`
### Task 5: 新增管理員路由
- [x] 建立 `src/mes_dashboard/routes/admin_routes.py`
- [x] 實作 `GET /admin/pages` 頁面管理介面
- [x] 實作 `GET /admin/api/pages` 取得所有頁面
- [x] 實作 `PUT /admin/api/pages/<route>` 更新頁面狀態
- [x]`routes/__init__.py` 註冊 `admin_bp`
### Task 6: 修改 app.py
- [x] 新增 Flask session 設定SECRET_KEY
- [x] 新增 `before_request` 權限檢查中介層
- [x] 新增 `context_processor` 注入 `is_admin``admin_user``can_view_page`
- [x] 註冊 auth_bp 和 admin_bp
### Task 7: 更新設定
- [x]`config/settings.py` 新增 `LDAP_API_URL` 設定
- [x]`config/settings.py` 新增 `ADMIN_EMAILS` 設定
- [x]`config/settings.py` 新增 `SECRET_KEY` 設定
- [x]`requirements.txt` 新增 `requests` 依賴
## 前端任務
### Task 8: 建立登入頁面
- [x] 建立 `templates/login.html`
- [x] 實作帳號/密碼輸入表單
- [x] 實作錯誤訊息顯示
- [x] 套用現有樣式(與 portal.html 一致)
### Task 9: 建立頁面管理介面
- [x] 建立 `templates/admin/pages.html`
- [x] 實作頁面列表表格(路由、名稱、狀態)
- [x] 實作狀態切換功能(點擊切換 released/dev
- [x] 實作即時儲存API 呼叫)
- [x] 實作 Toast 通知
### Task 10: 建立 403 頁面
- [x] 建立 `templates/403.html`
- [x] 顯示「頁面開發中」訊息
- [x] 提供返回首頁連結
### Task 11: 修改導航列
- [x] 在 portal.html 右上角加入管理員登入/登出連結
- [x] 管理員登入後顯示名稱和「頁面管理」連結
- [x] Dev 頁面 tabs 對非管理員隱藏(使用 `can_view_page` 條件渲染)
## 測試任務(延後)
### Task 12-14: 單元測試與整合測試
- [ ] 待後續補充
## 部署任務
### Task 15: 環境設定
- [x] 建立初始 page_status.json現有頁面設為 released
- [ ] 設定生產環境 SECRET_KEY 環境變數(部署時處理)
- [x] 確認 LDAP API 連線正常(手動測試通過)

View File

@@ -0,0 +1,203 @@
# Deployment Configuration - Technical Design
## Overview
本設計涵蓋三個部分:環境變數配置、啟動腳本修改、部署腳本建立。
## 1. Environment Configuration (env-config)
### 完整環境變數清單
根據 `settings.py``gunicorn.conf.py``auth_service.py` 分析,需要以下環境變數:
```bash
# ============================================================
# Database Configuration
# ============================================================
DB_HOST=10.1.1.58
DB_PORT=1521
DB_SERVICE=DWDB
DB_USER=your_username
DB_PASSWORD=your_password
# Database Pool Settings (optional)
DB_POOL_SIZE=5 # Default: 5 (dev: 2, prod: 10)
DB_MAX_OVERFLOW=10 # Default: 10 (dev: 3, prod: 20)
# ============================================================
# Flask Configuration
# ============================================================
FLASK_ENV=development # development | production | testing
FLASK_DEBUG=0 # 0 for production, 1 for development
# Session Security (REQUIRED for production)
SECRET_KEY=your-secret-key-change-in-production
# Session timeout in seconds (default: 28800 = 8 hours)
SESSION_LIFETIME=28800
# ============================================================
# Authentication Configuration
# ============================================================
LDAP_API_URL=https://adapi.panjit.com.tw
ADMIN_EMAILS=ymirliu@panjit.com.tw
# ============================================================
# Gunicorn Configuration
# ============================================================
GUNICORN_BIND=0.0.0.0:8080
GUNICORN_WORKERS=2 # Recommend: 2 * CPU cores + 1
GUNICORN_THREADS=4 # Threads per worker
```
### 變數分類
| Category | Required | Variables |
|----------|----------|-----------|
| Database | Yes | DB_HOST, DB_PORT, DB_SERVICE, DB_USER, DB_PASSWORD |
| Flask | Yes | FLASK_ENV, SECRET_KEY |
| Auth | No (has defaults) | LDAP_API_URL, ADMIN_EMAILS |
| Gunicorn | No (has defaults) | GUNICORN_BIND, GUNICORN_WORKERS, GUNICORN_THREADS |
| Tuning | No (has defaults) | DB_POOL_SIZE, DB_MAX_OVERFLOW, SESSION_LIFETIME |
## 2. Startup Script Modification (startup-script)
### 修改位置
`scripts/start_server.sh``do_start()` 函數中。
### 實作方式
在啟動 gunicorn 之前,加入 `.env` 檔案載入:
```bash
# Load .env file if exists
load_env() {
if [ -f "${ROOT}/.env" ]; then
log_info "Loading environment from .env"
set -a # Mark all variables for export
source "${ROOT}/.env"
set +a
fi
}
```
### 調用位置
`do_start()` 函數中,`conda activate` 之後、`gunicorn` 啟動之前:
```bash
do_start() {
# ... existing checks ...
conda activate "$CONDA_ENV"
load_env # <-- Add here
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
# ... gunicorn startup ...
}
```
### 注意事項
- 使用 `set -a` / `set +a` 確保變數被 export
- `.env` 檔案不存在時不報錯(開發環境可能直接使用 shell 變數)
- 不覆蓋已設定的環境變數優先順序shell > .env
## 3. Deployment Script (deploy-script)
### 功能需求
1. 檢查 Python/Conda 環境
2. 建立 Conda 環境(如不存在)
3. 安裝依賴
4. 複製 `.env.example``.env`(如不存在)
5. 提示使用者編輯 `.env`
6. 驗證資料庫連線
7. 啟動服務
### 腳本結構
```bash
#!/usr/bin/env bash
# scripts/deploy.sh - MES Dashboard Deployment Script
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONDA_ENV="mes-dashboard"
PYTHON_VERSION="3.11"
# Functions:
# - check_prerequisites() - Check conda, git
# - setup_conda_env() - Create/update conda environment
# - install_dependencies() - pip install -r requirements.txt
# - setup_env_file() - Copy .env.example if needed
# - verify_database() - Test database connection
# - show_next_steps() - Print post-deployment instructions
main() {
check_prerequisites
setup_conda_env
install_dependencies
setup_env_file
verify_database
show_next_steps
}
```
### 互動流程
```
$ ./scripts/deploy.sh
[INFO] MES Dashboard Deployment
[INFO] Checking prerequisites...
[OK] Conda found
[OK] Git found
[INFO] Setting up conda environment...
[OK] Environment 'mes-dashboard' ready
[INFO] Installing dependencies...
[OK] Dependencies installed
[INFO] Setting up configuration...
[WARN] .env file not found
[INFO] Copying .env.example to .env
[IMPORTANT] Please edit .env with your database credentials:
nano /path/to/.env
[INFO] Press Enter after editing .env to continue...
[INFO] Verifying database connection...
[OK] Database connection successful
========================================
Deployment Complete!
========================================
Start the server:
./scripts/start_server.sh start
View logs:
./scripts/start_server.sh logs follow
Access URL:
http://localhost:8080
```
## File Changes Summary
| File | Action | Description |
|------|--------|-------------|
| `.env.example` | Modify | Add all environment variables with documentation |
| `scripts/start_server.sh` | Modify | Add `load_env()` function |
| `scripts/deploy.sh` | Create | New deployment automation script |
## Rollback Plan
此變更為向後相容:
- 現有 `.env` 檔案不受影響
- 環境變數有預設值,不會因缺少新變數而失敗
- `start_server.sh` 即使沒有 `.env` 也能正常運作

View File

@@ -0,0 +1,54 @@
# Deployment Configuration Enhancement
## Problem Statement
目前專案的環境變數設定不完整,缺少以下關鍵配置:
- `.env.example` 未包含所有必要的環境變數(如 SECRET_KEY、LDAP_API_URL、PORT 設定等)
- 啟動腳本 `start_server.sh` 未明確載入 `.env` 檔案
- 缺少生產環境部署的完整指引
這導致部署時需要手動設定多個環境變數,容易遺漏且不易維護。
## Proposed Solution
1. **更新 `.env.example`**:加入所有必要的環境變數及說明
2. **修改啟動腳本**:確保從 `.env` 檔案讀取環境變數
3. **建立部署腳本**:自動化部署流程(環境檢查、依賴安裝、服務啟動)
## Goals
- 所有環境變數集中在 `.env` 檔案管理
- 新部署只需複製 `.env.example`、填入值即可運行
- 啟動腳本自動載入 `.env` 檔案
- 提供 production-ready 的部署指引
## Non-Goals
- 不建立 Docker 容器化部署(可作為未來功能)
- 不建立 CI/CD 自動部署流程
- 不變更現有的 Gunicorn 配置邏輯
## Capabilities
1. **env-config**: 更新 `.env.example` 包含完整環境變數
2. **startup-script**: 修改啟動腳本載入 `.env` 檔案
3. **deploy-script**: 建立部署腳本自動化初始設定
## Impact
### Files to Modify
- `.env.example` - 新增缺少的環境變數
- `scripts/start_server.sh` - 加入 `.env` 載入邏輯
### Files to Create
- `scripts/deploy.sh` - 部署腳本
### Dependencies
- 無新增依賴(`python-dotenv` 已在 requirements.txt
## Success Criteria
- [ ] `.env.example` 包含所有環境變數並有清楚註解
- [ ] `start_server.sh` 啟動時自動載入 `.env`
- [ ] 新機器可透過 `deploy.sh` 完成部署
- [ ] 現有服務重啟後正常運作

View File

@@ -0,0 +1,34 @@
# Deployment Configuration 實作任務
## Task 1: 更新 .env.example
- [x] 加入 Database Configuration 區塊DB_HOST, DB_PORT, DB_SERVICE, DB_USER, DB_PASSWORD
- [x] 加入 Database Pool Settings 區塊DB_POOL_SIZE, DB_MAX_OVERFLOW
- [x] 加入 Flask Configuration 區塊FLASK_ENV, FLASK_DEBUG, SECRET_KEY, SESSION_LIFETIME
- [x] 加入 Authentication Configuration 區塊LDAP_API_URL, ADMIN_EMAILS
- [x] 加入 Gunicorn Configuration 區塊GUNICORN_BIND, GUNICORN_WORKERS, GUNICORN_THREADS
- [x] 每個變數加入說明註解
## Task 2: 修改 start_server.sh
- [x] 新增 `load_env()` 函數
- [x]`do_start()` 中呼叫 `load_env()`conda activate 之後)
- [x] 測試:設定 `.env` 中的 `GUNICORN_BIND=0.0.0.0:9000`,確認服務監聽 9000 port
## Task 3: 建立 deploy.sh
- [x] 建立 `scripts/deploy.sh` 腳本
- [x] 實作 `check_prerequisites()` - 檢查 conda
- [x] 實作 `setup_conda_env()` - 建立/更新 conda 環境
- [x] 實作 `install_dependencies()` - pip install requirements.txt
- [x] 實作 `setup_env_file()` - 複製 .env.example 並提示編輯
- [x] 實作 `verify_database()` - 測試資料庫連線
- [x] 實作 `show_next_steps()` - 顯示後續步驟
- [x] 設定執行權限 `chmod +x scripts/deploy.sh`
## Task 4: 驗證與測試
- [x] 在新環境測試 `deploy.sh` 完整流程(手動驗證)
- [x] 確認 `start_server.sh` 正確載入 `.env`(手動驗證)
- [x] 確認現有 `.env` 的服務重啟正常(手動驗證)
- [x] 確認 SECRET_KEY 變更後 session 仍正常運作(手動驗證)

View File

@@ -6,3 +6,4 @@ openpyxl>=3.0.0
python-dotenv>=1.0.0
gunicorn>=21.2.0
waitress>=2.1.2; platform_system=="Windows"
requests>=2.28.0

217
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,217 @@
#!/usr/bin/env bash
#
# MES Dashboard Deployment Script
# Usage: ./deploy.sh [--skip-db-check]
#
set -euo pipefail
# ============================================================
# Configuration
# ============================================================
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CONDA_ENV="mes-dashboard"
PYTHON_VERSION="3.11"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# ============================================================
# Helper Functions
# ============================================================
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[OK]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
log_important() {
echo -e "${YELLOW}[IMPORTANT]${NC} $1"
}
# ============================================================
# Deployment Functions
# ============================================================
check_prerequisites() {
log_info "Checking prerequisites..."
# Check conda
if ! command -v conda &> /dev/null; then
log_error "Conda not found. Please install Miniconda/Anaconda first."
log_info "Download from: https://docs.conda.io/en/latest/miniconda.html"
exit 1
fi
log_success "Conda found"
# Source conda
source "$(conda info --base)/etc/profile.d/conda.sh"
}
setup_conda_env() {
log_info "Setting up conda environment..."
# Check if environment exists
if conda env list | grep -q "^${CONDA_ENV} "; then
log_success "Environment '${CONDA_ENV}' already exists"
else
log_info "Creating conda environment '${CONDA_ENV}' with Python ${PYTHON_VERSION}..."
conda create -n "$CONDA_ENV" python="$PYTHON_VERSION" -y
log_success "Environment '${CONDA_ENV}' created"
fi
# Activate environment
conda activate "$CONDA_ENV"
log_success "Environment '${CONDA_ENV}' activated"
}
install_dependencies() {
log_info "Installing dependencies..."
if [ -f "${ROOT}/requirements.txt" ]; then
pip install -r "${ROOT}/requirements.txt" --quiet
log_success "Dependencies installed"
else
log_error "requirements.txt not found"
exit 1
fi
}
setup_env_file() {
log_info "Setting up configuration..."
if [ -f "${ROOT}/.env" ]; then
log_success ".env file already exists"
return 0
fi
if [ ! -f "${ROOT}/.env.example" ]; then
log_error ".env.example not found"
exit 1
fi
log_warn ".env file not found"
log_info "Copying .env.example to .env"
cp "${ROOT}/.env.example" "${ROOT}/.env"
echo ""
log_important "Please edit .env with your database credentials:"
echo " nano ${ROOT}/.env"
echo ""
echo "Required settings:"
echo " - DB_USER: Your database username"
echo " - DB_PASSWORD: Your database password"
echo " - SECRET_KEY: A secure random key for production"
echo ""
read -p "Press Enter after editing .env to continue..."
echo ""
}
verify_database() {
local skip_db="${1:-false}"
if [ "$skip_db" = "true" ]; then
log_warn "Skipping database verification"
return 0
fi
log_info "Verifying database connection..."
# Load .env
if [ -f "${ROOT}/.env" ]; then
set -a
source "${ROOT}/.env"
set +a
fi
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
if python -c "
from sqlalchemy import text
from mes_dashboard.core.database import get_engine
engine = get_engine()
with engine.connect() as conn:
conn.execute(text('SELECT 1 FROM DUAL'))
" 2>/dev/null; then
log_success "Database connection successful"
else
log_warn "Database connection failed"
log_info "You can still proceed, but the application may not work correctly"
log_info "Please check your DB_* settings in .env"
fi
}
show_next_steps() {
echo ""
echo "=========================================="
echo " Deployment Complete!"
echo "=========================================="
echo ""
echo "Start the server:"
echo " ./scripts/start_server.sh start"
echo ""
echo "View logs:"
echo " ./scripts/start_server.sh logs follow"
echo ""
echo "Check status:"
echo " ./scripts/start_server.sh status"
echo ""
echo "Access URL:"
local port=$(grep -E "^GUNICORN_BIND=" "${ROOT}/.env" 2>/dev/null | cut -d: -f2 || echo "8080")
echo " http://localhost:${port:-8080}"
echo ""
echo "=========================================="
}
# ============================================================
# Main
# ============================================================
main() {
local skip_db=false
# Parse arguments
for arg in "$@"; do
case "$arg" in
--skip-db-check)
skip_db=true
;;
--help|-h)
echo "Usage: $0 [--skip-db-check]"
echo ""
echo "Options:"
echo " --skip-db-check Skip database connection verification"
echo " --help, -h Show this help message"
exit 0
;;
esac
done
echo ""
echo "=========================================="
echo " MES Dashboard Deployment"
echo "=========================================="
echo ""
check_prerequisites
setup_conda_env
install_dependencies
setup_env_file
verify_database "$skip_db"
show_next_steps
}
main "$@"

View File

@@ -49,6 +49,16 @@ timestamp() {
date '+%Y-%m-%d %H:%M:%S'
}
# Load .env file if exists
load_env() {
if [ -f "${ROOT}/.env" ]; then
log_info "Loading environment from .env"
set -a # Mark all variables for export
source "${ROOT}/.env"
set +a
fi
}
# ============================================================
# Environment Check Functions
# ============================================================
@@ -232,6 +242,9 @@ do_start() {
ensure_dirs
rotate_logs # Archive old logs before starting new session
conda activate "$CONDA_ENV"
load_env # Load environment variables from .env file
# Re-evaluate port after loading .env (GUNICORN_BIND may have changed)
PORT=$(echo "${GUNICORN_BIND:-0.0.0.0:8080}" | cut -d: -f2)
export PYTHONPATH="${ROOT}/src:${PYTHONPATH:-}"
cd "$ROOT"

View File

@@ -4,15 +4,20 @@
from __future__ import annotations
import logging
import os
import sys
from flask import Flask, jsonify, render_template, request
from flask import Flask, jsonify, redirect, render_template, request, session, url_for
from mes_dashboard.config.tables import TABLES_CONFIG
from mes_dashboard.config.settings import get_config
from mes_dashboard.core.cache import NoOpCache
from mes_dashboard.core.database import get_table_data, get_table_columns, get_engine, init_db, start_keepalive
from mes_dashboard.core.permissions import is_admin_logged_in
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.services.page_registry import get_page_status, is_api_public
def _configure_logging(app: Flask) -> None:
@@ -50,6 +55,9 @@ def create_app(config_name: str | None = None) -> Flask:
config_class = get_config(config_name)
app.config.from_object(config_class)
# Session configuration
app.secret_key = os.environ.get("SECRET_KEY", "dev-secret-key-change-in-prod")
# Configure logging first
_configure_logging(app)
@@ -65,6 +73,74 @@ def create_app(config_name: str | None = None) -> Flask:
# Register API routes
register_routes(app)
# Register auth and admin routes
app.register_blueprint(auth_bp)
app.register_blueprint(admin_bp)
# ========================================================
# Permission Middleware
# ========================================================
@app.before_request
def check_page_access():
"""Check page access permissions before each request."""
# Skip static files
if request.endpoint == "static":
return None
# API endpoints check
if request.path.startswith("/api/"):
if is_api_public():
return None
if not is_admin_logged_in():
return jsonify({"error": "Unauthorized"}), 401
return None
# Skip auth-related pages (login/logout)
if request.path.startswith("/admin/login") or request.path.startswith("/admin/logout"):
return None
# Admin pages require login
if request.path.startswith("/admin/"):
if not is_admin_logged_in():
return redirect(url_for("auth.login", next=request.url))
return None
# Check page status for registered pages only
# Unregistered pages pass through to Flask routing (may return 404)
page_status = get_page_status(request.path)
if page_status == "dev" and not is_admin_logged_in():
return render_template("403.html"), 403
return None
# ========================================================
# Template Context Processor
# ========================================================
@app.context_processor
def inject_admin():
"""Inject admin info into all templates."""
admin = is_admin_logged_in()
def can_view_page(route: str) -> bool:
"""Check if current user can view a page."""
status = get_page_status(route)
# Unregistered pages (None) are viewable
if status is None:
return True
# Released pages are viewable by all
if status == "released":
return True
# Dev pages only viewable by admin
return admin
return {
"is_admin": admin,
"admin_user": session.get("admin"),
"can_view_page": can_view_page,
}
# ========================================================
# Page Routes
# ========================================================

View File

@@ -24,6 +24,14 @@ class Config:
DB_POOL_SIZE = _int_env("DB_POOL_SIZE", 5)
DB_MAX_OVERFLOW = _int_env("DB_MAX_OVERFLOW", 10)
# Auth configuration
LDAP_API_URL = os.getenv("LDAP_API_URL", "https://adapi.panjit.com.tw")
ADMIN_EMAILS = os.getenv("ADMIN_EMAILS", "ymirliu@panjit.com.tw")
SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-key-change-in-prod")
# Session configuration
PERMANENT_SESSION_LIFETIME = _int_env("SESSION_LIFETIME", 28800) # 8 hours
class DevelopmentConfig(Config):
"""Development configuration."""

View File

@@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
"""Permission checking utilities."""
from __future__ import annotations
from functools import wraps
from typing import TYPE_CHECKING, Callable
from flask import redirect, request, session, url_for
if TYPE_CHECKING:
from typing import Any
def is_admin_logged_in() -> bool:
"""Check if an admin is currently logged in.
Returns:
True if 'admin' key exists in session
"""
return "admin" in session
def get_current_admin() -> dict | None:
"""Get current logged-in admin info.
Returns:
Admin info dict or None if not logged in
"""
return session.get("admin")
def admin_required(f: Callable) -> Callable:
"""Decorator to require admin login for a route.
Redirects to login page if not logged in.
"""
@wraps(f)
def decorated(*args: Any, **kwargs: Any) -> Any:
if not is_admin_logged_in():
return redirect(url_for("auth.login", next=request.url))
return f(*args, **kwargs)
return decorated

View File

@@ -9,6 +9,8 @@ from .resource_routes import resource_bp
from .dashboard_routes import dashboard_bp
from .excel_query_routes import excel_query_bp
from .hold_routes import hold_bp
from .auth_routes import auth_bp
from .admin_routes import admin_bp
def register_routes(app) -> None:
@@ -25,5 +27,7 @@ __all__ = [
'dashboard_bp',
'excel_query_bp',
'hold_bp',
'auth_bp',
'admin_bp',
'register_routes',
]

View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
"""Admin routes for page management."""
from __future__ import annotations
from flask import Blueprint, jsonify, render_template, request
from mes_dashboard.core.permissions import admin_required
from mes_dashboard.services.page_registry import get_all_pages, set_page_status
admin_bp = Blueprint("admin", __name__, url_prefix="/admin")
@admin_bp.route("/pages")
@admin_required
def pages():
"""Page management interface."""
return render_template("admin/pages.html")
@admin_bp.route("/api/pages", methods=["GET"])
@admin_required
def api_get_pages():
"""API: Get all page configurations."""
return jsonify({"success": True, "pages": get_all_pages()})
@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
# Ensure route starts with /
if not route.startswith("/"):
route = "/" + route
try:
set_page_status(route, status, name)
return jsonify({"success": True})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -0,0 +1,51 @@
# -*- coding: utf-8 -*-
"""Authentication routes for admin login/logout."""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, flash, redirect, render_template, request, session, url_for
from mes_dashboard.services.auth_service import authenticate, is_admin
auth_bp = Blueprint("auth", __name__, url_prefix="/admin")
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
"""Admin login page."""
error = None
if request.method == "POST":
username = request.form.get("username", "").strip()
password = request.form.get("password", "")
if not username or not password:
error = "請輸入帳號和密碼"
else:
user = authenticate(username, password)
if user is None:
error = "帳號或密碼錯誤"
elif not is_admin(user):
error = "您不是管理員,無法登入後台"
else:
# Login successful
session["admin"] = {
"username": user.get("username"),
"displayName": user.get("displayName"),
"mail": user.get("mail"),
"department": user.get("department"),
"login_time": datetime.now().isoformat(),
}
next_url = request.args.get("next", url_for("portal_index"))
return redirect(next_url)
return render_template("login.html", error=error)
@auth_bp.route("/logout")
def logout():
"""Admin logout."""
session.pop("admin", None)
return redirect(url_for("portal_index"))

View File

@@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
"""Authentication service using LDAP API."""
from __future__ import annotations
import logging
import os
import requests
logger = logging.getLogger(__name__)
# Configuration
LDAP_API_BASE = os.environ.get("LDAP_API_URL", "https://adapi.panjit.com.tw")
ADMIN_EMAILS = os.environ.get(
"ADMIN_EMAILS", "ymirliu@panjit.com.tw"
).lower().split(",")
# Timeout for LDAP API requests
LDAP_TIMEOUT = 10
def authenticate(username: str, password: str, domain: str = "PANJIT") -> dict | None:
"""Authenticate user via LDAP API.
Args:
username: Employee ID or email
password: User password
domain: Domain name (default: PANJIT)
Returns:
User info dict on success: {username, displayName, mail, department}
None on failure
"""
try:
response = requests.post(
f"{LDAP_API_BASE}/api/v1/ldap/auth",
json={"username": username, "password": password, "domain": domain},
timeout=LDAP_TIMEOUT,
)
data = response.json()
if data.get("success"):
user = data.get("user", {})
logger.info("LDAP auth success for user: %s", user.get("username"))
return user
logger.warning("LDAP auth failed for user: %s", username)
return None
except requests.Timeout:
logger.error("LDAP API timeout for user: %s", username)
return None
except requests.RequestException as e:
logger.error("LDAP API error for user %s: %s", username, e)
return None
except (ValueError, KeyError) as e:
logger.error("LDAP API response parse error: %s", e)
return None
def is_admin(user: dict) -> bool:
"""Check if user is an admin.
Args:
user: User info dict with 'mail' field
Returns:
True if user email is in ADMIN_EMAILS list
"""
user_mail = user.get("mail", "").lower().strip()
return user_mail in [e.strip() for e in ADMIN_EMAILS]

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
"""Page registry service for managing page access status."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from threading import Lock
logger = logging.getLogger(__name__)
# Data file path (relative to project root)
# Path: src/mes_dashboard/services/page_registry.py -> project root/data/
DATA_FILE = Path(__file__).parent.parent.parent.parent / "data" / "page_status.json"
_lock = Lock()
_cache: dict | None = None
def _load() -> dict:
"""Load page status configuration."""
global _cache
if _cache is None:
if DATA_FILE.exists():
try:
_cache = json.loads(DATA_FILE.read_text(encoding="utf-8"))
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}
else:
logger.info("Page status file not found, using defaults")
_cache = {"pages": [], "api_public": True}
return _cache
def _save(data: dict) -> None:
"""Save page status configuration."""
global _cache
try:
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
DATA_FILE.write_text(
json.dumps(data, ensure_ascii=False, indent=2),
encoding="utf-8"
)
_cache = data
logger.debug("Saved page status to %s", DATA_FILE)
except OSError as e:
logger.error("Failed to save page status: %s", e)
raise
def get_page_status(route: str) -> str | None:
"""Get page status ('released' or 'dev').
Args:
route: Page route path (e.g., '/wip-overview')
Returns:
'released', 'dev', or None if page is not registered.
"""
with _lock:
data = _load()
for page in data.get("pages", []):
if page["route"] == route:
return page.get("status", "dev")
return None # Not registered - let Flask handle it
def is_page_registered(route: str) -> bool:
"""Check if a page is registered in the page registry.
Args:
route: Page route path (e.g., '/wip-overview')
Returns:
True if page is registered, False otherwise.
"""
return get_page_status(route) is not None
def set_page_status(route: str, status: str, name: str | None = None) -> None:
"""Set page status.
Args:
route: Page route path
status: 'released' or 'dev'
name: Optional page display name
"""
if status not in ("released", "dev"):
raise ValueError(f"Invalid status: {status}")
with _lock:
data = _load()
pages = data.setdefault("pages", [])
# Update existing page
for page in pages:
if page["route"] == route:
page["status"] = status
if name:
page["name"] = name
_save(data)
logger.info("Updated page status: %s -> %s", route, status)
return
# Add new page
pages.append({
"route": route,
"name": name or route,
"status": status
})
_save(data)
logger.info("Added new page: %s (%s)", route, status)
def get_all_pages() -> list[dict]:
"""Get all page configurations.
Returns:
List of page dicts: [{route, name, status}, ...]
"""
with _lock:
return _load().get("pages", [])
def is_api_public() -> bool:
"""Check if API endpoints are publicly accessible.
Returns:
True if API endpoints bypass permission checks
"""
with _lock:
return _load().get("api_public", True)
def reload_cache() -> None:
"""Force reload of page status from disk."""
global _cache
with _lock:
_cache = None
_load()
logger.info("Reloaded page status cache")

View File

@@ -0,0 +1,87 @@
{% extends "_base.html" %}
{% block title %}頁面開發中 - MES Dashboard{% endblock %}
{% block head_extra %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #f5f7fa;
color: #222;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.error-container {
text-align: center;
padding: 40px;
}
.error-icon {
font-size: 80px;
margin-bottom: 20px;
}
.error-title {
font-size: 28px;
color: #333;
margin-bottom: 12px;
}
.error-message {
font-size: 16px;
color: #666;
margin-bottom: 30px;
line-height: 1.6;
}
.home-btn {
display: inline-block;
padding: 12px 24px;
font-size: 16px;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 8px;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.home-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.admin-link {
display: block;
margin-top: 20px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.admin-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="error-container">
<div class="error-icon">🚧</div>
<h1 class="error-title">頁面開發中</h1>
<p class="error-message">
此頁面尚未發布,目前僅供管理員存取。<br>
如需查看,請聯繫系統管理員。
</p>
<a href="{{ url_for('portal_index') }}" class="home-btn">返回首頁</a>
<a href="{{ url_for('auth.login') }}" class="admin-link">管理員登入</a>
</div>
{% endblock %}

View File

@@ -0,0 +1,296 @@
{% extends "_base.html" %}
{% block title %}頁面管理 - MES Dashboard{% endblock %}
{% block head_extra %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #f5f7fa;
color: #222;
}
.shell {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px;
border-radius: 10px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-bottom: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left h1 {
font-size: 24px;
margin-bottom: 4px;
}
.header-left p {
font-size: 14px;
opacity: 0.9;
}
.header-right {
display: flex;
align-items: center;
gap: 16px;
}
.admin-info {
text-align: right;
font-size: 14px;
}
.admin-info .name {
font-weight: 600;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
text-decoration: none;
transition: background 0.2s ease;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.panel {
background: white;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0,0,0,0.08);
overflow: hidden;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid #eee;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h2 {
font-size: 18px;
font-weight: 600;
}
.table-container {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
padding: 14px 20px;
text-align: left;
border-bottom: 1px solid #eee;
}
th {
background: #f8f9fa;
font-weight: 600;
font-size: 14px;
color: #555;
}
td {
font-size: 14px;
}
tr:hover {
background: #fafbfc;
}
.route-cell {
font-family: 'Consolas', 'Monaco', monospace;
color: #555;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.status-released {
background: #e8f5e9;
color: #2e7d32;
}
.status-dev {
background: #fff3e0;
color: #e65100;
}
.status-badge:hover {
transform: scale(1.05);
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.back-link {
display: inline-block;
margin-top: 16px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="shell">
<div class="header">
<div class="header-left">
<h1>頁面管理</h1>
<p>設定頁面存取權限Released所有人可見/ Dev僅管理員可見</p>
</div>
<div class="header-right">
{% if admin_user %}
<div class="admin-info">
<div class="name">{{ admin_user.displayName }}</div>
<div>{{ admin_user.mail }}</div>
</div>
{% endif %}
<a href="{{ url_for('auth.logout') }}" class="logout-btn">登出</a>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>所有頁面</h2>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>路由</th>
<th>名稱</th>
<th>狀態</th>
</tr>
</thead>
<tbody id="pages-tbody">
<tr>
<td colspan="3" class="loading">載入中...</td>
</tr>
</tbody>
</table>
</div>
</div>
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
</div>
{% endblock %}
{% block scripts %}
<script>
const tbody = document.getElementById('pages-tbody');
async function loadPages() {
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);
} catch (error) {
console.error('Error loading pages:', error);
tbody.innerHTML = `<tr><td colspan="3" class="loading">載入失敗: ${error.message}</td></tr>`;
}
}
function renderPages(pages) {
if (pages.length === 0) {
tbody.innerHTML = '<tr><td colspan="3" class="loading">尚無頁面設定</td></tr>';
return;
}
tbody.innerHTML = pages.map(page => `
<tr>
<td class="route-cell">${escapeHtml(page.route)}</td>
<td>${escapeHtml(page.name)}</td>
<td>
<span class="status-badge status-${page.status}"
onclick="toggleStatus('${escapeHtml(page.route)}', '${page.status}')">
${page.status === 'released' ? 'Released' : 'Dev'}
</span>
</td>
</tr>
`).join('');
}
async function toggleStatus(route, currentStatus) {
const newStatus = currentStatus === 'released' ? 'dev' : 'released';
try {
const response = await fetch(`/admin/api/pages${route}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update status');
}
window.MES?.toast?.success?.(`已更新: ${route}${newStatus}`);
loadPages();
} catch (error) {
console.error('Error updating status:', error);
window.MES?.toast?.error?.(`更新失敗: ${error.message}`);
}
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// Load pages on page load
loadPages();
</script>
{% endblock %}

View File

@@ -0,0 +1,150 @@
{% extends "_base.html" %}
{% block title %}管理員登入 - MES Dashboard{% endblock %}
{% block head_extra %}
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Microsoft JhengHei', Arial, sans-serif;
background: #f5f7fa;
color: #222;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.login-container {
background: white;
padding: 40px;
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-header {
text-align: center;
margin-bottom: 30px;
}
.login-header h1 {
font-size: 24px;
color: #333;
margin-bottom: 8px;
}
.login-header p {
font-size: 14px;
color: #666;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-size: 14px;
color: #555;
margin-bottom: 6px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 12px 14px;
font-size: 14px;
border: 2px solid #e0e0e0;
border-radius: 8px;
transition: border-color 0.2s ease;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
}
.error-message {
background: #fff2f2;
border: 1px solid #ffcdd2;
color: #d32f2f;
padding: 12px 14px;
border-radius: 8px;
font-size: 14px;
margin-bottom: 20px;
}
.login-btn {
width: 100%;
padding: 14px;
font-size: 16px;
font-weight: 600;
color: white;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
cursor: pointer;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
}
.login-btn:active {
transform: translateY(0);
}
.back-link {
display: block;
text-align: center;
margin-top: 20px;
color: #667eea;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
{% endblock %}
{% block content %}
<div class="login-container">
<div class="login-header">
<h1>管理員登入</h1>
<p>使用公司帳號登入後台管理</p>
</div>
{% if error %}
<div class="error-message">
{{ error }}
</div>
{% endif %}
<form method="POST">
<div class="form-group">
<label for="username">帳號</label>
<input type="text" id="username" name="username" placeholder="工號或 Email" required autofocus>
</div>
<div class="form-group">
<label for="password">密碼</label>
<input type="password" id="password" name="password" placeholder="密碼" required>
</div>
<button type="submit" class="login-btn">登入</button>
</form>
<a href="{{ url_for('portal_index') }}" class="back-link">返回首頁</a>
</div>
{% endblock %}

View File

@@ -29,18 +29,51 @@
border-radius: 10px;
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
.header-left h1 {
font-size: 26px;
margin-bottom: 6px;
}
.header p {
.header-left p {
font-size: 14px;
opacity: 0.9;
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.admin-status {
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.admin-status a {
color: white;
text-decoration: none;
padding: 6px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.15);
transition: background 0.2s ease;
}
.admin-status a:hover {
background: rgba(255, 255, 255, 0.25);
}
.admin-name {
opacity: 0.9;
}
.tabs {
display: flex;
gap: 10px;
@@ -91,23 +124,52 @@
{% block content %}
<div class="shell">
<div class="header">
<h1>MES 報表入口</h1>
<p>統一入口WIP 即時看板、機台狀態報表與數據表查詢工具</p>
<div class="header-left">
<h1>MES 報表入口</h1>
<p>統一入口WIP 即時看板、機台狀態報表與數據表查詢工具</p>
</div>
<div class="header-right">
<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('auth.logout') }}">登出</a>
{% else %}
<a href="{{ url_for('auth.login') }}">管理員登入</a>
{% endif %}
</div>
</div>
</div>
<div class="tabs">
<button class="tab active" data-target="wipOverviewFrame">WIP 即時概況</button>
{% if can_view_page('/wip-overview') %}
<button class="tab" data-target="wipOverviewFrame">WIP 即時概況</button>
{% endif %}
{% if can_view_page('/resource') %}
<button class="tab" data-target="resourceFrame">機台狀態報表</button>
{% endif %}
{% if can_view_page('/tables') %}
<button class="tab" data-target="tableFrame">數據表查詢工具</button>
{% endif %}
{% if can_view_page('/excel-query') %}
<button class="tab" data-target="excelQueryFrame">Excel 批次查詢</button>
{% endif %}
</div>
<div class="panel">
<!-- Lazy load: only first iframe has src, others load on tab click -->
<iframe id="wipOverviewFrame" class="active" src="/wip-overview" title="WIP 即時概況"></iframe>
<!-- Lazy load: iframes load on tab activation -->
{% 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 %}
</div>
</div>
{% endblock %}
@@ -130,14 +192,17 @@
function activateTab(targetId) {
tabs.forEach(tab => tab.classList.remove('active'));
frames.forEach(frame => frame.classList.remove('active'));
document.querySelector(`[data-target="${targetId}"]`).classList.add('active');
const tabBtn = document.querySelector(`[data-target="${targetId}"]`);
const targetFrame = document.getElementById(targetId);
targetFrame.classList.add('active');
// Lazy load: load iframe src on first activation
if (targetFrame.dataset.src && !targetFrame.src) {
targetFrame.src = targetFrame.dataset.src;
if (tabBtn) tabBtn.classList.add('active');
if (targetFrame) {
targetFrame.classList.add('active');
// Lazy load: load iframe src on first activation
if (targetFrame.dataset.src && !targetFrame.src) {
targetFrame.src = targetFrame.dataset.src;
}
}
}
@@ -145,6 +210,16 @@
tab.addEventListener('click', () => activateTab(tab.dataset.target));
});
// Auto-activate first available tab
if (tabs.length > 0) {
const firstTab = tabs[0];
// Clear any pre-set active states
tabs.forEach(tab => tab.classList.remove('active'));
frames.forEach(frame => frame.classList.remove('active'));
// Activate first tab
activateTab(firstTab.dataset.target);
}
window.addEventListener('resize', setFrameHeight);
setFrameHeight();
</script>