feat(douyu): add audience trend chart to daily report
This commit is contained in:
@@ -190,6 +190,158 @@ def _render_active_users(top_active_users: List[Dict[str, Any]]) -> str:
|
||||
return "".join(blocks)
|
||||
|
||||
|
||||
def _render_audience_trend_chart(audience_trend: Dict[str, Any]) -> str:
|
||||
points = audience_trend.get("points", []) or []
|
||||
if len(points) < 2:
|
||||
return (
|
||||
'<div class="chart-empty">'
|
||||
'<div class="chart-empty-title">人数趋势</div>'
|
||||
'<div class="chart-empty-desc">当前直播场次还没有足够的 WS 采样点,暂时无法绘制趋势图。</div>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
width = 820
|
||||
height = 276
|
||||
padding_left = 62
|
||||
padding_right = 64
|
||||
padding_top = 24
|
||||
padding_bottom = 38
|
||||
plot_width = width - padding_left - padding_right
|
||||
plot_height = height - padding_top - padding_bottom
|
||||
|
||||
vip_values = [int(item.get("vip_count", 0) or 0) for item in points]
|
||||
diamond_values = [int(item.get("diamond_count", 0) or 0) for item in points]
|
||||
labels = [str(item.get("timestamp") or "")[-8:-3] for item in points]
|
||||
|
||||
vip_min = min(vip_values)
|
||||
vip_max = max(vip_values)
|
||||
diamond_min = min(diamond_values)
|
||||
diamond_max = max(diamond_values)
|
||||
vip_span = max(vip_max - vip_min, 1)
|
||||
diamond_span = max(diamond_max - diamond_min, 1)
|
||||
step_x = plot_width / max(len(points) - 1, 1)
|
||||
bar_width = max(min(step_x * 0.58, 18), 6)
|
||||
|
||||
def x_at(index: int) -> float:
|
||||
return padding_left + step_x * index
|
||||
|
||||
def y_vip(value: int) -> float:
|
||||
ratio = (value - vip_min) / vip_span if vip_span else 0
|
||||
return padding_top + plot_height - ratio * plot_height
|
||||
|
||||
def y_diamond(value: int) -> float:
|
||||
ratio = (value - diamond_min) / diamond_span if diamond_span else 0
|
||||
return padding_top + plot_height - ratio * plot_height
|
||||
|
||||
grid_lines = []
|
||||
left_ticks = []
|
||||
right_ticks = []
|
||||
for idx in range(5):
|
||||
ratio = idx / 4
|
||||
y = padding_top + plot_height - ratio * plot_height
|
||||
vip_tick = round(vip_min + vip_span * ratio)
|
||||
diamond_tick = round(diamond_min + diamond_span * ratio)
|
||||
grid_lines.append(
|
||||
f'<line x1="{padding_left}" y1="{y:.1f}" x2="{width - padding_right}" y2="{y:.1f}" class="chart-grid" />'
|
||||
)
|
||||
left_ticks.append(
|
||||
f'<text x="{padding_left - 10}" y="{y + 4:.1f}" text-anchor="end" class="axis-label axis-left">{vip_tick}</text>'
|
||||
)
|
||||
right_ticks.append(
|
||||
f'<text x="{width - padding_right + 10}" y="{y + 4:.1f}" text-anchor="start" class="axis-label axis-right">{diamond_tick}</text>'
|
||||
)
|
||||
|
||||
bars = []
|
||||
line_points = []
|
||||
dot_points = []
|
||||
label_marks = []
|
||||
annotations = []
|
||||
if len(points) <= 12:
|
||||
label_indexes = list(range(len(points)))
|
||||
else:
|
||||
label_indexes = sorted(set([0, len(points) - 1] + [int(round((len(points) - 1) * i / 6)) for i in range(1, 6)]))
|
||||
vip_peak_index = vip_values.index(vip_max)
|
||||
vip_valley_index = vip_values.index(vip_min)
|
||||
diamond_latest_index = len(points) - 1
|
||||
for idx, item in enumerate(points):
|
||||
x = x_at(idx)
|
||||
vip_y = y_vip(int(item.get("vip_count", 0) or 0))
|
||||
diamond_y = y_diamond(int(item.get("diamond_count", 0) or 0))
|
||||
bar_height = padding_top + plot_height - vip_y
|
||||
bars.append(
|
||||
f'<rect x="{x - bar_width / 2:.1f}" y="{vip_y:.1f}" width="{bar_width:.1f}" height="{bar_height:.1f}" rx="7" class="vip-bar" />'
|
||||
)
|
||||
line_points.append(f"{x:.1f},{diamond_y:.1f}")
|
||||
dot_points.append(f'<circle cx="{x:.1f}" cy="{diamond_y:.1f}" r="3.5" class="diamond-dot" />')
|
||||
if idx in label_indexes:
|
||||
label_marks.append(
|
||||
f'<text x="{x:.1f}" y="{height - 16}" text-anchor="middle" class="time-label">{_escape(labels[idx])}</text>'
|
||||
)
|
||||
if idx == vip_peak_index:
|
||||
annotations.append(
|
||||
f'<g class="chart-annotation">'
|
||||
f'<text x="{x:.1f}" y="{max(vip_y - 16, 18):.1f}" text-anchor="middle" class="value-label vip">峰值 {vip_max}</text>'
|
||||
f'<text x="{x:.1f}" y="{max(vip_y - 2, 30):.1f}" text-anchor="middle" class="value-sub-label vip">{_escape(labels[idx])}</text>'
|
||||
f'</g>'
|
||||
)
|
||||
elif idx == vip_valley_index and vip_min != vip_max:
|
||||
annotations.append(
|
||||
f'<g class="chart-annotation">'
|
||||
f'<text x="{x:.1f}" y="{min(vip_y + 18, padding_top + plot_height - 18):.1f}" text-anchor="middle" class="value-label vip soft">低点 {vip_min}</text>'
|
||||
f'<text x="{x:.1f}" y="{min(vip_y + 32, padding_top + plot_height - 6):.1f}" text-anchor="middle" class="value-sub-label vip soft">{_escape(labels[idx])}</text>'
|
||||
f'</g>'
|
||||
)
|
||||
if idx == diamond_latest_index:
|
||||
annotations.append(
|
||||
f'<g class="chart-annotation">'
|
||||
f'<text x="{min(x + 10, width - padding_right - 6):.1f}" y="{max(diamond_y - 14, 18):.1f}" text-anchor="start" class="value-label diamond">最新 {int(item.get("diamond_count", 0) or 0)}</text>'
|
||||
f'<text x="{min(x + 10, width - padding_right - 6):.1f}" y="{max(diamond_y, 30):.1f}" text-anchor="start" class="value-sub-label diamond">{_escape(labels[idx])}</text>'
|
||||
f'</g>'
|
||||
)
|
||||
|
||||
polyline = f'<polyline points="{" ".join(line_points)}" class="diamond-line" />'
|
||||
summary = audience_trend.get("summary", {}) or {}
|
||||
vip_latest = int(summary.get("vip_latest", vip_values[-1]) or 0)
|
||||
diamond_latest = int(summary.get("diamond_latest", diamond_values[-1]) or 0)
|
||||
|
||||
return f"""
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-head">
|
||||
<div class="chart-title">贵宾 / 钻粉趋势</div>
|
||||
<div class="chart-legend">
|
||||
<span class="legend-item"><span class="legend-swatch vip"></span>贵宾柱状</span>
|
||||
<span class="legend-item"><span class="legend-swatch diamond"></span>钻粉折线</span>
|
||||
<span class="legend-meta">最新:贵宾 {_escape(vip_latest)} / 钻粉 {_escape(diamond_latest)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg viewBox="0 0 {width} {height}" class="audience-chart" role="img" aria-label="贵宾与钻粉趋势图">
|
||||
<defs>
|
||||
<linearGradient id="vipBarGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#2b59ff" stop-opacity="0.95" />
|
||||
<stop offset="100%" stop-color="#7aa2ff" stop-opacity="0.72" />
|
||||
</linearGradient>
|
||||
<linearGradient id="diamondLineGradient" x1="0" y1="0" x2="1" y2="0">
|
||||
<stop offset="0%" stop-color="#f59e0b" />
|
||||
<stop offset="100%" stop-color="#ffd166" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="0" y="0" width="{width}" height="{height}" rx="24" class="chart-bg" />
|
||||
{''.join(grid_lines)}
|
||||
<line x1="{padding_left}" y1="{padding_top + plot_height:.1f}" x2="{width - padding_right}" y2="{padding_top + plot_height:.1f}" class="chart-axis" />
|
||||
<line x1="{padding_left}" y1="{padding_top}" x2="{padding_left}" y2="{padding_top + plot_height:.1f}" class="chart-axis soft" />
|
||||
<line x1="{width - padding_right}" y1="{padding_top}" x2="{width - padding_right}" y2="{padding_top + plot_height:.1f}" class="chart-axis soft" />
|
||||
{''.join(left_ticks)}
|
||||
{''.join(right_ticks)}
|
||||
{''.join(bars)}
|
||||
{polyline}
|
||||
{''.join(dot_points)}
|
||||
{''.join(annotations)}
|
||||
{''.join(label_marks)}
|
||||
</svg>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_daily_report_html(
|
||||
payload: Dict[str, Any],
|
||||
danmu_summary: str,
|
||||
@@ -212,6 +364,7 @@ def render_daily_report_html(
|
||||
|
||||
template_items = _build_template_items(payload)
|
||||
top_active_users = payload.get("operator_metrics", {}).get("top_active_users", []) or []
|
||||
audience_trend = payload.get("audience_trend", {}) or {}
|
||||
|
||||
lead_summary, danmu_bullets = _split_summary_blocks(danmu_summary)
|
||||
danmu_bullets = _normalize_summary_bullets(payload, danmu_bullets, target_count=5)
|
||||
@@ -578,6 +731,146 @@ def render_daily_report_html(
|
||||
font-size: 12px;
|
||||
letter-spacing: .04em;
|
||||
}}
|
||||
.chart-wrap {{
|
||||
padding: 18px 18px 14px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
|
||||
border: 1px solid rgba(129,147,181,0.16);
|
||||
}}
|
||||
.chart-head {{
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}}
|
||||
.chart-title {{
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--navy);
|
||||
}}
|
||||
.chart-legend {{
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}}
|
||||
.legend-item {{
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}}
|
||||
.legend-swatch {{
|
||||
width: 18px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
}}
|
||||
.legend-swatch.vip {{
|
||||
background: linear-gradient(90deg, #2b59ff, #7aa2ff);
|
||||
}}
|
||||
.legend-swatch.diamond {{
|
||||
background: linear-gradient(90deg, #f59e0b, #ffd166);
|
||||
}}
|
||||
.legend-meta {{
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.audience-chart {{
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}}
|
||||
.chart-bg {{
|
||||
fill: rgba(255,255,255,0.72);
|
||||
}}
|
||||
.chart-grid {{
|
||||
stroke: rgba(148, 163, 184, 0.20);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 6;
|
||||
}}
|
||||
.chart-axis {{
|
||||
stroke: rgba(71, 85, 105, 0.38);
|
||||
stroke-width: 1.2;
|
||||
}}
|
||||
.chart-axis.soft {{
|
||||
stroke: rgba(148, 163, 184, 0.28);
|
||||
}}
|
||||
.axis-label {{
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.axis-left {{
|
||||
fill: #3557c8;
|
||||
}}
|
||||
.axis-right {{
|
||||
fill: #b26a00;
|
||||
}}
|
||||
.vip-bar {{
|
||||
fill: url(#vipBarGradient);
|
||||
}}
|
||||
.diamond-line {{
|
||||
fill: none;
|
||||
stroke: url(#diamondLineGradient);
|
||||
stroke-width: 3;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}}
|
||||
.diamond-dot {{
|
||||
fill: #ffcc66;
|
||||
stroke: #ffffff;
|
||||
stroke-width: 1.4;
|
||||
}}
|
||||
.value-label {{
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.value-label.vip {{
|
||||
fill: #2449ad;
|
||||
}}
|
||||
.value-label.vip.soft {{
|
||||
fill: #5573c7;
|
||||
}}
|
||||
.value-label.diamond {{
|
||||
fill: #b26a00;
|
||||
}}
|
||||
.value-sub-label {{
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}}
|
||||
.value-sub-label.vip {{
|
||||
fill: #5f7bd0;
|
||||
}}
|
||||
.value-sub-label.vip.soft {{
|
||||
fill: #7d94d8;
|
||||
}}
|
||||
.value-sub-label.diamond {{
|
||||
fill: #c27c16;
|
||||
}}
|
||||
.time-label {{
|
||||
fill: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}}
|
||||
.chart-empty {{
|
||||
padding: 26px 24px;
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, rgba(244,247,255,0.96), rgba(255,255,255,0.96));
|
||||
border: 1px solid rgba(129,147,181,0.16);
|
||||
}}
|
||||
.chart-empty-title {{
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
color: var(--navy);
|
||||
margin-bottom: 8px;
|
||||
}}
|
||||
.chart-empty-desc {{
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}}
|
||||
@media (max-width: 900px) {{
|
||||
.active-user-grid {{
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -627,6 +920,9 @@ def render_daily_report_html(
|
||||
|
||||
<div class="section ops">
|
||||
<div class="section-title"><span class="icon"></span><span>运营数据总结</span></div>
|
||||
<div style="margin-top: 2px; margin-bottom: 16px;">
|
||||
{_render_audience_trend_chart(audience_trend)}
|
||||
</div>
|
||||
<div class="summary-grid">
|
||||
<div class="prose">
|
||||
{_render_list(operator_summary_lines)}
|
||||
|
||||
Reference in New Issue
Block a user