From 1121245c899ca4f9c3fb5a690cffb55951068a7c Mon Sep 17 00:00:00 2001 From: 2977094657 <2977094657@qq.com> Date: Thu, 19 Feb 2026 20:00:48 +0800 Subject: [PATCH] =?UTF-8?q?improvement(wrapped):=20=E5=9B=9E=E5=A4=8D?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E6=8E=92=E8=A1=8C=E6=94=AF=E6=8C=81=E6=94=B6?= =?UTF-8?q?=E5=8F=91=E6=8B=86=E5=88=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端按 sender_username 拆分 outgoing/incoming,并输出累计序列用于动画 - 前端条形图改为分段堆叠(我发/对方),补充图例与容错降级逻辑 - 文案更新为 我发 + 对方 --- .../wrapped/cards/Card03ReplySpeed.vue | 144 +++++++++++++++--- .../wrapped/cards/card_03_reply_speed.py | 71 ++++++--- 2 files changed, 178 insertions(+), 37 deletions(-) diff --git a/frontend/components/wrapped/cards/Card03ReplySpeed.vue b/frontend/components/wrapped/cards/Card03ReplySpeed.vue index 542f159..63661b3 100644 --- a/frontend/components/wrapped/cards/Card03ReplySpeed.vue +++ b/frontend/components/wrapped/cards/Card03ReplySpeed.vue @@ -172,12 +172,22 @@ >
-
年度聊天排行(总消息数)
+
年度聊天排行(我发 + 对方)
{{ raceDate }} · 0.1秒/天
+
+ + + 我发 + + + + 对方 + +
@@ -221,15 +231,24 @@ {{ item.displayName }}
-
+
{{ formatInt(item.value) }}
+ > +
+
+
@@ -615,16 +634,62 @@ const startTypewriter = () => { const race = computed(() => props.card?.data?.race || null) const raceDays = computed(() => Math.max(0, Number(race.value?.days || 0))) const raceSeriesRaw = computed(() => (Array.isArray(race.value?.series) ? race.value.series : [])) +const topTotalsByUsername = computed(() => { + const out = new Map() + const arr = Array.isArray(props.card?.data?.topTotals) ? props.card.data.topTotals : [] + for (const x of arr) { + if (!x || typeof x !== 'object') continue + const username = String(x.username || '').trim() + if (!username) continue + out.set(username, { + outgoingMessages: Math.max(0, Number(x.outgoingMessages || 0)), + incomingMessages: Math.max(0, Number(x.incomingMessages || 0)) + }) + } + return out +}) + const raceSeries = computed(() => { // Pre-resolve avatar URLs once to avoid doing it in tight animation loops. + const totalsByUsername = topTotalsByUsername.value return raceSeriesRaw.value .filter((x) => x && typeof x === 'object' && typeof x.username === 'string') - .map((x) => ({ - username: String(x.username || ''), - displayName: String(x.displayName || x.maskedName || ''), - avatarUrl: resolveMediaUrl(x.avatarUrl), - cumulativeCounts: Array.isArray(x.cumulativeCounts) ? x.cumulativeCounts.map((v) => Number(v) || 0) : [] - })) + .map((x) => { + const username = String(x.username || '') + const fallback = totalsByUsername.get(username) + const outgoingMessages = Math.max(0, Number(x.outgoingMessages ?? fallback?.outgoingMessages ?? 0)) + const incomingMessages = Math.max(0, Number(x.incomingMessages ?? fallback?.incomingMessages ?? 0)) + + let cumulativeCounts = Array.isArray(x.cumulativeCounts) ? x.cumulativeCounts.map((v) => Math.max(0, Number(v) || 0)) : [] + let cumulativeOutgoingCounts = Array.isArray(x.cumulativeOutgoingCounts) ? x.cumulativeOutgoingCounts.map((v) => Math.max(0, Number(v) || 0)) : [] + let cumulativeIncomingCounts = Array.isArray(x.cumulativeIncomingCounts) ? x.cumulativeIncomingCounts.map((v) => Math.max(0, Number(v) || 0)) : [] + + if (cumulativeCounts.length === 0 && (cumulativeOutgoingCounts.length > 0 || cumulativeIncomingCounts.length > 0)) { + const len = Math.max(cumulativeOutgoingCounts.length, cumulativeIncomingCounts.length) + cumulativeCounts = Array.from({ length: len }, (_, i) => ( + Number(cumulativeOutgoingCounts[i] || 0) + Number(cumulativeIncomingCounts[i] || 0) + )) + } + + // Backward compatibility for old caches: split total curve using final in/out ratio. + if (cumulativeCounts.length > 0 && (cumulativeOutgoingCounts.length === 0 || cumulativeIncomingCounts.length === 0)) { + const splitBase = outgoingMessages + incomingMessages + const outgoingRatio = splitBase > 0 ? outgoingMessages / splitBase : 0 + cumulativeOutgoingCounts = cumulativeCounts.map((v) => Math.max(0, Math.round((Number(v) || 0) * outgoingRatio))) + cumulativeIncomingCounts = cumulativeCounts.map((v, i) => ( + Math.max(0, (Number(v) || 0) - Number(cumulativeOutgoingCounts[i] || 0)) + )) + } + + return { + username, + displayName: String(x.displayName || x.maskedName || ''), + avatarUrl: resolveMediaUrl(x.avatarUrl), + cumulativeCounts, + cumulativeOutgoingCounts, + cumulativeIncomingCounts + } + }) }) const raceDay = ref(0) @@ -643,21 +708,50 @@ const raceDate = computed(() => { return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}` }) +const valueAtRaceStep = (arr, step) => { + if (step <= 0 || !Array.isArray(arr) || arr.length === 0) return 0 + if (step - 1 < arr.length) return Math.max(0, Number(arr[step - 1] || 0)) + return Math.max(0, Number(arr[arr.length - 1] || 0)) +} + const raceItems = computed(() => { const step = Math.max(0, Math.min(Math.max(0, raceDays.value), Number(raceDay.value || 0))) const list = raceSeries.value if (!Array.isArray(list) || list.length === 0) return [] let items = list.map((s) => { - const arr = s.cumulativeCounts - const v = step <= 0 - ? 0 - : ( - arr && arr.length > 0 - ? (step - 1 < arr.length ? Number(arr[step - 1] || 0) : Number(arr[arr.length - 1] || 0)) - : 0 - ) - return { ...s, value: Math.max(0, v) } + const totalV = valueAtRaceStep(s.cumulativeCounts, step) + let outgoingV = valueAtRaceStep(s.cumulativeOutgoingCounts, step) + let incomingV = valueAtRaceStep(s.cumulativeIncomingCounts, step) + let value = Math.max(0, totalV) + let splitTotal = outgoingV + incomingV + + if (value <= 0 && splitTotal > 0) value = splitTotal + if (splitTotal <= 0 && value > 0) { + incomingV = value + splitTotal = value + } + + if (splitTotal > 0 && splitTotal !== value) { + const scale = value / splitTotal + outgoingV = Math.max(0, Math.round(outgoingV * scale)) + incomingV = Math.max(0, value - outgoingV) + splitTotal = outgoingV + incomingV + } + + const outgoingPartPct = splitTotal > 0 + ? Math.max(0, Math.min(100, Math.round((outgoingV / splitTotal) * 100))) + : 0 + const incomingPartPct = splitTotal > 0 ? 100 - outgoingPartPct : 0 + + return { + ...s, + value, + outgoingValue: outgoingV, + incomingValue: incomingV, + outgoingPartPct, + incomingPartPct + } }) // Hide 0-value rows so the "TOP10" can evolve naturally (people enter/leave the list over time), @@ -760,7 +854,19 @@ onBeforeUnmount(() => { transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important; } +.race-bar-fill { + transition: width 120ms linear !important; +} + .race-bar { transition: width 120ms linear !important; } + +.race-bar-outgoing { + background: #07c160; +} + +.race-bar-incoming { + background: #f2aa00; +} diff --git a/src/wechat_decrypt_tool/wrapped/cards/card_03_reply_speed.py b/src/wechat_decrypt_tool/wrapped/cards/card_03_reply_speed.py index 86b0c1c..b7e0ce8 100644 --- a/src/wechat_decrypt_tool/wrapped/cards/card_03_reply_speed.py +++ b/src/wechat_decrypt_tool/wrapped/cards/card_03_reply_speed.py @@ -483,17 +483,20 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any] sql_daily = ( "SELECT username, " "CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, " + "sender_username, " "COUNT(1) AS cnt " "FROM (" - f" SELECT username, {ts_expr} AS ts " + f" SELECT username, sender_username, {ts_expr} AS ts " " FROM message_fts " f" WHERE {base_where}" ") sub " - "GROUP BY username, doy" + "GROUP BY username, doy, sender_username" ) u_set = set(u_list) - per_user_daily: dict[str, list[int]] = {} + per_user_daily_total: dict[str, list[int]] = {} + per_user_daily_outgoing: dict[str, list[int]] = {} + per_user_daily_incoming: dict[str, list[int]] = {} try: conn2 = sqlite3.connect(str(index_path)) try: @@ -511,16 +514,30 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any] continue try: doy = int(r[1] if r[1] is not None else -1) - cnt = int(r[2] or 0) + sender = str(r[2] or "").strip() + cnt = int(r[3] or 0) except Exception: continue if cnt <= 0 or doy < 0 or doy >= days_in_year: continue - daily = per_user_daily.get(u) - if daily is None: - daily = [0] * days_in_year - per_user_daily[u] = daily - daily[doy] += cnt + daily_total = per_user_daily_total.get(u) + if daily_total is None: + daily_total = [0] * days_in_year + per_user_daily_total[u] = daily_total + daily_total[doy] += cnt + + if sender == my_username: + daily_outgoing = per_user_daily_outgoing.get(u) + if daily_outgoing is None: + daily_outgoing = [0] * days_in_year + per_user_daily_outgoing[u] = daily_outgoing + daily_outgoing[doy] += cnt + else: + daily_incoming = per_user_daily_incoming.get(u) + if daily_incoming is None: + daily_incoming = [0] * days_in_year + per_user_daily_incoming[u] = daily_incoming + daily_incoming[doy] += cnt # Ensure we can render display names/avatars for the whole race list. extra_usernames = [u for u in u_list if u and u not in contact_rows] @@ -535,14 +552,28 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any] series: list[dict[str, Any]] = [] for u in u_list: - daily = per_user_daily.get(u) - if not daily: + daily_total = per_user_daily_total.get(u) + if not daily_total: continue - cum: list[int] = [] - running = 0 - for x in daily: - running += int(x or 0) - cum.append(int(running)) + daily_outgoing = per_user_daily_outgoing.get(u) or [0] * days_in_year + daily_incoming = per_user_daily_incoming.get(u) or [0] * days_in_year + cum_total: list[int] = [] + cum_outgoing: list[int] = [] + cum_incoming: list[int] = [] + running_total = 0 + running_outgoing = 0 + running_incoming = 0 + for i in range(days_in_year): + running_total += int(daily_total[i] or 0) + running_outgoing += int(daily_outgoing[i] or 0) + running_incoming += int(daily_incoming[i] or 0) + cum_total.append(int(running_total)) + cum_outgoing.append(int(running_outgoing)) + cum_incoming.append(int(running_incoming)) + + total_messages = int(cum_total[-1]) if cum_total else int(all_totals.get(u) or 0) + outgoing_messages = int(cum_outgoing[-1]) if cum_outgoing else 0 + incoming_messages = int(cum_incoming[-1]) if cum_incoming else 0 row = contact_rows.get(u) display = _pick_display_name(row, u) @@ -553,8 +584,12 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any] "displayName": display, "maskedName": _mask_name(display), "avatarUrl": avatar, - "totalMessages": int(all_totals.get(u) or 0), - "cumulativeCounts": cum, + "totalMessages": int(total_messages), + "outgoingMessages": int(outgoing_messages), + "incomingMessages": int(incoming_messages), + "cumulativeCounts": cum_total, + "cumulativeOutgoingCounts": cum_outgoing, + "cumulativeIncomingCounts": cum_incoming, } )