v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
465
backend/main.py
Normal file
465
backend/main.py
Normal file
@@ -0,0 +1,465 @@
|
||||
"""
|
||||
FastAPI 主程式
|
||||
|
||||
本模組提供時間軸設計工具的 REST API 服務。
|
||||
遵循 SDD.md 定義的 API 規範。
|
||||
|
||||
Author: AI Agent
|
||||
Version: 1.0.0
|
||||
DocID: SDD-API-001
|
||||
Rationale: 實現 SDD.md 第3節定義的 API 接口
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, File, UploadFile, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
|
||||
from .schemas import (
|
||||
Event, TimelineConfig, ExportOptions, Theme,
|
||||
ImportResult, RenderResult, APIResponse,
|
||||
ThemeStyle, ExportFormat
|
||||
)
|
||||
from .importer import CSVImporter, ImporterError
|
||||
from .renderer_timeline import ClassicTimelineRenderer
|
||||
from .export import TimelineExporter, ExportError
|
||||
|
||||
# 設定日誌
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 建立 FastAPI 應用
|
||||
app = FastAPI(
|
||||
title="TimeLine Designer API",
|
||||
description="時間軸設計工具 REST API",
|
||||
version="1.0.0",
|
||||
docs_url="/api/docs",
|
||||
redoc_url="/api/redoc"
|
||||
)
|
||||
|
||||
# 設定 CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # 在生產環境應該限制為特定來源
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# 全域儲存(簡單起見,使用記憶體儲存,實際應用應使用資料庫)
|
||||
events_store: List[Event] = []
|
||||
|
||||
# 初始化服務
|
||||
csv_importer = CSVImporter()
|
||||
timeline_renderer = ClassicTimelineRenderer()
|
||||
timeline_exporter = TimelineExporter()
|
||||
|
||||
|
||||
# ==================== 健康檢查 ====================
|
||||
|
||||
@app.get("/health", tags=["System"])
|
||||
async def health_check():
|
||||
"""健康檢查端點"""
|
||||
return APIResponse(
|
||||
success=True,
|
||||
message="Service is healthy",
|
||||
data={
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ==================== 匯入 API ====================
|
||||
|
||||
@app.post("/api/import", response_model=ImportResult, tags=["Import"])
|
||||
async def import_events(file: UploadFile = File(...)):
|
||||
"""
|
||||
匯入事件資料
|
||||
|
||||
對應 SDD.md - POST /import
|
||||
支援 CSV 和 XLSX 格式
|
||||
|
||||
Args:
|
||||
file: 上傳的檔案
|
||||
|
||||
Returns:
|
||||
ImportResult: 匯入結果
|
||||
"""
|
||||
try:
|
||||
# 驗證檔案類型
|
||||
if not file.filename:
|
||||
raise HTTPException(status_code=400, detail="未提供檔案名稱")
|
||||
|
||||
file_ext = Path(file.filename).suffix.lower()
|
||||
if file_ext not in ['.csv', '.xlsx', '.xls']:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"不支援的檔案格式: {file_ext},僅支援 CSV 和 XLSX"
|
||||
)
|
||||
|
||||
# 儲存上傳檔案到臨時目錄
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=file_ext) as tmp_file:
|
||||
content = await file.read()
|
||||
tmp_file.write(content)
|
||||
tmp_path = tmp_file.name
|
||||
|
||||
try:
|
||||
# 匯入資料
|
||||
result = csv_importer.import_file(tmp_path)
|
||||
|
||||
if result.success:
|
||||
# 更新全域儲存
|
||||
global events_store
|
||||
events_store = result.events
|
||||
logger.info(f"成功匯入 {result.imported_count} 筆事件")
|
||||
|
||||
# 🔍 調試:檢查 result 的所有欄位類型
|
||||
logger.debug(f"ImportResult 類型檢查:")
|
||||
logger.debug(f" success: {type(result.success).__name__}")
|
||||
logger.debug(f" total_rows: {type(result.total_rows).__name__} = {result.total_rows}")
|
||||
logger.debug(f" imported_count: {type(result.imported_count).__name__} = {result.imported_count}")
|
||||
logger.debug(f" events count: {len(result.events)}")
|
||||
logger.debug(f" errors count: {len(result.errors)}")
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
# 清理臨時檔案
|
||||
os.unlink(tmp_path)
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions (from validation)
|
||||
raise
|
||||
except ImporterError as e:
|
||||
logger.error(f"匯入失敗: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"未預期的錯誤: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"伺服器錯誤: {str(e)}")
|
||||
|
||||
|
||||
# ==================== 事件管理 API ====================
|
||||
|
||||
@app.get("/api/events", response_model=List[Event], tags=["Events"])
|
||||
async def get_events():
|
||||
"""
|
||||
取得事件列表
|
||||
|
||||
對應 SDD.md - GET /events
|
||||
|
||||
Returns:
|
||||
List[Event]: 事件列表
|
||||
"""
|
||||
return events_store
|
||||
|
||||
|
||||
@app.get("/api/events/raw", tags=["Events"])
|
||||
async def get_raw_events():
|
||||
"""
|
||||
取得原始事件資料(用於前端 D3.js 渲染)
|
||||
|
||||
返回不經過任何布局計算的原始事件資料,
|
||||
供前端 D3 Force-Directed Layout 使用。
|
||||
|
||||
Returns:
|
||||
dict: 包含原始事件資料的字典
|
||||
"""
|
||||
return {
|
||||
"success": True,
|
||||
"events": [
|
||||
{
|
||||
"id": i,
|
||||
"start": event.start.isoformat(),
|
||||
"end": event.end.isoformat() if event.end else None,
|
||||
"title": event.title,
|
||||
"description": event.description or "",
|
||||
"color": event.color or "#3B82F6",
|
||||
"layer": i % 4 # 簡單的層級分配:0-3 循環
|
||||
}
|
||||
for i, event in enumerate(events_store)
|
||||
],
|
||||
"count": len(events_store)
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/events", response_model=Event, tags=["Events"])
|
||||
async def add_event(event: Event):
|
||||
"""
|
||||
新增單一事件
|
||||
|
||||
Args:
|
||||
event: 事件物件
|
||||
|
||||
Returns:
|
||||
Event: 新增的事件
|
||||
"""
|
||||
global events_store
|
||||
events_store.append(event)
|
||||
logger.info(f"新增事件: {event.id} - {event.title}")
|
||||
return event
|
||||
|
||||
|
||||
@app.delete("/api/events/{event_id}", tags=["Events"])
|
||||
async def delete_event(event_id: str):
|
||||
"""
|
||||
刪除事件
|
||||
|
||||
Args:
|
||||
event_id: 事件ID
|
||||
|
||||
Returns:
|
||||
APIResponse: 操作結果
|
||||
"""
|
||||
global events_store
|
||||
original_count = len(events_store)
|
||||
events_store = [e for e in events_store if e.id != event_id]
|
||||
|
||||
if len(events_store) < original_count:
|
||||
logger.info(f"刪除事件: {event_id}")
|
||||
return APIResponse(success=True, message=f"成功刪除事件 {event_id}")
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail=f"找不到事件: {event_id}")
|
||||
|
||||
|
||||
@app.delete("/api/events", tags=["Events"])
|
||||
async def clear_events():
|
||||
"""
|
||||
清空所有事件
|
||||
|
||||
Returns:
|
||||
APIResponse: 操作結果
|
||||
"""
|
||||
global events_store
|
||||
count = len(events_store)
|
||||
events_store = []
|
||||
logger.info(f"清空事件,共 {count} 筆")
|
||||
return APIResponse(success=True, message=f"成功清空 {count} 筆事件")
|
||||
|
||||
|
||||
# ==================== 渲染 API ====================
|
||||
|
||||
class RenderRequest(BaseModel):
|
||||
"""渲染請求模型"""
|
||||
events: Optional[List[Event]] = None
|
||||
config: TimelineConfig = TimelineConfig()
|
||||
|
||||
|
||||
@app.post("/api/render", response_model=RenderResult, tags=["Render"])
|
||||
async def render_timeline(request: RenderRequest):
|
||||
"""
|
||||
生成時間軸 JSON
|
||||
|
||||
對應 SDD.md - POST /render
|
||||
生成 Plotly JSON 格式的時間軸資料
|
||||
|
||||
Args:
|
||||
request: 渲染請求(可選事件列表與配置)
|
||||
|
||||
Returns:
|
||||
RenderResult: Plotly JSON 資料
|
||||
"""
|
||||
try:
|
||||
# 使用請求中的事件或全域事件
|
||||
events = request.events if request.events is not None else events_store
|
||||
|
||||
if not events:
|
||||
logger.warning("嘗試渲染空白事件列表")
|
||||
|
||||
# 渲染
|
||||
result = timeline_renderer.render(events, request.config)
|
||||
|
||||
if result.success:
|
||||
logger.info(f"成功渲染 {len(events)} 筆事件")
|
||||
else:
|
||||
logger.error("渲染失敗")
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"渲染錯誤: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"渲染失敗: {str(e)}")
|
||||
|
||||
|
||||
# ==================== 匯出 API ====================
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
"""匯出請求模型"""
|
||||
plotly_data: dict
|
||||
plotly_layout: dict
|
||||
options: ExportOptions
|
||||
filename: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/export", tags=["Export"])
|
||||
async def export_timeline(request: ExportRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
導出時間軸圖
|
||||
|
||||
對應 SDD.md - POST /export
|
||||
匯出為 PNG、PDF 或 SVG 格式
|
||||
|
||||
Args:
|
||||
request: 匯出請求
|
||||
background_tasks: 背景任務(用於清理臨時檔案)
|
||||
|
||||
Returns:
|
||||
FileResponse: 圖檔
|
||||
"""
|
||||
try:
|
||||
# 建立臨時輸出目錄
|
||||
temp_dir = Path(tempfile.gettempdir()) / "timeline_exports"
|
||||
temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# 生成檔名
|
||||
if request.filename:
|
||||
filename = request.filename
|
||||
else:
|
||||
filename = timeline_exporter.generate_default_filename(request.options.fmt)
|
||||
|
||||
output_path = temp_dir / filename
|
||||
|
||||
# 匯出
|
||||
result_path = timeline_exporter.export_from_plotly_json(
|
||||
request.plotly_data,
|
||||
request.plotly_layout,
|
||||
output_path,
|
||||
request.options
|
||||
)
|
||||
|
||||
logger.info(f"成功匯出: {result_path}")
|
||||
|
||||
# 設定背景任務清理檔案(1小時後)
|
||||
def cleanup_file():
|
||||
try:
|
||||
if result_path.exists():
|
||||
os.unlink(result_path)
|
||||
logger.info(f"清理臨時檔案: {result_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理檔案失敗: {str(e)}")
|
||||
|
||||
background_tasks.add_task(cleanup_file)
|
||||
|
||||
# 回傳檔案
|
||||
media_type_map = {
|
||||
ExportFormat.PNG: "image/png",
|
||||
ExportFormat.PDF: "application/pdf",
|
||||
ExportFormat.SVG: "image/svg+xml",
|
||||
}
|
||||
|
||||
return FileResponse(
|
||||
path=str(result_path),
|
||||
media_type=media_type_map.get(request.options.fmt, "application/octet-stream"),
|
||||
filename=result_path.name
|
||||
)
|
||||
|
||||
except ExportError as e:
|
||||
logger.error(f"匯出失敗: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"未預期的錯誤: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"伺服器錯誤: {str(e)}")
|
||||
|
||||
|
||||
# ==================== 主題 API ====================
|
||||
|
||||
@app.get("/api/themes", response_model=List[Theme], tags=["Themes"])
|
||||
async def get_themes():
|
||||
"""
|
||||
取得主題列表
|
||||
|
||||
對應 SDD.md - GET /themes
|
||||
|
||||
Returns:
|
||||
List[Theme]: 主題列表
|
||||
"""
|
||||
themes = [
|
||||
Theme(
|
||||
name="現代風格",
|
||||
style=ThemeStyle.MODERN,
|
||||
primary_color="#3B82F6",
|
||||
background_color="#FFFFFF",
|
||||
text_color="#1F2937"
|
||||
),
|
||||
Theme(
|
||||
name="經典風格",
|
||||
style=ThemeStyle.CLASSIC,
|
||||
primary_color="#6366F1",
|
||||
background_color="#F9FAFB",
|
||||
text_color="#374151"
|
||||
),
|
||||
Theme(
|
||||
name="極簡風格",
|
||||
style=ThemeStyle.MINIMAL,
|
||||
primary_color="#000000",
|
||||
background_color="#FFFFFF",
|
||||
text_color="#000000"
|
||||
),
|
||||
Theme(
|
||||
name="企業風格",
|
||||
style=ThemeStyle.CORPORATE,
|
||||
primary_color="#1F2937",
|
||||
background_color="#F3F4F6",
|
||||
text_color="#111827"
|
||||
),
|
||||
]
|
||||
return themes
|
||||
|
||||
|
||||
# ==================== 錯誤處理 ====================
|
||||
|
||||
@app.exception_handler(404)
|
||||
async def not_found_handler(request, exc):
|
||||
"""404 錯誤處理"""
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content=APIResponse(
|
||||
success=False,
|
||||
message="找不到請求的資源",
|
||||
error_code="NOT_FOUND"
|
||||
).dict()
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(500)
|
||||
async def internal_error_handler(request, exc):
|
||||
"""500 錯誤處理"""
|
||||
logger.error(f"內部伺服器錯誤: {str(exc)}")
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content=APIResponse(
|
||||
success=False,
|
||||
message="內部伺服器錯誤",
|
||||
error_code="INTERNAL_ERROR"
|
||||
).dict()
|
||||
)
|
||||
|
||||
|
||||
# ==================== 啟動事件 ====================
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""應用啟動時執行"""
|
||||
logger.info("TimeLine Designer API 啟動")
|
||||
logger.info("API 文檔: http://localhost:8000/api/docs")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
"""應用關閉時執行"""
|
||||
logger.info("TimeLine Designer API 關閉")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000, log_level="info")
|
||||
Reference in New Issue
Block a user