feat(douyu): add audience trend chart to daily report
This commit is contained in:
@@ -24,6 +24,7 @@ daily_report_use_llm = true
|
||||
daily_report_max_sessions = 4
|
||||
daily_report_max_length = 1800
|
||||
daily_report_send_image = true
|
||||
audience_stats_sample_interval_seconds = 0
|
||||
|
||||
[Douyu.report_api]
|
||||
backend = "openai_compatible_ai_auto_response"
|
||||
|
||||
@@ -34,9 +34,11 @@ from wechat_ipad.models.appmsg_xml import DOUYU_MESSAGE_XML
|
||||
|
||||
|
||||
class DouyuDanmuRecorder:
|
||||
def __init__(self, room_id: str, user_agent: str):
|
||||
def __init__(self, room_id: str, user_agent: str, stats_callback=None, stats_sample_interval_seconds: int = 60):
|
||||
self.room_id = room_id
|
||||
self.user_agent = user_agent
|
||||
self.stats_callback = stats_callback
|
||||
self.stats_sample_interval_seconds = max(0, int(stats_sample_interval_seconds or 0))
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop_event = threading.Event()
|
||||
self._ws: Optional[websocket.WebSocketApp] = None
|
||||
@@ -45,6 +47,9 @@ class DouyuDanmuRecorder:
|
||||
self._buffer_date: Optional[str] = None
|
||||
self._lock = threading.Lock()
|
||||
self._websocket_available = websocket is not None
|
||||
self._latest_vip_count: Optional[int] = None
|
||||
self._latest_diamond_count: Optional[int] = None
|
||||
self._last_stats_signature: Tuple[Optional[int], Optional[int]] = (None, None)
|
||||
|
||||
def _encode(self, msg: str) -> bytes:
|
||||
content = msg.encode("utf-8") + b"\x00"
|
||||
@@ -54,6 +59,42 @@ class DouyuDanmuRecorder:
|
||||
head += b"\x00\x00"
|
||||
return head + content
|
||||
|
||||
@staticmethod
|
||||
def _parse_parts(line: str) -> Dict[str, Any]:
|
||||
parts: Dict[str, Any] = {}
|
||||
for pair in line.split("/"):
|
||||
if "@=" in pair:
|
||||
key, value = pair.split("@=", 1)
|
||||
parts[key] = value
|
||||
return parts
|
||||
|
||||
@staticmethod
|
||||
def _safe_int(value: Any, default: Optional[int] = None) -> Optional[int]:
|
||||
try:
|
||||
return int(str(value))
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
def _maybe_emit_stats(self, force: bool = False) -> None:
|
||||
if not self.stats_callback:
|
||||
return
|
||||
if self._latest_vip_count is None and self._latest_diamond_count is None:
|
||||
return
|
||||
signature = (self._latest_vip_count, self._latest_diamond_count)
|
||||
if not force:
|
||||
if signature == self._last_stats_signature:
|
||||
return
|
||||
point = {
|
||||
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"vip_count": self._latest_vip_count,
|
||||
"diamond_count": self._latest_diamond_count,
|
||||
}
|
||||
try:
|
||||
self.stats_callback(self.room_id, point)
|
||||
self._last_stats_signature = signature
|
||||
except Exception as e:
|
||||
logger.warning(f"斗鱼人数采样回调失败({self.room_id}): {e}")
|
||||
|
||||
def _on_message(self, ws, message):
|
||||
try:
|
||||
decompressed = zlib.decompress(message, -zlib.MAX_WBITS)
|
||||
@@ -64,13 +105,25 @@ class DouyuDanmuRecorder:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if "type@=chatmsg" not in line:
|
||||
parts = self._parse_parts(line)
|
||||
msg_type = str(parts.get("type") or "").strip()
|
||||
|
||||
if msg_type == "oni":
|
||||
vip_count = self._safe_int(parts.get("vn"))
|
||||
if vip_count is not None:
|
||||
self._latest_vip_count = vip_count
|
||||
self._maybe_emit_stats()
|
||||
continue
|
||||
|
||||
if msg_type == "dfnum":
|
||||
diamond_count = self._safe_int(parts.get("dfc"))
|
||||
if diamond_count is not None:
|
||||
self._latest_diamond_count = diamond_count
|
||||
self._maybe_emit_stats()
|
||||
continue
|
||||
|
||||
if msg_type != "chatmsg":
|
||||
continue
|
||||
parts: Dict[str, Any] = {}
|
||||
for pair in line.split("/"):
|
||||
if "@=" in pair:
|
||||
key, value = pair.split("@=", 1)
|
||||
parts[key] = value
|
||||
nick = parts.get("nn", "未知")
|
||||
txt = parts.get("txt", "")
|
||||
uid = parts.get("uid", "未知")
|
||||
@@ -129,7 +182,7 @@ class DouyuDanmuRecorder:
|
||||
|
||||
def _on_open(self, ws):
|
||||
ws.send(self._encode(f"type@=loginreq/roomid@={self.room_id}/dmbt@=chrome/dmbv@=0/"))
|
||||
ws.send(self._encode(f"type@=joingroup/rid@={self.room_id}/gid@={self.room_id}/"))
|
||||
ws.send(self._encode(f"type@=joingroup/rid@={self.room_id}/gid@=-9999/"))
|
||||
|
||||
def heartbeat():
|
||||
while ws.sock and ws.sock.connected and not self._stop_event.is_set():
|
||||
@@ -199,6 +252,7 @@ class DouyuDanmuRecorder:
|
||||
self._thread.start()
|
||||
|
||||
def stop(self):
|
||||
self._maybe_emit_stats(force=True)
|
||||
self._flush()
|
||||
self._stop_event.set()
|
||||
if self._ws:
|
||||
@@ -379,7 +433,7 @@ class DouyuRedisManager:
|
||||
|
||||
|
||||
class DouyuPlugin(MessagePluginInterface):
|
||||
_DAILY_REPORT_CACHE_VERSION = 2
|
||||
_DAILY_REPORT_CACHE_VERSION = 3
|
||||
FEATURE_KEY = "DOUYU_MONITOR"
|
||||
FEATURE_DESCRIPTION = "🎮 斗鱼开播提醒 [订阅斗鱼 房间号, 取消订阅斗鱼 房间号]"
|
||||
|
||||
@@ -435,6 +489,7 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
self._daily_report_max_sessions = 4
|
||||
self._daily_report_max_length = 1800
|
||||
self._daily_report_send_image = True
|
||||
self._audience_stats_sample_interval_seconds = 60
|
||||
self._daily_report_llm_client: Optional[LLMClient] = None
|
||||
self._danmu_recorders: Dict[str, DouyuDanmuRecorder] = {}
|
||||
async_job.every_minutes(self._check_interval)(self._scheduled_unified_check_job)
|
||||
@@ -482,6 +537,9 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
self._daily_report_max_sessions = int(cfg.get("daily_report_max_sessions", self._daily_report_max_sessions))
|
||||
self._daily_report_max_length = int(cfg.get("daily_report_max_length", self._daily_report_max_length))
|
||||
self._daily_report_send_image = bool(cfg.get("daily_report_send_image", self._daily_report_send_image))
|
||||
self._audience_stats_sample_interval_seconds = int(
|
||||
cfg.get("audience_stats_sample_interval_seconds", self._audience_stats_sample_interval_seconds)
|
||||
)
|
||||
|
||||
report_api_cfg = cfg.get("report_api", {}) or {}
|
||||
if report_api_cfg:
|
||||
@@ -822,10 +880,48 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
def _get_danmu_recorder(self, room_id: str) -> DouyuDanmuRecorder:
|
||||
recorder = self._danmu_recorders.get(room_id)
|
||||
if not recorder:
|
||||
recorder = DouyuDanmuRecorder(room_id, self._user_agent)
|
||||
recorder = DouyuDanmuRecorder(
|
||||
room_id,
|
||||
self._user_agent,
|
||||
stats_callback=self._record_room_audience_point,
|
||||
stats_sample_interval_seconds=self._audience_stats_sample_interval_seconds,
|
||||
)
|
||||
self._danmu_recorders[room_id] = recorder
|
||||
return recorder
|
||||
|
||||
@staticmethod
|
||||
def _normalize_audience_points(points: List[Dict[str, Any]], limit: int = 720) -> List[Dict[str, Any]]:
|
||||
normalized: List[Dict[str, Any]] = []
|
||||
seen = set()
|
||||
for item in points or []:
|
||||
timestamp = str(item.get("timestamp") or "").strip()
|
||||
if not timestamp or timestamp in seen:
|
||||
continue
|
||||
seen.add(timestamp)
|
||||
normalized.append({
|
||||
"timestamp": timestamp,
|
||||
"vip_count": int(item.get("vip_count", 0) or 0),
|
||||
"diamond_count": int(item.get("diamond_count", 0) or 0),
|
||||
})
|
||||
normalized.sort(key=lambda row: row.get("timestamp", ""))
|
||||
if len(normalized) > limit:
|
||||
normalized = normalized[-limit:]
|
||||
return normalized
|
||||
|
||||
def _record_room_audience_point(self, room_id: str, point: Dict[str, Any]) -> None:
|
||||
if not self.redis_manager or not room_id:
|
||||
return
|
||||
session = self.redis_manager.get_latest_room_session(room_id)
|
||||
if not session or not bool(session.get("is_live")):
|
||||
return
|
||||
current_points = self._normalize_audience_points(list(session.get("audience_points", []) or []))
|
||||
merged_points = self._normalize_audience_points(current_points + [point])
|
||||
if merged_points == current_points:
|
||||
return
|
||||
session["audience_points"] = merged_points
|
||||
session["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.redis_manager.save_room_session(room_id, session)
|
||||
|
||||
def _resolve_anchor_day(self, target_dt: datetime) -> str:
|
||||
if target_dt.hour < self._session_cutoff_hour:
|
||||
target_dt = target_dt - timedelta(days=1)
|
||||
@@ -885,6 +981,7 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
"nickname": nickname,
|
||||
"room_name": room_name,
|
||||
"segments": [{"start_time": now_str, "end_time": ""}],
|
||||
"audience_points": [],
|
||||
"is_live": True,
|
||||
"summary_status": "pending",
|
||||
"summary_generated_at": "",
|
||||
@@ -893,6 +990,7 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
|
||||
session["nickname"] = nickname or session.get("nickname", "")
|
||||
session["room_name"] = room_name or session.get("room_name", "")
|
||||
session["audience_points"] = self._normalize_audience_points(list(session.get("audience_points", []) or []))
|
||||
session["is_live"] = True
|
||||
session["updated_at"] = now_str
|
||||
session["last_live_at"] = now_str
|
||||
@@ -1083,6 +1181,37 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
"source": "fallback_full_day",
|
||||
}]
|
||||
|
||||
def _build_audience_trend(self, sessions: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
points: List[Dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
for item in session.get("audience_points", []) or []:
|
||||
point = {
|
||||
"timestamp": str(item.get("timestamp") or "").strip(),
|
||||
"vip_count": int(item.get("vip_count", 0) or 0),
|
||||
"diamond_count": int(item.get("diamond_count", 0) or 0),
|
||||
}
|
||||
if point["timestamp"]:
|
||||
points.append(point)
|
||||
points = self._normalize_audience_points(points, limit=1440)
|
||||
if not points:
|
||||
return {"points": [], "summary": {}}
|
||||
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]
|
||||
return {
|
||||
"points": points,
|
||||
"summary": {
|
||||
"point_count": len(points),
|
||||
"vip_min": min(vip_values),
|
||||
"vip_max": max(vip_values),
|
||||
"vip_latest": vip_values[-1],
|
||||
"diamond_min": min(diamond_values),
|
||||
"diamond_max": max(diamond_values),
|
||||
"diamond_latest": diamond_values[-1],
|
||||
"labels": labels,
|
||||
},
|
||||
}
|
||||
|
||||
def _build_daily_report_payload(self, room_id: str, anchor_day: str, sessions: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
if not sessions:
|
||||
return None
|
||||
@@ -1203,6 +1332,7 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
|
||||
artifact_dir = os.path.join("temp", "douyu_materials")
|
||||
os.makedirs(artifact_dir, exist_ok=True)
|
||||
audience_trend = self._build_audience_trend(sessions)
|
||||
payload = {
|
||||
"report_meta": {
|
||||
"room_id": room_id,
|
||||
@@ -1244,6 +1374,7 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
}
|
||||
for item in session_payloads
|
||||
],
|
||||
"audience_trend": audience_trend,
|
||||
"merged_templates": merged_templates[:24],
|
||||
"repeated_messages": repeated_messages[:24],
|
||||
"top_terms": [{"term": term, "count": count} for term, count in top_terms_counter.most_common(24)],
|
||||
@@ -1437,6 +1568,16 @@ class DouyuPlugin(MessagePluginInterface):
|
||||
f"- 用户质量:房间等级 30 级以上活跃用户 {high_room_users} 人,说明高等级老观众参与度不低。",
|
||||
]
|
||||
|
||||
audience_summary = (payload.get("audience_trend", {}) or {}).get("summary", {}) or {}
|
||||
if audience_summary:
|
||||
vip_min = int(audience_summary.get("vip_min", 0) or 0)
|
||||
vip_max = int(audience_summary.get("vip_max", 0) or 0)
|
||||
diamond_latest = int(audience_summary.get("diamond_latest", 0) or 0)
|
||||
point_count = int(audience_summary.get("point_count", 0) or 0)
|
||||
lines.append(
|
||||
f"- 人数走势:WS 侧共采样 {point_count} 个时间点,贵宾在 {vip_min}-{vip_max} 区间波动,钻粉收盘约 {diamond_latest}。"
|
||||
)
|
||||
|
||||
top_badges = payload.get("operator_metrics", {}).get("top_badges", []) or []
|
||||
if top_badges:
|
||||
badge_parts = []
|
||||
|
||||
@@ -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