106 lines
3.5 KiB
Python
106 lines
3.5 KiB
Python
"""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
|