Files
egg 01aee1fd0d feat: Extract hardcoded configs to environment variables
- Add environment variable configuration for backend and frontend
- Backend: DB_POOL_SIZE, JWT_EXPIRE_HOURS, timeout configs, directory paths
- Frontend: VITE_API_BASE_URL, VITE_UPLOAD_TIMEOUT, Whisper configs
- Create deployment script (scripts/deploy-backend.sh)
- Create 1Panel deployment guide (docs/1panel-deployment.md)
- Update DEPLOYMENT.md with env var documentation
- Create README.md with project overview
- Remove obsolete PRD.md, SDD.md, TDD.md (replaced by OpenSpec)
- Keep CORS allow_origins=["*"] for Electron EXE distribution

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 14:31:55 +08:00

227 lines
7.8 KiB
Python

from fastapi import APIRouter, HTTPException, Depends
from fastapi.responses import StreamingResponse
from openpyxl import Workbook, load_workbook
from openpyxl.styles import Font, Alignment, Border, Side
import io
import os
from ..database import get_db_cursor
from ..config import settings
from ..models import TokenPayload
from .auth import get_current_user, is_admin
router = APIRouter()
# Base directory for resolving relative paths
BASE_DIR = os.path.join(os.path.dirname(__file__), "..", "..")
def fill_template_workbook(
wb: Workbook, meeting: dict, conclusions: list, actions: list
) -> Workbook:
"""Fill Excel template with meeting data using cell coordinates."""
ws = wb.active
# Fill meeting metadata using cell coordinates
# D3: 會議主題
ws["D3"] = meeting.get("subject", "")
# D4: 會議時間
meeting_time = meeting.get("meeting_time")
if meeting_time:
ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if hasattr(meeting_time, 'strftime') else str(meeting_time)
# D5: 會議主席
ws["D5"] = meeting.get("chairperson", "")
# F4: 會議地點
ws["F4"] = meeting.get("location", "")
# F5: 記錄人員
ws["F5"] = meeting.get("recorder", "")
# D6: 會議參與人員
ws["D6"] = meeting.get("attendees", "")
# C8: 會議編號
ws["C8"] = meeting.get("meeting_number", "")
# D8: 會議結論 (多條用換行合併)
if conclusions:
conclusion_texts = [c.get("content", "") for c in conclusions]
ws["D8"] = "\n".join(conclusion_texts)
# Enable text wrap for multi-line content
ws["D8"].alignment = Alignment(wrap_text=True, vertical="top")
# Fill action items starting from row 10
# C: 編號, D: 內容, F: 負責人, G: 預計完成日期, H: 執行現況
start_row = 10
for i, action in enumerate(actions):
row = start_row + i
# C column: 代辦事項編號
ws[f"C{row}"] = action.get("system_code", "")
# D column: 事項內容
ws[f"D{row}"] = action.get("content", "")
# F column: 負責人
ws[f"F{row}"] = action.get("owner", "")
# G column: 預計完成日期
due_date = action.get("due_date")
if due_date:
ws[f"G{row}"] = due_date.strftime("%Y-%m-%d") if hasattr(due_date, 'strftime') else str(due_date)
else:
ws[f"G{row}"] = ""
# H column: 執行現況 (status)
ws[f"H{row}"] = action.get("status", "")
return wb
def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook:
"""Create Excel workbook with meeting data (fallback when no template)."""
wb = Workbook()
ws = wb.active
ws.title = "Meeting Record"
# Styles
header_font = Font(bold=True, size=14)
label_font = Font(bold=True)
thin_border = Border(
left=Side(style="thin"),
right=Side(style="thin"),
top=Side(style="thin"),
bottom=Side(style="thin"),
)
# Title
ws.merge_cells("A1:H1")
ws["A1"] = "會議紀錄"
ws["A1"].font = Font(bold=True, size=16)
ws["A1"].alignment = Alignment(horizontal="center")
# Metadata section
ws["C3"] = "會議主題:"
ws["D3"] = meeting.get("subject", "")
ws["C4"] = "會議時間:"
meeting_time = meeting.get("meeting_time")
ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if meeting_time and hasattr(meeting_time, 'strftime') else str(meeting_time or "")
ws["C5"] = "會議主席:"
ws["D5"] = meeting.get("chairperson", "")
ws["E4"] = "會議地點:"
ws["F4"] = meeting.get("location", "")
ws["E5"] = "記錄人員:"
ws["F5"] = meeting.get("recorder", "")
ws["C6"] = "參與人員:"
ws["D6"] = meeting.get("attendees", "")
# Meeting number and conclusions
ws["C8"] = meeting.get("meeting_number", "")
ws["C8"].font = label_font
if conclusions:
conclusion_texts = [c.get("content", "") for c in conclusions]
ws["D8"] = "\n".join(conclusion_texts)
ws["D8"].alignment = Alignment(wrap_text=True, vertical="top")
# Action Items header
row = 9
headers = ["編號", "事項內容", "", "負責人", "預計完成日期", "執行現況"]
cols = ["C", "D", "E", "F", "G", "H"]
for col, header in zip(cols, headers):
ws[f"{col}{row}"] = header
ws[f"{col}{row}"].font = label_font
ws[f"{col}{row}"].border = thin_border
# Action Items data
start_row = 10
for i, action in enumerate(actions):
r = start_row + i
ws[f"C{r}"] = action.get("system_code", "")
ws[f"C{r}"].border = thin_border
ws[f"D{r}"] = action.get("content", "")
ws[f"D{r}"].border = thin_border
ws[f"F{r}"] = action.get("owner", "")
ws[f"F{r}"].border = thin_border
due_date = action.get("due_date")
ws[f"G{r}"] = due_date.strftime("%Y-%m-%d") if due_date and hasattr(due_date, 'strftime') else str(due_date or "")
ws[f"G{r}"].border = thin_border
ws[f"H{r}"] = action.get("status", "")
ws[f"H{r}"].border = thin_border
# Adjust column widths
ws.column_dimensions["C"].width = 18
ws.column_dimensions["D"].width = 40
ws.column_dimensions["E"].width = 5
ws.column_dimensions["F"].width = 15
ws.column_dimensions["G"].width = 15
ws.column_dimensions["H"].width = 15
return wb
@router.get("/meetings/{meeting_id}/export")
async def export_meeting(
meeting_id: int, current_user: TokenPayload = Depends(get_current_user)
):
"""Export meeting to Excel file using template."""
with get_db_cursor() as cursor:
cursor.execute(
"SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,)
)
meeting = cursor.fetchone()
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
# Check access
if not is_admin(current_user):
if (
meeting["created_by"] != current_user.email
and meeting["recorder"] != current_user.email
and current_user.email not in (meeting["attendees"] or "")
):
raise HTTPException(status_code=403, detail="Access denied")
# Get conclusions
cursor.execute(
"SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,)
)
conclusions = cursor.fetchall()
# Get action items
cursor.execute(
"SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,)
)
actions = cursor.fetchall()
# Get directory paths from config
template_dir = settings.get_template_dir(BASE_DIR)
record_dir = settings.get_record_dir(BASE_DIR)
# Check for template file
template_path = os.path.join(template_dir, "meeting_template.xlsx")
if os.path.exists(template_path):
# Load and fill template
wb = load_workbook(template_path)
wb = fill_template_workbook(wb, meeting, conclusions, actions)
else:
# Use default template generation
wb = create_default_workbook(meeting, conclusions, actions)
# Generate filename with meeting number
meeting_number = meeting.get("meeting_number", "")
if meeting_number:
filename = f"{meeting_number}.xlsx"
else:
filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx"
# Ensure record directory exists
os.makedirs(record_dir, exist_ok=True)
# Save to record directory
record_path = os.path.join(record_dir, filename)
wb.save(record_path)
# Save to bytes buffer for download
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return StreamingResponse(
buffer,
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)