"""Excel 导出。 统一用 openpyxl 生成带格式的工作簿:表头加粗底色、冻结首行、自动筛选、列宽自适应。 """ from __future__ import annotations from datetime import datetime from io import BytesIO from typing import Any, Iterable, Sequence from urllib.parse import quote from openpyxl import Workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter HEAD_FILL = PatternFill("solid", fgColor="EEF2F8") HEAD_FONT = Font(bold=True, color="1F2430", size=11) TITLE_FONT = Font(bold=True, size=14) THIN = Side(style="thin", color="D8DEE8") BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) NUM_FMT = "#,##0.00" def _cell_width(value: Any) -> int: """按中文字符占 2 个宽度估算显示宽度。""" s = "" if value is None else str(value) w = 0 for ch in s: w += 2 if ord(ch) > 127 else 1 return w def build_xlsx( sheet_name: str, headers: Sequence[str], rows: Iterable[Sequence[Any]], widths: Sequence[int] | None = None, title: str | None = None, money_cols: Sequence[int] = (), ) -> bytes: """生成 xlsx 字节流。 money_cols 中的列索引按 1 开始计(对应 headers 的下标 + 1)。 """ wb = Workbook() ws = wb.active ws.title = sheet_name[:31] or "Sheet1" rows = list(rows) offset = 0 if title: ws.cell(row=1, column=1, value=title).font = TITLE_FONT ws.cell(row=2, column=1, value=f"导出时间:{datetime.now():%Y-%m-%d %H:%M}") ws.cell(row=2, column=1).font = Font(size=9, color="7A8394") offset = 2 head_row = offset + 1 for c, h in enumerate(headers, 1): cell = ws.cell(row=head_row, column=c, value=h) cell.fill = HEAD_FILL cell.font = HEAD_FONT cell.border = BORDER cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) for r, row in enumerate(rows, head_row + 1): for c, v in enumerate(row, 1): cell = ws.cell(row=r, column=c, value=v) cell.border = BORDER cell.alignment = Alignment(vertical="top", wrap_text=False) if c in money_cols and isinstance(v, (int, float)): cell.number_format = NUM_FMT # 列宽:显式指定优先,否则按内容估算 n_cols = len(headers) for c in range(1, n_cols + 1): if widths and c <= len(widths): width = widths[c - 1] else: longest = _cell_width(headers[c - 1]) for row in rows: if c <= len(row): longest = max(longest, _cell_width(row[c - 1])) width = min(max(longest + 4, 10), 60) ws.column_dimensions[get_column_letter(c)].width = width ws.freeze_panes = ws.cell(row=head_row + 1, column=1) ws.auto_filter.ref = ( f"A{head_row}:{get_column_letter(n_cols)}{head_row + max(len(rows), 1)}" ) buf = BytesIO() wb.save(buf) return buf.getvalue() def xlsx_response(content: bytes, filename: str) -> tuple[bytes, dict]: """返回 (内容, 响应头),中文文件名用 RFC 5987 编码。""" stamp = datetime.now().strftime("%Y%m%d_%H%M") full = f"{filename}_{stamp}.xlsx" headers = { "Content-Disposition": f"attachment; filename=\"{quote(full)}\"; filename*=UTF-8''{quote(full)}", "Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "Access-Control-Expose-Headers": "Content-Disposition", } return content, headers