93 lines
3.6 KiB
Python
93 lines
3.6 KiB
Python
|
|
"""KPI 卡片布局 — 投行级克制风格"""
|
|||
|
|
|
|||
|
|
from pptx.util import Inches, Pt
|
|||
|
|
from pptx.enum.shapes import MSO_SHAPE
|
|||
|
|
from ..helpers import set_slide_bg, add_text, add_rounded_rect, add_rect, add_page_header, setup_content_page
|
|||
|
|
|
|||
|
|
|
|||
|
|
def layout_kpi_cards(slide, data, theme):
|
|||
|
|
"""KPI 大数字卡片 — 3~6 个指标,分栏+极浅分割线
|
|||
|
|
|
|||
|
|
data keys:
|
|||
|
|
title (str): 页面标题
|
|||
|
|
subtitle (str, optional): 副标题/说明
|
|||
|
|
cards (list[dict]): 每个 card 包含:
|
|||
|
|
label (str): 指标名
|
|||
|
|
value (str): 数值(已格式化)
|
|||
|
|
change (str, optional): 变化(如 "+5.9%")
|
|||
|
|
note (str, optional): 脚注
|
|||
|
|
footnote (str, optional): 数据来源/脚注(auto-footer 渲染)
|
|||
|
|
"""
|
|||
|
|
m, sw, content_y, content_w, footer_y = setup_content_page(slide, data, theme)
|
|||
|
|
|
|||
|
|
if data.get("subtitle"):
|
|||
|
|
add_text(slide, data["subtitle"],
|
|||
|
|
x=m, y=content_y, w=content_w, h=0.3,
|
|||
|
|
font_size=theme["body_size"], color=theme["text_muted"],
|
|||
|
|
font_name=theme["body_font"])
|
|||
|
|
|
|||
|
|
cards = data["cards"]
|
|||
|
|
n = len(cards)
|
|||
|
|
|
|||
|
|
# 自动 2x3 网格(6 卡时)或单行
|
|||
|
|
if n <= 4:
|
|||
|
|
_render_row(slide, cards, theme, m, sw, content_y + 0.5, footer_y)
|
|||
|
|
else:
|
|||
|
|
# 2x3 网格
|
|||
|
|
row1 = cards[:3]
|
|||
|
|
row2 = cards[3:]
|
|||
|
|
mid_y = content_y + 0.5
|
|||
|
|
row_h = (footer_y - mid_y - 0.3) / 2
|
|||
|
|
_render_row(slide, row1, theme, m, sw, mid_y, mid_y + row_h)
|
|||
|
|
_render_row(slide, row2, theme, m, sw, mid_y + row_h + 0.15, footer_y - 0.15)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _render_row(slide, cards, theme, m, sw, start_y, end_y):
|
|||
|
|
"""渲染一行 KPI 卡片 — 分栏+极浅分割线,无阴影/圆角"""
|
|||
|
|
n = len(cards)
|
|||
|
|
content_w = sw - 2 * m
|
|||
|
|
col_w = content_w / n
|
|||
|
|
card_h = end_y - start_y - 0.2
|
|||
|
|
|
|||
|
|
# KPI 字号: 收敛到 28-32pt(原 44pt 太 SaaS 感)
|
|||
|
|
kpi_num_size = min(theme.get("kpi_size", 44), 32)
|
|||
|
|
change_size = min(theme["body_size"], 11)
|
|||
|
|
|
|||
|
|
for i, card in enumerate(cards):
|
|||
|
|
cx = m + i * col_w
|
|||
|
|
|
|||
|
|
# 分割线(第一栏不画左边线)
|
|||
|
|
if i > 0:
|
|||
|
|
add_rect(slide, cx, start_y + 0.2, 0.008, card_h - 0.4,
|
|||
|
|
fill=theme["border"])
|
|||
|
|
|
|||
|
|
# 指标名 — 小字、顶部
|
|||
|
|
add_text(slide, card["label"],
|
|||
|
|
x=cx + 0.25, y=start_y + 0.15, w=col_w - 0.5, h=0.35,
|
|||
|
|
font_size=theme["body_size"], color=theme["text_muted"],
|
|||
|
|
align="left", font_name=theme["body_font"])
|
|||
|
|
|
|||
|
|
# 大数字 — 28-32pt,克制
|
|||
|
|
add_text(slide, card["value"],
|
|||
|
|
x=cx + 0.25, y=start_y + 0.55, w=col_w - 0.5, h=1.0,
|
|||
|
|
font_size=kpi_num_size, color=theme["primary"],
|
|||
|
|
bold=True, align="left", valign="middle",
|
|||
|
|
font_name=theme["header_font"])
|
|||
|
|
|
|||
|
|
# 变化值 — 10-12pt + 次要色
|
|||
|
|
if card.get("change"):
|
|||
|
|
change = card["change"]
|
|||
|
|
is_positive = change.startswith("+") or (not change.startswith("-") and "%" in change)
|
|||
|
|
color = theme["positive"] if is_positive else theme["negative"]
|
|||
|
|
add_text(slide, change,
|
|||
|
|
x=cx + 0.25, y=start_y + 1.6, w=col_w - 0.5, h=0.3,
|
|||
|
|
font_size=change_size, color=color,
|
|||
|
|
font_name=theme["body_font"])
|
|||
|
|
|
|||
|
|
# 脚注
|
|||
|
|
if card.get("note"):
|
|||
|
|
add_text(slide, card["note"],
|
|||
|
|
x=cx + 0.25, y=start_y + 2.0, w=col_w - 0.5, h=0.5,
|
|||
|
|
font_size=theme.get("footer_size", 8), color=theme["text_muted"],
|
|||
|
|
font_name=theme["body_font"])
|