unis_manager/app/export.py

106 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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