mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-20 23:00:50 +08:00
improvement(wrapped): 回复速度排行支持收发拆分
- 后端按 sender_username 拆分 outgoing/incoming,并输出累计序列用于动画 - 前端条形图改为分段堆叠(我发/对方),补充图例与容错降级逻辑 - 文案更新为 我发 + 对方
This commit is contained in:
@@ -172,12 +172,22 @@
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="wrapped-label text-xs text-[#00000066]">年度聊天排行(总消息数)</div>
|
||||
<div class="wrapped-label text-xs text-[#00000066]">年度聊天排行(我发 + 对方)</div>
|
||||
<div class="wrapped-body text-sm text-[#000000e6] mt-1">
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ raceDate }}</span>
|
||||
<span class="text-[#00000055]"> · 0.1秒/天</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 text-[11px] text-[#00000066] shrink-0">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-[#07C160]"></span>
|
||||
我发
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
<span class="w-2 h-2 rounded-full bg-[#F2AA00]"></span>
|
||||
对方
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="raceDay > 0 && raceItems.length === 0" class="mt-4 wrapped-body text-sm text-[#7F7F7F]">
|
||||
@@ -221,15 +231,24 @@
|
||||
{{ item.displayName }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrapped-number text-xs text-[#07C160] font-semibold">
|
||||
<div class="wrapped-number text-xs text-[#00000080] font-semibold">
|
||||
{{ formatInt(item.value) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 h-2 rounded-full bg-[#00000008] overflow-hidden">
|
||||
<div
|
||||
class="race-bar h-full rounded-full bg-[#07C160]"
|
||||
class="race-bar-fill h-full rounded-full overflow-hidden flex"
|
||||
:style="{ width: `${item.pct}%` }"
|
||||
/>
|
||||
>
|
||||
<div
|
||||
class="race-bar race-bar-outgoing h-full"
|
||||
:style="{ width: `${item.outgoingPartPct}%` }"
|
||||
/>
|
||||
<div
|
||||
class="race-bar race-bar-incoming h-full"
|
||||
:style="{ width: `${item.incomingPartPct}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user