feat(wrapped): 新增便当总览卡片(Card #7)

- 增加 Card07BentoSummary 前端渲染与加载/失败重试交互(注入 wrappedRetryCard)

- 后端新增 card_07_bento_summary:基于已实现卡片聚合生成 snapshot,保证渲染稳定

- Wrapped manifest/implemented_upto 升级到 7,并支持按卡片 id 单独构建

- 新增 manifest 末尾卡片校验测试
This commit is contained in:
2977094657
2026-02-26 18:29:48 +08:00
Unverified
parent 108b0b18ab
commit 3072297769
5 changed files with 3054 additions and 8 deletions
File diff suppressed because it is too large Load Diff
+8
View File
@@ -181,6 +181,12 @@
variant="slide"
class="h-full w-full"
/>
<Card07BentoSummary
v-else-if="c && (c.kind === 'global/bento_summary' || c.id === 7)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<WrappedCardShell
v-else
:card-id="Number(c?.id || (idx + 1))"
@@ -478,6 +484,8 @@ const retryCard = async (cardId) => {
await ensureCardLoaded(cardId)
}
provide('wrappedRetryCard', retryCard)
const reload = async (forceRefresh = false, preserveIndex = false) => {
const token = ++reportToken
const keepIndex = preserveIndex ? activeIndex.value : 0
@@ -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},
}
+51 -8
View File
@@ -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}")
@@ -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()