mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
feat(wrapped): 新增便当总览卡片(Card #7)
- 增加 Card07BentoSummary 前端渲染与加载/失败重试交互(注入 wrappedRetryCard) - 后端新增 card_07_bento_summary:基于已实现卡片聚合生成 snapshot,保证渲染稳定 - Wrapped manifest/implemented_upto 升级到 7,并支持按卡片 id 单独构建 - 新增 manifest 末尾卡片校验测试
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -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},
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user