v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
45
backend/__init__.py
Normal file
45
backend/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
TimeLine Designer Backend Package
|
||||
|
||||
本套件提供時間軸設計工具的後端 API 服務。
|
||||
|
||||
Modules:
|
||||
- schemas: 資料模型定義
|
||||
- importer: CSV/XLSX 匯入處理
|
||||
- renderer: Plotly 時間軸渲染
|
||||
- export: PDF/SVG/PNG 匯出
|
||||
- main: FastAPI 主程式
|
||||
|
||||
Version: 1.0.0
|
||||
Author: AI Agent
|
||||
DocID: SDD-BACKEND-001
|
||||
"""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
__author__ = "AI Agent"
|
||||
|
||||
from .schemas import (
|
||||
Event,
|
||||
EventType,
|
||||
TimelineConfig,
|
||||
ThemeStyle,
|
||||
ExportOptions,
|
||||
ExportFormat,
|
||||
Theme,
|
||||
ImportResult,
|
||||
RenderResult,
|
||||
APIResponse
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Event",
|
||||
"EventType",
|
||||
"TimelineConfig",
|
||||
"ThemeStyle",
|
||||
"ExportOptions",
|
||||
"ExportFormat",
|
||||
"Theme",
|
||||
"ImportResult",
|
||||
"RenderResult",
|
||||
"APIResponse"
|
||||
]
|
||||
343
backend/export.py
Normal file
343
backend/export.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
匯出模組
|
||||
|
||||
本模組負責將時間軸圖表匯出為各種格式(PDF、PNG、SVG)。
|
||||
使用 Plotly 的 kaleido 引擎進行圖片生成。
|
||||
|
||||
Author: AI Agent
|
||||
Version: 1.0.0
|
||||
DocID: SDD-EXP-001
|
||||
Related: TDD-UT-EXP-001
|
||||
Rationale: 實現 SDD.md 定義的 POST /export API 功能
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from typing import Union, Optional
|
||||
import logging
|
||||
import re
|
||||
|
||||
try:
|
||||
import plotly.graph_objects as go
|
||||
from plotly.io import write_image
|
||||
PLOTLY_AVAILABLE = True
|
||||
except ImportError:
|
||||
PLOTLY_AVAILABLE = False
|
||||
|
||||
from .schemas import ExportOptions, ExportFormat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ExportError(Exception):
|
||||
"""匯出錯誤基礎類別"""
|
||||
pass
|
||||
|
||||
|
||||
class FileNameSanitizer:
|
||||
"""
|
||||
檔名淨化器
|
||||
|
||||
移除非法字元並處理過長的檔名。
|
||||
"""
|
||||
|
||||
# 非法字元(Windows + Unix)
|
||||
ILLEGAL_CHARS = r'[<>:"/\\|?*\x00-\x1f]'
|
||||
|
||||
# 保留字(Windows)
|
||||
RESERVED_NAMES = [
|
||||
'CON', 'PRN', 'AUX', 'NUL',
|
||||
'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9',
|
||||
'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9',
|
||||
]
|
||||
|
||||
MAX_LENGTH = 200 # 最大檔名長度
|
||||
|
||||
@classmethod
|
||||
def sanitize(cls, filename: str) -> str:
|
||||
"""
|
||||
淨化檔名
|
||||
|
||||
Args:
|
||||
filename: 原始檔名
|
||||
|
||||
Returns:
|
||||
淨化後的檔名
|
||||
"""
|
||||
# 移除非法字元
|
||||
sanitized = re.sub(cls.ILLEGAL_CHARS, '_', filename)
|
||||
|
||||
# 移除前後空白
|
||||
sanitized = sanitized.strip()
|
||||
|
||||
# 移除尾部的點和空格(Windows 限制)
|
||||
sanitized = sanitized.rstrip('. ')
|
||||
|
||||
# 檢查保留字
|
||||
name_upper = sanitized.upper()
|
||||
if name_upper in cls.RESERVED_NAMES:
|
||||
sanitized = '_' + sanitized
|
||||
|
||||
# 限制長度
|
||||
if len(sanitized) > cls.MAX_LENGTH:
|
||||
sanitized = sanitized[:cls.MAX_LENGTH]
|
||||
|
||||
# 如果為空,使用預設名稱
|
||||
if not sanitized:
|
||||
sanitized = 'timeline'
|
||||
|
||||
return sanitized
|
||||
|
||||
|
||||
class ExportEngine:
|
||||
"""
|
||||
匯出引擎
|
||||
|
||||
負責將 Plotly 圖表匯出為不同格式的檔案。
|
||||
對應 TDD.md - UT-EXP-01
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if not PLOTLY_AVAILABLE:
|
||||
raise ImportError("需要安裝 plotly 和 kaleido 以使用匯出功能")
|
||||
|
||||
self.filename_sanitizer = FileNameSanitizer()
|
||||
|
||||
def export(
|
||||
self,
|
||||
fig: go.Figure,
|
||||
output_path: Union[str, Path],
|
||||
options: ExportOptions
|
||||
) -> Path:
|
||||
"""
|
||||
匯出圖表
|
||||
|
||||
Args:
|
||||
fig: Plotly Figure 物件
|
||||
output_path: 輸出路徑
|
||||
options: 匯出選項
|
||||
|
||||
Returns:
|
||||
實際輸出檔案的路徑
|
||||
|
||||
Raises:
|
||||
ExportError: 匯出失敗時拋出
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
|
||||
# 確保目錄存在
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 淨化檔名
|
||||
filename = self.filename_sanitizer.sanitize(output_path.stem)
|
||||
sanitized_path = output_path.parent / f"{filename}{output_path.suffix}"
|
||||
|
||||
try:
|
||||
if options.fmt == ExportFormat.PDF:
|
||||
return self._export_pdf(fig, sanitized_path, options)
|
||||
elif options.fmt == ExportFormat.PNG:
|
||||
return self._export_png(fig, sanitized_path, options)
|
||||
elif options.fmt == ExportFormat.SVG:
|
||||
return self._export_svg(fig, sanitized_path, options)
|
||||
else:
|
||||
raise ExportError(f"不支援的匯出格式: {options.fmt}")
|
||||
|
||||
except PermissionError:
|
||||
raise ExportError(f"無法寫入檔案(權限不足): {sanitized_path}")
|
||||
except OSError as e:
|
||||
if e.errno == 28: # ENOSPC
|
||||
raise ExportError("磁碟空間不足")
|
||||
else:
|
||||
raise ExportError(f"檔案系統錯誤: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"匯出失敗: {str(e)}")
|
||||
raise ExportError(f"匯出失敗: {str(e)}")
|
||||
|
||||
def _export_pdf(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path:
|
||||
"""
|
||||
匯出為 PDF
|
||||
|
||||
Args:
|
||||
fig: Plotly Figure
|
||||
output_path: 輸出路徑
|
||||
options: 匯出選項
|
||||
|
||||
Returns:
|
||||
輸出檔案路徑
|
||||
"""
|
||||
# 確保副檔名
|
||||
if output_path.suffix.lower() != '.pdf':
|
||||
output_path = output_path.with_suffix('.pdf')
|
||||
|
||||
# 設定 DPI 和尺寸
|
||||
scale = options.dpi / 72.0 # Plotly 使用 72 DPI 作為基準
|
||||
|
||||
# 匯出
|
||||
write_image(
|
||||
fig,
|
||||
str(output_path),
|
||||
format='pdf',
|
||||
width=options.width,
|
||||
height=options.height,
|
||||
scale=scale
|
||||
)
|
||||
|
||||
logger.info(f"PDF 匯出成功: {output_path}")
|
||||
return output_path
|
||||
|
||||
def _export_png(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path:
|
||||
"""
|
||||
匯出為 PNG
|
||||
|
||||
Args:
|
||||
fig: Plotly Figure
|
||||
output_path: 輸出路徑
|
||||
options: 匯出選項
|
||||
|
||||
Returns:
|
||||
輸出檔案路徑
|
||||
"""
|
||||
# 確保副檔名
|
||||
if output_path.suffix.lower() != '.png':
|
||||
output_path = output_path.with_suffix('.png')
|
||||
|
||||
# 設定 DPI 和尺寸
|
||||
scale = options.dpi / 72.0
|
||||
|
||||
# 處理透明背景
|
||||
if options.transparent_background:
|
||||
fig.update_layout(
|
||||
paper_bgcolor='rgba(0,0,0,0)',
|
||||
plot_bgcolor='rgba(0,0,0,0)'
|
||||
)
|
||||
|
||||
# 匯出
|
||||
write_image(
|
||||
fig,
|
||||
str(output_path),
|
||||
format='png',
|
||||
width=options.width,
|
||||
height=options.height,
|
||||
scale=scale
|
||||
)
|
||||
|
||||
logger.info(f"PNG 匯出成功: {output_path}")
|
||||
return output_path
|
||||
|
||||
def _export_svg(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path:
|
||||
"""
|
||||
匯出為 SVG
|
||||
|
||||
Args:
|
||||
fig: Plotly Figure
|
||||
output_path: 輸出路徑
|
||||
options: 匯出選項
|
||||
|
||||
Returns:
|
||||
輸出檔案路徑
|
||||
"""
|
||||
# 確保副檔名
|
||||
if output_path.suffix.lower() != '.svg':
|
||||
output_path = output_path.with_suffix('.svg')
|
||||
|
||||
# SVG 是向量格式,不需要 DPI 設定
|
||||
write_image(
|
||||
fig,
|
||||
str(output_path),
|
||||
format='svg',
|
||||
width=options.width,
|
||||
height=options.height
|
||||
)
|
||||
|
||||
logger.info(f"SVG 匯出成功: {output_path}")
|
||||
return output_path
|
||||
|
||||
|
||||
class TimelineExporter:
|
||||
"""
|
||||
時間軸匯出器
|
||||
|
||||
高層級介面,整合渲染與匯出功能。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.export_engine = ExportEngine()
|
||||
|
||||
def export_from_plotly_json(
|
||||
self,
|
||||
plotly_data: dict,
|
||||
plotly_layout: dict,
|
||||
output_path: Union[str, Path],
|
||||
options: ExportOptions,
|
||||
filename_prefix: str = "timeline"
|
||||
) -> Path:
|
||||
"""
|
||||
從 Plotly JSON 資料匯出
|
||||
|
||||
Args:
|
||||
plotly_data: Plotly data 部分
|
||||
plotly_layout: Plotly layout 部分
|
||||
output_path: 輸出路徑(目錄或完整路徑)
|
||||
options: 匯出選項
|
||||
filename_prefix: 檔名前綴
|
||||
|
||||
Returns:
|
||||
實際輸出檔案的路徑
|
||||
"""
|
||||
# 建立 Plotly Figure
|
||||
fig = go.Figure(data=plotly_data.get('data', []), layout=plotly_layout)
|
||||
|
||||
# 處理輸出路徑
|
||||
output_path = Path(output_path)
|
||||
if output_path.is_dir():
|
||||
# 如果是目錄,生成預設檔名
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{filename_prefix}_{timestamp}.{options.fmt.value}"
|
||||
full_path = output_path / filename
|
||||
else:
|
||||
full_path = output_path
|
||||
|
||||
# 匯出
|
||||
return self.export_engine.export(fig, full_path, options)
|
||||
|
||||
def generate_default_filename(self, fmt: ExportFormat) -> str:
|
||||
"""
|
||||
生成預設檔名
|
||||
|
||||
Args:
|
||||
fmt: 檔案格式
|
||||
|
||||
Returns:
|
||||
預設檔名
|
||||
"""
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
return f"timeline_{timestamp}.{fmt.value}"
|
||||
|
||||
|
||||
def create_metadata(title: str = "TimeLine Designer") -> dict:
|
||||
"""
|
||||
建立 PDF 元資料
|
||||
|
||||
Args:
|
||||
title: 文件標題
|
||||
|
||||
Returns:
|
||||
元資料字典
|
||||
"""
|
||||
return {
|
||||
'Title': title,
|
||||
'Creator': 'TimeLine Designer v1.0',
|
||||
'Producer': 'Plotly + Kaleido',
|
||||
'CreationDate': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
|
||||
}
|
||||
|
||||
|
||||
# 匯出主要介面
|
||||
__all__ = [
|
||||
'ExportEngine',
|
||||
'TimelineExporter',
|
||||
'ExportError',
|
||||
'ExportOptions',
|
||||
'ExportFormat',
|
||||
]
|
||||
517
backend/importer.py
Normal file
517
backend/importer.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
CSV/XLSX 匯入模組
|
||||
|
||||
本模組負責處理時間軸事件的資料匯入。
|
||||
支援 CSV 和 XLSX 格式,包含欄位自動對應與格式容錯功能。
|
||||
|
||||
Author: AI Agent
|
||||
Version: 1.0.0
|
||||
DocID: SDD-IMP-001
|
||||
Related: TDD-UT-IMP-001
|
||||
Rationale: 實現 SDD.md 定義的 POST /import API 功能
|
||||
"""
|
||||
|
||||
import csv
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any, Optional, Union
|
||||
import logging
|
||||
|
||||
try:
|
||||
import pandas as pd
|
||||
PANDAS_AVAILABLE = True
|
||||
except ImportError:
|
||||
PANDAS_AVAILABLE = False
|
||||
|
||||
from .schemas import Event, ImportResult, EventType
|
||||
|
||||
# 設定日誌
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImporterError(Exception):
|
||||
"""匯入器錯誤基礎類別"""
|
||||
pass
|
||||
|
||||
|
||||
class FieldMapper:
|
||||
"""
|
||||
欄位對應器
|
||||
|
||||
負責將不同的欄位名稱映射到標準欄位。
|
||||
支援多語言和不同命名習慣。
|
||||
"""
|
||||
|
||||
# 欄位對應字典
|
||||
FIELD_MAPPING = {
|
||||
'id': ['id', 'ID', '編號', '序號', 'identifier'],
|
||||
'title': ['title', 'Title', '標題', '名稱', 'name', 'event'],
|
||||
'start': ['start', 'Start', '開始', '開始時間', 'start_time', 'begin', 'time', 'Time', '時間', 'date', 'Date', '日期'],
|
||||
'group': ['group', 'Group', '群組', '分類', 'category', 'phase'],
|
||||
'description': ['description', 'Description', '描述', '說明', 'detail', 'note'],
|
||||
'color': ['color', 'Color', '顏色', 'colour'],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def map_fields(cls, headers: List[str]) -> Dict[str, str]:
|
||||
"""
|
||||
將 CSV/XLSX 的欄位名稱映射到標準欄位
|
||||
|
||||
Args:
|
||||
headers: 原始欄位名稱列表
|
||||
|
||||
Returns:
|
||||
映射字典 {標準欄位: 原始欄位}
|
||||
"""
|
||||
mapping = {}
|
||||
headers_lower = [h.strip() for h in headers]
|
||||
|
||||
for standard_field, variants in cls.FIELD_MAPPING.items():
|
||||
for header in headers_lower:
|
||||
if header in variants or header.lower() in [v.lower() for v in variants]:
|
||||
# 找到原始 header(保留大小寫)
|
||||
original_header = headers[headers_lower.index(header)]
|
||||
mapping[standard_field] = original_header
|
||||
break
|
||||
|
||||
return mapping
|
||||
|
||||
@classmethod
|
||||
def validate_required_fields(cls, mapping: Dict[str, str]) -> List[str]:
|
||||
"""
|
||||
驗證必要欄位是否存在
|
||||
|
||||
Args:
|
||||
mapping: 欄位映射字典
|
||||
|
||||
Returns:
|
||||
缺少的必要欄位列表
|
||||
"""
|
||||
required_fields = ['id', 'title', 'start']
|
||||
missing_fields = [f for f in required_fields if f not in mapping]
|
||||
return missing_fields
|
||||
|
||||
|
||||
class DateParser:
|
||||
"""
|
||||
日期解析器
|
||||
|
||||
支援多種日期格式的容錯解析。
|
||||
"""
|
||||
|
||||
# 支援的日期格式列表
|
||||
DATE_FORMATS = [
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y/%m/%d %H:%M:%S',
|
||||
'%Y-%m-%d',
|
||||
'%Y/%m/%d',
|
||||
'%d-%m-%Y',
|
||||
'%d/%m/%Y',
|
||||
'%Y年%m月%d日',
|
||||
'%Y年%m月%d日 %H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M:%S.%f',
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def parse(cls, date_str: str) -> Optional[datetime]:
|
||||
"""
|
||||
解析日期字串
|
||||
|
||||
Args:
|
||||
date_str: 日期字串或 Excel 日期序列號
|
||||
|
||||
Returns:
|
||||
datetime 物件,解析失敗則回傳 None
|
||||
"""
|
||||
if not date_str or (isinstance(date_str, str) and not date_str.strip()):
|
||||
return None
|
||||
|
||||
# 如果是數字(Excel 日期序列號),先轉換
|
||||
if isinstance(date_str, (int, float)):
|
||||
if PANDAS_AVAILABLE:
|
||||
try:
|
||||
# Excel 日期從 1899-12-30 開始計算
|
||||
return pd.to_datetime(date_str, origin='1899-12-30', unit='D')
|
||||
except Exception as e:
|
||||
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
|
||||
return None
|
||||
else:
|
||||
# 沒有 pandas,使用標準庫手動計算
|
||||
try:
|
||||
excel_epoch = datetime(1899, 12, 30)
|
||||
return excel_epoch + timedelta(days=int(date_str))
|
||||
except Exception as e:
|
||||
logger.warning(f"無法解析 Excel 日期序列號 {date_str}: {str(e)}")
|
||||
return None
|
||||
|
||||
date_str = str(date_str).strip()
|
||||
|
||||
# 嘗試各種格式
|
||||
for fmt in cls.DATE_FORMATS:
|
||||
try:
|
||||
return datetime.strptime(date_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# 嘗試使用 pandas 的智能解析(如果可用)
|
||||
if PANDAS_AVAILABLE:
|
||||
try:
|
||||
return pd.to_datetime(date_str)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
logger.warning(f"無法解析日期: {date_str}")
|
||||
return None
|
||||
|
||||
|
||||
class ColorValidator:
|
||||
"""
|
||||
顏色格式驗證器
|
||||
"""
|
||||
|
||||
# HEX 顏色正則表達式
|
||||
HEX_PATTERN = re.compile(r'^#[0-9A-Fa-f]{6}$')
|
||||
|
||||
# 預設顏色
|
||||
DEFAULT_COLORS = [
|
||||
'#3B82F6', # 藍色
|
||||
'#10B981', # 綠色
|
||||
'#F59E0B', # 橙色
|
||||
'#EF4444', # 紅色
|
||||
'#8B5CF6', # 紫色
|
||||
'#EC4899', # 粉色
|
||||
'#14B8A6', # 青色
|
||||
'#F97316', # 深橙
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def validate(cls, color: str, index: int = 0) -> str:
|
||||
"""
|
||||
驗證顏色格式
|
||||
|
||||
Args:
|
||||
color: 顏色字串
|
||||
index: 索引(用於選擇預設顏色)
|
||||
|
||||
Returns:
|
||||
有效的 HEX 顏色代碼
|
||||
"""
|
||||
# 確保 index 是整數(防止 pandas 傳入 float)
|
||||
index = int(index) if index is not None else 0
|
||||
|
||||
if not color:
|
||||
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
|
||||
|
||||
color = str(color).strip().upper()
|
||||
|
||||
# 補充 # 符號
|
||||
if not color.startswith('#'):
|
||||
color = '#' + color
|
||||
|
||||
# 驗證格式
|
||||
if cls.HEX_PATTERN.match(color):
|
||||
return color
|
||||
|
||||
# 格式無效,使用預設顏色
|
||||
logger.warning(f"無效的顏色格式: {color},使用預設顏色")
|
||||
return cls.DEFAULT_COLORS[index % len(cls.DEFAULT_COLORS)]
|
||||
|
||||
|
||||
class CSVImporter:
|
||||
"""
|
||||
CSV/XLSX 匯入器
|
||||
|
||||
負責讀取 CSV 或 XLSX 檔案並轉換為 Event 物件列表。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.field_mapper = FieldMapper()
|
||||
self.date_parser = DateParser()
|
||||
self.color_validator = ColorValidator()
|
||||
|
||||
def import_file(self, file_path: Union[str, Path]) -> ImportResult:
|
||||
"""
|
||||
匯入 CSV 或 XLSX 檔案
|
||||
|
||||
Args:
|
||||
file_path: 檔案路徑
|
||||
|
||||
Returns:
|
||||
ImportResult 物件
|
||||
"""
|
||||
file_path = Path(file_path)
|
||||
|
||||
if not file_path.exists():
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=[f"檔案不存在: {file_path}"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
# 根據副檔名選擇處理方式
|
||||
if file_path.suffix.lower() == '.csv':
|
||||
return self._import_csv(file_path)
|
||||
elif file_path.suffix.lower() in ['.xlsx', '.xls']:
|
||||
return self._import_xlsx(file_path)
|
||||
else:
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=[f"不支援的檔案格式: {file_path.suffix}"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
def _import_csv(self, file_path: Path) -> ImportResult:
|
||||
"""
|
||||
匯入 CSV 檔案
|
||||
|
||||
Args:
|
||||
file_path: CSV 檔案路徑
|
||||
|
||||
Returns:
|
||||
ImportResult 物件
|
||||
"""
|
||||
events = []
|
||||
errors = []
|
||||
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8-sig') as f:
|
||||
reader = csv.DictReader(f)
|
||||
headers = reader.fieldnames
|
||||
|
||||
if not headers:
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=["CSV 檔案為空"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
# 欄位映射
|
||||
field_mapping = self.field_mapper.map_fields(headers)
|
||||
logger.info(f"CSV 欄位映射結果: {field_mapping}")
|
||||
logger.info(f"原始欄位: {headers}")
|
||||
|
||||
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
|
||||
|
||||
if missing_fields:
|
||||
logger.error(f"缺少必要欄位: {missing_fields}")
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
# 逐行處理
|
||||
row_num = 1
|
||||
for row in reader:
|
||||
row_num += 1
|
||||
try:
|
||||
logger.debug(f"處理第 {row_num} 行: {row}")
|
||||
event = self._parse_row(row, field_mapping, row_num)
|
||||
if event:
|
||||
events.append(event)
|
||||
logger.debug(f"成功匯入第 {row_num} 行")
|
||||
else:
|
||||
logger.warning(f"第 {row_num} 行返回 None")
|
||||
except Exception as e:
|
||||
error_msg = f"第 {row_num} 行錯誤: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
logger.error(error_msg)
|
||||
|
||||
return ImportResult(
|
||||
success=True,
|
||||
events=events,
|
||||
errors=errors,
|
||||
total_rows=int(row_num - 1),
|
||||
imported_count=int(len(events))
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"CSV 匯入失敗: {str(e)}")
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=[f"CSV 匯入失敗: {str(e)}"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
def _import_xlsx(self, file_path: Path) -> ImportResult:
|
||||
"""
|
||||
匯入 XLSX 檔案
|
||||
|
||||
Args:
|
||||
file_path: XLSX 檔案路徑
|
||||
|
||||
Returns:
|
||||
ImportResult 物件
|
||||
"""
|
||||
if not PANDAS_AVAILABLE:
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=["需要安裝 pandas 和 openpyxl 以支援 XLSX 匯入"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
try:
|
||||
# 讀取第一個工作表
|
||||
df = pd.read_excel(file_path, sheet_name=0)
|
||||
|
||||
if df.empty:
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=["XLSX 檔案為空"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
# 轉換為字典列表
|
||||
records = df.to_dict('records')
|
||||
headers = df.columns.tolist()
|
||||
|
||||
# 欄位映射
|
||||
field_mapping = self.field_mapper.map_fields(headers)
|
||||
logger.info(f"XLSX 欄位映射結果: {field_mapping}")
|
||||
logger.info(f"原始欄位: {headers}")
|
||||
|
||||
missing_fields = self.field_mapper.validate_required_fields(field_mapping)
|
||||
|
||||
if missing_fields:
|
||||
logger.error(f"缺少必要欄位: {missing_fields}")
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=[f"缺少必要欄位: {', '.join(missing_fields)}"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
# 逐行處理
|
||||
events = []
|
||||
errors = []
|
||||
|
||||
for idx, row in enumerate(records, start=2): # Excel 從第 2 行開始(第 1 行是標題)
|
||||
try:
|
||||
event = self._parse_row(row, field_mapping, idx)
|
||||
if event:
|
||||
events.append(event)
|
||||
except Exception as e:
|
||||
errors.append(f"第 {idx} 行錯誤: {str(e)}")
|
||||
|
||||
return ImportResult(
|
||||
success=True,
|
||||
events=events,
|
||||
errors=errors,
|
||||
total_rows=int(len(records)),
|
||||
imported_count=int(len(events))
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX 匯入失敗: {str(e)}")
|
||||
return ImportResult(
|
||||
success=False,
|
||||
errors=[f"XLSX 匯入失敗: {str(e)}"],
|
||||
total_rows=0,
|
||||
imported_count=0
|
||||
)
|
||||
|
||||
def _parse_row(self, row: Dict[str, Any], field_mapping: Dict[str, str], row_num: int) -> Optional[Event]:
|
||||
"""
|
||||
解析單行資料
|
||||
|
||||
Args:
|
||||
row: 行資料字典
|
||||
field_mapping: 欄位映射
|
||||
row_num: 行號
|
||||
|
||||
Returns:
|
||||
Event 物件或 None
|
||||
"""
|
||||
# 輔助函數:安全地轉換為字串(處理 NaN、None、float 等)
|
||||
def safe_str(value):
|
||||
if pd.isna(value) if PANDAS_AVAILABLE else (value is None or value == ''):
|
||||
return ''
|
||||
# 如果是 float 且接近整數,轉為整數後再轉字串
|
||||
if isinstance(value, float):
|
||||
if value == int(value):
|
||||
return str(int(value))
|
||||
return str(value).strip()
|
||||
|
||||
# 🔍 DEBUG: 顯示原始 row 和 field_mapping
|
||||
logger.debug(f" Row keys: {list(row.keys())}")
|
||||
logger.debug(f" Field mapping: {field_mapping}")
|
||||
|
||||
# 提取欄位值
|
||||
event_id = safe_str(row.get(field_mapping['id'], ''))
|
||||
title = safe_str(row.get(field_mapping['title'], ''))
|
||||
start_str = safe_str(row.get(field_mapping['start'], '')) # 🔧 修復:也要使用 safe_str 轉換
|
||||
group = safe_str(row.get(field_mapping.get('group', ''), '')) or None
|
||||
description = safe_str(row.get(field_mapping.get('description', ''), '')) or None
|
||||
color = safe_str(row.get(field_mapping.get('color', ''), ''))
|
||||
|
||||
# 🔍 DEBUG: 顯示提取的欄位值
|
||||
logger.debug(f" 提取欄位 - ID: '{event_id}', 標題: '{title}', 時間: '{start_str}'")
|
||||
|
||||
# 驗證必要欄位
|
||||
if not event_id or not title:
|
||||
raise ValueError("缺少 ID 或標題")
|
||||
|
||||
if not start_str:
|
||||
raise ValueError("缺少時間欄位")
|
||||
|
||||
# 解析時間(只有一個時間欄位)
|
||||
start = self.date_parser.parse(start_str)
|
||||
if not start:
|
||||
raise ValueError(f"無效的時間: {start_str}")
|
||||
|
||||
# 🔧 修復:將 pandas Timestamp 轉換為標準 datetime
|
||||
if PANDAS_AVAILABLE:
|
||||
if isinstance(start, pd.Timestamp):
|
||||
start = start.to_pydatetime()
|
||||
|
||||
# 驗證顏色(確保返回的是字串,不是 None)
|
||||
color = self.color_validator.validate(color, int(row_num))
|
||||
if not color: # 防禦性檢查
|
||||
color = self.color_validator.DEFAULT_COLORS[0]
|
||||
|
||||
# 所有事件都是時間點類型(不再有區間)
|
||||
event_type = EventType.POINT
|
||||
end = None # 不再使用 end 欄位
|
||||
|
||||
# 建立 Event 物件
|
||||
try:
|
||||
event = Event(
|
||||
id=event_id,
|
||||
title=title,
|
||||
start=start,
|
||||
end=end,
|
||||
group=group,
|
||||
description=description,
|
||||
color=color,
|
||||
event_type=event_type
|
||||
)
|
||||
# 調試:確認所有欄位類型
|
||||
logger.debug(f"Event 創建成功: id={type(event.id).__name__}, title={type(event.title).__name__}, "
|
||||
f"start={type(event.start).__name__}, end={type(event.end).__name__ if event.end else 'None'}, "
|
||||
f"group={type(event.group).__name__ if event.group else 'None'}, "
|
||||
f"description={type(event.description).__name__ if event.description else 'None'}, "
|
||||
f"color={type(event.color).__name__}")
|
||||
return event
|
||||
except Exception as e:
|
||||
logger.error(f"創建 Event 失敗: {str(e)}")
|
||||
logger.error(f" id={event_id} ({type(event_id).__name__})")
|
||||
logger.error(f" title={title} ({type(title).__name__})")
|
||||
logger.error(f" start={start} ({type(start).__name__})")
|
||||
logger.error(f" end={end} ({type(end).__name__ if end else 'None'})")
|
||||
logger.error(f" group={group} ({type(group).__name__ if group else 'None'})")
|
||||
logger.error(f" description={description} ({type(description).__name__ if description else 'None'})")
|
||||
logger.error(f" color={color} ({type(color).__name__})")
|
||||
raise
|
||||
|
||||
|
||||
# 匯出主要介面
|
||||
__all__ = ['CSVImporter', 'ImportResult', 'ImporterError']
|
||||
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")
|
||||
455
backend/path_planner.py
Normal file
455
backend/path_planner.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""
|
||||
網格化路徑規劃器
|
||||
|
||||
使用BFS算法在網格化的繪圖區域中為連接線尋找最佳路徑,
|
||||
完全避開標籤障礙物。
|
||||
|
||||
Author: AI Agent
|
||||
Version: 1.0.0
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Tuple, Optional, Dict
|
||||
from datetime import datetime, timedelta
|
||||
from collections import deque
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GridMap:
|
||||
"""
|
||||
2D網格地圖
|
||||
|
||||
用於路徑規劃的網格化表示,支持障礙物標記和路徑搜尋。
|
||||
"""
|
||||
|
||||
# 格點狀態常量
|
||||
FREE = 0
|
||||
OBSTACLE = 1
|
||||
PATH = 2
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
time_range_seconds: float,
|
||||
y_min: float,
|
||||
y_max: float,
|
||||
grid_cols: int,
|
||||
grid_rows: int,
|
||||
time_start: datetime
|
||||
):
|
||||
"""
|
||||
初始化網格地圖
|
||||
|
||||
Args:
|
||||
time_range_seconds: 時間範圍(秒)
|
||||
y_min: Y軸最小值
|
||||
y_max: Y軸最大值
|
||||
grid_cols: 網格列數(X方向)
|
||||
grid_rows: 網格行數(Y方向)
|
||||
time_start: 時間軸起始時間
|
||||
"""
|
||||
self.time_range_seconds = time_range_seconds
|
||||
self.y_min = y_min
|
||||
self.y_max = y_max
|
||||
self.grid_cols = grid_cols
|
||||
self.grid_rows = grid_rows
|
||||
self.time_start = time_start
|
||||
|
||||
# 創建網格(初始全為FREE)
|
||||
self.grid = np.zeros((grid_rows, grid_cols), dtype=np.int8)
|
||||
|
||||
# 座標轉換比例
|
||||
self.seconds_per_col = time_range_seconds / grid_cols
|
||||
self.y_per_row = (y_max - y_min) / grid_rows
|
||||
|
||||
logger.info(f"創建網格地圖: {grid_cols}列 × {grid_rows}行")
|
||||
logger.info(f" 時間範圍: {time_range_seconds:.0f}秒 ({time_range_seconds/86400:.1f}天)")
|
||||
logger.info(f" Y軸範圍: {y_min:.1f} ~ {y_max:.1f}")
|
||||
logger.info(f" 解析度: {self.seconds_per_col:.2f}秒/格, {self.y_per_row:.3f}Y/格")
|
||||
|
||||
def datetime_to_grid_x(self, dt: datetime) -> int:
|
||||
"""將datetime轉換為網格X座標"""
|
||||
seconds = (dt - self.time_start).total_seconds()
|
||||
col = int(seconds / self.seconds_per_col)
|
||||
return max(0, min(col, self.grid_cols - 1))
|
||||
|
||||
def seconds_to_grid_x(self, seconds: float) -> int:
|
||||
"""將秒數轉換為網格X座標"""
|
||||
col = int(seconds / self.seconds_per_col)
|
||||
return max(0, min(col, self.grid_cols - 1))
|
||||
|
||||
def y_to_grid_y(self, y: float) -> int:
|
||||
"""將Y座標轉換為網格Y座標(注意:Y軸向上,但行索引向下)"""
|
||||
# Y軸向上為正,但網格行索引向下增加,需要翻轉
|
||||
normalized_y = (y - self.y_min) / (self.y_max - self.y_min)
|
||||
row = int((1 - normalized_y) * self.grid_rows)
|
||||
return max(0, min(row, self.grid_rows - 1))
|
||||
|
||||
def grid_to_datetime(self, col: int) -> datetime:
|
||||
"""將網格X座標轉換為datetime"""
|
||||
seconds = col * self.seconds_per_col
|
||||
return self.time_start + timedelta(seconds=seconds)
|
||||
|
||||
def grid_to_y(self, row: int) -> float:
|
||||
"""將網格Y座標轉換為Y座標"""
|
||||
normalized_y = 1 - (row / self.grid_rows)
|
||||
return self.y_min + normalized_y * (self.y_max - self.y_min)
|
||||
|
||||
def mark_rectangle(
|
||||
self,
|
||||
center_x_datetime: datetime,
|
||||
center_y: float,
|
||||
width_seconds: float,
|
||||
height: float,
|
||||
state: int = OBSTACLE,
|
||||
expansion_ratio: float = 0.1
|
||||
):
|
||||
"""
|
||||
標記矩形區域
|
||||
|
||||
Args:
|
||||
center_x_datetime: 矩形中心X座標(datetime)
|
||||
center_y: 矩形中心Y座標
|
||||
width_seconds: 矩形寬度(秒)
|
||||
height: 矩形高度
|
||||
state: 標記狀態(OBSTACLE或PATH)
|
||||
expansion_ratio: 外擴比例(默認10%)
|
||||
"""
|
||||
# 外擴
|
||||
expanded_width = width_seconds * (1 + expansion_ratio)
|
||||
expanded_height = height * (1 + expansion_ratio)
|
||||
|
||||
# 計算矩形範圍
|
||||
center_x_seconds = (center_x_datetime - self.time_start).total_seconds()
|
||||
x_min = center_x_seconds - expanded_width / 2
|
||||
x_max = center_x_seconds + expanded_width / 2
|
||||
y_min = center_y - expanded_height / 2
|
||||
y_max = center_y + expanded_height / 2
|
||||
|
||||
# 轉換為網格座標
|
||||
col_min = self.seconds_to_grid_x(x_min)
|
||||
col_max = self.seconds_to_grid_x(x_max)
|
||||
row_min = self.y_to_grid_y(y_max) # 注意Y軸翻轉
|
||||
row_max = self.y_to_grid_y(y_min)
|
||||
|
||||
# 標記網格
|
||||
for row in range(row_min, row_max + 1):
|
||||
for col in range(col_min, col_max + 1):
|
||||
if 0 <= row < self.grid_rows and 0 <= col < self.grid_cols:
|
||||
self.grid[row, col] = state
|
||||
|
||||
def mark_path(
|
||||
self,
|
||||
path_points: List[Tuple[datetime, float]],
|
||||
width_expansion: float = 2.5
|
||||
):
|
||||
"""
|
||||
標記路徑為障礙物
|
||||
|
||||
Args:
|
||||
path_points: 路徑點列表 [(datetime, y), ...]
|
||||
width_expansion: 寬度擴展倍數
|
||||
|
||||
策略:
|
||||
1. 標記所有線段(包括起點線段)
|
||||
2. 但是起點線段只標記離開時間軸的垂直部分
|
||||
3. 時間軸 y=0 本身不標記,避免阻擋其他起點
|
||||
"""
|
||||
if len(path_points) < 2:
|
||||
return
|
||||
|
||||
# 標記所有線段
|
||||
for i in range(len(path_points) - 1):
|
||||
dt1, y1 = path_points[i]
|
||||
dt2, y2 = path_points[i + 1]
|
||||
|
||||
# 如果是從時間軸(y=0)出發的第一段線段
|
||||
if i == 0 and abs(y1) < 0.1:
|
||||
# 只標記離開時間軸的部分(從 y=0.2 開始)
|
||||
# 避免阻擋其他事件的起點
|
||||
if abs(y2) > 0.2: # 確保終點不在時間軸上
|
||||
# 使用線性插值找到 y=0.2 的點
|
||||
if abs(y2 - y1) > 0.01:
|
||||
t = (0.2 - y1) / (y2 - y1) if y2 > y1 else (-0.2 - y1) / (y2 - y1)
|
||||
if 0 < t < 1:
|
||||
# 計算 y=0.2 時的 datetime
|
||||
seconds_offset = (dt2 - dt1).total_seconds() * t
|
||||
dt_cutoff = dt1 + timedelta(seconds=seconds_offset)
|
||||
y_cutoff = 0.2 if y2 > 0 else -0.2
|
||||
|
||||
# 只標記從 cutoff 點到終點的部分
|
||||
col1 = self.datetime_to_grid_x(dt_cutoff)
|
||||
row1 = self.y_to_grid_y(y_cutoff)
|
||||
col2 = self.datetime_to_grid_x(dt2)
|
||||
row2 = self.y_to_grid_y(y2)
|
||||
self._mark_line(row1, col1, row2, col2, int(width_expansion))
|
||||
else:
|
||||
# t 不在範圍內,標記整段
|
||||
col1 = self.datetime_to_grid_x(dt1)
|
||||
row1 = self.y_to_grid_y(y1)
|
||||
col2 = self.datetime_to_grid_x(dt2)
|
||||
row2 = self.y_to_grid_y(y2)
|
||||
self._mark_line(row1, col1, row2, col2, int(width_expansion))
|
||||
# 如果終點也在時間軸上,不標記
|
||||
else:
|
||||
# 非起點線段,全部標記
|
||||
col1 = self.datetime_to_grid_x(dt1)
|
||||
row1 = self.y_to_grid_y(y1)
|
||||
col2 = self.datetime_to_grid_x(dt2)
|
||||
row2 = self.y_to_grid_y(y2)
|
||||
self._mark_line(row1, col1, row2, col2, int(width_expansion))
|
||||
|
||||
def _mark_line(self, row1: int, col1: int, row2: int, col2: int, thickness: int = 1):
|
||||
"""使用Bresenham算法標記線段"""
|
||||
d_col = abs(col2 - col1)
|
||||
d_row = abs(row2 - row1)
|
||||
col_step = 1 if col1 < col2 else -1
|
||||
row_step = 1 if row1 < row2 else -1
|
||||
|
||||
if d_col > d_row:
|
||||
error = d_col / 2
|
||||
row = row1
|
||||
for col in range(col1, col2 + col_step, col_step):
|
||||
self._mark_point_with_thickness(row, col, thickness)
|
||||
error -= d_row
|
||||
if error < 0:
|
||||
row += row_step
|
||||
error += d_col
|
||||
else:
|
||||
error = d_row / 2
|
||||
col = col1
|
||||
for row in range(row1, row2 + row_step, row_step):
|
||||
self._mark_point_with_thickness(row, col, thickness)
|
||||
error -= d_col
|
||||
if error < 0:
|
||||
col += col_step
|
||||
error += d_row
|
||||
|
||||
def _mark_point_with_thickness(self, row: int, col: int, thickness: int):
|
||||
"""標記點及其周圍(模擬線寬)"""
|
||||
for dr in range(-thickness, thickness + 1):
|
||||
for dc in range(-thickness, thickness + 1):
|
||||
r = row + dr
|
||||
c = col + dc
|
||||
if 0 <= r < self.grid_rows and 0 <= c < self.grid_cols:
|
||||
self.grid[r, c] = self.PATH
|
||||
|
||||
def is_free(self, row: int, col: int) -> bool:
|
||||
"""檢查格點是否可通行"""
|
||||
if not (0 <= row < self.grid_rows and 0 <= col < self.grid_cols):
|
||||
return False
|
||||
return self.grid[row, col] == self.FREE
|
||||
|
||||
|
||||
def auto_calculate_grid_resolution(
|
||||
num_events: int,
|
||||
time_range_seconds: float,
|
||||
canvas_width: int = 1200,
|
||||
canvas_height: int = 600,
|
||||
label_width_ratio: float = 0.15
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
自動計算最佳網格解析度
|
||||
|
||||
綜合考慮:
|
||||
1. 畫布大小(目標:每格12像素)
|
||||
2. 事件密度(密集時提高解析度)
|
||||
3. 標籤大小(每個標籤至少10格)
|
||||
|
||||
Args:
|
||||
num_events: 事件數量
|
||||
time_range_seconds: 時間範圍(秒)
|
||||
canvas_width: 畫布寬度(像素)
|
||||
canvas_height: 畫布高度(像素)
|
||||
label_width_ratio: 標籤寬度佔時間軸的比例
|
||||
|
||||
Returns:
|
||||
(grid_cols, grid_rows): 網格列數和行數
|
||||
"""
|
||||
# 策略1:基於畫布大小(進一步提高密度:每格3像素)
|
||||
pixels_per_cell = 3 # 每格3像素 = 非常精細的網格
|
||||
cols_by_canvas = canvas_width // pixels_per_cell
|
||||
rows_by_canvas = canvas_height // pixels_per_cell
|
||||
|
||||
# 策略2:基於事件密度(提高倍數)
|
||||
density = num_events / time_range_seconds if time_range_seconds > 0 else 0
|
||||
if density > 0.001: # 高密度(<1000秒/事件)
|
||||
density_multiplier = 2.5 # 提高倍數
|
||||
elif density > 0.0001: # 中密度
|
||||
density_multiplier = 2.0 # 提高倍數
|
||||
else: # 低密度
|
||||
density_multiplier = 1.5 # 提高倍數
|
||||
|
||||
cols_by_density = int(cols_by_canvas * density_multiplier)
|
||||
rows_by_density = int(rows_by_canvas * density_multiplier)
|
||||
|
||||
# 策略3:基於標籤大小(每個標籤至少40格,大幅提高精度)
|
||||
label_width_seconds = time_range_seconds * label_width_ratio
|
||||
min_grids_per_label = 40 # 每標籤至少40格,確保精確判斷
|
||||
cols_by_label = int((time_range_seconds / label_width_seconds) * min_grids_per_label)
|
||||
|
||||
# 取最大值(最細網格),大幅提高上限
|
||||
grid_cols = min(max(cols_by_canvas, cols_by_density, cols_by_label), 800) # 上限提高到800
|
||||
grid_rows = min(max(rows_by_canvas, rows_by_density, 100), 400) # 上限提高到400
|
||||
|
||||
logger.info(f"自動計算網格解析度:")
|
||||
logger.info(f" 基於畫布: {cols_by_canvas} × {rows_by_canvas}")
|
||||
logger.info(f" 基於密度: {cols_by_density} × {rows_by_density} (倍數: {density_multiplier:.1f})")
|
||||
logger.info(f" 基於標籤: {cols_by_label} × 30")
|
||||
logger.info(f" 最終選擇: {grid_cols} × {grid_rows}")
|
||||
|
||||
return (grid_cols, grid_rows)
|
||||
|
||||
|
||||
def find_path_bfs(
|
||||
start_row: int,
|
||||
start_col: int,
|
||||
end_row: int,
|
||||
end_col: int,
|
||||
grid_map: GridMap,
|
||||
direction_constraint: str = "up" # "up" or "down"
|
||||
) -> Optional[List[Tuple[int, int]]]:
|
||||
"""
|
||||
使用BFS尋找路徑(改進版:優先離開時間軸)
|
||||
|
||||
策略:
|
||||
1. 優先垂直移動(離開時間軸)
|
||||
2. 遇到障礙物才水平繞行
|
||||
3. 使用優先隊列,根據與時間軸的距離排序
|
||||
|
||||
Args:
|
||||
start_row, start_col: 起點網格座標
|
||||
end_row, end_col: 終點網格座標
|
||||
grid_map: 網格地圖
|
||||
direction_constraint: 方向約束("up"往上,"down"往下)
|
||||
|
||||
Returns:
|
||||
路徑點列表 [(row, col), ...] 或 None(找不到路徑)
|
||||
"""
|
||||
# 檢查起點和終點是否可通行
|
||||
if not grid_map.is_free(start_row, start_col):
|
||||
logger.warning(f"起點 ({start_row},{start_col}) 被障礙物佔據")
|
||||
return None
|
||||
|
||||
if not grid_map.is_free(end_row, end_col):
|
||||
logger.warning(f"終點 ({end_row},{end_col}) 被障礙物佔據")
|
||||
return None
|
||||
|
||||
import heapq
|
||||
|
||||
# 計算時間軸的Y座標(row)
|
||||
timeline_row = grid_map.y_to_grid_y(0)
|
||||
|
||||
# 優先隊列:(優先度, row, col, path)
|
||||
# 優先度 = 與時間軸的距離(越遠越好)+ 路徑長度(越短越好)
|
||||
start_priority = 0
|
||||
heap = [(start_priority, start_row, start_col, [(start_row, start_col)])]
|
||||
visited = set()
|
||||
visited.add((start_row, start_col))
|
||||
|
||||
# 方向優先順序(垂直優先於水平)
|
||||
if direction_constraint == "up":
|
||||
# 優先往上,然後才左右
|
||||
directions = [(-1, 0), (0, 1), (0, -1)] # 上、右、左
|
||||
else: # "down"
|
||||
# 優先往下,然後才左右
|
||||
directions = [(1, 0), (0, 1), (0, -1)] # 下、右、左
|
||||
|
||||
max_iterations = grid_map.grid_rows * grid_map.grid_cols * 2
|
||||
iterations = 0
|
||||
|
||||
while heap and iterations < max_iterations:
|
||||
iterations += 1
|
||||
_, current_row, current_col, path = heapq.heappop(heap)
|
||||
|
||||
# 到達終點
|
||||
if current_row == end_row and current_col == end_col:
|
||||
logger.info(f"找到路徑,長度: {len(path)},迭代: {iterations}")
|
||||
return path
|
||||
|
||||
# 探索鄰居(按優先順序)
|
||||
for d_row, d_col in directions:
|
||||
next_row = current_row + d_row
|
||||
next_col = current_col + d_col
|
||||
|
||||
# 檢查是否可通行
|
||||
if (next_row, next_col) in visited:
|
||||
continue
|
||||
|
||||
if not grid_map.is_free(next_row, next_col):
|
||||
continue
|
||||
|
||||
# 計算優先度
|
||||
# 1. 與時間軸的距離(主要因素)
|
||||
distance_from_timeline = abs(next_row - timeline_row)
|
||||
|
||||
# 2. 曼哈頓距離到終點(次要因素)
|
||||
manhattan_to_goal = abs(next_row - end_row) + abs(next_col - end_col)
|
||||
|
||||
# 3. 路徑長度(避免繞太遠)
|
||||
path_length = len(path)
|
||||
|
||||
# 綜合優先度:離時間軸越遠越好,離目標越近越好
|
||||
# 權重調整:優先離開時間軸
|
||||
priority = (
|
||||
-distance_from_timeline * 100 + # 負數因為要最大化
|
||||
manhattan_to_goal * 10 +
|
||||
path_length
|
||||
)
|
||||
|
||||
# 添加到優先隊列
|
||||
visited.add((next_row, next_col))
|
||||
new_path = path + [(next_row, next_col)]
|
||||
heapq.heappush(heap, (priority, next_row, next_col, new_path))
|
||||
|
||||
logger.warning(f"BFS未找到路徑 ({start_row},{start_col}) → ({end_row},{end_col})")
|
||||
return None
|
||||
|
||||
|
||||
def simplify_path(
|
||||
path_grid: List[Tuple[int, int]],
|
||||
grid_map: GridMap
|
||||
) -> List[Tuple[datetime, float]]:
|
||||
"""
|
||||
簡化路徑並轉換為實際座標
|
||||
|
||||
合併連續同向的線段,移除不必要的轉折點。
|
||||
|
||||
Args:
|
||||
path_grid: 網格路徑點 [(row, col), ...]
|
||||
grid_map: 網格地圖
|
||||
|
||||
Returns:
|
||||
簡化後的路徑 [(datetime, y), ...]
|
||||
"""
|
||||
if not path_grid:
|
||||
return []
|
||||
|
||||
simplified = [path_grid[0]] # 起點
|
||||
|
||||
for i in range(1, len(path_grid) - 1):
|
||||
prev_point = path_grid[i - 1]
|
||||
curr_point = path_grid[i]
|
||||
next_point = path_grid[i + 1]
|
||||
|
||||
# 計算方向
|
||||
dir1 = (curr_point[0] - prev_point[0], curr_point[1] - prev_point[1])
|
||||
dir2 = (next_point[0] - curr_point[0], next_point[1] - curr_point[1])
|
||||
|
||||
# 如果方向改變,保留這個轉折點
|
||||
if dir1 != dir2:
|
||||
simplified.append(curr_point)
|
||||
|
||||
simplified.append(path_grid[-1]) # 終點
|
||||
|
||||
# 轉換為實際座標
|
||||
result = []
|
||||
for row, col in simplified:
|
||||
dt = grid_map.grid_to_datetime(col)
|
||||
y = grid_map.grid_to_y(row)
|
||||
result.append((dt, y))
|
||||
|
||||
logger.debug(f"路徑簡化: {len(path_grid)} → {len(simplified)} 點")
|
||||
|
||||
return result
|
||||
566
backend/renderer.py
Normal file
566
backend/renderer.py
Normal file
@@ -0,0 +1,566 @@
|
||||
"""
|
||||
時間軸渲染模組
|
||||
|
||||
本模組負責將事件資料轉換為視覺化的時間軸圖表。
|
||||
使用 Plotly 進行渲染,支援時間刻度自動調整與節點避碰。
|
||||
|
||||
Author: AI Agent
|
||||
Version: 1.0.0
|
||||
DocID: SDD-REN-001
|
||||
Related: TDD-UT-REN-001, TDD-UT-REN-002
|
||||
Rationale: 實現 SDD.md 定義的 POST /render API 功能
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Tuple, Optional
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
from .schemas import Event, TimelineConfig, RenderResult, ThemeStyle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TimeUnit(str, Enum):
|
||||
"""時間刻度單位"""
|
||||
HOUR = "hour"
|
||||
DAY = "day"
|
||||
WEEK = "week"
|
||||
MONTH = "month"
|
||||
QUARTER = "quarter"
|
||||
YEAR = "year"
|
||||
|
||||
|
||||
class TimeScaleCalculator:
|
||||
"""
|
||||
時間刻度計算器
|
||||
|
||||
根據事件的時間跨度自動選擇最適合的刻度單位與間隔。
|
||||
對應 TDD.md - UT-REN-01
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def calculate_time_range(events: List[Event]) -> Tuple[datetime, datetime]:
|
||||
"""
|
||||
計算事件的時間範圍
|
||||
|
||||
Args:
|
||||
events: 事件列表
|
||||
|
||||
Returns:
|
||||
(最早時間, 最晚時間)
|
||||
"""
|
||||
if not events:
|
||||
now = datetime.now()
|
||||
return now, now + timedelta(days=30)
|
||||
|
||||
min_time = min(event.start for event in events)
|
||||
max_time = max(
|
||||
event.end if event.end else event.start
|
||||
for event in events
|
||||
)
|
||||
|
||||
# 添加一些邊距(10%)
|
||||
time_span = max_time - min_time
|
||||
margin = time_span * 0.1 if time_span.total_seconds() > 0 else timedelta(days=1)
|
||||
|
||||
return min_time - margin, max_time + margin
|
||||
|
||||
@staticmethod
|
||||
def determine_time_unit(start: datetime, end: datetime) -> TimeUnit:
|
||||
"""
|
||||
根據時間跨度決定刻度單位
|
||||
|
||||
Args:
|
||||
start: 開始時間
|
||||
end: 結束時間
|
||||
|
||||
Returns:
|
||||
最適合的時間單位
|
||||
"""
|
||||
time_span = end - start
|
||||
days = time_span.days
|
||||
|
||||
if days <= 2:
|
||||
return TimeUnit.HOUR
|
||||
elif days <= 31:
|
||||
return TimeUnit.DAY
|
||||
elif days <= 90:
|
||||
return TimeUnit.WEEK
|
||||
elif days <= 730: # 2 年
|
||||
return TimeUnit.MONTH
|
||||
elif days <= 1825: # 5 年
|
||||
return TimeUnit.QUARTER
|
||||
else:
|
||||
return TimeUnit.YEAR
|
||||
|
||||
@staticmethod
|
||||
def generate_tick_values(start: datetime, end: datetime, unit: TimeUnit) -> List[datetime]:
|
||||
"""
|
||||
生成刻度值列表
|
||||
|
||||
Args:
|
||||
start: 開始時間
|
||||
end: 結束時間
|
||||
unit: 時間單位
|
||||
|
||||
Returns:
|
||||
刻度時間點列表
|
||||
"""
|
||||
ticks = []
|
||||
current = start
|
||||
|
||||
if unit == TimeUnit.HOUR:
|
||||
# 每小時一個刻度
|
||||
current = current.replace(minute=0, second=0, microsecond=0)
|
||||
while current <= end:
|
||||
ticks.append(current)
|
||||
current += timedelta(hours=1)
|
||||
|
||||
elif unit == TimeUnit.DAY:
|
||||
# 每天一個刻度
|
||||
current = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
while current <= end:
|
||||
ticks.append(current)
|
||||
current += timedelta(days=1)
|
||||
|
||||
elif unit == TimeUnit.WEEK:
|
||||
# 每週一個刻度(週一)
|
||||
current = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
days_to_monday = current.weekday()
|
||||
current -= timedelta(days=days_to_monday)
|
||||
while current <= end:
|
||||
ticks.append(current)
|
||||
current += timedelta(weeks=1)
|
||||
|
||||
elif unit == TimeUnit.MONTH:
|
||||
# 每月一個刻度(月初)
|
||||
current = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
while current <= end:
|
||||
ticks.append(current)
|
||||
# 移到下個月
|
||||
if current.month == 12:
|
||||
current = current.replace(year=current.year + 1, month=1)
|
||||
else:
|
||||
current = current.replace(month=current.month + 1)
|
||||
|
||||
elif unit == TimeUnit.QUARTER:
|
||||
# 每季一個刻度
|
||||
current = current.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
quarter_month = ((current.month - 1) // 3) * 3 + 1
|
||||
current = current.replace(month=quarter_month)
|
||||
while current <= end:
|
||||
ticks.append(current)
|
||||
# 移到下一季
|
||||
new_month = current.month + 3
|
||||
if new_month > 12:
|
||||
current = current.replace(year=current.year + 1, month=new_month - 12)
|
||||
else:
|
||||
current = current.replace(month=new_month)
|
||||
|
||||
elif unit == TimeUnit.YEAR:
|
||||
# 每年一個刻度
|
||||
current = current.replace(month=1, day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
while current <= end:
|
||||
ticks.append(current)
|
||||
current = current.replace(year=current.year + 1)
|
||||
|
||||
return ticks
|
||||
|
||||
|
||||
class CollisionResolver:
|
||||
"""
|
||||
節點避碰解析器
|
||||
|
||||
處理時間軸上重疊事件的排版,確保事件不會相互覆蓋。
|
||||
對應 TDD.md - UT-REN-02
|
||||
"""
|
||||
|
||||
def __init__(self, min_spacing: int = 10):
|
||||
"""
|
||||
Args:
|
||||
min_spacing: 最小間距(像素)
|
||||
"""
|
||||
self.min_spacing = min_spacing
|
||||
|
||||
def resolve_collisions(self, events: List[Event]) -> Dict[str, int]:
|
||||
"""
|
||||
解決事件碰撞,分配 Y 軸位置(層級)
|
||||
|
||||
Args:
|
||||
events: 事件列表
|
||||
|
||||
Returns:
|
||||
事件 ID 到層級的映射 {event_id: layer}
|
||||
"""
|
||||
if not events:
|
||||
return {}
|
||||
|
||||
# 按開始時間排序
|
||||
sorted_events = sorted(events, key=lambda e: (e.start, e.end or e.start))
|
||||
|
||||
# 儲存每層的最後結束時間
|
||||
layers: List[Optional[datetime]] = []
|
||||
event_layers: Dict[str, int] = {}
|
||||
|
||||
for event in sorted_events:
|
||||
event_end = event.end if event.end else event.start + timedelta(hours=1)
|
||||
|
||||
# 尋找可以放置的層級
|
||||
placed = False
|
||||
for layer_idx, layer_end_time in enumerate(layers):
|
||||
if layer_end_time is None or event.start >= layer_end_time:
|
||||
# 這層可以放置
|
||||
event_layers[event.id] = layer_idx
|
||||
layers[layer_idx] = event_end
|
||||
placed = True
|
||||
break
|
||||
|
||||
if not placed:
|
||||
# 需要新增一層
|
||||
layer_idx = len(layers)
|
||||
event_layers[event.id] = layer_idx
|
||||
layers.append(event_end)
|
||||
|
||||
return event_layers
|
||||
|
||||
def group_based_layout(self, events: List[Event]) -> Dict[str, int]:
|
||||
"""
|
||||
基於群組的排版
|
||||
|
||||
同組事件優先排在一起。
|
||||
|
||||
Args:
|
||||
events: 事件列表
|
||||
|
||||
Returns:
|
||||
事件 ID 到層級的映射
|
||||
"""
|
||||
if not events:
|
||||
return {}
|
||||
|
||||
# 按群組分組
|
||||
groups: Dict[str, List[Event]] = {}
|
||||
for event in events:
|
||||
group_key = event.group if event.group else "_default_"
|
||||
if group_key not in groups:
|
||||
groups[group_key] = []
|
||||
groups[group_key].append(event)
|
||||
|
||||
# 為每個群組分配層級
|
||||
event_layers: Dict[str, int] = {}
|
||||
current_layer = 0
|
||||
|
||||
for group_key, group_events in groups.items():
|
||||
# 在群組內解決碰撞
|
||||
group_layers = self.resolve_collisions(group_events)
|
||||
|
||||
# 將群組層級加上偏移量
|
||||
max_layer_in_group = max(group_layers.values()) if group_layers else 0
|
||||
|
||||
for event_id, layer in group_layers.items():
|
||||
event_layers[event_id] = current_layer + layer
|
||||
|
||||
current_layer += max_layer_in_group + 1
|
||||
|
||||
return event_layers
|
||||
|
||||
|
||||
class ThemeManager:
|
||||
"""
|
||||
主題管理器
|
||||
|
||||
管理不同的視覺主題。
|
||||
"""
|
||||
|
||||
THEMES = {
|
||||
ThemeStyle.MODERN: {
|
||||
'background': '#FFFFFF',
|
||||
'text': '#1F2937',
|
||||
'grid': '#E5E7EB',
|
||||
'primary': '#3B82F6',
|
||||
'font_family': 'Arial, sans-serif',
|
||||
},
|
||||
ThemeStyle.CLASSIC: {
|
||||
'background': '#F9FAFB',
|
||||
'text': '#374151',
|
||||
'grid': '#D1D5DB',
|
||||
'primary': '#6366F1',
|
||||
'font_family': 'Georgia, serif',
|
||||
},
|
||||
ThemeStyle.MINIMAL: {
|
||||
'background': '#FFFFFF',
|
||||
'text': '#000000',
|
||||
'grid': '#CCCCCC',
|
||||
'primary': '#000000',
|
||||
'font_family': 'Helvetica, sans-serif',
|
||||
},
|
||||
ThemeStyle.CORPORATE: {
|
||||
'background': '#F3F4F6',
|
||||
'text': '#111827',
|
||||
'grid': '#9CA3AF',
|
||||
'primary': '#1F2937',
|
||||
'font_family': 'Calibri, sans-serif',
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_theme(cls, theme_style: ThemeStyle) -> Dict[str, str]:
|
||||
"""
|
||||
獲取主題配置
|
||||
|
||||
Args:
|
||||
theme_style: 主題樣式
|
||||
|
||||
Returns:
|
||||
主題配置字典
|
||||
"""
|
||||
return cls.THEMES.get(theme_style, cls.THEMES[ThemeStyle.MODERN])
|
||||
|
||||
|
||||
class TimelineRenderer:
|
||||
"""
|
||||
時間軸渲染器
|
||||
|
||||
負責將事件資料轉換為 Plotly JSON 格式。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.scale_calculator = TimeScaleCalculator()
|
||||
self.collision_resolver = CollisionResolver()
|
||||
self.theme_manager = ThemeManager()
|
||||
|
||||
def render(self, events: List[Event], config: TimelineConfig) -> RenderResult:
|
||||
"""
|
||||
渲染時間軸
|
||||
|
||||
Args:
|
||||
events: 事件列表
|
||||
config: 時間軸配置
|
||||
|
||||
Returns:
|
||||
RenderResult 物件
|
||||
"""
|
||||
try:
|
||||
if not events:
|
||||
return self._create_empty_result()
|
||||
|
||||
# 計算時間範圍
|
||||
time_start, time_end = self.scale_calculator.calculate_time_range(events)
|
||||
|
||||
# 決定時間單位
|
||||
time_unit = self.scale_calculator.determine_time_unit(time_start, time_end)
|
||||
|
||||
# 生成刻度
|
||||
tick_values = self.scale_calculator.generate_tick_values(time_start, time_end, time_unit)
|
||||
|
||||
# 解決碰撞
|
||||
if config.direction == 'horizontal':
|
||||
event_layers = self.collision_resolver.resolve_collisions(events)
|
||||
else:
|
||||
event_layers = self.collision_resolver.group_based_layout(events)
|
||||
|
||||
# 獲取主題
|
||||
theme = self.theme_manager.get_theme(config.theme)
|
||||
|
||||
# 生成 Plotly 資料
|
||||
data = self._generate_plotly_data(events, event_layers, config, theme)
|
||||
layout = self._generate_plotly_layout(time_start, time_end, tick_values, config, theme)
|
||||
plot_config = self._generate_plotly_config(config)
|
||||
|
||||
return RenderResult(
|
||||
success=True,
|
||||
data=data,
|
||||
layout=layout,
|
||||
config=plot_config
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"渲染失敗: {str(e)}")
|
||||
return RenderResult(
|
||||
success=False,
|
||||
data={},
|
||||
layout={},
|
||||
config={}
|
||||
)
|
||||
|
||||
def _generate_plotly_data(
|
||||
self,
|
||||
events: List[Event],
|
||||
event_layers: Dict[str, int],
|
||||
config: TimelineConfig,
|
||||
theme: Dict[str, str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
生成 Plotly data 部分
|
||||
|
||||
Args:
|
||||
events: 事件列表
|
||||
event_layers: 事件層級映射
|
||||
config: 配置
|
||||
theme: 主題
|
||||
|
||||
Returns:
|
||||
Plotly data 字典
|
||||
"""
|
||||
traces = []
|
||||
|
||||
for event in events:
|
||||
layer = event_layers.get(event.id, 0)
|
||||
|
||||
# 計算事件的時間範圍
|
||||
start_time = event.start
|
||||
end_time = event.end if event.end else event.start + timedelta(hours=1)
|
||||
|
||||
# 生成提示訊息
|
||||
hover_text = self._generate_hover_text(event)
|
||||
|
||||
trace = {
|
||||
'type': 'scatter',
|
||||
'mode': 'lines+markers',
|
||||
'x': [start_time, end_time] if config.direction == 'horizontal' else [layer, layer],
|
||||
'y': [layer, layer] if config.direction == 'horizontal' else [start_time, end_time],
|
||||
'name': event.title,
|
||||
'line': {
|
||||
'color': event.color,
|
||||
'width': 10,
|
||||
},
|
||||
'marker': {
|
||||
'size': 10,
|
||||
'color': event.color,
|
||||
},
|
||||
'text': hover_text,
|
||||
'hoverinfo': 'text' if config.show_tooltip else 'skip',
|
||||
}
|
||||
|
||||
traces.append(trace)
|
||||
|
||||
return {'data': traces}
|
||||
|
||||
def _generate_plotly_layout(
|
||||
self,
|
||||
time_start: datetime,
|
||||
time_end: datetime,
|
||||
tick_values: List[datetime],
|
||||
config: TimelineConfig,
|
||||
theme: Dict[str, str]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
生成 Plotly layout 部分
|
||||
|
||||
Args:
|
||||
time_start: 開始時間
|
||||
time_end: 結束時間
|
||||
tick_values: 刻度值
|
||||
config: 配置
|
||||
theme: 主題
|
||||
|
||||
Returns:
|
||||
Plotly layout 字典
|
||||
"""
|
||||
layout = {
|
||||
'title': '時間軸',
|
||||
'showlegend': True,
|
||||
'hovermode': 'closest',
|
||||
'plot_bgcolor': theme['background'],
|
||||
'paper_bgcolor': theme['background'],
|
||||
'font': {
|
||||
'family': theme['font_family'],
|
||||
'color': theme['text'],
|
||||
},
|
||||
}
|
||||
|
||||
if config.direction == 'horizontal':
|
||||
layout['xaxis'] = {
|
||||
'title': '時間',
|
||||
'type': 'date',
|
||||
'range': [time_start, time_end],
|
||||
'tickvals': tick_values,
|
||||
'showgrid': config.show_grid,
|
||||
'gridcolor': theme['grid'],
|
||||
}
|
||||
layout['yaxis'] = {
|
||||
'title': '事件層級',
|
||||
'showticklabels': False,
|
||||
'showgrid': False,
|
||||
}
|
||||
else:
|
||||
layout['xaxis'] = {
|
||||
'title': '事件層級',
|
||||
'showticklabels': False,
|
||||
'showgrid': False,
|
||||
}
|
||||
layout['yaxis'] = {
|
||||
'title': '時間',
|
||||
'type': 'date',
|
||||
'range': [time_start, time_end],
|
||||
'tickvals': tick_values,
|
||||
'showgrid': config.show_grid,
|
||||
'gridcolor': theme['grid'],
|
||||
}
|
||||
|
||||
return layout
|
||||
|
||||
def _generate_plotly_config(self, config: TimelineConfig) -> Dict[str, Any]:
|
||||
"""
|
||||
生成 Plotly config 部分
|
||||
|
||||
Args:
|
||||
config: 配置
|
||||
|
||||
Returns:
|
||||
Plotly config 字典
|
||||
"""
|
||||
return {
|
||||
'scrollZoom': config.enable_zoom,
|
||||
'displayModeBar': True,
|
||||
'displaylogo': False,
|
||||
}
|
||||
|
||||
def _generate_hover_text(self, event: Event) -> str:
|
||||
"""
|
||||
生成事件的提示訊息
|
||||
|
||||
Args:
|
||||
event: 事件
|
||||
|
||||
Returns:
|
||||
提示訊息文字
|
||||
"""
|
||||
lines = [f"<b>{event.title}</b>"]
|
||||
|
||||
if event.start:
|
||||
lines.append(f"開始: {event.start.strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
if event.end:
|
||||
lines.append(f"結束: {event.end.strftime('%Y-%m-%d %H:%M')}")
|
||||
|
||||
if event.group:
|
||||
lines.append(f"群組: {event.group}")
|
||||
|
||||
if event.description:
|
||||
lines.append(f"說明: {event.description}")
|
||||
|
||||
return '<br>'.join(lines)
|
||||
|
||||
def _create_empty_result(self) -> RenderResult:
|
||||
"""
|
||||
建立空白結果
|
||||
|
||||
Returns:
|
||||
空白的 RenderResult
|
||||
"""
|
||||
return RenderResult(
|
||||
success=True,
|
||||
data={'data': []},
|
||||
layout={
|
||||
'title': '時間軸(無資料)',
|
||||
'xaxis': {'title': '時間'},
|
||||
'yaxis': {'title': '事件'},
|
||||
},
|
||||
config={}
|
||||
)
|
||||
|
||||
|
||||
# 匯出主要介面
|
||||
__all__ = ['TimelineRenderer', 'RenderResult']
|
||||
1632
backend/renderer_timeline.py
Normal file
1632
backend/renderer_timeline.py
Normal file
File diff suppressed because it is too large
Load Diff
257
backend/schemas.py
Normal file
257
backend/schemas.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""
|
||||
資料模型定義 (Data Schemas)
|
||||
|
||||
本模組定義 TimeLine Designer 所有資料結構。
|
||||
遵循 Pydantic BaseModel 進行嚴格型別驗證。
|
||||
|
||||
Author: AI Agent
|
||||
Version: 1.0.0
|
||||
DocID: SDD-SCHEMA-001
|
||||
Rationale: 實現 SDD.md 第2節定義的資料模型
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, Literal, List
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class EventType(str, Enum):
|
||||
"""事件類型枚舉"""
|
||||
POINT = "point" # 時間點事件
|
||||
RANGE = "range" # 時間區間事件
|
||||
MILESTONE = "milestone" # 里程碑
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
"""
|
||||
時間軸事件模型
|
||||
|
||||
對應 SDD.md - 2. 資料模型 - Event
|
||||
用於表示時間軸上的單一事件或時間區間。
|
||||
"""
|
||||
id: str = Field(..., description="事件唯一識別碼")
|
||||
title: str = Field(..., min_length=1, max_length=200, description="事件標題")
|
||||
start: datetime = Field(..., description="開始時間")
|
||||
end: Optional[datetime] = Field(None, description="結束時間(可選)")
|
||||
group: Optional[str] = Field(None, description="事件群組/分類")
|
||||
description: Optional[str] = Field(None, max_length=1000, description="事件詳細描述")
|
||||
color: str = Field(default='#3B82F6', pattern=r'^#[0-9A-Fa-f]{6}$', description="事件顏色(HEX格式)")
|
||||
event_type: EventType = Field(EventType.POINT, description="事件類型")
|
||||
|
||||
@field_validator('end')
|
||||
@classmethod
|
||||
def validate_end_after_start(cls, end, info):
|
||||
"""驗證結束時間必須晚於開始時間"""
|
||||
if end and info.data.get('start') and end < info.data['start']:
|
||||
raise ValueError('結束時間必須晚於開始時間')
|
||||
return end
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"id": "evt-001",
|
||||
"title": "專案啟動",
|
||||
"start": "2024-01-01T09:00:00",
|
||||
"end": "2024-01-01T17:00:00",
|
||||
"group": "Phase 1",
|
||||
"description": "專案正式啟動會議",
|
||||
"color": "#3B82F6",
|
||||
"event_type": "range"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ThemeStyle(str, Enum):
|
||||
"""主題樣式枚舉"""
|
||||
MODERN = "modern"
|
||||
CLASSIC = "classic"
|
||||
MINIMAL = "minimal"
|
||||
CORPORATE = "corporate"
|
||||
|
||||
|
||||
class TimelineConfig(BaseModel):
|
||||
"""
|
||||
時間軸配置模型
|
||||
|
||||
對應 SDD.md - 2. 資料模型 - TimelineConfig
|
||||
控制時間軸的顯示方式與視覺樣式。
|
||||
"""
|
||||
direction: Literal['horizontal', 'vertical'] = Field(
|
||||
'horizontal',
|
||||
description="時間軸方向"
|
||||
)
|
||||
theme: ThemeStyle = Field(
|
||||
ThemeStyle.MODERN,
|
||||
description="視覺主題"
|
||||
)
|
||||
show_grid: bool = Field(
|
||||
True,
|
||||
description="是否顯示網格線"
|
||||
)
|
||||
show_tooltip: bool = Field(
|
||||
True,
|
||||
description="是否顯示提示訊息"
|
||||
)
|
||||
enable_zoom: bool = Field(
|
||||
True,
|
||||
description="是否啟用縮放功能"
|
||||
)
|
||||
enable_drag: bool = Field(
|
||||
True,
|
||||
description="是否啟用拖曳功能"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"direction": "horizontal",
|
||||
"theme": "modern",
|
||||
"show_grid": True,
|
||||
"show_tooltip": True,
|
||||
"enable_zoom": True,
|
||||
"enable_drag": True
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ExportFormat(str, Enum):
|
||||
"""匯出格式枚舉"""
|
||||
PNG = "png"
|
||||
PDF = "pdf"
|
||||
SVG = "svg"
|
||||
|
||||
|
||||
class ExportOptions(BaseModel):
|
||||
"""
|
||||
匯出選項模型
|
||||
|
||||
對應 SDD.md - 2. 資料模型 - ExportOptions
|
||||
控制時間軸圖檔的匯出格式與品質。
|
||||
"""
|
||||
fmt: ExportFormat = Field(..., description="匯出格式")
|
||||
dpi: int = Field(
|
||||
300,
|
||||
ge=72,
|
||||
le=600,
|
||||
description="解析度(DPI)"
|
||||
)
|
||||
width: Optional[int] = Field(
|
||||
1920,
|
||||
ge=800,
|
||||
le=4096,
|
||||
description="圖片寬度(像素)"
|
||||
)
|
||||
height: Optional[int] = Field(
|
||||
1080,
|
||||
ge=600,
|
||||
le=4096,
|
||||
description="圖片高度(像素)"
|
||||
)
|
||||
transparent_background: bool = Field(
|
||||
False,
|
||||
description="是否使用透明背景"
|
||||
)
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"fmt": "pdf",
|
||||
"dpi": 300,
|
||||
"width": 1920,
|
||||
"height": 1080,
|
||||
"transparent_background": False
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class Theme(BaseModel):
|
||||
"""
|
||||
主題定義模型
|
||||
|
||||
用於 /themes API 回傳主題列表。
|
||||
"""
|
||||
name: str = Field(..., description="主題名稱")
|
||||
style: ThemeStyle = Field(..., description="主題樣式識別碼")
|
||||
primary_color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$', description="主要顏色")
|
||||
background_color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$', description="背景顏色")
|
||||
text_color: str = Field(..., pattern=r'^#[0-9A-Fa-f]{6}$', description="文字顏色")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"name": "現代風格",
|
||||
"style": "modern",
|
||||
"primary_color": "#3B82F6",
|
||||
"background_color": "#FFFFFF",
|
||||
"text_color": "#1F2937"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class ImportResult(BaseModel):
|
||||
"""
|
||||
匯入結果模型
|
||||
|
||||
用於 /import API 回傳匯入結果。
|
||||
"""
|
||||
success: bool = Field(..., description="是否成功")
|
||||
events: List[Event] = Field(default_factory=list, description="成功匯入的事件列表")
|
||||
errors: List[str] = Field(default_factory=list, description="錯誤訊息列表")
|
||||
total_rows: int = Field(0, description="總行數")
|
||||
imported_count: int = Field(0, description="成功匯入數量")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"events": [],
|
||||
"errors": [],
|
||||
"total_rows": 100,
|
||||
"imported_count": 98
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class RenderResult(BaseModel):
|
||||
"""
|
||||
渲染結果模型
|
||||
|
||||
用於 /render API 回傳 Plotly JSON 格式的時間軸資料。
|
||||
"""
|
||||
success: bool = Field(..., description="是否成功")
|
||||
data: dict = Field(..., description="Plotly 圖表資料(JSON格式)")
|
||||
layout: dict = Field(..., description="Plotly 佈局設定")
|
||||
config: dict = Field(default_factory=dict, description="Plotly 配置")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"data": {},
|
||||
"layout": {},
|
||||
"config": {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class APIResponse(BaseModel):
|
||||
"""
|
||||
通用 API 回應模型
|
||||
|
||||
用於標準化 API 回應格式,提供一致的錯誤處理。
|
||||
"""
|
||||
success: bool = Field(..., description="操作是否成功")
|
||||
message: str = Field("", description="回應訊息")
|
||||
data: Optional[dict] = Field(None, description="回應資料")
|
||||
error_code: Optional[str] = Field(None, description="錯誤代碼(如有)")
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
"example": {
|
||||
"success": True,
|
||||
"message": "操作成功",
|
||||
"data": None,
|
||||
"error_code": None
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user