mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-20 06:40:49 +08:00
feat(wrapped): 新增回复速度卡片 #3(秒回置顶关心)
- 新增年度总结卡片 #3:回复速度/置顶关心统计与排行\n- 前端新增 Card03 渲染与交互(含抽奖揭晓/Top 列表)\n- 更新年度总结卡片清单并加入评分单测
This commit is contained in:
766
frontend/components/wrapped/cards/Card03ReplySpeed.vue
Normal file
766
frontend/components/wrapped/cards/Card03ReplySpeed.vue
Normal file
@@ -0,0 +1,766 @@
|
|||||||
|
<template>
|
||||||
|
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
|
||||||
|
<!-- 子描述:仅在揭晓后出现,并使用“打字机”效果逐段输出 -->
|
||||||
|
<template #narrative>
|
||||||
|
<div v-if="phase === 'revealed'" class="mt-2 wrapped-body text-sm sm:text-base text-[#7F7F7F] leading-relaxed">
|
||||||
|
<p class="whitespace-pre-wrap">
|
||||||
|
<template v-for="(seg, i) in segments" :key="`${seg.type}-${i}`">
|
||||||
|
<template v-if="seg.type === 'buddy'">
|
||||||
|
<span
|
||||||
|
v-if="isSegVisible(i)"
|
||||||
|
class="inline-flex items-center gap-2 align-bottom px-1.5 py-0.5 rounded-lg bg-[#00000008]"
|
||||||
|
:title="bestBuddy?.displayName || ''"
|
||||||
|
>
|
||||||
|
<span class="w-5 h-5 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="bestBuddyAvatarUrl && avatarOk.best"
|
||||||
|
:src="bestBuddyAvatarUrl"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
@error="avatarOk.best = false"
|
||||||
|
/>
|
||||||
|
<span v-else class="wrapped-number text-[11px] text-[#00000066]">
|
||||||
|
{{ avatarFallback(bestBuddy?.displayName) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="wrapped-body text-sm text-[#000000e6] max-w-[12rem] truncate">
|
||||||
|
{{ bestBuddy?.displayName || '' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="seg.type === 'contact'">
|
||||||
|
<span
|
||||||
|
v-if="isSegVisible(i)"
|
||||||
|
class="inline-flex items-center gap-1.5 align-bottom px-1.5 py-0.5 rounded-lg bg-[#00000008]"
|
||||||
|
:title="seg.contact?.displayName || ''"
|
||||||
|
>
|
||||||
|
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="resolveMediaUrl(seg.contact?.avatarUrl) && avatarOk[seg.contact?.username] !== false"
|
||||||
|
:src="resolveMediaUrl(seg.contact?.avatarUrl)"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
@error="avatarOk[seg.contact?.username] = false"
|
||||||
|
/>
|
||||||
|
<span v-else class="wrapped-number text-[9px] text-[#00000066]">
|
||||||
|
{{ avatarFallback(seg.contact?.displayName) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="wrapped-body text-sm text-[#000000e6] max-w-[8rem] truncate">
|
||||||
|
{{ seg.contact?.displayName || '' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<span
|
||||||
|
v-if="seg.type === 'num'"
|
||||||
|
class="wrapped-number text-[#07C160] font-semibold"
|
||||||
|
>
|
||||||
|
{{ segTextShown(i) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ segTextShown(i) }}</span>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<span v-if="typingActive" class="type-caret" aria-hidden="true"></span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 无可统计数据/索引未就绪:保留原来的引导与进度展示 -->
|
||||||
|
<div v-if="replyEvents <= 0" class="text-sm text-[#7F7F7F]">
|
||||||
|
<div class="rounded-xl border border-[#EDEDED] bg-white/60 p-4">
|
||||||
|
<div class="wrapped-label text-xs text-[#00000066]">如何生成本页数据</div>
|
||||||
|
<div class="mt-2 wrapped-body text-sm text-[#7F7F7F] leading-relaxed">
|
||||||
|
<p>本页需要使用“消息搜索索引”来合并所有消息分片并计算回复耗时。</p>
|
||||||
|
<p v-if="indexBuild && indexBuild.status === 'building'" class="mt-2">
|
||||||
|
索引正在构建中:已索引
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(indexBuild.indexedMessages) }}</span>
|
||||||
|
条消息。
|
||||||
|
<span v-if="indexBuild.currentConversation" class="text-[#00000055]">(当前:{{ indexBuild.currentConversation }})</span>
|
||||||
|
</p>
|
||||||
|
<p v-else-if="indexBuild && indexBuild.status === 'error'" class="mt-2 text-red-600">
|
||||||
|
索引构建失败:{{ indexBuild.error || '未知错误' }}
|
||||||
|
</p>
|
||||||
|
<p v-if="!usedIndex" class="mt-2">
|
||||||
|
你可以先在「聊天记录搜索」中构建索引(或调用后端接口
|
||||||
|
<code class="px-1 py-0.5 bg-[#00000008] rounded">/api/chat/search-index/build</code>),
|
||||||
|
然后回到这里点击左上角“强制刷新”或本页“重试”。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 主内容:抽奖揭晓 + 右侧年度 Top10 总消息 bar race -->
|
||||||
|
<div v-else class="w-full">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center" :class="isRetro ? 'lg:items-start' : ''">
|
||||||
|
<!-- Left: 抽奖区 -->
|
||||||
|
<div
|
||||||
|
class="reply-buddy-rail flex flex-col items-center justify-center transition-transform duration-500 will-change-transform"
|
||||||
|
:class="leftRailClass"
|
||||||
|
>
|
||||||
|
<div class="wrapped-label text-xs text-[#00000066]">最佳聊天搭子</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-4 w-28 h-28 sm:w-32 sm:h-32 rounded-2xl border border-[#EDEDED] overflow-hidden flex items-center justify-center"
|
||||||
|
:class="isRetro ? 'bg-transparent' : 'bg-white/60'"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="shownAvatarUrl && shownAvatarOk"
|
||||||
|
:src="shownAvatarUrl"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
@error="onShownAvatarError"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="isGameboy && phase === 'idle'"
|
||||||
|
src="/assets/images/LuckyBlock.png"
|
||||||
|
class="w-full h-full object-contain"
|
||||||
|
alt="Lucky Block"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="w-full h-full flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<span class="wrapped-number text-3xl text-[#00000066]">
|
||||||
|
{{ shownAvatarFallback }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 min-h-[1.75rem] wrapped-body text-base text-[#000000e6] max-w-[18rem] truncate" :title="shownDisplayName">
|
||||||
|
{{ shownDisplayName }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5">
|
||||||
|
<button
|
||||||
|
v-if="phase === 'idle'"
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center px-5 py-2.5 rounded-xl bg-[#07C160] text-white text-sm sm:text-base wrapped-label hover:bg-[#06AD56] transition shadow-sm"
|
||||||
|
@click="startLottery"
|
||||||
|
>
|
||||||
|
今年谁是你的最佳聊天搭子呢?
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else-if="phase === 'rolling'"
|
||||||
|
type="button"
|
||||||
|
disabled
|
||||||
|
class="inline-flex items-center justify-center px-5 py-2.5 rounded-xl bg-[#07C160]/70 text-white text-sm sm:text-base wrapped-label cursor-not-allowed"
|
||||||
|
>
|
||||||
|
生成中…
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center justify-center px-4 py-2 rounded-xl bg-transparent border border-[#07C160]/35 text-[#07C160] text-sm wrapped-label hover:bg-[#07C160]/10 transition"
|
||||||
|
@click="restart"
|
||||||
|
>
|
||||||
|
再看一次
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: bar race(揭晓后出现) -->
|
||||||
|
<Transition name="chart-fade">
|
||||||
|
<div v-if="showChart" class="w-full" :class="isRetro ? 'lg:self-start' : ''">
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-[#EDEDED] bg-white/60"
|
||||||
|
:class="isRetro ? 'p-3 sm:p-4' : 'p-4 sm:p-5'"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="wrapped-label text-xs text-[#00000066]">年度聊天排行(总消息数)</div>
|
||||||
|
<div class="wrapped-body text-sm text-[#000000e6]" :class="isRetro ? 'mt-0.5' : 'mt-1'">
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ raceDate }}</span>
|
||||||
|
<span class="text-[#00000055]"> · 0.1秒/天</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="raceDay > 0 && raceItems.length === 0" class="mt-4 wrapped-body text-sm text-[#7F7F7F]">
|
||||||
|
暂无可展示的排行榜数据。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="race-scroll mt-4 max-h-[26rem] overflow-auto pr-1">
|
||||||
|
<TransitionGroup
|
||||||
|
name="race"
|
||||||
|
tag="div"
|
||||||
|
:class="isRetro ? 'space-y-1.5' : 'space-y-2'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in raceItems"
|
||||||
|
:key="item.username"
|
||||||
|
class="race-row flex items-center"
|
||||||
|
:class="isRetro ? 'gap-3' : 'gap-3'"
|
||||||
|
>
|
||||||
|
<div class="w-6 text-right wrapped-label text-[11px] text-[#00000055]">
|
||||||
|
{{ item.rank }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0"
|
||||||
|
:class="isRetro ? 'w-6 h-6' : 'w-7 h-7'"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.avatarUrl && avatarOk[item.username] !== false"
|
||||||
|
:src="item.avatarUrl"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
@error="avatarOk[item.username] = false"
|
||||||
|
/>
|
||||||
|
<span v-else class="wrapped-number text-[11px] text-[#00000066]">
|
||||||
|
{{ avatarFallback(item.displayName) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="wrapped-body text-[#000000e6] truncate" :class="isRetro ? 'text-xs' : 'text-sm'" :title="item.displayName">
|
||||||
|
{{ item.displayName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wrapped-number text-xs text-[#07C160] font-semibold">
|
||||||
|
{{ formatInt(item.value) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 rounded-full bg-[#00000008] overflow-hidden" :class="isRetro ? 'h-1.5' : 'h-2'">
|
||||||
|
<div
|
||||||
|
class="race-bar h-full rounded-full bg-[#07C160]"
|
||||||
|
:style="{ width: `${item.pct}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WrappedCardShell>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
|
||||||
|
import { useWrappedTheme } from '~/composables/useWrappedTheme'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
card: { type: Object, required: true },
|
||||||
|
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { theme } = useWrappedTheme()
|
||||||
|
const isGameboy = computed(() => theme.value === 'gameboy')
|
||||||
|
const isDos = computed(() => theme.value === 'dos')
|
||||||
|
const isRetro = computed(() => isGameboy.value || isDos.value)
|
||||||
|
|
||||||
|
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||||
|
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||||
|
|
||||||
|
// Data (from backend)
|
||||||
|
const replyEvents = computed(() => Number(props.card?.data?.replyEvents || 0))
|
||||||
|
const fastestReplySeconds = computed(() => props.card?.data?.fastestReplySeconds ?? null)
|
||||||
|
const longestReplySeconds = computed(() => props.card?.data?.longestReplySeconds ?? null)
|
||||||
|
const sentToContacts = computed(() => Number(props.card?.data?.sentToContacts || 0))
|
||||||
|
|
||||||
|
const bestBuddy = computed(() => {
|
||||||
|
const o = props.card?.data?.bestBuddy
|
||||||
|
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const fastestContact = computed(() => {
|
||||||
|
const o = props.card?.data?.fastest
|
||||||
|
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const slowestContact = computed(() => {
|
||||||
|
const o = props.card?.data?.slowest
|
||||||
|
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const usedIndex = computed(() => !!props.card?.data?.settings?.usedIndex)
|
||||||
|
const indexBuild = computed(() => {
|
||||||
|
const st = props.card?.data?.settings?.indexStatus
|
||||||
|
const b = st?.index?.build
|
||||||
|
if (!b || typeof b !== 'object') return null
|
||||||
|
return {
|
||||||
|
status: String(b.status || ''),
|
||||||
|
indexedMessages: Number(b.indexedMessages || 0),
|
||||||
|
currentConversation: String(b.currentConversation || ''),
|
||||||
|
error: String(b.error || '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Media URL resolving (same behavior as other wrapped components)
|
||||||
|
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||||
|
const resolveMediaUrl = (value) => {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (/^https?:\/\//i.test(raw)) {
|
||||||
|
try {
|
||||||
|
const host = new URL(raw).hostname.toLowerCase()
|
||||||
|
if (host.endsWith('.qpic.cn') || host.endsWith('.qlogo.cn')) {
|
||||||
|
return `${mediaBase}/api/chat/media/proxy_image?url=${encodeURIComponent(raw)}`
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
return raw
|
||||||
|
}
|
||||||
|
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarFallback = (name) => {
|
||||||
|
const s = String(name || '').trim()
|
||||||
|
return s ? s[0] : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const avatarOk = reactive({ best: true })
|
||||||
|
const bestBuddyAvatarUrl = computed(() => resolveMediaUrl(bestBuddy.value?.avatarUrl))
|
||||||
|
watch(bestBuddyAvatarUrl, () => { avatarOk.best = true })
|
||||||
|
|
||||||
|
const resetAvatarOk = () => {
|
||||||
|
for (const k of Object.keys(avatarOk)) delete avatarOk[k]
|
||||||
|
avatarOk.best = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Lottery (7s, ease-out slowdown) ----------------
|
||||||
|
const phase = ref('idle') // idle | rolling | revealed
|
||||||
|
const shownUser = ref(null) // current candidate object
|
||||||
|
const shownAvatarOk = ref(true)
|
||||||
|
const leftDocked = ref(false) // center -> left after reveal (lg)
|
||||||
|
const showChart = ref(false) // shown after the left block docks
|
||||||
|
let lotteryTimer = null
|
||||||
|
let typingTimer = null
|
||||||
|
let raceTimer = null
|
||||||
|
let dockTimer = null
|
||||||
|
let chartTimer = null
|
||||||
|
|
||||||
|
const candidates = computed(() => {
|
||||||
|
// Prefer allContacts (all contacts from contact.db) for more variety in lottery animation
|
||||||
|
const allContacts = Array.isArray(props.card?.data?.allContacts) ? props.card.data.allContacts : []
|
||||||
|
const topTotals = Array.isArray(props.card?.data?.topTotals) ? props.card.data.topTotals : []
|
||||||
|
|
||||||
|
// Merge allContacts and topTotals, deduplicate by username
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
for (const x of [...allContacts, ...topTotals]) {
|
||||||
|
if (x && typeof x === 'object' && typeof x.displayName === 'string' && !seen.has(x.username)) {
|
||||||
|
seen.add(x.username)
|
||||||
|
out.push(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure bestBuddy is in candidate pool
|
||||||
|
if (bestBuddy.value && !seen.has(bestBuddy.value.username)) {
|
||||||
|
out.unshift(bestBuddy.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const shownDisplayName = computed(() => {
|
||||||
|
if (phase.value === 'idle') return '点击按钮揭晓'
|
||||||
|
const o = shownUser.value
|
||||||
|
const name = String(o?.displayName || o?.maskedName || '').trim()
|
||||||
|
return name || '…'
|
||||||
|
})
|
||||||
|
|
||||||
|
const shownAvatarUrl = computed(() => {
|
||||||
|
const o = shownUser.value
|
||||||
|
if (!o) return ''
|
||||||
|
return resolveMediaUrl(o.avatarUrl)
|
||||||
|
})
|
||||||
|
|
||||||
|
const shownAvatarFallback = computed(() => (
|
||||||
|
phase.value === 'idle' ? '?' : avatarFallback(shownDisplayName.value)
|
||||||
|
))
|
||||||
|
const onShownAvatarError = () => { shownAvatarOk.value = false }
|
||||||
|
|
||||||
|
const pickRandomCandidate = (prevUsername) => {
|
||||||
|
const pool = candidates.value
|
||||||
|
if (!Array.isArray(pool) || pool.length === 0) return bestBuddy.value || null
|
||||||
|
if (pool.length === 1) return pool[0]
|
||||||
|
for (let i = 0; i < 6; i += 1) {
|
||||||
|
const idx = Math.floor(Math.random() * pool.length)
|
||||||
|
const c = pool[idx]
|
||||||
|
if (c && c.username !== prevUsername) return c
|
||||||
|
}
|
||||||
|
return pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearTimers = () => {
|
||||||
|
if (lotteryTimer) clearTimeout(lotteryTimer)
|
||||||
|
lotteryTimer = null
|
||||||
|
if (typingTimer) clearTimeout(typingTimer)
|
||||||
|
typingTimer = null
|
||||||
|
if (raceTimer) clearInterval(raceTimer)
|
||||||
|
raceTimer = null
|
||||||
|
if (dockTimer) clearTimeout(dockTimer)
|
||||||
|
dockTimer = null
|
||||||
|
if (chartTimer) clearTimeout(chartTimer)
|
||||||
|
chartTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const leftRailClass = computed(() => {
|
||||||
|
const shouldCenter = phase.value !== 'revealed' || !leftDocked.value
|
||||||
|
return [
|
||||||
|
'ease-[cubic-bezier(0.22,1,0.36,1)]',
|
||||||
|
shouldCenter ? 'lg:translate-x-1/2' : ''
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const startLottery = () => {
|
||||||
|
clearTimers()
|
||||||
|
resetAvatarOk()
|
||||||
|
shownAvatarOk.value = true
|
||||||
|
leftDocked.value = false
|
||||||
|
showChart.value = false
|
||||||
|
|
||||||
|
phase.value = 'rolling'
|
||||||
|
typingReset()
|
||||||
|
raceReset()
|
||||||
|
|
||||||
|
const durationMs = 7000
|
||||||
|
// Too-fast swapping makes the avatar transition lag behind; slow it down a bit (but keep it lively).
|
||||||
|
const minDelay = 60
|
||||||
|
const maxDelay = 220
|
||||||
|
const startedAt = performance.now()
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
const now = performance.now()
|
||||||
|
const elapsed = now - startedAt
|
||||||
|
const t = Math.max(0, Math.min(1, elapsed / durationMs))
|
||||||
|
|
||||||
|
const prev = String(shownUser.value?.username || '')
|
||||||
|
let next = pickRandomCandidate(prev)
|
||||||
|
const target = bestBuddy.value
|
||||||
|
// Near the end, gradually "stick" to the final result to create a smooth slow-stop feeling.
|
||||||
|
if (target && typeof target === 'object') {
|
||||||
|
if (t >= 0.97) {
|
||||||
|
next = target
|
||||||
|
} else if (t >= 0.85) {
|
||||||
|
const p = Math.max(0, Math.min(1, (t - 0.85) / 0.12))
|
||||||
|
if (Math.random() < p) next = target
|
||||||
|
}
|
||||||
|
}
|
||||||
|
shownUser.value = next
|
||||||
|
shownAvatarOk.value = true
|
||||||
|
|
||||||
|
if (t >= 1) {
|
||||||
|
finishReveal()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ease-out: slow down near the end to build suspense.
|
||||||
|
const easeOutCubic = 1 - Math.pow(1 - t, 3)
|
||||||
|
const delay = Math.round(minDelay + (maxDelay - minDelay) * easeOutCubic)
|
||||||
|
lotteryTimer = setTimeout(tick, delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
tick()
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishReveal = () => {
|
||||||
|
clearTimers()
|
||||||
|
phase.value = 'revealed'
|
||||||
|
shownUser.value = bestBuddy.value || shownUser.value
|
||||||
|
shownAvatarOk.value = true
|
||||||
|
leftDocked.value = false
|
||||||
|
showChart.value = false
|
||||||
|
|
||||||
|
// Start the narrative right away; dock left, then show the chart.
|
||||||
|
startTypewriter()
|
||||||
|
|
||||||
|
const settleMs = 240
|
||||||
|
const slideMs = 520
|
||||||
|
dockTimer = setTimeout(() => { leftDocked.value = true }, settleMs)
|
||||||
|
chartTimer = setTimeout(() => {
|
||||||
|
showChart.value = true
|
||||||
|
startRace()
|
||||||
|
}, settleMs + slideMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
const restart = () => {
|
||||||
|
// Keep UX simple: replay the same reveal, but still run the suspense animation.
|
||||||
|
startLottery()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Typewriter narrative ----------------
|
||||||
|
const typedSegIdx = ref(0)
|
||||||
|
const typedCharIdx = ref(0)
|
||||||
|
const typingActive = ref(false)
|
||||||
|
|
||||||
|
const formatDuration = (sec) => {
|
||||||
|
const s = Math.max(0, Math.round(Number(sec) || 0))
|
||||||
|
if (!Number.isFinite(s) || s <= 0) return '0秒'
|
||||||
|
if (s < 60) return `${s}秒`
|
||||||
|
const m = Math.floor(s / 60)
|
||||||
|
const ss = s % 60
|
||||||
|
if (m < 60) return ss ? `${m}分${ss}秒` : `${m}分钟`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
const mm = m % 60
|
||||||
|
if (h < 24) return mm ? `${h}小时${mm}分钟` : `${h}小时`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
const hh = h % 24
|
||||||
|
return hh ? `${d}天${hh}小时` : `${d}天`
|
||||||
|
}
|
||||||
|
|
||||||
|
const segments = computed(() => {
|
||||||
|
const buddy = bestBuddy.value
|
||||||
|
if (!buddy) return []
|
||||||
|
|
||||||
|
const outMsg = Number(buddy.outgoingMessages || 0)
|
||||||
|
const inMsg = Number(buddy.incomingMessages || 0)
|
||||||
|
const replyCount = Number(buddy.replyCount || 0)
|
||||||
|
const avgReply = Math.round(Number(buddy.avgReplySeconds || 0))
|
||||||
|
const fastest = fastestReplySeconds.value
|
||||||
|
const longest = longestReplySeconds.value
|
||||||
|
|
||||||
|
const segs = [
|
||||||
|
{ type: 'text', text: '今年你总共给 ' },
|
||||||
|
{ type: 'num', text: formatInt(sentToContacts.value) },
|
||||||
|
{ type: 'text', text: ' 人发送过消息,其中给 ' },
|
||||||
|
{ type: 'buddy' },
|
||||||
|
{ type: 'text', text: ' 发送了 ' },
|
||||||
|
{ type: 'num', text: formatInt(outMsg) },
|
||||||
|
{ type: 'text', text: ' 条消息,收到了 ' },
|
||||||
|
{ type: 'num', text: formatInt(inMsg) },
|
||||||
|
{ type: 'text', text: ' 条消息。' },
|
||||||
|
{ type: 'text', text: '你们之间统计到 ' },
|
||||||
|
{ type: 'num', text: formatInt(replyCount) },
|
||||||
|
{ type: 'text', text: ' 次回复,平均每条回复用时 ' },
|
||||||
|
{ type: 'num', text: formatDuration(avgReply) },
|
||||||
|
{ type: 'text', text: '。' }
|
||||||
|
]
|
||||||
|
|
||||||
|
if (fastest != null) {
|
||||||
|
segs.push({ type: 'text', text: '今年你最快一次只用了 ' })
|
||||||
|
segs.push({ type: 'num', text: formatDuration(fastest) })
|
||||||
|
segs.push({ type: 'text', text: ' 就回了' })
|
||||||
|
if (fastestContact.value) {
|
||||||
|
segs.push({ type: 'contact', contact: fastestContact.value })
|
||||||
|
}
|
||||||
|
segs.push({ type: 'text', text: '的消息;' })
|
||||||
|
}
|
||||||
|
if (longest != null) {
|
||||||
|
segs.push({ type: 'text', text: '最长一次让' })
|
||||||
|
if (slowestContact.value) {
|
||||||
|
segs.push({ type: 'contact', contact: slowestContact.value })
|
||||||
|
} else {
|
||||||
|
segs.push({ type: 'text', text: '对方' })
|
||||||
|
}
|
||||||
|
segs.push({ type: 'text', text: '等了 ' })
|
||||||
|
segs.push({ type: 'num', text: formatDuration(longest) })
|
||||||
|
segs.push({ type: 'text', text: '。' })
|
||||||
|
}
|
||||||
|
return segs
|
||||||
|
})
|
||||||
|
|
||||||
|
const typingReset = () => {
|
||||||
|
typedSegIdx.value = 0
|
||||||
|
typedCharIdx.value = 0
|
||||||
|
typingActive.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSegVisible = (i) => {
|
||||||
|
const segType = segments.value[i]?.type
|
||||||
|
return i < typedSegIdx.value || (i === typedSegIdx.value && (segType === 'buddy' || segType === 'contact'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const segTextShown = (i) => {
|
||||||
|
const seg = segments.value[i]
|
||||||
|
if (!seg || seg.type === 'buddy') return ''
|
||||||
|
|
||||||
|
if (i < typedSegIdx.value) return String(seg.text || '')
|
||||||
|
if (i > typedSegIdx.value) return ''
|
||||||
|
return String(seg.text || '').slice(0, Math.max(0, typedCharIdx.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTypewriter = () => {
|
||||||
|
typingReset()
|
||||||
|
typingActive.value = true
|
||||||
|
|
||||||
|
const charDelay = 26
|
||||||
|
const segPause = 140
|
||||||
|
|
||||||
|
const step = () => {
|
||||||
|
const seg = segments.value[typedSegIdx.value]
|
||||||
|
if (!seg) {
|
||||||
|
typingActive.value = false
|
||||||
|
typingTimer = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seg.type === 'buddy') {
|
||||||
|
// Show the buddy tag as a whole, then continue.
|
||||||
|
typedSegIdx.value += 1
|
||||||
|
typedCharIdx.value = 0
|
||||||
|
typingTimer = setTimeout(step, segPause)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const txt = String(seg.text || '')
|
||||||
|
typedCharIdx.value += 1
|
||||||
|
if (typedCharIdx.value >= txt.length) {
|
||||||
|
typedSegIdx.value += 1
|
||||||
|
typedCharIdx.value = 0
|
||||||
|
typingTimer = setTimeout(step, segPause)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
typingTimer = setTimeout(step, charDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
step()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Bar race (0.1s per day) ----------------
|
||||||
|
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 raceSeries = computed(() => {
|
||||||
|
// Pre-resolve avatar URLs once to avoid doing it in tight animation loops.
|
||||||
|
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) : []
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const raceDay = ref(0)
|
||||||
|
|
||||||
|
const raceReset = () => {
|
||||||
|
raceDay.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const pad2 = (n) => String(n).padStart(2, '0')
|
||||||
|
const raceDate = computed(() => {
|
||||||
|
const y = Number(race.value?.year || props.card?.data?.year || new Date().getFullYear())
|
||||||
|
const step = Math.max(0, Math.min(Math.max(0, raceDays.value), Number(raceDay.value || 0)))
|
||||||
|
if (step <= 0) return `${y} 开局`
|
||||||
|
const d = Math.max(0, Math.min(Math.max(0, raceDays.value - 1), step - 1))
|
||||||
|
const dt = new Date(y, 0, 1 + d)
|
||||||
|
return `${dt.getFullYear()}-${pad2(dt.getMonth() + 1)}-${pad2(dt.getDate())}`
|
||||||
|
})
|
||||||
|
|
||||||
|
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) }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Hide 0-value rows so the "TOP10" can evolve naturally (people enter/leave the list over time),
|
||||||
|
// and avoid showing an arbitrary fixed set of names at the very beginning.
|
||||||
|
items = items.filter((x) => x.value > 0)
|
||||||
|
if (items.length === 0) return []
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (b.value !== a.value) return b.value - a.value
|
||||||
|
return String(a.username).localeCompare(String(b.username))
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxV = Math.max(1, ...items.map((x) => x.value))
|
||||||
|
return items.slice(0, 10).map((x, idx) => ({
|
||||||
|
...x,
|
||||||
|
rank: idx + 1,
|
||||||
|
pct: Math.max(0, Math.min(100, Math.round((x.value / maxV) * 100)))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const startRace = () => {
|
||||||
|
if (!race.value || raceDays.value <= 0 || raceSeries.value.length === 0) return
|
||||||
|
if (raceTimer) clearInterval(raceTimer)
|
||||||
|
raceDay.value = 0
|
||||||
|
|
||||||
|
raceTimer = setInterval(() => {
|
||||||
|
if (raceDay.value >= raceDays.value) {
|
||||||
|
clearInterval(raceTimer)
|
||||||
|
raceTimer = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raceDay.value += 1
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep state stable when backend card updates (e.g., refresh/retry).
|
||||||
|
watch(
|
||||||
|
() => props.card?.data,
|
||||||
|
() => {
|
||||||
|
clearTimers()
|
||||||
|
resetAvatarOk()
|
||||||
|
phase.value = 'idle'
|
||||||
|
shownUser.value = null
|
||||||
|
shownAvatarOk.value = true
|
||||||
|
leftDocked.value = false
|
||||||
|
showChart.value = false
|
||||||
|
typingReset()
|
||||||
|
raceReset()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearTimers()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.type-caret {
|
||||||
|
display: inline-block;
|
||||||
|
width: 0.6ch;
|
||||||
|
height: 1em;
|
||||||
|
margin-left: 2px;
|
||||||
|
vertical-align: -0.12em;
|
||||||
|
background: rgba(7, 193, 96, 0.85);
|
||||||
|
animation: caret-blink 1s steps(1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes caret-blink {
|
||||||
|
0%, 49% { opacity: 1; }
|
||||||
|
50%, 100% { opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-fade-enter-active,
|
||||||
|
.chart-fade-leave-active {
|
||||||
|
transition: opacity 240ms ease, transform 240ms ease !important;
|
||||||
|
}
|
||||||
|
.chart-fade-enter-from,
|
||||||
|
.chart-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-buddy-rail {
|
||||||
|
/* DOS theme sets `transition: text-shadow ... !important` on `*` (global).
|
||||||
|
Use an explicit transition here so the rail slide stays smooth in all themes. */
|
||||||
|
transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-move {
|
||||||
|
transition: transform 350ms cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-bar {
|
||||||
|
transition: width 120ms linear !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -151,6 +151,12 @@
|
|||||||
variant="slide"
|
variant="slide"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
<Card03ReplySpeed
|
||||||
|
v-else-if="c && (c.kind === 'chat/reply_speed' || c.id === 3)"
|
||||||
|
:card="c"
|
||||||
|
variant="slide"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
<WrappedCardShell
|
<WrappedCardShell
|
||||||
v-else
|
v-else
|
||||||
:card-id="Number(c?.id || (idx + 1))"
|
:card-id="Number(c?.id || (idx + 1))"
|
||||||
|
|||||||
BIN
frontend/public/assets/images/LuckyBlock.png
Normal file
BIN
frontend/public/assets/images/LuckyBlock.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
668
src/wechat_decrypt_tool/wrapped/cards/card_03_reply_speed.py
Normal file
668
src/wechat_decrypt_tool/wrapped/cards/card_03_reply_speed.py
Normal file
@@ -0,0 +1,668 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import heapq
|
||||||
|
import math
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from ...chat_helpers import (
|
||||||
|
_build_avatar_url,
|
||||||
|
_load_contact_rows,
|
||||||
|
_pick_avatar_url,
|
||||||
|
_pick_display_name,
|
||||||
|
_should_keep_session,
|
||||||
|
)
|
||||||
|
from ...chat_search_index import (
|
||||||
|
get_chat_search_index_db_path,
|
||||||
|
get_chat_search_index_status,
|
||||||
|
start_chat_search_index_build,
|
||||||
|
)
|
||||||
|
from ...logging_config import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
|
||||||
|
# Use local time boundaries (same semantics as sqlite "localtime").
|
||||||
|
start = int(datetime(year, 1, 1).timestamp())
|
||||||
|
end = int(datetime(year + 1, 1, 1).timestamp())
|
||||||
|
return start, end
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_name(name: str) -> str:
|
||||||
|
s = str(name or "").strip()
|
||||||
|
if not s:
|
||||||
|
return ""
|
||||||
|
if len(s) == 1:
|
||||||
|
return "*"
|
||||||
|
if len(s) == 2:
|
||||||
|
return s[0] + "*"
|
||||||
|
return s[0] + ("*" * (len(s) - 2)) + s[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration_zh(seconds: int | None) -> str:
|
||||||
|
if seconds is None:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
s = int(seconds)
|
||||||
|
except Exception:
|
||||||
|
s = 0
|
||||||
|
if s < 0:
|
||||||
|
s = 0
|
||||||
|
|
||||||
|
if s < 60:
|
||||||
|
return f"{s}秒"
|
||||||
|
m, sec = divmod(s, 60)
|
||||||
|
if m < 60:
|
||||||
|
return f"{m}分{sec}秒" if sec else f"{m}分钟"
|
||||||
|
h, mm = divmod(m, 60)
|
||||||
|
if h < 24:
|
||||||
|
return f"{h}小时{mm}分钟" if mm else f"{h}小时"
|
||||||
|
d, hh = divmod(h, 24)
|
||||||
|
return f"{d}天{hh}小时" if hh else f"{d}天"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _ConvAgg:
|
||||||
|
username: str
|
||||||
|
incoming: int
|
||||||
|
outgoing: int
|
||||||
|
replies: int
|
||||||
|
sum_gap: int
|
||||||
|
sum_gap_capped: int
|
||||||
|
min_gap: int
|
||||||
|
max_gap: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total(self) -> int:
|
||||||
|
return int(self.incoming) + int(self.outgoing)
|
||||||
|
|
||||||
|
def avg_gap(self) -> float:
|
||||||
|
return float(self.sum_gap) / float(self.replies) if self.replies > 0 else 0.0
|
||||||
|
|
||||||
|
def avg_gap_capped(self) -> float:
|
||||||
|
return float(self.sum_gap_capped) / float(self.replies) if self.replies > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _score_conv(*, agg: _ConvAgg, tau_seconds: float) -> float:
|
||||||
|
# "聊天频率":更偏向双向互动(取 min(in, out))。
|
||||||
|
interaction = float(min(int(agg.incoming), int(agg.outgoing)))
|
||||||
|
if interaction <= 0.0 or agg.replies <= 0:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# "回复频率/速度":用 capped 平均耗时做一个饱和衰减,避免极端长等待把分数打穿。
|
||||||
|
avg_s = float(agg.avg_gap_capped())
|
||||||
|
speed_score = 1.0 / (1.0 + (avg_s / float(max(1.0, tau_seconds))))
|
||||||
|
|
||||||
|
volume_score = math.log1p(interaction)
|
||||||
|
return float(speed_score * volume_score)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]:
|
||||||
|
"""
|
||||||
|
统计“回复速度”相关指标(全局 + 每个好友),用于 Wrapped 年度总结卡片。
|
||||||
|
|
||||||
|
Notes / 口径说明:
|
||||||
|
- 仅统计 1v1(非群聊)会话:username 不以 "@chatroom" 结尾。
|
||||||
|
- “一次回复”定义:对方发出消息后,你发送的第一条消息(同一段连续你发的消息只计 1 次)。
|
||||||
|
- 默认过滤系统消息(local_type=10000),并排除 biz_message*.db。
|
||||||
|
- 优先使用 chat_search_index.db(全量合并所有 shard),没有索引时做 best-effort 降级。
|
||||||
|
"""
|
||||||
|
|
||||||
|
start_ts, end_ts = _year_range_epoch_seconds(int(year))
|
||||||
|
my_username = str(account_dir.name or "").strip()
|
||||||
|
|
||||||
|
# Scoring hyper-params (tuned for "更偏向聊天频率高的" 的直觉)。
|
||||||
|
gap_cap_seconds = 6 * 60 * 60 # 6h: scoring 上限(超过当作一样慢)
|
||||||
|
tau_seconds = 30 * 60 # 30min: 速度衰减的尺度
|
||||||
|
|
||||||
|
total_replies = 0
|
||||||
|
global_fastest: int | None = None
|
||||||
|
global_fastest_u: str | None = None
|
||||||
|
global_slowest: int | None = None
|
||||||
|
global_slowest_u: str | None = None
|
||||||
|
|
||||||
|
best_score = -1.0
|
||||||
|
best_agg: _ConvAgg | None = None
|
||||||
|
|
||||||
|
# NOTE: Use (score, username, agg) so the heap is always comparable even when scores tie.
|
||||||
|
top_heap: list[tuple[float, str, _ConvAgg]] = []
|
||||||
|
top_n = 8
|
||||||
|
|
||||||
|
# For "今年你总共给 xxx 人发送过消息" & top-total bar-race.
|
||||||
|
sent_to_contacts: set[str] = set()
|
||||||
|
# Collect totals for *all* 1v1 sessions so the frontend ranking can naturally grow over time.
|
||||||
|
all_totals: dict[str, int] = {}
|
||||||
|
# NOTE: Use (total, username, agg) so the heap is always comparable even when totals tie.
|
||||||
|
top_total_heap: list[tuple[int, str, _ConvAgg]] = []
|
||||||
|
# Keep more than 10 so the bar-race "TOP10" can actually evolve (members can enter/leave over time).
|
||||||
|
top_total_n = 100
|
||||||
|
|
||||||
|
def consider_conv(agg: _ConvAgg) -> None:
|
||||||
|
nonlocal best_score, best_agg
|
||||||
|
if not agg.username:
|
||||||
|
return
|
||||||
|
if agg.replies <= 0:
|
||||||
|
return
|
||||||
|
if min(agg.incoming, agg.outgoing) <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
score = _score_conv(agg=agg, tau_seconds=tau_seconds)
|
||||||
|
if score > best_score:
|
||||||
|
best_score = float(score)
|
||||||
|
best_agg = agg
|
||||||
|
|
||||||
|
if score <= 0:
|
||||||
|
return
|
||||||
|
key = (float(score), str(agg.username), agg)
|
||||||
|
if len(top_heap) < top_n:
|
||||||
|
heapq.heappush(top_heap, key)
|
||||||
|
else:
|
||||||
|
heapq.heappushpop(top_heap, key)
|
||||||
|
|
||||||
|
def consider_total(agg: _ConvAgg) -> None:
|
||||||
|
if not agg.username:
|
||||||
|
return
|
||||||
|
if agg.total <= 0:
|
||||||
|
return
|
||||||
|
# Keep the same filtering behavior as other wrapped cards.
|
||||||
|
if not _should_keep_session(agg.username, include_official=False):
|
||||||
|
return
|
||||||
|
|
||||||
|
if agg.outgoing > 0:
|
||||||
|
sent_to_contacts.add(agg.username)
|
||||||
|
|
||||||
|
total = int(agg.total)
|
||||||
|
all_totals[agg.username] = int(total)
|
||||||
|
key = (total, str(agg.username), agg)
|
||||||
|
if len(top_total_heap) < top_total_n:
|
||||||
|
heapq.heappush(top_total_heap, key)
|
||||||
|
else:
|
||||||
|
heapq.heappushpop(top_total_heap, key)
|
||||||
|
|
||||||
|
used_index = False
|
||||||
|
|
||||||
|
# -------- Preferred path: unified search index --------
|
||||||
|
index_path = get_chat_search_index_db_path(account_dir)
|
||||||
|
if index_path.exists():
|
||||||
|
conn = sqlite3.connect(str(index_path))
|
||||||
|
try:
|
||||||
|
has_fts = (
|
||||||
|
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
if has_fts and my_username:
|
||||||
|
used_index = True
|
||||||
|
t0 = time.time()
|
||||||
|
|
||||||
|
ts_expr = (
|
||||||
|
"CASE "
|
||||||
|
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
|
||||||
|
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
|
||||||
|
"ELSE CAST(create_time AS INTEGER) "
|
||||||
|
"END"
|
||||||
|
)
|
||||||
|
|
||||||
|
where = (
|
||||||
|
f"{ts_expr} >= ? AND {ts_expr} < ? "
|
||||||
|
"AND db_stem NOT LIKE 'biz_message%' "
|
||||||
|
"AND CAST(local_type AS INTEGER) != 10000 "
|
||||||
|
"AND username NOT LIKE '%@chatroom'"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Order by username, then time (ties broken by sort_seq/local_id if possible).
|
||||||
|
sql = (
|
||||||
|
"SELECT "
|
||||||
|
"username, sender_username, "
|
||||||
|
f"{ts_expr} AS ts, "
|
||||||
|
"CAST(sort_seq AS INTEGER) AS sort_seq_i, "
|
||||||
|
"CAST(local_id AS INTEGER) AS local_id_i "
|
||||||
|
"FROM message_fts "
|
||||||
|
f"WHERE {where} "
|
||||||
|
"ORDER BY username ASC, ts ASC, sort_seq_i ASC, local_id_i ASC"
|
||||||
|
)
|
||||||
|
|
||||||
|
cur = conn.execute(sql, (start_ts, end_ts))
|
||||||
|
|
||||||
|
cur_username: str = ""
|
||||||
|
incoming = 0
|
||||||
|
outgoing = 0
|
||||||
|
replies = 0
|
||||||
|
sum_gap = 0
|
||||||
|
sum_gap_capped = 0
|
||||||
|
min_gap = 0
|
||||||
|
max_gap = 0
|
||||||
|
prev_other_ts: int | None = None
|
||||||
|
|
||||||
|
def flush() -> None:
|
||||||
|
nonlocal cur_username, incoming, outgoing, replies, sum_gap, sum_gap_capped, min_gap, max_gap
|
||||||
|
if not cur_username:
|
||||||
|
return
|
||||||
|
agg = _ConvAgg(
|
||||||
|
username=cur_username,
|
||||||
|
incoming=int(incoming),
|
||||||
|
outgoing=int(outgoing),
|
||||||
|
replies=int(replies),
|
||||||
|
sum_gap=int(sum_gap),
|
||||||
|
sum_gap_capped=int(sum_gap_capped),
|
||||||
|
min_gap=int(min_gap),
|
||||||
|
max_gap=int(max_gap),
|
||||||
|
)
|
||||||
|
consider_total(agg)
|
||||||
|
consider_conv(agg)
|
||||||
|
|
||||||
|
for row in cur:
|
||||||
|
try:
|
||||||
|
username = str(row[0] or "").strip()
|
||||||
|
sender = str(row[1] or "").strip()
|
||||||
|
ts = int(row[2] or 0)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ts <= 0 or not username:
|
||||||
|
continue
|
||||||
|
if username != cur_username:
|
||||||
|
# flush old
|
||||||
|
flush()
|
||||||
|
# reset for new conversation
|
||||||
|
cur_username = username
|
||||||
|
incoming = outgoing = replies = 0
|
||||||
|
sum_gap = sum_gap_capped = 0
|
||||||
|
min_gap = max_gap = 0
|
||||||
|
prev_other_ts = None
|
||||||
|
|
||||||
|
# Drop system/official-ish sessions (best-effort).
|
||||||
|
if not _should_keep_session(username, include_official=False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_me = sender == my_username
|
||||||
|
if is_me:
|
||||||
|
outgoing += 1
|
||||||
|
if prev_other_ts is not None and ts >= prev_other_ts:
|
||||||
|
gap = int(ts - prev_other_ts)
|
||||||
|
replies += 1
|
||||||
|
total_replies += 1
|
||||||
|
sum_gap += gap
|
||||||
|
sum_gap_capped += min(gap, gap_cap_seconds)
|
||||||
|
|
||||||
|
if replies == 1 or gap < min_gap:
|
||||||
|
min_gap = gap
|
||||||
|
if replies == 1 or gap > max_gap:
|
||||||
|
max_gap = gap
|
||||||
|
|
||||||
|
if global_fastest is None or gap < global_fastest:
|
||||||
|
global_fastest = gap
|
||||||
|
global_fastest_u = username
|
||||||
|
if global_slowest is None or gap > global_slowest:
|
||||||
|
global_slowest = gap
|
||||||
|
global_slowest_u = username
|
||||||
|
|
||||||
|
# Only count the first outgoing message as the "reply" to this prompt.
|
||||||
|
prev_other_ts = None
|
||||||
|
else:
|
||||||
|
incoming += 1
|
||||||
|
prev_other_ts = ts
|
||||||
|
|
||||||
|
flush()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Wrapped card#3 reply_speed computed (search index): account=%s year=%s conversations_top=%s replies=%s db=%s elapsed=%.2fs",
|
||||||
|
str(account_dir.name or "").strip(),
|
||||||
|
int(year),
|
||||||
|
len(top_heap),
|
||||||
|
int(total_replies),
|
||||||
|
str(index_path.name),
|
||||||
|
time.time() - t0,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# -------- Fallback path: no index --------
|
||||||
|
# Best-effort: if the index doesn't exist / isn't ready, auto-start building it (async) so user can
|
||||||
|
# retry this page later. We intentionally do NOT block here.
|
||||||
|
index_status: dict[str, Any] | None = None
|
||||||
|
if not used_index:
|
||||||
|
try:
|
||||||
|
index_status = get_chat_search_index_status(account_dir)
|
||||||
|
index = dict(index_status.get("index") or {})
|
||||||
|
build = dict(index.get("build") or {})
|
||||||
|
index_ready = bool(index.get("ready"))
|
||||||
|
build_status = str(build.get("status") or "")
|
||||||
|
index_exists = bool(index.get("exists"))
|
||||||
|
|
||||||
|
if (not index_ready) and build_status not in {"building", "error"}:
|
||||||
|
start_chat_search_index_build(account_dir, rebuild=bool(index_exists))
|
||||||
|
index_status = get_chat_search_index_status(account_dir)
|
||||||
|
except Exception:
|
||||||
|
index_status = None
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"Wrapped card#3 reply_speed: search index missing/not ready; returning empty stats. account=%s year=%s index=%s",
|
||||||
|
str(account_dir.name or "").strip(),
|
||||||
|
int(year),
|
||||||
|
str(index_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort top buddies by score desc.
|
||||||
|
top_buddies: list[tuple[float, _ConvAgg]] = sorted(
|
||||||
|
[(score, agg) for score, _, agg in top_heap],
|
||||||
|
key=lambda x: (-x[0], x[1].username),
|
||||||
|
)
|
||||||
|
top_totals: list[tuple[int, _ConvAgg]] = sorted(
|
||||||
|
[(total, agg) for total, _, agg in top_total_heap],
|
||||||
|
key=lambda x: (-x[0], x[1].username),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Resolve contact display names/avatars for a small set (bestBuddy + extremes + top list).
|
||||||
|
need_usernames: list[str] = []
|
||||||
|
if best_agg is not None:
|
||||||
|
need_usernames.append(best_agg.username)
|
||||||
|
if global_fastest_u:
|
||||||
|
need_usernames.append(global_fastest_u)
|
||||||
|
if global_slowest_u:
|
||||||
|
need_usernames.append(global_slowest_u)
|
||||||
|
for _, agg in top_buddies:
|
||||||
|
need_usernames.append(agg.username)
|
||||||
|
for _, agg in top_totals:
|
||||||
|
need_usernames.append(agg.username)
|
||||||
|
|
||||||
|
uniq_usernames = []
|
||||||
|
seen = set()
|
||||||
|
for u in need_usernames:
|
||||||
|
if u and u not in seen:
|
||||||
|
seen.add(u)
|
||||||
|
uniq_usernames.append(u)
|
||||||
|
|
||||||
|
contact_rows = _load_contact_rows(account_dir / "contact.db", uniq_usernames) if uniq_usernames else {}
|
||||||
|
|
||||||
|
def conv_to_obj(score: float | None, agg: _ConvAgg) -> dict[str, Any]:
|
||||||
|
row = contact_rows.get(agg.username)
|
||||||
|
display = _pick_display_name(row, agg.username)
|
||||||
|
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), agg.username) if agg.username else "")
|
||||||
|
avg_s = agg.avg_gap()
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"username": agg.username,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"avatarUrl": avatar,
|
||||||
|
"incomingMessages": int(agg.incoming),
|
||||||
|
"outgoingMessages": int(agg.outgoing),
|
||||||
|
"totalMessages": int(agg.total),
|
||||||
|
"replyCount": int(agg.replies),
|
||||||
|
"avgReplySeconds": float(avg_s),
|
||||||
|
"fastestReplySeconds": int(agg.min_gap) if agg.replies > 0 else None,
|
||||||
|
"slowestReplySeconds": int(agg.max_gap) if agg.replies > 0 else None,
|
||||||
|
}
|
||||||
|
if score is not None:
|
||||||
|
out["score"] = float(score)
|
||||||
|
return out
|
||||||
|
|
||||||
|
best_buddy_obj = None
|
||||||
|
if best_agg is not None:
|
||||||
|
best_buddy_obj = conv_to_obj(best_score, best_agg)
|
||||||
|
|
||||||
|
fastest_obj = None
|
||||||
|
if global_fastest is not None and global_fastest_u:
|
||||||
|
# Use the best agg if it matches; otherwise create a minimal object.
|
||||||
|
agg = next((a for _, a in top_buddies if a.username == global_fastest_u), None)
|
||||||
|
if agg is None and best_agg is not None and best_agg.username == global_fastest_u:
|
||||||
|
agg = best_agg
|
||||||
|
if agg is not None:
|
||||||
|
fastest_obj = conv_to_obj(None, agg)
|
||||||
|
fastest_obj["seconds"] = int(global_fastest)
|
||||||
|
else:
|
||||||
|
row = contact_rows.get(global_fastest_u)
|
||||||
|
display = _pick_display_name(row, global_fastest_u)
|
||||||
|
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), global_fastest_u) if global_fastest_u else "")
|
||||||
|
fastest_obj = {
|
||||||
|
"username": global_fastest_u,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"avatarUrl": avatar,
|
||||||
|
"seconds": int(global_fastest),
|
||||||
|
}
|
||||||
|
|
||||||
|
slowest_obj = None
|
||||||
|
if global_slowest is not None and global_slowest_u:
|
||||||
|
agg = next((a for _, a in top_buddies if a.username == global_slowest_u), None)
|
||||||
|
if agg is None and best_agg is not None and best_agg.username == global_slowest_u:
|
||||||
|
agg = best_agg
|
||||||
|
if agg is not None:
|
||||||
|
slowest_obj = conv_to_obj(None, agg)
|
||||||
|
slowest_obj["seconds"] = int(global_slowest)
|
||||||
|
else:
|
||||||
|
row = contact_rows.get(global_slowest_u)
|
||||||
|
display = _pick_display_name(row, global_slowest_u)
|
||||||
|
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), global_slowest_u) if global_slowest_u else "")
|
||||||
|
slowest_obj = {
|
||||||
|
"username": global_slowest_u,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"avatarUrl": avatar,
|
||||||
|
"seconds": int(global_slowest),
|
||||||
|
}
|
||||||
|
|
||||||
|
top_list = [conv_to_obj(score, agg) for score, agg in top_buddies]
|
||||||
|
|
||||||
|
top_totals_list = [
|
||||||
|
{
|
||||||
|
**conv_to_obj(None, agg),
|
||||||
|
"totalMessages": int(total),
|
||||||
|
}
|
||||||
|
for total, agg in top_totals
|
||||||
|
]
|
||||||
|
|
||||||
|
# Prepare "bar race" data: all 1v1 sessions (exclude official/system), cumulative per day.
|
||||||
|
race = None
|
||||||
|
if used_index and all_totals:
|
||||||
|
days_in_year = int((datetime(int(year) + 1, 1, 1) - datetime(int(year), 1, 1)).days)
|
||||||
|
u_list = [u for u, _ in sorted(all_totals.items(), key=lambda kv: (-int(kv[1] or 0), str(kv[0] or ""))) if u]
|
||||||
|
if days_in_year > 0 and u_list:
|
||||||
|
# Convert millisecond timestamps defensively.
|
||||||
|
ts_expr = (
|
||||||
|
"CASE "
|
||||||
|
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
|
||||||
|
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
|
||||||
|
"ELSE CAST(create_time AS INTEGER) "
|
||||||
|
"END"
|
||||||
|
)
|
||||||
|
|
||||||
|
base_where = (
|
||||||
|
f"{ts_expr} >= ? AND {ts_expr} < ? "
|
||||||
|
"AND db_stem NOT LIKE 'biz_message%' "
|
||||||
|
"AND CAST(local_type AS INTEGER) != 10000 "
|
||||||
|
"AND username NOT LIKE '%@chatroom'"
|
||||||
|
)
|
||||||
|
|
||||||
|
sql_daily = (
|
||||||
|
"SELECT username, "
|
||||||
|
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||||
|
"COUNT(1) AS cnt "
|
||||||
|
"FROM ("
|
||||||
|
f" SELECT username, {ts_expr} AS ts "
|
||||||
|
" FROM message_fts "
|
||||||
|
f" WHERE {base_where}"
|
||||||
|
") sub "
|
||||||
|
"GROUP BY username, doy"
|
||||||
|
)
|
||||||
|
|
||||||
|
u_set = set(u_list)
|
||||||
|
per_user_daily: dict[str, list[int]] = {}
|
||||||
|
try:
|
||||||
|
conn2 = sqlite3.connect(str(index_path))
|
||||||
|
try:
|
||||||
|
rows = conn2.execute(sql_daily, (start_ts, end_ts)).fetchall()
|
||||||
|
finally:
|
||||||
|
conn2.close()
|
||||||
|
except Exception:
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if not r:
|
||||||
|
continue
|
||||||
|
u = str(r[0] or "").strip()
|
||||||
|
if not u or u not in u_set:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
doy = int(r[1] if r[1] is not None else -1)
|
||||||
|
cnt = int(r[2] 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
|
||||||
|
|
||||||
|
# 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]
|
||||||
|
if extra_usernames:
|
||||||
|
try:
|
||||||
|
# sqlite has a default var limit; query in chunks.
|
||||||
|
CHUNK = 900
|
||||||
|
for i in range(0, len(extra_usernames), CHUNK):
|
||||||
|
contact_rows.update(_load_contact_rows(account_dir / "contact.db", extra_usernames[i : i + CHUNK]))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
series: list[dict[str, Any]] = []
|
||||||
|
for u in u_list:
|
||||||
|
daily = per_user_daily.get(u)
|
||||||
|
if not daily:
|
||||||
|
continue
|
||||||
|
cum: list[int] = []
|
||||||
|
running = 0
|
||||||
|
for x in daily:
|
||||||
|
running += int(x or 0)
|
||||||
|
cum.append(int(running))
|
||||||
|
|
||||||
|
row = contact_rows.get(u)
|
||||||
|
display = _pick_display_name(row, u)
|
||||||
|
avatar = _pick_avatar_url(row) or (_build_avatar_url(str(account_dir.name or ""), u) if u else "")
|
||||||
|
series.append(
|
||||||
|
{
|
||||||
|
"username": u,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"avatarUrl": avatar,
|
||||||
|
"totalMessages": int(all_totals.get(u) or 0),
|
||||||
|
"cumulativeCounts": cum,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
race = {
|
||||||
|
"year": int(year),
|
||||||
|
"startDate": f"{int(year)}-01-01",
|
||||||
|
"endDate": f"{int(year)}-12-31",
|
||||||
|
"days": int(days_in_year),
|
||||||
|
"series": series,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Load all contacts for lottery animation (up to 50 random contacts)
|
||||||
|
all_contacts_list: list[dict[str, Any]] = []
|
||||||
|
try:
|
||||||
|
contact_db_path = account_dir / "contact.db"
|
||||||
|
if contact_db_path.exists():
|
||||||
|
conn = sqlite3.connect(str(contact_db_path))
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
# Get contacts that are real users (not chatrooms, not official accounts)
|
||||||
|
sql = """
|
||||||
|
SELECT username, remark, nick_name, alias, big_head_url, small_head_url
|
||||||
|
FROM contact
|
||||||
|
WHERE username NOT LIKE '%@chatroom'
|
||||||
|
AND username NOT LIKE 'gh_%'
|
||||||
|
AND username NOT LIKE 'weixin'
|
||||||
|
AND username NOT LIKE 'filehelper'
|
||||||
|
AND username NOT LIKE 'fmessage'
|
||||||
|
AND username NOT IN ('medianote', 'floatbottle', 'shakeapp', 'lbsapp', 'newsapp')
|
||||||
|
AND (nick_name IS NOT NULL AND nick_name != '')
|
||||||
|
ORDER BY RANDOM()
|
||||||
|
LIMIT 50
|
||||||
|
"""
|
||||||
|
rows = conn.execute(sql).fetchall()
|
||||||
|
for r in rows:
|
||||||
|
u = str(r["username"] or "").strip()
|
||||||
|
if not u:
|
||||||
|
continue
|
||||||
|
display = _pick_display_name(r, u)
|
||||||
|
avatar = _pick_avatar_url(r) or (_build_avatar_url(str(account_dir.name or ""), u) if u else "")
|
||||||
|
all_contacts_list.append({
|
||||||
|
"username": u,
|
||||||
|
"displayName": display,
|
||||||
|
"maskedName": _mask_name(display),
|
||||||
|
"avatarUrl": avatar,
|
||||||
|
})
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"year": int(year),
|
||||||
|
"sentToContacts": int(len(sent_to_contacts)),
|
||||||
|
"replyEvents": int(total_replies),
|
||||||
|
"fastestReplySeconds": int(global_fastest) if global_fastest is not None else None,
|
||||||
|
"longestReplySeconds": int(global_slowest) if global_slowest is not None else None,
|
||||||
|
"bestBuddy": best_buddy_obj,
|
||||||
|
"fastest": fastest_obj,
|
||||||
|
"slowest": slowest_obj,
|
||||||
|
"topBuddies": top_list,
|
||||||
|
"topTotals": top_totals_list,
|
||||||
|
"allContacts": all_contacts_list,
|
||||||
|
"race": race,
|
||||||
|
"settings": {
|
||||||
|
"gapCapSeconds": int(gap_cap_seconds),
|
||||||
|
"tauSeconds": int(tau_seconds),
|
||||||
|
"usedIndex": bool(used_index),
|
||||||
|
"indexStatus": index_status,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_card_03_reply_speed(*, account_dir: Path, year: int) -> dict[str, Any]:
|
||||||
|
stats = compute_reply_speed_stats(account_dir=account_dir, year=year)
|
||||||
|
|
||||||
|
fastest = stats.get("fastestReplySeconds")
|
||||||
|
longest = stats.get("longestReplySeconds")
|
||||||
|
best = stats.get("bestBuddy") or None
|
||||||
|
replies = int(stats.get("replyEvents") or 0)
|
||||||
|
|
||||||
|
if replies <= 0:
|
||||||
|
narrative = "今年你还没有可统计的“回复”记录(或尚未构建搜索索引)。"
|
||||||
|
else:
|
||||||
|
parts: list[str] = []
|
||||||
|
if fastest is not None:
|
||||||
|
parts.append(f"最快一次,你只用了 {_format_duration_zh(int(fastest))} 就回了消息。")
|
||||||
|
if longest is not None:
|
||||||
|
parts.append(f"最长一次,你让对方等了 {_format_duration_zh(int(longest))}。")
|
||||||
|
if best and isinstance(best, dict) and best.get("displayName"):
|
||||||
|
avg_s = best.get("avgReplySeconds")
|
||||||
|
try:
|
||||||
|
avg_i = int(round(float(avg_s or 0.0)))
|
||||||
|
except Exception:
|
||||||
|
avg_i = 0
|
||||||
|
parts.append(
|
||||||
|
f"最像你的聊天搭子是「{_mask_name(str(best.get('displayName') or ''))}」,平均每条回复用时 {_format_duration_zh(avg_i)}。"
|
||||||
|
)
|
||||||
|
narrative = "".join(parts) if parts else "你的回复速度,藏着你最在意的人。"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": 3,
|
||||||
|
"title": "谁是你「秒回」的置顶关心?",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "B",
|
||||||
|
"status": "ok",
|
||||||
|
"kind": "chat/reply_speed",
|
||||||
|
"narrative": narrative,
|
||||||
|
"data": stats,
|
||||||
|
}
|
||||||
@@ -15,15 +15,16 @@ from .storage import wrapped_cache_dir, wrapped_cache_path
|
|||||||
from .cards.card_00_global_overview import build_card_00_global_overview
|
from .cards.card_00_global_overview import build_card_00_global_overview
|
||||||
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
||||||
from .cards.card_02_message_chars import build_card_02_message_chars
|
from .cards.card_02_message_chars import build_card_02_message_chars
|
||||||
|
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||||
# an older partial cache.
|
# an older partial cache.
|
||||||
_IMPLEMENTED_UPTO_ID = 2
|
_IMPLEMENTED_UPTO_ID = 3
|
||||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||||
_CACHE_VERSION = 5
|
_CACHE_VERSION = 8
|
||||||
|
|
||||||
|
|
||||||
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
||||||
@@ -50,6 +51,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
|||||||
"category": "C",
|
"category": "C",
|
||||||
"kind": "text/message_chars",
|
"kind": "text/message_chars",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "谁是你「秒回」的置顶关心?",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "B",
|
||||||
|
"kind": "chat/reply_speed",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
||||||
|
|
||||||
@@ -266,7 +274,7 @@ def build_wrapped_annual_response(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build annual wrapped response for the given account/year.
|
"""Build annual wrapped response for the given account/year.
|
||||||
|
|
||||||
For now we implement cards up to id=2 (plus a meta overview card id=0).
|
For now we implement cards up to id=3 (plus a meta overview card id=0).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
@@ -307,6 +315,8 @@ def build_wrapped_annual_response(
|
|||||||
cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent))
|
cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent))
|
||||||
# Page 4: message char counts (sent vs received).
|
# Page 4: message char counts (sent vs received).
|
||||||
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
||||||
|
# Page 5: reply speed / best chat buddy.
|
||||||
|
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
||||||
|
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
"account": account_dir.name,
|
"account": account_dir.name,
|
||||||
@@ -496,6 +506,8 @@ def build_wrapped_annual_card(
|
|||||||
card = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)
|
card = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)
|
||||||
elif cid == 2:
|
elif cid == 2:
|
||||||
card = build_card_02_message_chars(account_dir=account_dir, year=y)
|
card = build_card_02_message_chars(account_dir=account_dir, year=y)
|
||||||
|
elif cid == 3:
|
||||||
|
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||||
else:
|
else:
|
||||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||||
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
||||||
|
|||||||
73
tests/test_wrapped_reply_speed.py
Normal file
73
tests/test_wrapped_reply_speed.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Ensure "src/" is importable when running tests from repo root.
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWrappedReplySpeedScoring(unittest.TestCase):
|
||||||
|
def test_score_prefers_more_chat_when_speed_similar(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_03_reply_speed import _ConvAgg, _score_conv
|
||||||
|
|
||||||
|
tau = 30 * 60 # 30min, keep in sync with production default
|
||||||
|
|
||||||
|
# A: 秒回,但聊天很少
|
||||||
|
a = _ConvAgg(
|
||||||
|
username="wxid_a",
|
||||||
|
incoming=3,
|
||||||
|
outgoing=3,
|
||||||
|
replies=3,
|
||||||
|
sum_gap=30,
|
||||||
|
sum_gap_capped=30,
|
||||||
|
min_gap=5,
|
||||||
|
max_gap=15,
|
||||||
|
)
|
||||||
|
|
||||||
|
# B: 稍慢,但聊天明显更多
|
||||||
|
b = _ConvAgg(
|
||||||
|
username="wxid_b",
|
||||||
|
incoming=50,
|
||||||
|
outgoing=50,
|
||||||
|
replies=50,
|
||||||
|
sum_gap=3000, # avg 60s
|
||||||
|
sum_gap_capped=3000,
|
||||||
|
min_gap=10,
|
||||||
|
max_gap=120,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreater(_score_conv(agg=b, tau_seconds=tau), _score_conv(agg=a, tau_seconds=tau))
|
||||||
|
|
||||||
|
def test_score_penalizes_extremely_slow_reply(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_03_reply_speed import _ConvAgg, _score_conv
|
||||||
|
|
||||||
|
tau = 30 * 60
|
||||||
|
|
||||||
|
fast_few = _ConvAgg(
|
||||||
|
username="wxid_fast",
|
||||||
|
incoming=5,
|
||||||
|
outgoing=5,
|
||||||
|
replies=5,
|
||||||
|
sum_gap=50, # avg 10s
|
||||||
|
sum_gap_capped=50,
|
||||||
|
min_gap=1,
|
||||||
|
max_gap=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
slow_many = _ConvAgg(
|
||||||
|
username="wxid_slow",
|
||||||
|
incoming=80,
|
||||||
|
outgoing=80,
|
||||||
|
replies=80,
|
||||||
|
sum_gap=80 * 7200, # avg 2h
|
||||||
|
sum_gap_capped=80 * 7200,
|
||||||
|
min_gap=60,
|
||||||
|
max_gap=100000,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertGreater(_score_conv(agg=fast_few, tau_seconds=tau), _score_conv(agg=slow_many, tau_seconds=tau))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user