949 lines
32 KiB
Python
949 lines
32 KiB
Python
# -*- coding: utf-8 -*-
|
||
import html
|
||
from typing import Any, Dict, List
|
||
|
||
|
||
def _escape(value: Any) -> str:
|
||
return html.escape(str(value or ""))
|
||
|
||
|
||
def _render_metric_card(label: str, value: Any, hint: str = "") -> str:
|
||
return (
|
||
'<div class="metric-card">'
|
||
f'<div class="metric-label">{_escape(label)}</div>'
|
||
f'<div class="metric-value">{_escape(value)}</div>'
|
||
f'<div class="metric-hint">{_escape(hint)}</div>'
|
||
"</div>"
|
||
)
|
||
|
||
|
||
def _render_list(items: List[str], item_class: str = "bullet-list") -> str:
|
||
if not items:
|
||
return ""
|
||
lis = "".join(f'<li>{_escape(item)}</li>' for item in items if str(item or "").strip())
|
||
return f'<ul class="{item_class}">{lis}</ul>' if lis else ""
|
||
|
||
|
||
def _split_summary_blocks(danmu_summary: str) -> tuple[str, List[str]]:
|
||
lead_parts = []
|
||
insight_items = []
|
||
for line in str(danmu_summary or "").splitlines():
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
continue
|
||
if stripped.startswith("- "):
|
||
insight_items.append(stripped[2:].strip())
|
||
else:
|
||
lead_parts.append(stripped)
|
||
lead = " ".join(lead_parts).strip()
|
||
return lead, insight_items
|
||
|
||
|
||
def _normalize_summary_bullets(payload: Dict[str, Any], items: List[str], target_count: int = 5) -> List[str]:
|
||
normalized = [str(item or "").strip() for item in items if str(item or "").strip()]
|
||
if len(normalized) >= target_count:
|
||
return normalized[:target_count]
|
||
|
||
top_terms = [str(item.get("term") or "").strip() for item in (payload.get("top_terms", []) or []) if str(item.get("term") or "").strip()]
|
||
merged_templates = [str(item.get("text") or "").strip() for item in (payload.get("merged_templates", []) or []) if str(item.get("text") or "").strip()]
|
||
peak_buckets = payload.get("peak_buckets", []) or []
|
||
representative_messages = payload.get("representative_messages", []) or []
|
||
|
||
supplements: List[str] = []
|
||
if top_terms:
|
||
supplements.append(f"讨论焦点比较集中,弹幕反复围绕 {'、'.join(top_terms[:5])} 展开。")
|
||
if merged_templates:
|
||
sample_templates = ";".join(text[:24] for text in merged_templates[:3])
|
||
supplements.append(f"复读和共识梗比较强,重复内容主要集中在 {sample_templates}。")
|
||
if peak_buckets:
|
||
top_bucket = peak_buckets[0]
|
||
bucket_terms = [str(term.get('term') or '').strip() for term in (top_bucket.get("top_terms", []) or []) if str(term.get('term') or '').strip()]
|
||
if bucket_terms:
|
||
supplements.append(
|
||
f"高峰时段出现在 {str(top_bucket.get('start_time') or '')[-8:-3]} 前后,话题明显偏向 {'、'.join(bucket_terms[:4])}。"
|
||
)
|
||
if representative_messages:
|
||
supplements.append("代表性发言里既有操作反馈,也有玩梗调侃和情绪宣泄,互动意愿比较强。")
|
||
|
||
existing = set(normalized)
|
||
for item in supplements:
|
||
if item not in existing:
|
||
normalized.append(item)
|
||
existing.add(item)
|
||
if len(normalized) >= target_count:
|
||
break
|
||
return normalized[:target_count]
|
||
|
||
|
||
def _build_template_items(payload: Dict[str, Any], limit: int = 8) -> List[str]:
|
||
items: List[str] = []
|
||
seen = set()
|
||
|
||
def push(text: str, suffix: str = "") -> None:
|
||
value = str(text or "").strip()
|
||
if not value:
|
||
return
|
||
normalized_key = value
|
||
if normalized_key in seen:
|
||
return
|
||
seen.add(normalized_key)
|
||
items.append(f"{value}{suffix}".strip())
|
||
|
||
for item in (payload.get("merged_templates", []) or [])[:6]:
|
||
text = str(item.get("text") or "").strip()
|
||
count = int(item.get("count", 0) or 0)
|
||
if text:
|
||
push(text[:72], f"({count}次)")
|
||
|
||
for item in (payload.get("repeated_messages", []) or [])[:6]:
|
||
text = str(item.get("text") or "").strip()
|
||
count = int(item.get("count", 0) or 0)
|
||
if text:
|
||
push(text[:72], f"({count}次)")
|
||
|
||
for item in (payload.get("burst_terms", []) or [])[:6]:
|
||
text = str(item.get("text") or "").strip()
|
||
count = int(item.get("count", 0) or 0)
|
||
if text:
|
||
push(text[:36], f"(爆发 {count} 次)")
|
||
|
||
for item in (payload.get("top_terms", []) or [])[:6]:
|
||
term = str(item.get("term") or "").strip()
|
||
count = int(item.get("count", 0) or 0)
|
||
if term:
|
||
push(term, f"({count}次提及)")
|
||
|
||
return items[:limit]
|
||
|
||
|
||
def _render_insight_cards(items: List[str]) -> str:
|
||
labels = ["主线", "情绪", "梗点", "节奏", "反馈", "补充"]
|
||
blocks = []
|
||
for idx, item in enumerate(items[:6]):
|
||
extra_class = " full-span" if len(items[:6]) % 2 == 1 and idx == len(items[:6]) - 1 else ""
|
||
blocks.append(
|
||
f'<div class="insight-card{extra_class}">'
|
||
f'<div class="insight-kicker">{_escape(labels[idx] if idx < len(labels) else "观察")}</div>'
|
||
f'<div class="insight-text">{_escape(item)}</div>'
|
||
"</div>"
|
||
)
|
||
return "".join(blocks)
|
||
|
||
|
||
def _render_badges(top_badges: List[Dict[str, Any]]) -> str:
|
||
blocks = []
|
||
for item in top_badges[:6]:
|
||
badge_name = str(item.get("badge_name") or "").strip()
|
||
if not badge_name:
|
||
continue
|
||
blocks.append(
|
||
'<div class="badge-chip">'
|
||
f'<span class="badge-name">{_escape(badge_name)}</span>'
|
||
f'<span class="badge-meta">{_escape(item.get("user_count", 0))}人 / {_escape(item.get("message_count", 0))}条</span>'
|
||
"</div>"
|
||
)
|
||
return "".join(blocks)
|
||
|
||
|
||
def _render_hot_times(peak_buckets: List[Dict[str, Any]]) -> str:
|
||
blocks = []
|
||
for item in peak_buckets[:3]:
|
||
start_time = str(item.get("start_time") or "")[-8:-3]
|
||
terms = [str(term.get("term") or "").strip() for term in (item.get("top_terms", []) or [])[:4]]
|
||
terms = [term for term in terms if term]
|
||
blocks.append(
|
||
'<div class="hot-card">'
|
||
f'<div class="hot-time">{_escape(start_time)}</div>'
|
||
f'<div class="hot-count">{_escape(item.get("message_count", 0))} 条弹幕</div>'
|
||
f'<div class="hot-terms">{_escape(" / ".join(terms))}</div>'
|
||
"</div>"
|
||
)
|
||
return "".join(blocks)
|
||
|
||
|
||
def _render_active_users(top_active_users: List[Dict[str, Any]]) -> str:
|
||
blocks = []
|
||
for item in top_active_users[:10]:
|
||
nickname = str(item.get("nickname") or item.get("uid") or "").strip()
|
||
fans_name = str(item.get("fans_name") or "").strip()
|
||
fans_level = int(item.get("fans_level", 0) or 0)
|
||
room_level = int(item.get("room_level", 0) or 0)
|
||
message_count = int(item.get("message_count", 0) or 0)
|
||
|
||
chips = []
|
||
if fans_name:
|
||
fans_label = f"{fans_name} Lv{fans_level}" if fans_level > 0 else fans_name
|
||
chips.append(f'<span class="user-chip fans">{_escape(fans_label)}</span>')
|
||
if room_level > 0:
|
||
chips.append(f'<span class="user-chip room">{_escape(f"平台 Lv{room_level}")}</span>')
|
||
meta_html = "".join(chips) if chips else '<span class="user-chip plain">普通观众</span>'
|
||
|
||
blocks.append(
|
||
'<div class="active-user-card">'
|
||
'<div class="active-user-top">'
|
||
f'<div class="active-user-name">{_escape(nickname)}</div>'
|
||
f'<div class="active-user-count">{_escape(message_count)}<span>条</span></div>'
|
||
'</div>'
|
||
f'<div class="active-user-meta">{meta_html}</div>'
|
||
'</div>'
|
||
)
|
||
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,
|
||
operator_summary_lines: List[str],
|
||
) -> str:
|
||
meta = payload.get("report_meta", {}) or {}
|
||
operator = payload.get("operator_metrics", {}) or {}
|
||
title_name = str(meta.get("nickname") or meta.get("room_name") or meta.get("room_id") or "主播")
|
||
subtitle = (
|
||
f"{meta.get('anchor_day', '')} | 场次 {meta.get('session_count', 0)}"
|
||
f" | 弹幕 {meta.get('message_count', 0)} | 活跃用户 {meta.get('unique_user_count', 0)}"
|
||
)
|
||
|
||
metrics_html = "".join([
|
||
_render_metric_card("活跃用户", meta.get("unique_user_count", 0), "当天参与弹幕的去重人数"),
|
||
_render_metric_card("带牌活跃", operator.get("fans_badge_user_count", 0), "带粉丝牌发言用户"),
|
||
_render_metric_card("10+粉丝牌", operator.get("high_fans_level_user_count", 0), "高粘性活跃用户"),
|
||
_render_metric_card("30+等级用户", operator.get("high_room_level_user_count", 0), "高等级老观众"),
|
||
])
|
||
|
||
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)
|
||
|
||
html_doc = f"""<html>
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<style>
|
||
:root {{
|
||
--bg-top: #f3efe5;
|
||
--bg-bottom: #e6edf5;
|
||
--paper: rgba(255, 252, 247, 0.97);
|
||
--text: #1f2937;
|
||
--muted: #6b7280;
|
||
--line: rgba(137, 148, 163, 0.18);
|
||
--navy: #14213d;
|
||
--blue: #2b59ff;
|
||
--cyan: #1fa8a0;
|
||
--gold: #c89b3c;
|
||
--gold-soft: rgba(200, 155, 60, 0.14);
|
||
--red-soft: rgba(210, 84, 61, 0.10);
|
||
--shadow: 0 26px 60px rgba(33, 52, 84, 0.14);
|
||
}}
|
||
* {{ box-sizing: border-box; }}
|
||
body {{
|
||
margin: 0;
|
||
padding: 34px;
|
||
background:
|
||
radial-gradient(circle at 0% 0%, rgba(43, 89, 255, 0.08), transparent 24%),
|
||
radial-gradient(circle at 100% 0%, rgba(31, 168, 160, 0.10), transparent 20%),
|
||
linear-gradient(180deg, var(--bg-top) 0%, var(--bg-bottom) 100%);
|
||
font-family: 'Microsoft YaHei', 'PingFang SC', 'Segoe UI', sans-serif;
|
||
color: var(--text);
|
||
}}
|
||
.sheet {{
|
||
width: 920px;
|
||
margin: 0 auto;
|
||
background: var(--paper);
|
||
border-radius: 34px;
|
||
overflow: visible;
|
||
box-shadow: var(--shadow);
|
||
border: 1px solid rgba(255,255,255,0.6);
|
||
}}
|
||
.hero {{
|
||
position: relative;
|
||
padding: 34px 40px 30px;
|
||
border-radius: 34px 34px 0 0;
|
||
background:
|
||
radial-gradient(circle at 18% 18%, rgba(255,255,255,0.12), transparent 18%),
|
||
radial-gradient(circle at 84% 14%, rgba(255,255,255,0.11), transparent 19%),
|
||
linear-gradient(135deg, #111827 0%, #1d4ed8 46%, #0f766e 100%);
|
||
color: #fff;
|
||
}}
|
||
.hero::after {{
|
||
content: "";
|
||
position: absolute;
|
||
right: -52px;
|
||
top: -40px;
|
||
width: 230px;
|
||
height: 230px;
|
||
border-radius: 50%;
|
||
border: 1px solid rgba(255,255,255,0.14);
|
||
box-shadow: 0 0 0 28px rgba(255,255,255,0.04), 0 0 0 60px rgba(255,255,255,0.02);
|
||
}}
|
||
.eyebrow {{
|
||
display: inline-block;
|
||
padding: 7px 14px;
|
||
border-radius: 999px;
|
||
background: rgba(255,255,255,0.12);
|
||
border: 1px solid rgba(255,255,255,0.18);
|
||
font-size: 12px;
|
||
letter-spacing: .08em;
|
||
}}
|
||
.hero-title {{
|
||
margin: 18px 0 10px;
|
||
font-size: 44px;
|
||
font-weight: 800;
|
||
line-height: 1.16;
|
||
letter-spacing: -0.03em;
|
||
}}
|
||
.hero-subtitle {{
|
||
color: rgba(240, 246, 255, 0.84);
|
||
font-size: 16px;
|
||
}}
|
||
.content {{
|
||
padding: 28px 30px 34px;
|
||
}}
|
||
.metric-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 14px;
|
||
margin-top: -34px;
|
||
position: relative;
|
||
z-index: 2;
|
||
}}
|
||
.metric-card {{
|
||
background: rgba(255,255,255,0.9);
|
||
backdrop-filter: blur(8px);
|
||
border: 1px solid rgba(255,255,255,0.8);
|
||
border-radius: 22px;
|
||
padding: 18px 18px 16px;
|
||
box-shadow: 0 10px 24px rgba(17, 24, 39, 0.08);
|
||
}}
|
||
.metric-label {{
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
margin-bottom: 8px;
|
||
}}
|
||
.metric-value {{
|
||
font-size: 32px;
|
||
font-weight: 800;
|
||
color: var(--navy);
|
||
line-height: 1;
|
||
}}
|
||
.metric-hint {{
|
||
color: #8090a7;
|
||
font-size: 12px;
|
||
margin-top: 8px;
|
||
}}
|
||
.section {{
|
||
margin-top: 20px;
|
||
padding: 24px;
|
||
border-radius: 26px;
|
||
border: 1px solid var(--line);
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.95), rgba(248,250,252,0.92));
|
||
}}
|
||
.section.danmu {{
|
||
background:
|
||
linear-gradient(180deg, rgba(255,255,255,0.95), rgba(247, 249, 255, 0.94));
|
||
}}
|
||
.section.ops {{
|
||
background:
|
||
linear-gradient(180deg, rgba(255,251,244,0.96), rgba(255,255,255,0.95));
|
||
}}
|
||
.section-title {{
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
font-size: 27px;
|
||
font-weight: 800;
|
||
margin-bottom: 16px;
|
||
color: var(--navy);
|
||
}}
|
||
.section-title .icon {{
|
||
width: 14px;
|
||
height: 30px;
|
||
border-radius: 999px;
|
||
background: linear-gradient(180deg, var(--blue), var(--cyan));
|
||
box-shadow: 0 6px 16px rgba(43,89,255,0.24);
|
||
}}
|
||
.section.ops .section-title .icon {{
|
||
background: linear-gradient(180deg, #d6a547, #f59e0b);
|
||
box-shadow: 0 6px 16px rgba(200,155,60,0.24);
|
||
}}
|
||
.summary-grid {{
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1.6fr) minmax(255px, 0.78fr);
|
||
gap: 18px;
|
||
}}
|
||
.prose p {{
|
||
margin: 0 0 12px;
|
||
color: #334155;
|
||
font-size: 17px;
|
||
line-height: 1.84;
|
||
}}
|
||
.lead-panel {{
|
||
padding: 18px 18px 16px;
|
||
border-radius: 20px;
|
||
background: linear-gradient(180deg, rgba(242,246,255,0.92), rgba(255,255,255,0.96));
|
||
border: 1px solid rgba(125, 145, 186, 0.14);
|
||
margin-bottom: 14px;
|
||
}}
|
||
.lead-title {{
|
||
color: #5e6d87;
|
||
font-size: 13px;
|
||
letter-spacing: .06em;
|
||
margin-bottom: 10px;
|
||
}}
|
||
.lead-text {{
|
||
color: #24364c;
|
||
font-size: 18px;
|
||
line-height: 1.9;
|
||
font-weight: 500;
|
||
}}
|
||
.insight-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}}
|
||
.insight-card {{
|
||
padding: 15px 16px;
|
||
border-radius: 18px;
|
||
background: rgba(255,255,255,0.9);
|
||
border: 1px solid rgba(125, 145, 186, 0.14);
|
||
min-height: 110px;
|
||
}}
|
||
.insight-card.full-span {{
|
||
grid-column: 1 / -1;
|
||
}}
|
||
.insight-kicker {{
|
||
color: #2b59ff;
|
||
font-size: 12px;
|
||
letter-spacing: .08em;
|
||
font-weight: 700;
|
||
margin-bottom: 8px;
|
||
}}
|
||
.insight-text {{
|
||
color: #334155;
|
||
font-size: 15px;
|
||
line-height: 1.76;
|
||
}}
|
||
.bullet-list {{
|
||
margin: 0;
|
||
padding-left: 22px;
|
||
}}
|
||
.bullet-list li {{
|
||
color: #334155;
|
||
margin: 10px 0;
|
||
line-height: 1.72;
|
||
font-size: 16px;
|
||
}}
|
||
.aside-card {{
|
||
padding: 18px;
|
||
border-radius: 20px;
|
||
background: rgba(245, 248, 255, 0.86);
|
||
border: 1px solid rgba(125, 145, 186, 0.16);
|
||
}}
|
||
.aside-card.warm {{
|
||
background: rgba(255, 248, 234, 0.82);
|
||
border: 1px solid rgba(200, 155, 60, 0.18);
|
||
}}
|
||
.aside-title {{
|
||
font-size: 14px;
|
||
letter-spacing: .06em;
|
||
color: #68758a;
|
||
margin-bottom: 12px;
|
||
}}
|
||
.badge-wall {{
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}}
|
||
.badge-chip {{
|
||
padding: 10px 12px;
|
||
border-radius: 16px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.92), rgba(242,246,255,0.92));
|
||
border: 1px solid rgba(129, 147, 181, 0.16);
|
||
min-width: 0;
|
||
}}
|
||
.badge-name {{
|
||
display: block;
|
||
font-weight: 700;
|
||
color: var(--navy);
|
||
margin-bottom: 4px;
|
||
}}
|
||
.badge-meta {{
|
||
display: block;
|
||
font-size: 12px;
|
||
color: #6b7280;
|
||
}}
|
||
.active-user-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}}
|
||
.active-user-card {{
|
||
padding: 12px 12px 11px;
|
||
border-radius: 16px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(244,247,255,0.92));
|
||
border: 1px solid rgba(129,147,181,0.18);
|
||
box-shadow: 0 10px 24px rgba(25, 38, 66, 0.05);
|
||
}}
|
||
.active-user-top {{
|
||
display: flex;
|
||
align-items: flex-start;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
margin-bottom: 8px;
|
||
}}
|
||
.active-user-name {{
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--navy);
|
||
line-height: 1.3;
|
||
word-break: break-all;
|
||
}}
|
||
.active-user-count {{
|
||
flex-shrink: 0;
|
||
min-width: 44px;
|
||
text-align: right;
|
||
font-size: 19px;
|
||
font-weight: 800;
|
||
color: var(--blue);
|
||
line-height: 1;
|
||
}}
|
||
.active-user-count span {{
|
||
margin-left: 2px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
color: #64748b;
|
||
}}
|
||
.active-user-meta {{
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}}
|
||
.user-chip {{
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 4px 8px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
line-height: 1;
|
||
border: 1px solid transparent;
|
||
}}
|
||
.user-chip.fans {{
|
||
color: #9a6700;
|
||
background: rgba(250, 204, 21, 0.14);
|
||
border-color: rgba(234, 179, 8, 0.24);
|
||
}}
|
||
.user-chip.room {{
|
||
color: #1d4ed8;
|
||
background: rgba(59, 130, 246, 0.12);
|
||
border-color: rgba(59, 130, 246, 0.18);
|
||
}}
|
||
.user-chip.plain {{
|
||
color: #64748b;
|
||
background: rgba(148, 163, 184, 0.12);
|
||
border-color: rgba(148, 163, 184, 0.16);
|
||
}}
|
||
.hot-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 12px;
|
||
margin-top: 16px;
|
||
}}
|
||
.hot-card {{
|
||
padding: 16px;
|
||
border-radius: 18px;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.94), rgba(241,246,255,0.92));
|
||
border: 1px solid rgba(129,147,181,0.16);
|
||
}}
|
||
.hot-time {{
|
||
font-size: 22px;
|
||
font-weight: 800;
|
||
color: var(--blue);
|
||
margin-bottom: 6px;
|
||
}}
|
||
.hot-count {{
|
||
font-size: 14px;
|
||
color: #334155;
|
||
margin-bottom: 8px;
|
||
}}
|
||
.hot-terms {{
|
||
font-size: 13px;
|
||
color: #64748b;
|
||
line-height: 1.56;
|
||
}}
|
||
.footer-note {{
|
||
margin-top: 20px;
|
||
text-align: right;
|
||
color: #7b8798;
|
||
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));
|
||
}}
|
||
}}
|
||
@media (max-width: 640px) {{
|
||
.active-user-grid {{
|
||
grid-template-columns: 1fr;
|
||
}}
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="sheet">
|
||
<div class="hero">
|
||
<div class="eyebrow">DOUYU DAILY REPORT</div>
|
||
<div class="hero-title">{_escape(title_name)}</div>
|
||
<div class="hero-subtitle">{_escape(subtitle)}</div>
|
||
</div>
|
||
<div class="content">
|
||
<div class="metric-grid">
|
||
{metrics_html}
|
||
</div>
|
||
|
||
<div class="section danmu">
|
||
<div class="section-title"><span class="icon"></span><span>弹幕总结</span></div>
|
||
<div class="summary-grid">
|
||
<div class="prose">
|
||
<div class="lead-panel">
|
||
<div class="lead-title">整体观察</div>
|
||
<div class="lead-text">{_escape(lead_summary)}</div>
|
||
</div>
|
||
<div class="insight-grid">
|
||
{_render_insight_cards(danmu_bullets)}
|
||
</div>
|
||
</div>
|
||
<div class="aside-card">
|
||
<div class="aside-title">高频梗</div>
|
||
{_render_list(template_items)}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="hot-grid">
|
||
{_render_hot_times(payload.get("peak_buckets", []) or [])}
|
||
</div>
|
||
</div>
|
||
|
||
<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)}
|
||
</div>
|
||
<div class="aside-card warm">
|
||
<div class="aside-title">活跃牌子</div>
|
||
<div class="badge-wall">{_render_badges(operator.get("top_badges", []) or [])}</div>
|
||
</div>
|
||
</div>
|
||
<div class="aside-card" style="margin-top: 16px;">
|
||
<div class="aside-title">核心发言用户</div>
|
||
<div class="active-user-grid">
|
||
{_render_active_users(top_active_users)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="footer-note">ABOT · Douyu Report Template</div>
|
||
</div>
|
||
</div>
|
||
</body>
|
||
</html>"""
|
||
return html_doc
|