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 class="flex items-center justify-between gap-4">
|
||||||
<div>
|
<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">
|
<div class="wrapped-body text-sm text-[#000000e6] mt-1">
|
||||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ raceDate }}</span>
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ raceDate }}</span>
|
||||||
<span class="text-[#00000055]"> · 0.1秒/天</span>
|
<span class="text-[#00000055]"> · 0.1秒/天</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
|
||||||
<div v-if="raceDay > 0 && raceItems.length === 0" class="mt-4 wrapped-body text-sm text-[#7F7F7F]">
|
<div v-if="raceDay > 0 && raceItems.length === 0" class="mt-4 wrapped-body text-sm text-[#7F7F7F]">
|
||||||
@@ -221,15 +231,24 @@
|
|||||||
{{ item.displayName }}
|
{{ item.displayName }}
|
||||||
</div>
|
</div>
|
||||||
</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) }}
|
{{ formatInt(item.value) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 h-2 rounded-full bg-[#00000008] overflow-hidden">
|
<div class="mt-1 h-2 rounded-full bg-[#00000008] overflow-hidden">
|
||||||
<div
|
<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}%` }"
|
: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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -615,16 +634,62 @@ const startTypewriter = () => {
|
|||||||
const race = computed(() => props.card?.data?.race || null)
|
const race = computed(() => props.card?.data?.race || null)
|
||||||
const raceDays = computed(() => Math.max(0, Number(race.value?.days || 0)))
|
const raceDays = computed(() => Math.max(0, Number(race.value?.days || 0)))
|
||||||
const raceSeriesRaw = computed(() => (Array.isArray(race.value?.series) ? race.value.series : []))
|
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(() => {
|
const raceSeries = computed(() => {
|
||||||
// Pre-resolve avatar URLs once to avoid doing it in tight animation loops.
|
// Pre-resolve avatar URLs once to avoid doing it in tight animation loops.
|
||||||
|
const totalsByUsername = topTotalsByUsername.value
|
||||||
return raceSeriesRaw.value
|
return raceSeriesRaw.value
|
||||||
.filter((x) => x && typeof x === 'object' && typeof x.username === 'string')
|
.filter((x) => x && typeof x === 'object' && typeof x.username === 'string')
|
||||||
.map((x) => ({
|
.map((x) => {
|
||||||
username: String(x.username || ''),
|
const username = String(x.username || '')
|
||||||
displayName: String(x.displayName || x.maskedName || ''),
|
const fallback = totalsByUsername.get(username)
|
||||||
avatarUrl: resolveMediaUrl(x.avatarUrl),
|
const outgoingMessages = Math.max(0, Number(x.outgoingMessages ?? fallback?.outgoingMessages ?? 0))
|
||||||
cumulativeCounts: Array.isArray(x.cumulativeCounts) ? x.cumulativeCounts.map((v) => Number(v) || 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)
|
const raceDay = ref(0)
|
||||||
@@ -643,21 +708,50 @@ const raceDate = computed(() => {
|
|||||||
return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`
|
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 raceItems = computed(() => {
|
||||||
const step = Math.max(0, Math.min(Math.max(0, raceDays.value), Number(raceDay.value || 0)))
|
const step = Math.max(0, Math.min(Math.max(0, raceDays.value), Number(raceDay.value || 0)))
|
||||||
const list = raceSeries.value
|
const list = raceSeries.value
|
||||||
if (!Array.isArray(list) || list.length === 0) return []
|
if (!Array.isArray(list) || list.length === 0) return []
|
||||||
|
|
||||||
let items = list.map((s) => {
|
let items = list.map((s) => {
|
||||||
const arr = s.cumulativeCounts
|
const totalV = valueAtRaceStep(s.cumulativeCounts, step)
|
||||||
const v = step <= 0
|
let outgoingV = valueAtRaceStep(s.cumulativeOutgoingCounts, step)
|
||||||
? 0
|
let incomingV = valueAtRaceStep(s.cumulativeIncomingCounts, step)
|
||||||
: (
|
let value = Math.max(0, totalV)
|
||||||
arr && arr.length > 0
|
let splitTotal = outgoingV + incomingV
|
||||||
? (step - 1 < arr.length ? Number(arr[step - 1] || 0) : Number(arr[arr.length - 1] || 0))
|
|
||||||
: 0
|
if (value <= 0 && splitTotal > 0) value = splitTotal
|
||||||
)
|
if (splitTotal <= 0 && value > 0) {
|
||||||
return { ...s, value: Math.max(0, v) }
|
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),
|
// 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;
|
transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-bar-fill {
|
||||||
|
transition: width 120ms linear !important;
|
||||||
|
}
|
||||||
|
|
||||||
.race-bar {
|
.race-bar {
|
||||||
transition: width 120ms linear !important;
|
transition: width 120ms linear !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.race-bar-outgoing {
|
||||||
|
background: #07c160;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-bar-incoming {
|
||||||
|
background: #f2aa00;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -483,17 +483,20 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
|||||||
sql_daily = (
|
sql_daily = (
|
||||||
"SELECT username, "
|
"SELECT username, "
|
||||||
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
"sender_username, "
|
||||||
"COUNT(1) AS cnt "
|
"COUNT(1) AS cnt "
|
||||||
"FROM ("
|
"FROM ("
|
||||||
f" SELECT username, {ts_expr} AS ts "
|
f" SELECT username, sender_username, {ts_expr} AS ts "
|
||||||
" FROM message_fts "
|
" FROM message_fts "
|
||||||
f" WHERE {base_where}"
|
f" WHERE {base_where}"
|
||||||
") sub "
|
") sub "
|
||||||
"GROUP BY username, doy"
|
"GROUP BY username, doy, sender_username"
|
||||||
)
|
)
|
||||||
|
|
||||||
u_set = set(u_list)
|
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:
|
try:
|
||||||
conn2 = sqlite3.connect(str(index_path))
|
conn2 = sqlite3.connect(str(index_path))
|
||||||
try:
|
try:
|
||||||
@@ -511,16 +514,30 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
|
|||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
doy = int(r[1] if r[1] is not None else -1)
|
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:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
if cnt <= 0 or doy < 0 or doy >= days_in_year:
|
if cnt <= 0 or doy < 0 or doy >= days_in_year:
|
||||||
continue
|
continue
|
||||||
daily = per_user_daily.get(u)
|
daily_total = per_user_daily_total.get(u)
|
||||||
if daily is None:
|
if daily_total is None:
|
||||||
daily = [0] * days_in_year
|
daily_total = [0] * days_in_year
|
||||||
per_user_daily[u] = daily
|
per_user_daily_total[u] = daily_total
|
||||||
daily[doy] += cnt
|
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.
|
# 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]
|
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]] = []
|
series: list[dict[str, Any]] = []
|
||||||
for u in u_list:
|
for u in u_list:
|
||||||
daily = per_user_daily.get(u)
|
daily_total = per_user_daily_total.get(u)
|
||||||
if not daily:
|
if not daily_total:
|
||||||
continue
|
continue
|
||||||
cum: list[int] = []
|
daily_outgoing = per_user_daily_outgoing.get(u) or [0] * days_in_year
|
||||||
running = 0
|
daily_incoming = per_user_daily_incoming.get(u) or [0] * days_in_year
|
||||||
for x in daily:
|
cum_total: list[int] = []
|
||||||
running += int(x or 0)
|
cum_outgoing: list[int] = []
|
||||||
cum.append(int(running))
|
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)
|
row = contact_rows.get(u)
|
||||||
display = _pick_display_name(row, 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,
|
"displayName": display,
|
||||||
"maskedName": _mask_name(display),
|
"maskedName": _mask_name(display),
|
||||||
"avatarUrl": avatar,
|
"avatarUrl": avatar,
|
||||||
"totalMessages": int(all_totals.get(u) or 0),
|
"totalMessages": int(total_messages),
|
||||||
"cumulativeCounts": cum,
|
"outgoingMessages": int(outgoing_messages),
|
||||||
|
"incomingMessages": int(incoming_messages),
|
||||||
|
"cumulativeCounts": cum_total,
|
||||||
|
"cumulativeOutgoingCounts": cum_outgoing,
|
||||||
|
"cumulativeIncomingCounts": cum_incoming,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user