From 3072297769e1fea02bf31048616847ad943a05f4 Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Thu, 26 Feb 2026 18:29:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(wrapped):=20=E6=96=B0=E5=A2=9E=E4=BE=BF?= =?UTF-8?q?=E5=BD=93=E6=80=BB=E8=A7=88=E5=8D=A1=E7=89=87=EF=BC=88Card=20#7?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 增加 Card07BentoSummary 前端渲染与加载/失败重试交互(注入 wrappedRetryCard) - 后端新增 card_07_bento_summary:基于已实现卡片聚合生成 snapshot,保证渲染稳定 - Wrapped manifest/implemented_upto 升级到 7,并支持按卡片 id 单独构建 - 新增 manifest 末尾卡片校验测试 --- .../wrapped/cards/Card07BentoSummary.vue | 2747 +++++++++++++++++ frontend/pages/wrapped/index.vue | 8 + .../wrapped/cards/card_07_bento_summary.py | 220 ++ src/wechat_decrypt_tool/wrapped/service.py | 59 +- tests/test_wrapped_manifest_bento_summary.py | 28 + 5 files changed, 3054 insertions(+), 8 deletions(-) create mode 100644 frontend/components/wrapped/cards/Card07BentoSummary.vue create mode 100644 src/wechat_decrypt_tool/wrapped/cards/card_07_bento_summary.py create mode 100644 tests/test_wrapped_manifest_bento_summary.py diff --git a/frontend/components/wrapped/cards/Card07BentoSummary.vue b/frontend/components/wrapped/cards/Card07BentoSummary.vue new file mode 100644 index 0000000..9d6e6ec --- /dev/null +++ b/frontend/components/wrapped/cards/Card07BentoSummary.vue @@ -0,0 +1,2747 @@ + + + + + + + + + + ✈ + 发送消息条数 + + + + + + + + + + + {{ formatInt(totalMessages) }} + 条 + + + 平均每天发送 {{ formatInt(messagesPerDayRounded) }} 条 + + + ✈ + + + + + + + + + ✍ + 发送消息字数 + + + + {{ sentCharsWan }} + 万字 + + + 📖 + 相当于写了一本《了不起的盖茨比》 + + + ✍ + + + + + + + + + ➕ + 新加好友 + + + + + + + + + + + + + + + + + + + {{ formatInt(addedFriends) }} + 位 + + 扩列了全新的生活圈 + + ➕ + + + + + + + + + 🕒 + 最常活跃时间 + + + {{ mostActiveHourLabel }} + + {{ mostActiveHourDesc }} + + + + + + + + + + + + + 🕒 + + + + + + + + + + + + + + + + + + 年度聊天搭子 + + + + + + + + + + + + + {{ avatarFallback(bestBuddyName) }} + + + + + MVP + + + + + {{ bestBuddyName }} + + + + + + + + + + + 总互动 + + {{ formatInt(bestBuddyTotal) }} + 次 + + + + + + + + + + + 最长连聊 + + {{ bestBuddyStreakDaysLabel }} + 天 + + + + + + + + + + + 同频时刻 + + {{ bestBuddyPeakLabel }} + + + + + + + + + + + + + + 👥 + 最爱群聊 + + + + + + + + + {{ avatarFallback(topGroupName) }} + + + + + + {{ topGroupName }} + + 🔥 + 全年发了 {{ formatInt(topGroupMessages) }} 条 + + + + + + + {{ topGroupSharePct }}% + 占全年 + + + + 日均 {{ topGroupDailyLabel }} 条 + + + + 👥 + + + + + + + + + ⏱ + 回复速度 + + + + + 中位数 P50 + + {{ replyP50Label }} + + ⚡ + + + + 🚀 + + 🚀 + 秒回 + + + {{ fastestReplyLabel }} + + + + + + + {{ avatarFallback(fastestContactName) }} + + + + + + + + 🐌 + + 🐌 + 意念 + + + {{ slowestReplyLabel }} + + + + + + + {{ avatarFallback(slowestContactName) }} + + + + + + + + + + + + + + + ❝ + 年度口头禅 + + + + "{{ topPhraseWord }}" + + + + 说了 {{ formatInt(topPhraseCount) }} 次 + + ❝ + + + + + + + + + 🖼 + 最爱表情包 + + + + + + + + + + 🧩 + + + + + + + {{ formatInt(sentStickerCount) }} + + 次发送 + + + 占全年消息的 {{ stickerShareText }} + + + 🖼 + + + + + + + + + ☺ + 最爱emoji + + + + + 😂 + 🤣 + ❤️ + 😭 + 🙏 + 👍 + + + {{ topUnicodeEmoji }} + + + + 使用了 {{ formatInt(topUnicodeEmojiCount) }} 次 + + ☺ + + + + + + + + + 📅 + 月度最佳好友 + + + + + + + + + {{ avatarFallback(monthlyMvpName) }} + + + + + + {{ monthlyMvpName }} + + + 上榜 {{ monthlyMvpMonths }}/12 个月 + + + + + + + {{ m.label }} + {{ m.pct }} + + + + + + + + + + + + + + + + {{ avatarFallback(item._nameLabel) }} + + + {{ item.month }}月 + {{ item._nameLabel }} + + + 📅 + + + + + + + + + + 🗓 + 活跃时段热力图 + + + {{ heatmapYearLabel }} + {{ heatmapMaxLabel }} + + + + + + + {{ w }} + + + + + + + + + {{ h.label }} + + + + + 少 + + 多 + + + + + 🗓 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 🍱便当装盒中… + {{ funLoaderText }} + + 翻到此页后开始聚合生成,首次加载可能稍久。 + 正在聚合各页数据,这一页会比其它页久一点点。 + 生成失败,可以重试一次。 + 正在准备数据… + + + {{ cardErrorText }} + + + 重试 + + + + + + + + + diff --git a/frontend/pages/wrapped/index.vue b/frontend/pages/wrapped/index.vue index f8d72a0..343fb6a 100644 --- a/frontend/pages/wrapped/index.vue +++ b/frontend/pages/wrapped/index.vue @@ -181,6 +181,12 @@ variant="slide" class="h-full w-full" /> + { await ensureCardLoaded(cardId) } +provide('wrappedRetryCard', retryCard) + const reload = async (forceRefresh = false, preserveIndex = false) => { const token = ++reportToken const keepIndex = preserveIndex ? activeIndex.value : 0 diff --git a/src/wechat_decrypt_tool/wrapped/cards/card_07_bento_summary.py b/src/wechat_decrypt_tool/wrapped/cards/card_07_bento_summary.py new file mode 100644 index 0000000..2d97054 --- /dev/null +++ b/src/wechat_decrypt_tool/wrapped/cards/card_07_bento_summary.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from typing import Any + + +def _as_data(obj: Any) -> dict[str, Any]: + if not isinstance(obj, dict): + return {} + data = obj.get("data") + if isinstance(data, dict): + return data + return obj + + +def _pick_int(x: Any, default: int = 0) -> int: + try: + return int(x) + except Exception: + return int(default) + + +def _pick_float(x: Any, default: float = 0.0) -> float: + try: + v = float(x) + return v if v == v else float(default) # NaN guard + except Exception: + return float(default) + + +def _pick_str(x: Any, default: str = "") -> str: + s = str(x or "").strip() + return s if s else str(default) + + +def _pick_obj(d: Any, keys: tuple[str, ...]) -> dict[str, Any] | None: + if not isinstance(d, dict): + return None + out: dict[str, Any] = {} + for k in keys: + if k in d: + out[k] = d.get(k) + return out if out else None + + +def build_card_07_bento_summary_from_sources( + *, + year: int, + overview: dict[str, Any], + heatmap: dict[str, Any], + message_chars: dict[str, Any], + reply_speed: dict[str, Any], + monthly: dict[str, Any], + emoji: dict[str, Any], +) -> dict[str, Any]: + """Card #7: Bento Summary (prototype style merged into Wrapped deck). + + The frontend expects a stable `data.snapshot` object to render without running extra JS. + """ + + overview_d = _as_data(overview) + heatmap_d = _as_data(heatmap) + message_chars_d = _as_data(message_chars) + reply_speed_d = _as_data(reply_speed) + monthly_d = _as_data(monthly) + emoji_d = _as_data(emoji) + + top_group_raw = overview_d.get("topGroup") + top_group = None + if isinstance(top_group_raw, dict): + display = _pick_str(top_group_raw.get("displayName"), "--") + top_group = { + "displayName": display, + "maskedName": display, + "avatarUrl": _pick_str(top_group_raw.get("avatarUrl"), ""), + "messages": _pick_int(top_group_raw.get("messages"), 0), + } + + best_buddy_raw = reply_speed_d.get("bestBuddy") + best_buddy = None + if isinstance(best_buddy_raw, dict): + display = _pick_str(best_buddy_raw.get("displayName"), "--") + best_buddy = { + "displayName": display, + "maskedName": display, + "avatarUrl": _pick_str(best_buddy_raw.get("avatarUrl"), ""), + "totalMessages": _pick_int(best_buddy_raw.get("totalMessages"), 0), + "longestStreakDays": _pick_int(best_buddy_raw.get("longestStreakDays"), 0), + "peakHour": best_buddy_raw.get("peakHour"), + "peakHourLabel": _pick_str(best_buddy_raw.get("peakHourLabel"), ""), + } + + fastest_raw = reply_speed_d.get("fastest") + fastest = None + if isinstance(fastest_raw, dict): + display = _pick_str(fastest_raw.get("displayName"), "--") + fastest = { + "displayName": display, + "maskedName": display, + "avatarUrl": _pick_str(fastest_raw.get("avatarUrl"), ""), + "seconds": _pick_int(fastest_raw.get("seconds"), 0), + } + + slowest_raw = reply_speed_d.get("slowest") + slowest = None + if isinstance(slowest_raw, dict): + display = _pick_str(slowest_raw.get("displayName"), "--") + slowest = { + "displayName": display, + "maskedName": display, + "avatarUrl": _pick_str(slowest_raw.get("avatarUrl"), ""), + "seconds": _pick_int(slowest_raw.get("seconds"), 0), + } + + reply_stats_raw = reply_speed_d.get("replyStats") + reply_stats = None + if isinstance(reply_stats_raw, dict): + reply_stats = { + "p50Seconds": reply_stats_raw.get("p50Seconds"), + "p90Seconds": reply_stats_raw.get("p90Seconds"), + } + + top_phrase_raw = overview_d.get("topPhrase") + top_phrase = None + if isinstance(top_phrase_raw, dict): + phrase = _pick_str(top_phrase_raw.get("phrase"), "") + count = _pick_int(top_phrase_raw.get("count"), 0) + if phrase and count > 0: + top_phrase = {"phrase": phrase, "count": count} + + sent_sticker_count = _pick_int(emoji_d.get("sentStickerCount"), _pick_int(overview_d.get("sentStickerCount"), 0)) + top_sticker = None + top_stickers = emoji_d.get("topStickers") + if isinstance(top_stickers, list) and top_stickers: + x0 = top_stickers[0] if isinstance(top_stickers[0], dict) else None + if x0: + url = _pick_str(x0.get("emojiUrl") or x0.get("imageUrl") or x0.get("url"), "") + cnt = _pick_int(x0.get("count"), 0) + if url: + top_sticker = {"imageUrl": url, "count": cnt} + + top_unicode_emoji = "" + top_unicode_emoji_count = 0 + top_unicode_emojis = emoji_d.get("topUnicodeEmojis") + if isinstance(top_unicode_emojis, list) and top_unicode_emojis: + x0 = top_unicode_emojis[0] if isinstance(top_unicode_emojis[0], dict) else None + if x0: + top_unicode_emoji = _pick_str(x0.get("emoji"), "") + top_unicode_emoji_count = _pick_int(x0.get("count"), 0) + + monthly_best_buddies: list[dict[str, Any]] = [] + months = monthly_d.get("months") + if isinstance(months, list) and months: + for item in months: + if not isinstance(item, dict): + continue + m = _pick_int(item.get("month"), 0) + winner = item.get("winner") if isinstance(item.get("winner"), dict) else None + metrics = item.get("metrics") if isinstance(item.get("metrics"), dict) else None + raw = item.get("raw") if isinstance(item.get("raw"), dict) else None + monthly_best_buddies.append( + { + "month": m, + "displayName": _pick_str((winner or {}).get("displayName"), "--"), + "maskedName": _pick_str((winner or {}).get("displayName"), "--"), + "avatarUrl": _pick_str((winner or {}).get("avatarUrl"), ""), + "messages": _pick_int((raw or {}).get("totalMessages"), 0), + "metrics": metrics if metrics else None, + } + ) + + # Ensure we always return 12 items for the grid. + if len(monthly_best_buddies) != 12: + fixed = {int(x.get("month") or 0): x for x in monthly_best_buddies if isinstance(x, dict)} + monthly_best_buddies = [] + for m in range(1, 13): + monthly_best_buddies.append( + fixed.get(m) + or { + "month": m, + "displayName": "--", + "maskedName": "--", + "avatarUrl": "", + "messages": 0, + "metrics": None, + } + ) + + snapshot: dict[str, Any] = { + "year": _pick_int(year), + "totalMessages": _pick_int(overview_d.get("totalMessages"), _pick_int(heatmap_d.get("totalMessages"), 0)), + "messagesPerDay": _pick_float(overview_d.get("messagesPerDay"), 0.0), + "sentChars": _pick_int(message_chars_d.get("sentChars"), 0), + "addedFriends": _pick_int(overview_d.get("addedFriends"), 0), + "mostActiveHour": overview_d.get("mostActiveHour"), + "topGroup": top_group, + "bestBuddy": best_buddy, + "fastest": fastest, + "slowest": slowest, + "replyStats": reply_stats, + "topPhrase": top_phrase, + "sentStickerCount": int(sent_sticker_count), + "topSticker": top_sticker, + "topUnicodeEmoji": top_unicode_emoji, + "topUnicodeEmojiCount": int(top_unicode_emoji_count), + "monthlyBestBuddies": monthly_best_buddies, + "weekdayLabels": heatmap_d.get("weekdayLabels") or [], + "hourLabels": heatmap_d.get("hourLabels") or [], + "weekdayHourMatrix": heatmap_d.get("matrix") or [], + } + + return { + "id": 7, + "title": "便当总览:一屏看完这一年", + "scope": "global", + "category": "A", + "status": "ok", + "kind": "global/bento_summary", + "narrative": "把这一年的关键信息装进一份便当。", + "data": {"snapshot": snapshot}, + } diff --git a/src/wechat_decrypt_tool/wrapped/service.py b/src/wechat_decrypt_tool/wrapped/service.py index 5caa922..5adb17e 100644 --- a/src/wechat_decrypt_tool/wrapped/service.py +++ b/src/wechat_decrypt_tool/wrapped/service.py @@ -19,13 +19,14 @@ from .cards.card_05_keywords_wordcloud import build_card_05_keywords_wordcloud from .cards.card_03_reply_speed import build_card_03_reply_speed from .cards.card_04_monthly_best_friends_wall import build_card_04_monthly_best_friends_wall from .cards.card_04_emoji_universe import build_card_04_emoji_universe +from .cards.card_07_bento_summary import build_card_07_bento_summary_from_sources logger = get_logger(__name__) # We use this number to version the cache filename so adding more cards won't accidentally serve # an older partial cache. -_IMPLEMENTED_UPTO_ID = 6 +_IMPLEMENTED_UPTO_ID = 7 # Bump this when we change card payloads/ordering while keeping the same implemented_upto. _CACHE_VERSION = 24 @@ -82,6 +83,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = ( "category": "B", "kind": "emoji/annual_universe", }, + { + "id": 7, + "title": "便当总览:一屏看完这一年", + "scope": "global", + "category": "A", + "kind": "global/bento_summary", + }, ) _WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST} @@ -300,7 +308,7 @@ def build_wrapped_annual_response( ) -> dict[str, Any]: """Build annual wrapped response for the given account/year. - For now we implement cards up to id=6 (plus a meta overview card id=0). + For now we implement cards up to id=7 (plus a meta overview card id=0). """ account_dir = _resolve_account_dir(account) @@ -345,19 +353,37 @@ def build_wrapped_annual_response( # in first-person narratives like "你最常...". heatmap_sent = _get_or_compute_heatmap_sent(account_dir=account_dir, scope=scope, year=y, refresh=refresh) # Page 2: global overview (page 1 is the frontend cover slide). - cards.append(build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent)) + card_overview = build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent) + cards.append(card_overview) # Page 3: cyber schedule heatmap. - cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)) + card_heatmap = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent) + cards.append(card_heatmap) # Page 4: message char counts (sent vs received). - cards.append(build_card_02_message_chars(account_dir=account_dir, year=y)) + card_message_chars = build_card_02_message_chars(account_dir=account_dir, year=y) + cards.append(card_message_chars) # Page 5: annual keywords (bubble storm -> word cloud). cards.append(build_card_05_keywords_wordcloud(account_dir=account_dir, year=y)) # Page 6: reply speed / best chat buddy. - cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y)) + card_reply_speed = build_card_03_reply_speed(account_dir=account_dir, year=y) + cards.append(card_reply_speed) # Page 7: monthly best friends wall (photo wall). - cards.append(build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)) + card_monthly = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y) + cards.append(card_monthly) # Page 8: annual emoji universe / meme almanac. - cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y)) + card_emoji = build_card_04_emoji_universe(account_dir=account_dir, year=y) + cards.append(card_emoji) + # Page 9: bento summary (prototype). Build from prior cards for consistency. + cards.append( + build_card_07_bento_summary_from_sources( + year=y, + overview=card_overview, + heatmap=card_heatmap, + message_chars=card_message_chars, + reply_speed=card_reply_speed, + monthly=card_monthly, + emoji=card_emoji, + ) + ) obj: dict[str, Any] = { "account": account_dir.name, @@ -557,6 +583,23 @@ def build_wrapped_annual_card( card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y) elif cid == 5: card = build_card_04_emoji_universe(account_dir=account_dir, year=y) + elif cid == 7: + # Build from already-implemented cards so we can reuse their caches if available. + overview = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=0, refresh=refresh) + heatmap = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=1, refresh=refresh) + message_chars = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=2, refresh=refresh) + reply_speed = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=3, refresh=refresh) + monthly = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=4, refresh=refresh) + emoji = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=5, refresh=refresh) + card = build_card_07_bento_summary_from_sources( + year=y, + overview=overview, + heatmap=heatmap, + message_chars=message_chars, + reply_speed=reply_speed, + monthly=monthly, + emoji=emoji, + ) else: # Should be unreachable due to _WRAPPED_CARD_ID_SET check. raise ValueError(f"Unknown Wrapped card id: {cid}") diff --git a/tests/test_wrapped_manifest_bento_summary.py b/tests/test_wrapped_manifest_bento_summary.py new file mode 100644 index 0000000..ef6d8c9 --- /dev/null +++ b/tests/test_wrapped_manifest_bento_summary.py @@ -0,0 +1,28 @@ +import sys +import unittest +from pathlib import Path + +# Ensure "src/" is importable when running tests from repo root. +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + + +class TestWrappedManifestBentoSummary(unittest.TestCase): + def test_manifest_appends_bento_summary(self): + try: + from wechat_decrypt_tool.wrapped.service import _WRAPPED_CARD_MANIFEST + except ModuleNotFoundError as e: + # Some dev/test environments may not have optional deps installed (e.g. pypinyin). + # The manifest itself doesn't depend on them, but importing the service module does. + if getattr(e, "name", "") == "pypinyin": + self.skipTest("pypinyin is not installed") + raise + + self.assertTrue(len(_WRAPPED_CARD_MANIFEST) > 0) + last = _WRAPPED_CARD_MANIFEST[-1] + self.assertEqual(int(last.get("id")), 7) + self.assertEqual(str(last.get("kind")), "global/bento_summary") + + +if __name__ == "__main__": + unittest.main()