improvement(wrapped): 回复速度排行支持收发拆分

- 后端按 sender_username 拆分 outgoing/incoming,并输出累计序列用于动画

- 前端条形图改为分段堆叠(我发/对方),补充图例与容错降级逻辑

- 文案更新为 我发 + 对方
This commit is contained in:
2977094657
2026-02-19 20:00:48 +08:00
parent 02bbf9d8e2
commit 1121245c89
2 changed files with 178 additions and 37 deletions

View File

@@ -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>

View File

@@ -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,
}
)