Files
Timeline_Generator/backend/main.py
beabigegg 2d37d23bcf v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 11:35:29 +08:00

466 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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")