mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
4 Commits
@@ -98,7 +98,9 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<GlobalOverviewChart :data="card.data || {}" />
|
||||
<div :class="variant === 'slide' ? 'w-full -mt-2 sm:-mt-4' : 'w-full'">
|
||||
<GlobalOverviewChart :data="card.data || {}" />
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
<template>
|
||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative || ''" :variant="variant" :wide="true">
|
||||
<div class="w-full">
|
||||
<div class="flex flex-wrap justify-center gap-x-3 gap-y-4 px-3 py-2">
|
||||
<article
|
||||
v-for="item in months"
|
||||
:key="`month-${item.month}`"
|
||||
class="relative flex-shrink-0 monthly-polaroid"
|
||||
:class="item.winner ? '' : 'monthly-polaroid--empty'"
|
||||
:style="monthCardStyle(item.month)"
|
||||
>
|
||||
<!-- 有获胜者 -->
|
||||
<template v-if="item.winner">
|
||||
<div class="flex items-start gap-1.5 pt-0.5 px-0.5">
|
||||
<!-- 头像 -->
|
||||
<div class="polaroid-photo flex-shrink-0">
|
||||
<img
|
||||
v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false"
|
||||
:src="winnerAvatar(item)"
|
||||
class="w-full h-full object-cover"
|
||||
alt="avatar"
|
||||
@error="avatarOk[item.winner.username] = false"
|
||||
/>
|
||||
<span v-else class="wrapped-number text-xl select-none" style="color:var(--accent)">
|
||||
{{ avatarFallback(item.winner.displayName) }}
|
||||
</span>
|
||||
</div>
|
||||
<!-- 右列:姓名 / 月份 / 综合分 / 4 维度 -->
|
||||
<div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-1 min-w-0">
|
||||
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName">
|
||||
{{ item.winner.displayName }}
|
||||
</div>
|
||||
<!-- 月份徽章 -->
|
||||
<div class="month-badge wrapped-number text-[8px] font-bold flex-shrink-0" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)' }">
|
||||
{{ item.month }}月
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-0.5 wrapped-number text-[9px] font-semibold" :style="{ color: 'var(--accent)' }">
|
||||
综合分 {{ formatScore(item.winner.score100) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 4 维度 2×2 -->
|
||||
<div class="grid grid-cols-2 gap-x-2 gap-y-1">
|
||||
<div v-for="metric in metricRows(item)" :key="metric.key" class="min-w-0">
|
||||
<div class="flex items-center justify-between wrapped-label text-[8px] text-[#00000066]">
|
||||
<span class="truncate">{{ metric.label }}</span>
|
||||
<span v-if="metric.pct !== 100" class="wrapped-number flex-shrink-0 ml-0.5">{{ metric.pct }}</span>
|
||||
</div>
|
||||
<div class="mt-0.5 h-1 rounded-full bg-[#0000000D] overflow-hidden">
|
||||
<div class="h-full rounded-full" :style="{ width: `${metric.pct}%`, backgroundColor: 'var(--accent)', opacity: '0.75' }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 统计行 -->
|
||||
<div class="polaroid-caption">
|
||||
<div class="wrapped-body text-[9px] text-[#00000055] leading-snug">
|
||||
共 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.totalMessages) }}</span> 条 ·
|
||||
互动 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.interaction) }}</span> 次 ·
|
||||
活跃 <span class="wrapped-number text-[#000000aa]">{{ formatInt(item?.raw?.activeDays) }}</span> 天
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 无数据:空白拍立得 -->
|
||||
<template v-else>
|
||||
<div class="polaroid-photo-empty flex-shrink-0 mx-auto">
|
||||
<span class="text-lg select-none" style="color:var(--accent);opacity:0.25">〜</span>
|
||||
</div>
|
||||
<div class="polaroid-caption">
|
||||
<div class="flex items-center justify-between gap-1">
|
||||
<div class="wrapped-label text-[9px] text-[#00000044]">本月静悄悄</div>
|
||||
<div class="month-badge wrapped-number text-[8px]" :style="{ color: 'var(--accent)', borderColor: 'var(--accent)', opacity: '0.5' }">
|
||||
{{ item.month }}月
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
card: { type: Object, required: true },
|
||||
variant: { type: String, default: 'panel' }
|
||||
})
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
const formatScore = (n) => {
|
||||
const x = Number(n)
|
||||
if (!Number.isFinite(x)) return '0.0'
|
||||
return x.toFixed(1)
|
||||
}
|
||||
const clampPct = (n) => Math.max(0, Math.min(100, Math.round(Number(n || 0) * 100)))
|
||||
|
||||
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 months = computed(() => {
|
||||
const raw = Array.isArray(props.card?.data?.months) ? props.card.data.months : []
|
||||
const byMonth = new Map()
|
||||
for (const x of raw) {
|
||||
const m = Number(x?.month)
|
||||
if (Number.isFinite(m) && m >= 1 && m <= 12) byMonth.set(m, x)
|
||||
}
|
||||
const out = []
|
||||
for (let m = 1; m <= 12; m += 1) {
|
||||
out.push(byMonth.get(m) || { month: m, winner: null, metrics: null, raw: null })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const avatarOk = reactive({})
|
||||
watch(
|
||||
months,
|
||||
() => { for (const key of Object.keys(avatarOk)) delete avatarOk[key] },
|
||||
{ deep: true, immediate: true }
|
||||
)
|
||||
|
||||
const winnerAvatar = (item) => resolveMediaUrl(item?.winner?.avatarUrl)
|
||||
|
||||
const metricRows = (item) => {
|
||||
const m = item?.metrics || {}
|
||||
return [
|
||||
{ key: 'interaction', label: '互动', pct: clampPct(m.interactionScore) },
|
||||
{ key: 'speed', label: '速度', pct: clampPct(m.speedScore) },
|
||||
{ key: 'continuity', label: '连续', pct: clampPct(m.continuityScore) },
|
||||
{ key: 'coverage', label: '覆盖', pct: clampPct(m.coverageScore) }
|
||||
]
|
||||
}
|
||||
|
||||
// 12 个月各自独立 accent 色,驱动胶带、徽章、进度条
|
||||
const accents = [
|
||||
'#C96A4E', // 1月 砖红
|
||||
'#5B82C4', // 2月 矢车菊蓝
|
||||
'#4EA87A', // 3月 薄荷绿
|
||||
'#C4953A', // 4月 琥珀金
|
||||
'#8B65B5', // 5月 薰衣草紫
|
||||
'#3A9FB5', // 6月 孔雀蓝
|
||||
'#C45F7A', // 7月 玫瑰粉
|
||||
'#3E7FC4', // 8月 天蓝
|
||||
'#6AA86A', // 9月 苔绿
|
||||
'#C47A3A', // 10月 暖橙
|
||||
'#9B6BAF', // 11月 丁香紫
|
||||
'#4A8EB5', // 12月 冬湖蓝
|
||||
]
|
||||
|
||||
const monthCardStyle = (month) => {
|
||||
const idx = Math.max(0, Math.min(11, Number(month || 1) - 1))
|
||||
const rotations = [-9, 5, -4, 11, -2, 8, -7, 3, -10, 6, -3, 9]
|
||||
const yOffsets = [5, -4, 6, -5, 3, -7, 3, -4, 7, -3, 5, -4]
|
||||
const widths = [172, 165, 178, 168, 175, 163, 172, 167, 165, 178, 165, 170]
|
||||
return {
|
||||
transform: `rotate(${rotations[idx]}deg) translateY(${yOffsets[idx]}px)`,
|
||||
width: `${widths[idx]}px`,
|
||||
'--delay': `${idx * 0.07}s`,
|
||||
'--accent': accents[idx],
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ── 拍立得卡片基础 ── */
|
||||
.monthly-polaroid {
|
||||
background: #FFFDF7; /* 暖奶油底色 */
|
||||
padding: 4px 4px 0;
|
||||
border-radius: 3px;
|
||||
box-shadow:
|
||||
0 1px 1px rgba(0,0,0,0.06),
|
||||
0 4px 12px rgba(0,0,0,0.10),
|
||||
0 12px 28px rgba(0,0,0,0.08);
|
||||
animation: cardAppear 0.4s ease-out both;
|
||||
animation-delay: var(--delay, 0s);
|
||||
}
|
||||
|
||||
@keyframes cardAppear {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* 空月卡片底色更浅 */
|
||||
.monthly-polaroid--empty {
|
||||
background: #F7F5F0;
|
||||
}
|
||||
|
||||
/* ── 彩色胶带条 ── */
|
||||
.monthly-polaroid::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 50%;
|
||||
width: 38px;
|
||||
height: 14px;
|
||||
transform: translateX(-50%) rotate(-1deg);
|
||||
border-radius: 2px;
|
||||
background: var(--accent, #c8a060);
|
||||
opacity: 0.55;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* ── 头像区域 ── */
|
||||
.polaroid-photo {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: #e0ddd8;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px; /* 照片圆角,更自然 */
|
||||
}
|
||||
|
||||
/* ── 空月占位图 ── */
|
||||
.polaroid-photo-empty {
|
||||
width: 70px;
|
||||
height: 44px;
|
||||
background: #E8E5DF;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 4px auto 0;
|
||||
}
|
||||
|
||||
/* ── 月份小徽章 ── */
|
||||
.month-badge {
|
||||
border: 1px solid;
|
||||
border-radius: 3px;
|
||||
padding: 0 3px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── 底部信息条 ── */
|
||||
.polaroid-caption {
|
||||
padding: 5px 5px 6px;
|
||||
border-top: 1px solid rgba(0,0,0,0.04); /* 细分隔线,区分照片与文字区 */
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -20,7 +20,12 @@
|
||||
|
||||
<!-- Slide 模式:单张卡片占据全页面,背景由外层(年度总结)统一控制 -->
|
||||
<section v-else class="relative h-full w-full overflow-hidden">
|
||||
<div class="relative h-full max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12 flex flex-col">
|
||||
<div
|
||||
class="relative h-full flex flex-col"
|
||||
:class="wide
|
||||
? 'px-10 pt-20 pb-12 sm:px-14 sm:pt-24 sm:pb-14 lg:px-20 xl:px-20 2xl:px-40'
|
||||
: 'max-w-5xl mx-auto px-6 py-10 sm:px-8 sm:py-12'"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="wrapped-title text-2xl sm:text-3xl text-[#000000e6]">{{ title }}</h2>
|
||||
@@ -47,6 +52,9 @@ defineProps({
|
||||
cardId: { type: Number, required: true },
|
||||
title: { type: String, required: true },
|
||||
narrative: { type: String, default: '' },
|
||||
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||
variant: { type: String, default: 'panel' }, // 'panel' | 'slide'
|
||||
// Slide 模式下是否取消 max-width 限制(让内容直接铺满页面宽度)。
|
||||
// 用于需要横向展示的可视化(如年度日历热力图)。
|
||||
wide: { type: Boolean, default: false }
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -242,6 +242,10 @@ const PREVIEW_BY_KIND = {
|
||||
summary: '回复速度',
|
||||
question: '谁是你愿意秒回的那个人?'
|
||||
},
|
||||
'chat/monthly_best_friends_wall': {
|
||||
summary: '月度好友墙',
|
||||
question: '每个月谁是你最有默契的聊天搭子?'
|
||||
},
|
||||
'emoji/annual_universe': {
|
||||
summary: '梗图年鉴',
|
||||
question: '你这一年最常丢出的表情包是哪张?'
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div v-if="weeks > 0" class="overflow-x-auto" data-wrapped-scroll-x>
|
||||
<div class="w-max mx-auto" :style="{ '--cell': `${cellPx}px` }">
|
||||
<!-- Month labels -->
|
||||
<div
|
||||
class="grid gap-[2px] text-[11px] text-[#00000066] mb-2"
|
||||
:style="{ gridTemplateColumns: `36px repeat(${weeks}, var(--cell))` }"
|
||||
>
|
||||
<div></div>
|
||||
<span
|
||||
v-for="(m, idx) in monthLabels"
|
||||
:key="idx"
|
||||
class="wrapped-number whitespace-nowrap"
|
||||
>
|
||||
{{ m }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div
|
||||
class="grid gap-[2px] items-stretch"
|
||||
:style="{
|
||||
gridTemplateColumns: `36px repeat(${weeks}, var(--cell))`,
|
||||
gridTemplateRows: `repeat(7, var(--cell))`
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(w, wi) in weekdayTicks"
|
||||
:key="wi"
|
||||
class="flex items-center wrapped-body text-[11px] text-[#00000066]"
|
||||
:style="{ gridColumn: '1', gridRow: String(wi + 1) }"
|
||||
>
|
||||
{{ w }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="(c, idx) in cells"
|
||||
:key="idx"
|
||||
class="heatmap-cell rounded-[2px] transition-transform duration-150 hover:scale-125 hover:z-10"
|
||||
:style="{
|
||||
backgroundColor: colorFor(c),
|
||||
transformOrigin: originFor(c),
|
||||
gridColumn: String((c.col ?? 0) + 2),
|
||||
gridRow: String((c.row ?? 0) + 1)
|
||||
}"
|
||||
@mouseenter="showTooltip(c, $event)"
|
||||
@mousemove="scheduleTooltipLayout"
|
||||
@mouseleave="hideTooltip"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between text-xs text-[#00000066] w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="wrapped-body">低</span>
|
||||
<div class="flex items-center gap-[2px]">
|
||||
<span
|
||||
v-for="i in 6"
|
||||
:key="i"
|
||||
class="heatmap-legend-cell w-4 h-2 rounded-[2px]"
|
||||
:style="{ backgroundColor: legendColor(i) }"
|
||||
/>
|
||||
</div>
|
||||
<span class="wrapped-body">高</span>
|
||||
</div>
|
||||
<div v-if="maxValue > 0" class="wrapped-number">最大 {{ maxValue }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="tooltipOpen && tooltipCell && tooltipCell.ymd"
|
||||
ref="tooltipEl"
|
||||
class="fixed z-[60] pointer-events-none"
|
||||
:style="{ left: `${tooltipX}px`, top: `${tooltipY}px` }"
|
||||
role="tooltip"
|
||||
>
|
||||
<div class="wr-heatmap-tooltip">
|
||||
<div class="flex justify-center mb-2">
|
||||
<span class="wr-heatmap-tooltip__time wrapped-number">{{ tooltipCell.ymd }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex justify-end">
|
||||
<div class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r">
|
||||
<div class="wrapped-body">{{ tooltipPrimaryText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(line, i) in tooltipHighlightLines" :key="i" class="flex justify-start">
|
||||
<div class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-white text-gray-800 bubble-tail-l">
|
||||
<div class="wrapped-body">{{ line }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="wr-heatmap-tooltip__arrow"
|
||||
:class="tooltipPlacement === 'bottom' ? 'wr-heatmap-tooltip__arrow--top' : 'wr-heatmap-tooltip__arrow--bottom'"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { heatColor } from '~/utils/wrapped/heatmap'
|
||||
|
||||
const props = defineProps({
|
||||
year: { type: Number, default: new Date().getFullYear() },
|
||||
// 0-indexed day-of-year array; length should be 365/366
|
||||
dailyCounts: { type: Array, default: () => [] },
|
||||
days: { type: Number, default: 0 },
|
||||
highlights: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
// Cell size of each day square (px). Tuned to fit Card00 slide width without truncation.
|
||||
const cellPx = 15
|
||||
|
||||
const MARKER_ORDER = [
|
||||
'sent_chars_max',
|
||||
'received_chars_max',
|
||||
'sent_messages_max',
|
||||
'received_messages_max',
|
||||
'added_friends_max',
|
||||
'sticker_messages_max',
|
||||
'emoji_chars_max'
|
||||
]
|
||||
|
||||
const isLeapYear = (y) => {
|
||||
const n = Number(y)
|
||||
if (!Number.isFinite(n)) return false
|
||||
return n % 4 === 0 && (n % 100 !== 0 || n % 400 === 0)
|
||||
}
|
||||
|
||||
const daysInYear = computed(() => {
|
||||
const d = Number(props.days || 0)
|
||||
const arr = Array.isArray(props.dailyCounts) ? props.dailyCounts : []
|
||||
if (d > 0) return d
|
||||
if (arr.length > 0) return arr.length
|
||||
return isLeapYear(props.year) ? 366 : 365
|
||||
})
|
||||
|
||||
const counts = computed(() => {
|
||||
const arr = Array.isArray(props.dailyCounts) ? props.dailyCounts : []
|
||||
const out = []
|
||||
for (let i = 0; i < daysInYear.value; i += 1) out.push(Number(arr[i] || 0))
|
||||
return out
|
||||
})
|
||||
|
||||
const highlightsMap = computed(() => {
|
||||
const hs = Array.isArray(props.highlights) ? props.highlights : []
|
||||
const map = new Map()
|
||||
for (const raw of hs) {
|
||||
const key = typeof raw?.key === 'string' ? raw.key : ''
|
||||
const doyNum = Number(raw?.doy)
|
||||
if (!key || !Number.isFinite(doyNum)) continue
|
||||
const doy = Math.floor(doyNum)
|
||||
if (doy < 0 || doy >= daysInYear.value) continue
|
||||
|
||||
const item = {
|
||||
key,
|
||||
label: typeof raw?.label === 'string' && raw.label.trim() ? raw.label.trim() : key,
|
||||
valueLabel: typeof raw?.valueLabel === 'string' ? raw.valueLabel : ''
|
||||
}
|
||||
|
||||
const arr = map.get(doy) || []
|
||||
arr.push(item)
|
||||
map.set(doy, arr)
|
||||
}
|
||||
|
||||
// Sort markers per-day by a stable order to keep UI deterministic.
|
||||
for (const [doy, arr] of map.entries()) {
|
||||
arr.sort((a, b) => {
|
||||
const ia = MARKER_ORDER.indexOf(a.key)
|
||||
const ib = MARKER_ORDER.indexOf(b.key)
|
||||
return (ia === -1 ? 999 : ia) - (ib === -1 ? 999 : ib)
|
||||
})
|
||||
map.set(doy, arr)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
const maxValue = computed(() => {
|
||||
let m = 0
|
||||
for (const v of counts.value) {
|
||||
const n = Number(v)
|
||||
if (Number.isFinite(n) && n > m) m = n
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const jan1UtcMs = computed(() => Date.UTC(Number(props.year), 0, 1))
|
||||
const startWeekday = computed(() => {
|
||||
const d = new Date(jan1UtcMs.value)
|
||||
const w = d.getUTCDay() // 0=Sun..6=Sat
|
||||
return (w + 6) % 7 // 0=Mon..6=Sun
|
||||
})
|
||||
|
||||
const weeks = computed(() => Math.ceil((daysInYear.value + startWeekday.value) / 7))
|
||||
|
||||
const weekdayTicks = computed(() => ['周一', '', '周三', '', '周五', '', '周日'])
|
||||
|
||||
const monthLabels = computed(() => {
|
||||
const cols = weeks.value
|
||||
const out = Array.from({ length: cols }, () => '')
|
||||
for (let m = 0; m < 12; m += 1) {
|
||||
const monthStart = Date.UTC(Number(props.year), m, 1)
|
||||
const doy = Math.round((monthStart - jan1UtcMs.value) / 86400000)
|
||||
const col = Math.floor((doy + startWeekday.value) / 7)
|
||||
if (col >= 0 && col < out.length && !out[col]) out[col] = `${m + 1}月`
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const cells = computed(() => {
|
||||
const out = []
|
||||
const cols = weeks.value
|
||||
const leading = startWeekday.value
|
||||
const totalCells = cols * 7
|
||||
for (let i = 0; i < totalCells; i += 1) {
|
||||
const col = Math.floor(i / 7)
|
||||
const row = i % 7
|
||||
const doy = i - leading
|
||||
if (doy < 0 || doy >= daysInYear.value) {
|
||||
out.push({
|
||||
valid: false,
|
||||
row,
|
||||
col,
|
||||
count: 0,
|
||||
ymd: '',
|
||||
highlights: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const d = new Date(Date.UTC(Number(props.year), 0, 1 + doy))
|
||||
const y = d.getUTCFullYear()
|
||||
const mo = String(d.getUTCMonth() + 1).padStart(2, '0')
|
||||
const da = String(d.getUTCDate()).padStart(2, '0')
|
||||
const ymd = `${y}-${mo}-${da}`
|
||||
|
||||
const highlights = highlightsMap.value.get(doy) || []
|
||||
const normalizedHighlights = Array.isArray(highlights) ? highlights : []
|
||||
|
||||
out.push({
|
||||
valid: true,
|
||||
row,
|
||||
col,
|
||||
doy,
|
||||
ymd,
|
||||
count: Number(counts.value[doy] || 0),
|
||||
highlights: normalizedHighlights
|
||||
})
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const colorFor = (cell) => {
|
||||
if (!cell || !cell.valid) return 'transparent'
|
||||
return heatColor(cell.count, maxValue.value)
|
||||
}
|
||||
|
||||
const tooltipOpen = ref(false)
|
||||
const tooltipCell = ref(null)
|
||||
const tooltipX = ref(0)
|
||||
const tooltipY = ref(0)
|
||||
const tooltipPlacement = ref('top') // 'top' | 'bottom'
|
||||
const tooltipEl = ref(null)
|
||||
const tooltipAnchorEl = ref(null)
|
||||
let tooltipRaf = 0
|
||||
|
||||
const tooltipPrimaryText = computed(() => {
|
||||
const c = tooltipCell.value
|
||||
if (!c || !c.valid) return ''
|
||||
const n = Number(c.count) || 0
|
||||
if (n <= 0) return '这一天没有聊天消息'
|
||||
return `这一天有 ${n} 条聊天消息`
|
||||
})
|
||||
|
||||
const tooltipHighlightLines = computed(() => {
|
||||
const c = tooltipCell.value
|
||||
if (!c || !c.valid) return []
|
||||
const hs = Array.isArray(c.highlights) ? c.highlights : []
|
||||
const out = []
|
||||
for (const h of hs) {
|
||||
if (!h) continue
|
||||
const label = String(h.label || h.key || '').trim()
|
||||
if (!label) continue
|
||||
const v = String(h.valueLabel || '').trim()
|
||||
out.push(v ? `${label}:${v}` : label)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const updateTooltipLayout = () => {
|
||||
if (!import.meta.client) return
|
||||
const anchor = tooltipAnchorEl.value
|
||||
const tip = tooltipEl.value
|
||||
if (!anchor || !tip) return
|
||||
|
||||
const a = anchor.getBoundingClientRect()
|
||||
const t = tip.getBoundingClientRect()
|
||||
if (!t.width || !t.height) return
|
||||
|
||||
const gap = 10
|
||||
const padding = 10
|
||||
|
||||
let left = a.left + a.width / 2 - t.width / 2
|
||||
left = Math.min(window.innerWidth - padding - t.width, Math.max(padding, left))
|
||||
|
||||
let top = a.top - gap - t.height
|
||||
let placement = 'top'
|
||||
if (top < padding) {
|
||||
top = a.bottom + gap
|
||||
placement = 'bottom'
|
||||
}
|
||||
|
||||
if (top + t.height > window.innerHeight - padding) {
|
||||
top = window.innerHeight - padding - t.height
|
||||
}
|
||||
|
||||
tooltipX.value = Math.round(left)
|
||||
tooltipY.value = Math.round(top)
|
||||
tooltipPlacement.value = placement
|
||||
}
|
||||
|
||||
const scheduleTooltipLayout = () => {
|
||||
if (!import.meta.client) return
|
||||
if (!tooltipOpen.value) return
|
||||
if (tooltipRaf) cancelAnimationFrame(tooltipRaf)
|
||||
tooltipRaf = requestAnimationFrame(() => {
|
||||
tooltipRaf = 0
|
||||
updateTooltipLayout()
|
||||
})
|
||||
}
|
||||
|
||||
const showTooltip = async (cell, e) => {
|
||||
if (!cell || !cell.valid || !cell.ymd) return
|
||||
tooltipCell.value = cell
|
||||
tooltipAnchorEl.value = e?.currentTarget || null
|
||||
tooltipOpen.value = true
|
||||
await nextTick()
|
||||
updateTooltipLayout()
|
||||
}
|
||||
|
||||
const hideTooltip = () => {
|
||||
tooltipOpen.value = false
|
||||
tooltipCell.value = null
|
||||
tooltipAnchorEl.value = null
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!import.meta.client) return
|
||||
window.addEventListener('resize', scheduleTooltipLayout)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) return
|
||||
window.removeEventListener('resize', scheduleTooltipLayout)
|
||||
if (tooltipRaf) cancelAnimationFrame(tooltipRaf)
|
||||
tooltipRaf = 0
|
||||
})
|
||||
|
||||
const legendColor = (i) => {
|
||||
const m = maxValue.value || 1
|
||||
const t = i / 6
|
||||
return heatColor(Math.max(1, t * m), m)
|
||||
}
|
||||
|
||||
const originFor = (cell) => {
|
||||
if (!cell) return 'center center'
|
||||
const col = Number(cell.col || 0)
|
||||
const row = Number(cell.row || 0)
|
||||
const x = col === 0 ? 'left' : (col === weeks.value - 1 ? 'right' : 'center')
|
||||
const y = row === 0 ? 'top' : (row === 6 ? 'bottom' : 'center')
|
||||
return `${x} ${y}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.wr-heatmap-tooltip {
|
||||
@apply relative w-[260px] max-w-[80vw] rounded-2xl border border-[#00000010] bg-[#F5F5F5]/95 backdrop-blur px-3 py-3 shadow-xl;
|
||||
}
|
||||
|
||||
.wr-heatmap-tooltip__time {
|
||||
@apply inline-flex items-center justify-center px-2 py-[2px] rounded-md border border-[#0000000a] bg-white/70 text-[10px] text-[#00000066];
|
||||
}
|
||||
|
||||
.wr-heatmap-tooltip__arrow {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.wr-heatmap-tooltip__arrow--bottom {
|
||||
bottom: -8px;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-top: 8px solid rgba(245, 245, 245, 0.95);
|
||||
filter: drop-shadow(0 1px 0 rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
|
||||
.wr-heatmap-tooltip__arrow--top {
|
||||
top: -8px;
|
||||
border-left: 8px solid transparent;
|
||||
border-right: 8px solid transparent;
|
||||
border-bottom: 8px solid rgba(245, 245, 245, 0.95);
|
||||
filter: drop-shadow(0 -1px 0 rgba(0, 0, 0, 0.06));
|
||||
}
|
||||
</style>
|
||||
@@ -1,271 +1,44 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="overview-card">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div>
|
||||
<div class="wrapped-body text-xs text-[#00000066]">Radar</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-6 sm:grid-cols-[280px_1fr] items-center">
|
||||
<div class="w-full max-w-[320px] mx-auto">
|
||||
<svg viewBox="0 0 220 220" class="w-full h-auto select-none">
|
||||
<!-- Grid -->
|
||||
<g>
|
||||
<polygon
|
||||
v-for="i in rings"
|
||||
:key="i"
|
||||
:points="gridPolygonPoints(i / rings)"
|
||||
fill="none"
|
||||
class="overview-grid-line"
|
||||
stroke-width="1"
|
||||
/>
|
||||
<line
|
||||
v-for="(p, idx) in axisPoints"
|
||||
:key="idx"
|
||||
:x1="cx"
|
||||
:y1="cy"
|
||||
:x2="p.x"
|
||||
:y2="p.y"
|
||||
class="overview-axis-line"
|
||||
stroke-width="1"
|
||||
/>
|
||||
</g>
|
||||
|
||||
<!-- Data polygon -->
|
||||
<polygon
|
||||
:points="dataPolygonPoints"
|
||||
class="overview-data-polygon"
|
||||
stroke-width="2"
|
||||
/>
|
||||
|
||||
<!-- Data nodes + tooltips -->
|
||||
<g>
|
||||
<circle
|
||||
v-for="(p, idx) in dataPoints"
|
||||
:key="idx"
|
||||
:cx="p.x"
|
||||
:cy="p.y"
|
||||
r="4"
|
||||
class="overview-data-node"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<title>{{ p.title }}</title>
|
||||
</circle>
|
||||
</g>
|
||||
|
||||
<!-- Labels -->
|
||||
<g>
|
||||
<text
|
||||
v-for="(l, idx) in labels"
|
||||
:key="idx"
|
||||
:x="l.x"
|
||||
:y="l.y"
|
||||
:text-anchor="l.anchor"
|
||||
dominant-baseline="middle"
|
||||
font-size="11"
|
||||
class="overview-label"
|
||||
>
|
||||
{{ l.label }}
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<div
|
||||
v-for="m in metrics"
|
||||
:key="m.key"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="wrapped-body text-sm text-[#00000099]">{{ m.name }}</div>
|
||||
<div class="flex items-center gap-3 min-w-[160px]">
|
||||
<div class="overview-progress-bg">
|
||||
<div class="overview-progress-fill" :style="{ width: Math.round(m.norm * 100) + '%' }" />
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'wrapped-number text-sm w-[74px] text-right',
|
||||
m.display === '—' ? 'text-[#00000055]' : 'text-[#07C160] font-semibold'
|
||||
]"
|
||||
>
|
||||
{{ m.display }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note removed per UI requirement (keep layout compact). -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AnnualCalendarHeatmap
|
||||
:year="year"
|
||||
:daily-counts="annualDailyCounts"
|
||||
:days="daysInYear"
|
||||
:highlights="annualHighlights"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import AnnualCalendarHeatmap from '~/components/wrapped/visualizations/AnnualCalendarHeatmap.vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Object, default: () => ({}) }
|
||||
})
|
||||
|
||||
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||
|
||||
const formatFloat = (n, digits = 1) => {
|
||||
const v = Number(n)
|
||||
if (!Number.isFinite(v)) return '0'
|
||||
return v.toFixed(digits)
|
||||
}
|
||||
|
||||
const clamp01 = (v) => Math.max(0, Math.min(1, Number(v) || 0))
|
||||
const logNorm = (v, maxLog) => {
|
||||
const n = Number(v) || 0
|
||||
const ml = Number(maxLog) || 1
|
||||
if (n <= 0) return 0
|
||||
return clamp01(Math.log10(1 + n) / ml)
|
||||
}
|
||||
|
||||
const totalMessages = computed(() => Number(props.data?.totalMessages || 0))
|
||||
const activeDays = computed(() => Number(props.data?.activeDays || 0))
|
||||
const messagesPerDay = computed(() => Number(props.data?.messagesPerDay || 0))
|
||||
|
||||
const topContactMessages = computed(() => Number(props.data?.topContact?.messages || 0))
|
||||
const topGroupMessages = computed(() => Number(props.data?.topGroup?.messages || 0))
|
||||
|
||||
const topKindPct = computed(() => {
|
||||
const ratio = Number(props.data?.topKind?.ratio || 0)
|
||||
if (!Number.isFinite(ratio) || ratio <= 0) return 0
|
||||
return Math.max(0, Math.min(100, Math.round(ratio * 100)))
|
||||
const year = computed(() => {
|
||||
const v = props.data?.annualHeatmap?.year ?? props.data?.year ?? new Date().getFullYear()
|
||||
const y = Number(v)
|
||||
return Number.isFinite(y) ? y : new Date().getFullYear()
|
||||
})
|
||||
|
||||
const metrics = computed(() => [
|
||||
{
|
||||
key: 'totalMessages',
|
||||
name: '发送消息',
|
||||
label: '发送',
|
||||
display: `${formatInt(totalMessages.value)} 条`,
|
||||
norm: logNorm(totalMessages.value, 6)
|
||||
},
|
||||
{
|
||||
key: 'activeDays',
|
||||
name: '发消息天数',
|
||||
label: '天数',
|
||||
display: `${formatInt(activeDays.value)}/365`,
|
||||
norm: clamp01(activeDays.value / 365)
|
||||
},
|
||||
{
|
||||
key: 'messagesPerDay',
|
||||
name: '日均发送',
|
||||
label: '日均',
|
||||
display: `${formatFloat(messagesPerDay.value, 1)} 条`,
|
||||
norm: logNorm(messagesPerDay.value, 3)
|
||||
},
|
||||
{
|
||||
key: 'topContactMessages',
|
||||
name: '发得最多的人',
|
||||
label: '常发',
|
||||
display: topContactMessages.value > 0 ? `${formatInt(topContactMessages.value)} 条` : '—',
|
||||
norm: logNorm(topContactMessages.value, 5)
|
||||
},
|
||||
{
|
||||
key: 'topGroupMessages',
|
||||
name: '发言最多的群',
|
||||
label: '发言',
|
||||
display: topGroupMessages.value > 0 ? `${formatInt(topGroupMessages.value)} 条` : '—',
|
||||
norm: logNorm(topGroupMessages.value, 5)
|
||||
},
|
||||
{
|
||||
key: 'topKindPct',
|
||||
name: '最常用表达',
|
||||
label: '表达',
|
||||
display: topKindPct.value > 0 ? `${topKindPct.value}%` : '—',
|
||||
norm: clamp01(topKindPct.value / 100)
|
||||
}
|
||||
])
|
||||
|
||||
const rings = 5
|
||||
const cx = 110
|
||||
const cy = 110
|
||||
const radius = 74
|
||||
|
||||
const axisPoints = computed(() => {
|
||||
const n = metrics.value.length
|
||||
return metrics.value.map((_, idx) => {
|
||||
const a = (Math.PI * 2 * idx) / n - Math.PI / 2
|
||||
return { x: cx + radius * Math.cos(a), y: cy + radius * Math.sin(a), a }
|
||||
})
|
||||
const daysInYear = computed(() => {
|
||||
const d = Number(props.data?.annualHeatmap?.days || 0)
|
||||
if (Number.isFinite(d) && d > 0) return d
|
||||
const y = Number(year.value)
|
||||
const isLeap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0)
|
||||
return isLeap ? 366 : 365
|
||||
})
|
||||
|
||||
const gridPolygonPoints = (t) => {
|
||||
const pts = axisPoints.value.map((p) => `${cx + (p.x - cx) * t},${cy + (p.y - cy) * t}`)
|
||||
return pts.join(' ')
|
||||
}
|
||||
|
||||
const dataPoints = computed(() => {
|
||||
const pts = []
|
||||
const n = metrics.value.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = metrics.value[i]
|
||||
const a = (Math.PI * 2 * i) / n - Math.PI / 2
|
||||
const r = radius * clamp01(m.norm)
|
||||
const x = cx + r * Math.cos(a)
|
||||
const y = cy + r * Math.sin(a)
|
||||
pts.push({ x, y, title: `${m.name}:${m.display}` })
|
||||
}
|
||||
return pts
|
||||
const annualDailyCounts = computed(() => {
|
||||
const a = props.data?.annualHeatmap
|
||||
const arr = a?.dailyCounts
|
||||
return Array.isArray(arr) ? arr : []
|
||||
})
|
||||
|
||||
const dataPolygonPoints = computed(() => dataPoints.value.map((p) => `${p.x},${p.y}`).join(' '))
|
||||
|
||||
const labels = computed(() => {
|
||||
const out = []
|
||||
const n = metrics.value.length
|
||||
for (let i = 0; i < n; i++) {
|
||||
const m = metrics.value[i]
|
||||
const a = (Math.PI * 2 * i) / n - Math.PI / 2
|
||||
const r = radius + 18
|
||||
const x = cx + r * Math.cos(a)
|
||||
const y = cy + r * Math.sin(a)
|
||||
const cos = Math.cos(a)
|
||||
let anchor = 'middle'
|
||||
if (cos > 0.35) anchor = 'start'
|
||||
else if (cos < -0.35) anchor = 'end'
|
||||
out.push({ x, y, label: m.label, anchor })
|
||||
}
|
||||
return out
|
||||
const annualHighlights = computed(() => {
|
||||
const a = props.data?.annualHeatmap
|
||||
const hs = a?.highlights
|
||||
return Array.isArray(hs) ? hs : []
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* ========== 基础样式 ========== */
|
||||
.overview-card {
|
||||
@apply rounded-2xl border border-[#00000010] bg-white/60 backdrop-blur p-4 sm:p-6;
|
||||
}
|
||||
|
||||
.overview-grid-line {
|
||||
stroke: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.overview-axis-line {
|
||||
stroke: rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
|
||||
.overview-data-polygon {
|
||||
fill: rgba(7, 193, 96, 0.18);
|
||||
stroke: rgba(7, 193, 96, 0.85);
|
||||
}
|
||||
|
||||
.overview-data-node {
|
||||
fill: #07C160;
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
.overview-label {
|
||||
fill: rgba(0, 0, 0, 0.70);
|
||||
}
|
||||
|
||||
.overview-progress-bg {
|
||||
@apply h-2 flex-1 rounded-full bg-[#0000000d] overflow-hidden;
|
||||
}
|
||||
|
||||
.overview-progress-fill {
|
||||
@apply h-full rounded-full bg-[#07C160];
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
<div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4">
|
||||
<div class="flex flex-col gap-3">
|
||||
<!-- Received (left) -->
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="avatar-box bg-white">
|
||||
<svg viewBox="0 0 24 24" class="w-4 h-4" fill="none" stroke="#07C160" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M8 3h10a2 2 0 0 1 2 2v14H6V5a2 2 0 0 1 2-2z" />
|
||||
<path d="M6 7H4a2 2 0 0 0-2 2v10h4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="bubble-left">
|
||||
<div class="px-3 py-2 text-sm max-w-[85%] relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-white text-gray-800 bubble-tail-l">
|
||||
<div class="wrapped-label text-xs text-[#00000066]">你收到的字</div>
|
||||
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]">
|
||||
{{ formatInt(receivedChars) }}
|
||||
@@ -28,8 +28,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Sent (right) -->
|
||||
<div class="flex items-end gap-2 justify-end">
|
||||
<div class="bubble-right">
|
||||
<div class="flex items-start gap-2 justify-end">
|
||||
<div class="px-3 py-2 text-sm max-w-[85%] relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r">
|
||||
<div class="wrapped-label text-xs text-[#00000080] text-right">你发送的字</div>
|
||||
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6] text-right">
|
||||
{{ formatInt(sentChars) }}
|
||||
@@ -321,40 +321,6 @@ const getLabelStyle = (code) => {
|
||||
@apply w-8 h-8 rounded-lg border border-[#00000010] flex items-center justify-center flex-shrink-0;
|
||||
}
|
||||
|
||||
/* 气泡 - 左侧 */
|
||||
.bubble-left {
|
||||
@apply relative max-w-[85%] bg-white shadow-sm rounded-xl px-3 py-2;
|
||||
}
|
||||
.bubble-left::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -6px;
|
||||
bottom: 8px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-right: 6px solid #fff;
|
||||
filter: drop-shadow(-1px 0 0 rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
/* 气泡 - 右侧 */
|
||||
.bubble-right {
|
||||
@apply relative max-w-[85%] bg-[#95EC69] shadow-sm rounded-xl px-3 py-2;
|
||||
}
|
||||
.bubble-right::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -6px;
|
||||
bottom: 8px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 6px solid transparent;
|
||||
border-bottom: 6px solid transparent;
|
||||
border-left: 6px solid #95EC69;
|
||||
filter: drop-shadow(1px 0 0 rgba(0,0,0,0.05));
|
||||
}
|
||||
|
||||
/* 键盘外框 */
|
||||
.keyboard-outer {
|
||||
@apply mt-3 rounded-2xl p-1;
|
||||
|
||||
+17
-29
@@ -179,9 +179,7 @@
|
||||
<div v-else-if="posts.length === 0" class="text-sm text-gray-400 py-16 text-center">暂无朋友圈数据</div>
|
||||
|
||||
<div v-if="!error && posts.length > 0" class="text-[11px] text-gray-500 mb-2 flex flex-wrap gap-x-3 gap-y-1">
|
||||
<span>已显示:{{ posts.length }}</span>
|
||||
<span v-if="selectedSnsUserInfo">缓存统计:{{ selectedSnsUserInfo.postCount || 0 }}</span>
|
||||
<span v-if="timelineSource">source: {{ timelineSource }}</span>
|
||||
<span v-if="!hasMore && !isLoading">(已到末尾)</span>
|
||||
</div>
|
||||
<div v-if="showSnsCountMismatchHint" class="text-[11px] text-amber-700 mb-3">
|
||||
@@ -247,30 +245,23 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 w-full" :class="{ 'privacy-blur': privacyMode }">
|
||||
<!-- 浏览器没有看微信视频号的环境,暂时不进行跳转!! -->
|
||||
<div class="block w-full bg-[#F7F7F7] p-2 rounded-sm">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0 flex items-center overflow-hidden h-12">
|
||||
<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{{ formatFinderFeedCardText(post) }}</div>
|
||||
</div>
|
||||
<div class="relative w-12 h-12 rounded-sm overflow-hidden flex-shrink-0 bg-white">
|
||||
<img
|
||||
v-if="getFinderFeedThumbSrc(post)"
|
||||
:src="getFinderFeedThumbSrc(post)"
|
||||
class="w-full h-full object-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="w-full h-full flex items-center justify-center bg-gray-200 text-gray-400 text-xs">
|
||||
视频
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="w-8 h-8 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="post.type === 28 && post.finderFeed && Object.keys(post.finderFeed).length > 0" class="mt-2 w-full max-w-[304px]" :class="{ 'privacy-blur': privacyMode }">
|
||||
<!-- 浏览器没有看微信视频号的环境,暂时不进行跳转 -->
|
||||
<div class="relative w-full overflow-hidden rounded-sm bg-[#F7F7F7]">
|
||||
<img
|
||||
v-if="getFinderFeedThumbSrc(post)"
|
||||
:src="getFinderFeedThumbSrc(post)"
|
||||
class="block w-full aspect-square object-cover"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
/>
|
||||
<div v-else class="w-full aspect-square flex items-center justify-center bg-gray-200">
|
||||
<span class="line-clamp-3 px-4 text-center text-[13px] leading-5 text-gray-500">{{ formatFinderFeedCardText(post) }}</span>
|
||||
</div>
|
||||
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
|
||||
<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -622,7 +613,6 @@ const seenPostIds = new Set()
|
||||
// NOTE: Backend `/api/sns/timeline` uses SQL OFFSET on the raw timeline rows.
|
||||
// The UI filters out some rows (e.g. type=7 cover), so `posts.length` must NOT be used as the next OFFSET.
|
||||
const timelineOffset = ref(0)
|
||||
const timelineSource = ref('')
|
||||
const hasMore = ref(true)
|
||||
// When timeline API reports `hasMore=false` but cached sidebar count indicates more, keep paging.
|
||||
// If we hit an empty page, stop trying to avoid infinite requests.
|
||||
@@ -1615,7 +1605,6 @@ const loadPosts = async ({ reset }) => {
|
||||
try {
|
||||
if (reset) {
|
||||
timelineOffset.value = 0
|
||||
timelineSource.value = ''
|
||||
hasMore.value = true
|
||||
cachePagingExhausted.value = false
|
||||
seenPostIds.clear()
|
||||
@@ -1633,7 +1622,6 @@ const loadPosts = async ({ reset }) => {
|
||||
offset,
|
||||
usernames: selectedSnsUser.value ? [String(selectedSnsUser.value).trim()] : []
|
||||
})
|
||||
timelineSource.value = String(resp?.source || '').trim()
|
||||
const items = Array.isArray(resp?.timeline) ? resp.timeline : []
|
||||
// Advance offset by the number of rows consumed by the backend.
|
||||
// When `hasMore` is true, the backend definitely scanned at least `limit` raw rows (even if it filtered some out).
|
||||
|
||||
@@ -163,8 +163,14 @@
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Card04MonthlyBestFriendsWall
|
||||
v-else-if="c && (c.kind === 'chat/monthly_best_friends_wall' || c.id === 4)"
|
||||
:card="c"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
/>
|
||||
<Card04EmojiUniverse
|
||||
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
|
||||
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 5)"
|
||||
:card="c"
|
||||
variant="slide"
|
||||
class="h-full w-full"
|
||||
@@ -199,7 +205,9 @@ const api = useApi()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const year = ref(Number(route.query?.year) || new Date().getFullYear())
|
||||
const queryYear = Number(route.query?.year)
|
||||
const defaultYear = new Date().getFullYear() - 1
|
||||
const year = ref(Number.isFinite(queryYear) ? queryYear : defaultYear)
|
||||
// 分享视图不展示账号信息:默认让后端自动选择;需要指定时可用 query ?account=wxid_xxx
|
||||
const account = ref(typeof route.query?.account === 'string' ? route.query.account : '')
|
||||
|
||||
@@ -459,9 +467,10 @@ const retryCard = async (cardId) => {
|
||||
await ensureCardLoaded(cardId)
|
||||
}
|
||||
|
||||
const reload = async (forceRefresh = false) => {
|
||||
const reload = async (forceRefresh = false, preserveIndex = false) => {
|
||||
const token = ++reportToken
|
||||
activeIndex.value = 0
|
||||
const keepIndex = preserveIndex ? activeIndex.value : 0
|
||||
if (!preserveIndex) activeIndex.value = 0
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
refreshCards.value = !!forceRefresh
|
||||
@@ -502,6 +511,15 @@ const reload = async (forceRefresh = false) => {
|
||||
}
|
||||
|
||||
availableYears.value = Array.isArray(resp?.availableYears) ? resp.availableYears : []
|
||||
|
||||
if (preserveIndex) {
|
||||
activeIndex.value = clampIndex(keepIndex)
|
||||
const cardIdx = Number(activeIndex.value) - 1
|
||||
if (cardIdx >= 0) {
|
||||
const id = Number(report.value?.cards?.[cardIdx]?.id)
|
||||
if (Number.isFinite(id)) void ensureCardLoaded(id)
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (token !== reportToken) return
|
||||
report.value = null
|
||||
@@ -576,7 +594,7 @@ watch(year, async (newYear, oldYear) => {
|
||||
year.value = oldYear
|
||||
return
|
||||
}
|
||||
await reload()
|
||||
await reload(false, true)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1204,29 +1204,24 @@ class SnsExportManager:
|
||||
ff = post.get("finderFeed") if isinstance(post.get("finderFeed"), dict) else {}
|
||||
thumb_url = str(ff.get("thumbUrl") or "").strip() if isinstance(ff, dict) else ""
|
||||
thumb_arc = export_external_thumb(thumb_url, kind="finder") if thumb_url else ""
|
||||
out.append('<div class="mt-2 w-full">')
|
||||
out.append('<div class="block w-full bg-[#F7F7F7] p-2 rounded-sm">')
|
||||
out.append('<div class="flex items-center gap-3">')
|
||||
out.append('<div class="flex-1 min-w-0 flex items-center overflow-hidden h-12">')
|
||||
out.append(
|
||||
f'<div class="text-[13px] text-gray-900 leading-tight line-clamp-2">{_esc_text(format_finder_feed_card_text(post))}</div>'
|
||||
)
|
||||
out.append("</div>")
|
||||
out.append('<div class="relative w-12 h-12 rounded-sm overflow-hidden flex-shrink-0 bg-white">')
|
||||
out.append('<div class="mt-2 w-full max-w-[304px]">')
|
||||
out.append('<div class="relative w-full overflow-hidden rounded-sm bg-[#F7F7F7]">')
|
||||
if thumb_arc:
|
||||
out.append(
|
||||
f'<img src="{_esc_attr(thumb_arc)}" class="w-full h-full object-cover" alt="" loading="lazy" referrerpolicy="no-referrer" />'
|
||||
f'<img src="{_esc_attr(thumb_arc)}" class="block w-full aspect-square object-cover" alt="" loading="lazy" referrerpolicy="no-referrer" />'
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
'<div class="w-full h-full flex items-center justify-center bg-gray-200 text-gray-400 text-xs">视频</div>'
|
||||
'<div class="w-full aspect-square flex items-center justify-center bg-gray-200">'
|
||||
f'<span class="line-clamp-3 px-4 text-center text-[13px] leading-5 text-gray-500">{_esc_text(format_finder_feed_card_text(post))}</span>'
|
||||
"</div>"
|
||||
)
|
||||
out.append('<div class="absolute inset-0 flex items-center justify-center pointer-events-none">')
|
||||
out.append('<div class="w-8 h-8 rounded-full bg-black/45 flex items-center justify-center">')
|
||||
out.append('<div class="w-12 h-12 rounded-full bg-black/45 flex items-center justify-center">')
|
||||
out.append(
|
||||
'<svg class="w-4 h-4 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>'
|
||||
'<svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>'
|
||||
)
|
||||
out.append("</div></div></div></div></div></div>")
|
||||
out.append("</div></div></div>")
|
||||
else:
|
||||
out.append(render_media_block(zf=zf, post=post))
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import sqlite3
|
||||
import time
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
@@ -28,6 +28,7 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
_MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}")
|
||||
_EMOJI_CHAR_RE = re.compile(r"[\U0001F300-\U0001FAFF\u2600-\u27BF]")
|
||||
# Best-effort heuristics for "new friends added" detection: WeChat system messages vary by version.
|
||||
_ADDED_FRIEND_PATTERNS: tuple[str, ...] = (
|
||||
"你已添加了",
|
||||
@@ -60,6 +61,13 @@ def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
|
||||
return start, end
|
||||
|
||||
|
||||
def _days_in_year(year: int) -> int:
|
||||
try:
|
||||
return int((datetime(int(year) + 1, 1, 1) - datetime(int(year), 1, 1)).days)
|
||||
except Exception:
|
||||
return 365
|
||||
|
||||
|
||||
def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
|
||||
try:
|
||||
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()
|
||||
@@ -76,6 +84,494 @@ def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
|
||||
return names
|
||||
|
||||
|
||||
def _accumulate_db_daily_counts(
|
||||
*,
|
||||
db_path: Path,
|
||||
start_ts: int,
|
||||
end_ts: int,
|
||||
counts: list[int],
|
||||
sender_username: str | None = None,
|
||||
) -> int:
|
||||
"""Accumulate per-day message counts from one message shard DB into counts list.
|
||||
|
||||
Returns the number of messages counted.
|
||||
"""
|
||||
|
||||
if not db_path.exists():
|
||||
return 0
|
||||
|
||||
conn: sqlite3.Connection | None = None
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
|
||||
tables = _list_message_tables(conn)
|
||||
if not tables:
|
||||
return 0
|
||||
|
||||
# Convert millisecond timestamps defensively.
|
||||
# The expression yields epoch seconds as INTEGER.
|
||||
ts_expr = (
|
||||
"CASE WHEN create_time > 1000000000000 THEN CAST(create_time/1000 AS INTEGER) ELSE create_time END"
|
||||
)
|
||||
|
||||
# Optional sender filter (best-effort). When provided, we only count
|
||||
# messages whose `real_sender_id` maps to `sender_username`.
|
||||
sender_rowid: int | None = None
|
||||
if sender_username and str(sender_username).strip():
|
||||
try:
|
||||
r = conn.execute(
|
||||
"SELECT rowid FROM Name2Id WHERE user_name = ? LIMIT 1",
|
||||
(str(sender_username).strip(),),
|
||||
).fetchone()
|
||||
if r is not None and r[0] is not None:
|
||||
sender_rowid = int(r[0])
|
||||
except Exception:
|
||||
sender_rowid = None
|
||||
|
||||
counted = 0
|
||||
for table_name in tables:
|
||||
qt = _quote_ident(table_name)
|
||||
sender_where = ""
|
||||
params: tuple[Any, ...]
|
||||
if sender_rowid is not None:
|
||||
sender_where = " AND real_sender_id = ?"
|
||||
params = (start_ts, end_ts, sender_rowid)
|
||||
else:
|
||||
params = (start_ts, end_ts)
|
||||
|
||||
sql = (
|
||||
"SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
"COUNT(1) AS cnt "
|
||||
"FROM ("
|
||||
f" SELECT {ts_expr} AS ts"
|
||||
f" FROM {qt}"
|
||||
f" WHERE {ts_expr} >= ? AND {ts_expr} < ?{sender_where}"
|
||||
") sub "
|
||||
"GROUP BY doy"
|
||||
)
|
||||
|
||||
try:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for doy, cnt in rows:
|
||||
try:
|
||||
d = int(doy if doy is not None else -1)
|
||||
c = int(cnt or 0)
|
||||
except Exception:
|
||||
continue
|
||||
if c <= 0 or d < 0 or d >= len(counts):
|
||||
continue
|
||||
counts[d] += c
|
||||
counted += c
|
||||
|
||||
return counted
|
||||
finally:
|
||||
try:
|
||||
if conn is not None:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def compute_annual_daily_counts(*, account_dir: Path, year: int, sender_username: str | None = None) -> list[int]:
|
||||
"""Compute per-day message counts for the given year.
|
||||
|
||||
The output is a 0-indexed day-of-year list (length 365/366). Counts default to
|
||||
"messages sent by me" when sender_username is provided.
|
||||
"""
|
||||
|
||||
start_ts, end_ts = _year_range_epoch_seconds(year)
|
||||
days = _days_in_year(year)
|
||||
counts: list[int] = [0 for _ in range(days)]
|
||||
|
||||
sender = str(sender_username or "").strip()
|
||||
|
||||
# Prefer using our unified search index if available; it's much faster than scanning all msg tables.
|
||||
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:
|
||||
# Convert millisecond timestamps defensively (some datasets store ms).
|
||||
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"
|
||||
)
|
||||
sender_clause = ""
|
||||
if sender:
|
||||
sender_clause = " AND sender_username = ?"
|
||||
|
||||
sql = (
|
||||
"SELECT "
|
||||
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
"COUNT(1) AS cnt "
|
||||
"FROM ("
|
||||
f" SELECT {ts_expr} AS ts"
|
||||
" FROM message_fts"
|
||||
f" WHERE {ts_expr} >= ? AND {ts_expr} < ?"
|
||||
" AND db_stem NOT LIKE 'biz_message%'"
|
||||
f"{sender_clause}"
|
||||
") sub "
|
||||
"GROUP BY doy"
|
||||
)
|
||||
|
||||
t0 = time.time()
|
||||
try:
|
||||
params: tuple[Any, ...] = (start_ts, end_ts)
|
||||
if sender:
|
||||
params = (start_ts, end_ts, sender)
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
except Exception:
|
||||
rows = []
|
||||
|
||||
total = 0
|
||||
for r in rows:
|
||||
if not r:
|
||||
continue
|
||||
try:
|
||||
doy = int(r[0] if r[0] is not None else -1)
|
||||
cnt = int(r[1] or 0)
|
||||
except Exception:
|
||||
continue
|
||||
if cnt <= 0 or doy < 0 or doy >= days:
|
||||
continue
|
||||
counts[doy] += cnt
|
||||
total += cnt
|
||||
|
||||
logger.info(
|
||||
"Wrapped annual heatmap computed (search index): account=%s year=%s total=%s sender=%s db=%s elapsed=%.2fs",
|
||||
str(account_dir.name or "").strip(),
|
||||
year,
|
||||
total,
|
||||
sender or "*",
|
||||
str(index_path.name),
|
||||
time.time() - t0,
|
||||
)
|
||||
|
||||
return counts
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
db_paths = _iter_message_db_paths(account_dir)
|
||||
# Default: exclude official/biz shards (biz_message*.db) to reduce noise.
|
||||
db_paths = [p for p in db_paths if not p.name.lower().startswith("biz_message")]
|
||||
my_wxid = str(account_dir.name or "").strip()
|
||||
t0 = time.time()
|
||||
total = 0
|
||||
for db_path in db_paths:
|
||||
total += _accumulate_db_daily_counts(
|
||||
db_path=db_path,
|
||||
start_ts=start_ts,
|
||||
end_ts=end_ts,
|
||||
counts=counts,
|
||||
sender_username=sender or None,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Wrapped annual heatmap computed: account=%s year=%s total=%s sender=%s dbs=%s elapsed=%.2fs",
|
||||
my_wxid,
|
||||
year,
|
||||
total,
|
||||
sender or "*",
|
||||
len(db_paths),
|
||||
time.time() - t0,
|
||||
)
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def _ymd_from_doy(*, year: int, doy: int) -> str:
|
||||
try:
|
||||
dt = datetime(int(year), 1, 1) + timedelta(days=int(doy))
|
||||
except Exception:
|
||||
return ""
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def _best_doy_by_max(values: list[int]) -> tuple[int, int] | tuple[None, int]:
|
||||
best_doy: int | None = None
|
||||
best = 0
|
||||
for i, v in enumerate(values):
|
||||
try:
|
||||
n = int(v or 0)
|
||||
except Exception:
|
||||
n = 0
|
||||
if n > best or (n == best and best_doy is not None and i < best_doy):
|
||||
best = n
|
||||
best_doy = i
|
||||
return best_doy, best
|
||||
|
||||
|
||||
def compute_annual_heatmap_highlights(
|
||||
*,
|
||||
account_dir: Path,
|
||||
year: int,
|
||||
sender_username: str,
|
||||
sent_daily_counts: list[int],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Compute special day highlights for the annual calendar heatmap (best-effort).
|
||||
|
||||
We prefer the unified search index when available; fallback mode returns the subset
|
||||
that can be derived from `sent_daily_counts` without scanning all message content.
|
||||
"""
|
||||
|
||||
days = int(len(sent_daily_counts) or _days_in_year(year))
|
||||
out: list[dict[str, Any]] = []
|
||||
|
||||
def add_highlight(
|
||||
*,
|
||||
key: str,
|
||||
label: str,
|
||||
doy: int,
|
||||
value: int | None = None,
|
||||
value_label: str = "",
|
||||
date: str = "",
|
||||
) -> None:
|
||||
try:
|
||||
d = int(doy)
|
||||
except Exception:
|
||||
return
|
||||
if d < 0 or d >= days:
|
||||
return
|
||||
ymd = str(date or "") or _ymd_from_doy(year=int(year), doy=d)
|
||||
if not ymd:
|
||||
return
|
||||
obj: dict[str, Any] = {
|
||||
"key": str(key),
|
||||
"label": str(label),
|
||||
"doy": int(d),
|
||||
"date": ymd,
|
||||
}
|
||||
if value is not None:
|
||||
try:
|
||||
obj["value"] = int(value)
|
||||
except Exception:
|
||||
pass
|
||||
if value_label:
|
||||
obj["valueLabel"] = str(value_label)
|
||||
out.append(obj)
|
||||
|
||||
# 3. Sent messages max day (always available from sent daily counts)
|
||||
best_doy, best_val = _best_doy_by_max(sent_daily_counts or [])
|
||||
if best_doy is not None and best_val > 0:
|
||||
add_highlight(key="sent_messages_max", label="发送消息条数最多的一天", doy=int(best_doy), value=int(best_val), value_label=f"{int(best_val)} 条")
|
||||
|
||||
sender = str(sender_username or "").strip()
|
||||
if not sender:
|
||||
return out
|
||||
|
||||
index_path = get_chat_search_index_db_path(account_dir)
|
||||
if not index_path.exists():
|
||||
return out
|
||||
|
||||
start_ts, end_ts = _year_range_epoch_seconds(year)
|
||||
|
||||
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 not has_fts:
|
||||
return out
|
||||
|
||||
# Convert millisecond timestamps defensively (some datasets store ms).
|
||||
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%'"
|
||||
base_params: tuple[Any, ...] = (start_ts, end_ts)
|
||||
|
||||
def fetch_best_doy_value(sql: str, params: tuple[Any, ...]) -> tuple[int, int] | None:
|
||||
try:
|
||||
r = conn.execute(sql, params).fetchone()
|
||||
except Exception:
|
||||
r = None
|
||||
if not r:
|
||||
return None
|
||||
try:
|
||||
doy = int(r[0] if r[0] is not None else -1)
|
||||
val = int(r[1] or 0)
|
||||
except Exception:
|
||||
return None
|
||||
if val <= 0 or doy < 0 or doy >= days:
|
||||
return None
|
||||
return doy, val
|
||||
|
||||
# 1. Sent chars max day (text messages only, non-whitespace approximation)
|
||||
char_expr = (
|
||||
"length(replace(replace(replace(replace(coalesce(text,''),' ',''), char(10), ''), char(13), ''), char(9), ''))"
|
||||
)
|
||||
sql_sent_chars = (
|
||||
"SELECT doy, chars FROM ("
|
||||
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
f" SUM({char_expr}) AS chars "
|
||||
" FROM ("
|
||||
f" SELECT {ts_expr} AS ts, text "
|
||||
" FROM message_fts "
|
||||
f" WHERE {base_where} AND sender_username = ? AND CAST(local_type AS INTEGER) = 1"
|
||||
" ) sub "
|
||||
" GROUP BY doy"
|
||||
") t ORDER BY chars DESC, doy ASC LIMIT 1"
|
||||
)
|
||||
r_sent_chars = fetch_best_doy_value(sql_sent_chars, base_params + (sender,))
|
||||
if r_sent_chars is not None:
|
||||
doy, v = r_sent_chars
|
||||
add_highlight(key="sent_chars_max", label="发送字最多的一天", doy=doy, value=v, value_label=f"{v} 字")
|
||||
|
||||
# 2. Received chars max day (text messages only)
|
||||
sql_recv_chars = (
|
||||
"SELECT doy, chars FROM ("
|
||||
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
f" SUM({char_expr}) AS chars "
|
||||
" FROM ("
|
||||
f" SELECT {ts_expr} AS ts, text "
|
||||
" FROM message_fts "
|
||||
f" WHERE {base_where} AND COALESCE(sender_username,'') != ? AND CAST(local_type AS INTEGER) = 1"
|
||||
" ) sub "
|
||||
" GROUP BY doy"
|
||||
") t ORDER BY chars DESC, doy ASC LIMIT 1"
|
||||
)
|
||||
r_recv_chars = fetch_best_doy_value(sql_recv_chars, base_params + (sender,))
|
||||
if r_recv_chars is not None:
|
||||
doy, v = r_recv_chars
|
||||
add_highlight(key="received_chars_max", label="接收字最多的一天", doy=doy, value=v, value_label=f"{v} 字")
|
||||
|
||||
# 4. Received message count max day (exclude system messages)
|
||||
sql_recv_msgs = (
|
||||
"SELECT doy, cnt FROM ("
|
||||
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
" COUNT(1) AS cnt "
|
||||
" FROM ("
|
||||
f" SELECT {ts_expr} AS ts "
|
||||
" FROM message_fts "
|
||||
f" WHERE {base_where} AND COALESCE(sender_username,'') != ? AND CAST(local_type AS INTEGER) != 10000"
|
||||
" ) sub "
|
||||
" GROUP BY doy"
|
||||
") t ORDER BY cnt DESC, doy ASC LIMIT 1"
|
||||
)
|
||||
r_recv_msgs = fetch_best_doy_value(sql_recv_msgs, base_params + (sender,))
|
||||
if r_recv_msgs is not None:
|
||||
doy, v = r_recv_msgs
|
||||
add_highlight(key="received_messages_max", label="接收消息条数最多的一天", doy=doy, value=v, value_label=f"{v} 条")
|
||||
|
||||
# 5. Added friends max day (best-effort via system message patterns, exclude official/chatrooms)
|
||||
added_like_patterns = [f"%{p}%" for p in _ADDED_FRIEND_PATTERNS if str(p or "").strip()]
|
||||
if added_like_patterns:
|
||||
cond_added = " OR ".join(["text LIKE ?"] * len(added_like_patterns))
|
||||
sql_added = (
|
||||
"SELECT doy, cnt FROM ("
|
||||
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
" COUNT(DISTINCT username) AS cnt "
|
||||
" FROM ("
|
||||
f" SELECT {ts_expr} AS ts, username, text "
|
||||
" FROM message_fts "
|
||||
f" WHERE {base_where} "
|
||||
" AND CAST(local_type AS INTEGER) = 10000 "
|
||||
" AND COALESCE(is_official, 0) = 0 "
|
||||
" AND username NOT LIKE '%@chatroom' "
|
||||
f" AND ({cond_added})"
|
||||
" ) sub "
|
||||
" GROUP BY doy"
|
||||
") t ORDER BY cnt DESC, doy ASC LIMIT 1"
|
||||
)
|
||||
params_added: tuple[Any, ...] = base_params + tuple(added_like_patterns)
|
||||
r_added = fetch_best_doy_value(sql_added, params_added)
|
||||
if r_added is not None:
|
||||
doy, v = r_added
|
||||
add_highlight(key="added_friends_max", label="加好友最多的一天", doy=doy, value=v, value_label=f"{v} 位")
|
||||
|
||||
# 6. Sticker/emoji messages max day (WeChat local_type=47)
|
||||
sql_emoji_msgs = (
|
||||
"SELECT doy, cnt FROM ("
|
||||
" SELECT CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
" COUNT(1) AS cnt "
|
||||
" FROM ("
|
||||
f" SELECT {ts_expr} AS ts "
|
||||
" FROM message_fts "
|
||||
f" WHERE {base_where} AND sender_username = ? AND CAST(local_type AS INTEGER) = 47"
|
||||
" ) sub "
|
||||
" GROUP BY doy"
|
||||
") t ORDER BY cnt DESC, doy ASC LIMIT 1"
|
||||
)
|
||||
r_emoji_msgs = fetch_best_doy_value(sql_emoji_msgs, base_params + (sender,))
|
||||
if r_emoji_msgs is not None:
|
||||
doy, v = r_emoji_msgs
|
||||
add_highlight(key="sticker_messages_max", label="发表情包最多的一天", doy=doy, value=v, value_label=f"{v} 条")
|
||||
|
||||
# 7. Unicode emoji chars max day (best-effort; count emoji codepoints in sent text)
|
||||
emoji_counts: list[int] = [0 for _ in range(days)]
|
||||
sql_emoji_text = (
|
||||
"SELECT doy, text FROM ("
|
||||
" SELECT "
|
||||
" CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) - 1 AS doy, "
|
||||
" text "
|
||||
" FROM ("
|
||||
f" SELECT {ts_expr} AS ts, text "
|
||||
" FROM message_fts "
|
||||
f" WHERE {base_where} AND sender_username = ? AND CAST(local_type AS INTEGER) = 1"
|
||||
" ) sub2"
|
||||
") sub "
|
||||
"WHERE text IS NOT NULL AND text != ''"
|
||||
)
|
||||
try:
|
||||
cur = conn.execute(sql_emoji_text, base_params + (sender,))
|
||||
except Exception:
|
||||
cur = None
|
||||
if cur is not None:
|
||||
for r in cur:
|
||||
try:
|
||||
doy = int(r[0] if r and r[0] is not None else -1)
|
||||
except Exception:
|
||||
continue
|
||||
if doy < 0 or doy >= days:
|
||||
continue
|
||||
try:
|
||||
txt = str(r[1] or "")
|
||||
except Exception:
|
||||
txt = ""
|
||||
if not txt:
|
||||
continue
|
||||
emoji_counts[doy] += len(_EMOJI_CHAR_RE.findall(txt))
|
||||
|
||||
best_emoji_doy, best_emoji = _best_doy_by_max(emoji_counts)
|
||||
if best_emoji_doy is not None and best_emoji > 0:
|
||||
add_highlight(
|
||||
key="emoji_chars_max",
|
||||
label="发emoji最多的一天",
|
||||
doy=int(best_emoji_doy),
|
||||
value=int(best_emoji),
|
||||
value_label=f"{int(best_emoji)} 个",
|
||||
)
|
||||
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _list_session_usernames(session_db_path: Path) -> list[str]:
|
||||
if not session_db_path.exists():
|
||||
return []
|
||||
@@ -769,6 +1265,22 @@ def build_card_00_global_overview(
|
||||
"action": "你还在微信里发送消息",
|
||||
}
|
||||
|
||||
daily_counts = compute_annual_daily_counts(account_dir=account_dir, year=year, sender_username=sender)
|
||||
annual_highlights = compute_annual_heatmap_highlights(
|
||||
account_dir=account_dir,
|
||||
year=year,
|
||||
sender_username=sender,
|
||||
sent_daily_counts=daily_counts,
|
||||
)
|
||||
annual_heatmap = {
|
||||
"year": int(year),
|
||||
"startDate": f"{int(year)}-01-01",
|
||||
"endDate": f"{int(year)}-12-31",
|
||||
"days": int(len(daily_counts)),
|
||||
"dailyCounts": daily_counts,
|
||||
"highlights": annual_highlights,
|
||||
}
|
||||
|
||||
lines: list[str] = []
|
||||
if heatmap.total_messages > 0:
|
||||
lines.append(f"今年以来,你在微信里发送了 {heatmap.total_messages:,} 条消息,平均每天 {messages_per_day:.1f} 条。")
|
||||
@@ -816,6 +1328,8 @@ def build_card_00_global_overview(
|
||||
"totalMessages": int(heatmap.total_messages),
|
||||
"activeDays": int(stats.active_days),
|
||||
"addedFriends": int(stats.added_friends),
|
||||
"sentMediaCount": int(stats.kind_counts.get("image", 0) + stats.kind_counts.get("video", 0)),
|
||||
"sentStickerCount": int(stats.kind_counts.get("emoji", 0)),
|
||||
"messagesPerDay": messages_per_day,
|
||||
"mostActiveHour": most_active_hour,
|
||||
"mostActiveWeekday": most_active_weekday,
|
||||
@@ -823,6 +1337,7 @@ def build_card_00_global_overview(
|
||||
"topContact": top_contact_obj,
|
||||
"topGroup": top_group_obj,
|
||||
"topKind": top_kind,
|
||||
"annualHeatmap": annual_heatmap,
|
||||
"topPhrase": {"phrase": stats.top_phrase[0], "count": int(stats.top_phrase[1])} if stats.top_phrase else None,
|
||||
"topEmoji": {"emoji": stats.top_emoji[0], "count": int(stats.top_emoji[1])} if stats.top_emoji else None,
|
||||
"highlight": highlight,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1254,7 +1254,7 @@ def build_card_04_emoji_universe(*, account_dir: Path, year: int) -> dict[str, A
|
||||
narrative = "".join(parts)
|
||||
|
||||
return {
|
||||
"id": 4,
|
||||
"id": 5,
|
||||
"title": "这一年,你的表情包里藏了多少心情?",
|
||||
"scope": "global",
|
||||
"category": "B",
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sqlite3
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ...chat_helpers import (
|
||||
_build_avatar_url,
|
||||
_load_contact_rows,
|
||||
_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]:
|
||||
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]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _MonthConvAgg:
|
||||
username: str
|
||||
month: int
|
||||
incoming: int = 0
|
||||
outgoing: int = 0
|
||||
replies: int = 0
|
||||
sum_gap: int = 0
|
||||
sum_gap_capped: int = 0
|
||||
active_days: set[int] = field(default_factory=set)
|
||||
time_bucket_mask: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return int(self.incoming) + int(self.outgoing)
|
||||
|
||||
@property
|
||||
def interaction(self) -> int:
|
||||
return min(int(self.incoming), int(self.outgoing))
|
||||
|
||||
@property
|
||||
def active_days_count(self) -> int:
|
||||
return len(self.active_days)
|
||||
|
||||
@property
|
||||
def time_bucket_count(self) -> int:
|
||||
m = int(self.time_bucket_mask) & 0xF
|
||||
return (m & 1) + ((m >> 1) & 1) + ((m >> 2) & 1) + ((m >> 3) & 1)
|
||||
|
||||
def avg_reply_seconds(self) -> float:
|
||||
if self.replies <= 0:
|
||||
return 0.0
|
||||
return float(self.sum_gap) / float(self.replies)
|
||||
|
||||
def avg_reply_seconds_capped(self) -> float:
|
||||
if self.replies <= 0:
|
||||
return 0.0
|
||||
return float(self.sum_gap_capped) / float(self.replies)
|
||||
|
||||
def observe(self, *, day: int, hour: int) -> None:
|
||||
if 1 <= day <= 31:
|
||||
self.active_days.add(int(day))
|
||||
bucket = max(0, min(3, int(hour) // 6))
|
||||
self.time_bucket_mask |= 1 << bucket
|
||||
|
||||
|
||||
def _score_month_agg(
|
||||
*,
|
||||
agg: _MonthConvAgg,
|
||||
month_max_interaction: int,
|
||||
month_max_active_days: int,
|
||||
tau_seconds: float,
|
||||
weights: dict[str, float],
|
||||
) -> dict[str, float]:
|
||||
max_interaction = max(1, int(month_max_interaction))
|
||||
max_active = max(1, int(month_max_active_days))
|
||||
interaction_score = math.log1p(float(agg.interaction)) / math.log1p(float(max_interaction))
|
||||
speed_score = 1.0 / (1.0 + (float(agg.avg_reply_seconds_capped()) / float(max(1.0, tau_seconds))))
|
||||
continuity_score = float(agg.active_days_count) / float(max_active)
|
||||
coverage_score = float(agg.time_bucket_count) / 4.0
|
||||
final_score = (
|
||||
float(weights["interaction"]) * interaction_score
|
||||
+ float(weights["speed"]) * speed_score
|
||||
+ float(weights["continuity"]) * continuity_score
|
||||
+ float(weights["coverage"]) * coverage_score
|
||||
)
|
||||
return {
|
||||
"interaction": float(interaction_score),
|
||||
"speed": float(speed_score),
|
||||
"continuity": float(continuity_score),
|
||||
"coverage": float(coverage_score),
|
||||
"final": float(final_score),
|
||||
}
|
||||
|
||||
|
||||
def compute_monthly_best_friends_wall_stats(*, account_dir: Path, year: int) -> dict[str, Any]:
|
||||
start_ts, end_ts = _year_range_epoch_seconds(int(year))
|
||||
my_username = str(account_dir.name or "").strip()
|
||||
|
||||
gap_cap_seconds = 6 * 60 * 60
|
||||
tau_seconds = 30 * 60
|
||||
weights = {
|
||||
"interaction": 0.40,
|
||||
"speed": 0.30,
|
||||
"continuity": 0.20,
|
||||
"coverage": 0.10,
|
||||
}
|
||||
eligibility = {
|
||||
"minTotalMessages": 8,
|
||||
"minInteraction": 3,
|
||||
"minReplyCount": 1,
|
||||
"minActiveDays": 2,
|
||||
}
|
||||
|
||||
per_month_aggs: dict[int, list[_MonthConvAgg]] = {m: [] for m in range(1, 13)}
|
||||
used_index = False
|
||||
index_status: dict[str, Any] | None = None
|
||||
|
||||
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'"
|
||||
)
|
||||
|
||||
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 = ""
|
||||
conv_month_aggs: dict[int, _MonthConvAgg] = {}
|
||||
prev_other_ts: int | None = None
|
||||
|
||||
def flush_conv() -> None:
|
||||
nonlocal cur_username, conv_month_aggs, prev_other_ts
|
||||
if not cur_username:
|
||||
return
|
||||
for m, agg in conv_month_aggs.items():
|
||||
if 1 <= int(m) <= 12 and agg.total > 0:
|
||||
per_month_aggs[int(m)].append(agg)
|
||||
conv_month_aggs = {}
|
||||
prev_other_ts = None
|
||||
|
||||
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_conv()
|
||||
cur_username = username
|
||||
|
||||
if not _should_keep_session(username, include_official=False):
|
||||
continue
|
||||
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
month = int(dt.month)
|
||||
if month < 1 or month > 12:
|
||||
continue
|
||||
agg = conv_month_aggs.get(month)
|
||||
if agg is None:
|
||||
agg = _MonthConvAgg(username=username, month=month)
|
||||
conv_month_aggs[month] = agg
|
||||
agg.observe(day=int(dt.day), hour=int(dt.hour))
|
||||
|
||||
is_me = sender == my_username
|
||||
if is_me:
|
||||
agg.outgoing += 1
|
||||
if prev_other_ts is not None and ts >= prev_other_ts:
|
||||
gap = int(ts - prev_other_ts)
|
||||
agg.replies += 1
|
||||
agg.sum_gap += gap
|
||||
agg.sum_gap_capped += min(gap, gap_cap_seconds)
|
||||
prev_other_ts = None
|
||||
else:
|
||||
agg.incoming += 1
|
||||
prev_other_ts = ts
|
||||
|
||||
flush_conv()
|
||||
|
||||
logger.info(
|
||||
"Wrapped card#4 monthly_best_friends computed (search index): account=%s year=%s elapsed=%.2fs",
|
||||
str(account_dir.name or "").strip(),
|
||||
int(year),
|
||||
time.time() - t0,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
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
|
||||
|
||||
month_winner_raw: dict[int, dict[str, Any]] = {}
|
||||
winner_usernames: list[str] = []
|
||||
for month in range(1, 13):
|
||||
aggs = list(per_month_aggs.get(month) or [])
|
||||
eligible: list[_MonthConvAgg] = []
|
||||
for agg in aggs:
|
||||
if agg.total < int(eligibility["minTotalMessages"]):
|
||||
continue
|
||||
if agg.interaction < int(eligibility["minInteraction"]):
|
||||
continue
|
||||
if agg.replies < int(eligibility["minReplyCount"]):
|
||||
continue
|
||||
if agg.active_days_count < int(eligibility["minActiveDays"]):
|
||||
continue
|
||||
eligible.append(agg)
|
||||
|
||||
if not eligible:
|
||||
continue
|
||||
|
||||
month_max_interaction = max(agg.interaction for agg in eligible)
|
||||
month_max_active_days = max(agg.active_days_count for agg in eligible)
|
||||
scored: list[tuple[tuple[float, float, float, float, str], _MonthConvAgg, dict[str, float]]] = []
|
||||
for agg in eligible:
|
||||
score = _score_month_agg(
|
||||
agg=agg,
|
||||
month_max_interaction=month_max_interaction,
|
||||
month_max_active_days=month_max_active_days,
|
||||
tau_seconds=float(tau_seconds),
|
||||
weights=weights,
|
||||
)
|
||||
tie_key = (
|
||||
-float(score["final"]),
|
||||
-float(agg.interaction),
|
||||
float(agg.avg_reply_seconds_capped()),
|
||||
-float(agg.active_days_count),
|
||||
str(agg.username),
|
||||
)
|
||||
scored.append((tie_key, agg, score))
|
||||
scored.sort(key=lambda x: x[0])
|
||||
_, winner_agg, winner_score = scored[0]
|
||||
month_winner_raw[month] = {
|
||||
"agg": winner_agg,
|
||||
"score": winner_score,
|
||||
}
|
||||
winner_usernames.append(winner_agg.username)
|
||||
|
||||
uniq_winner_usernames: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for u in winner_usernames:
|
||||
if u and u not in seen:
|
||||
seen.add(u)
|
||||
uniq_winner_usernames.append(u)
|
||||
|
||||
contact_rows = _load_contact_rows(account_dir / "contact.db", uniq_winner_usernames) if uniq_winner_usernames else {}
|
||||
|
||||
months: list[dict[str, Any]] = []
|
||||
for month in range(1, 13):
|
||||
winner_pack = month_winner_raw.get(month)
|
||||
if not winner_pack:
|
||||
months.append(
|
||||
{
|
||||
"month": month,
|
||||
"winner": None,
|
||||
"metrics": None,
|
||||
"raw": None,
|
||||
"isFallback": False,
|
||||
"reason": "insufficient_data",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
agg: _MonthConvAgg = winner_pack["agg"]
|
||||
score = dict(winner_pack["score"] or {})
|
||||
row = contact_rows.get(agg.username)
|
||||
display = _pick_display_name(row, agg.username)
|
||||
avatar = _build_avatar_url(str(account_dir.name or ""), agg.username) if agg.username else ""
|
||||
|
||||
months.append(
|
||||
{
|
||||
"month": month,
|
||||
"winner": {
|
||||
"username": agg.username,
|
||||
"displayName": display,
|
||||
"maskedName": _mask_name(display),
|
||||
"avatarUrl": avatar,
|
||||
"score": float(score.get("final") or 0.0),
|
||||
"score100": round(float(score.get("final") or 0.0) * 100.0, 1),
|
||||
},
|
||||
"metrics": {
|
||||
"interactionScore": float(score.get("interaction") or 0.0),
|
||||
"speedScore": float(score.get("speed") or 0.0),
|
||||
"continuityScore": float(score.get("continuity") or 0.0),
|
||||
"coverageScore": float(score.get("coverage") or 0.0),
|
||||
},
|
||||
"raw": {
|
||||
"incomingMessages": int(agg.incoming),
|
||||
"outgoingMessages": int(agg.outgoing),
|
||||
"totalMessages": int(agg.total),
|
||||
"interaction": int(agg.interaction),
|
||||
"replyCount": int(agg.replies),
|
||||
"avgReplySeconds": float(agg.avg_reply_seconds()),
|
||||
"avgReplySecondsCapped": float(agg.avg_reply_seconds_capped()),
|
||||
"activeDays": int(agg.active_days_count),
|
||||
"timeBucketsCount": int(agg.time_bucket_count),
|
||||
},
|
||||
"isFallback": False,
|
||||
}
|
||||
)
|
||||
|
||||
winner_month_counts: dict[str, int] = {}
|
||||
for item in months:
|
||||
w = item.get("winner")
|
||||
if not isinstance(w, dict):
|
||||
continue
|
||||
u = str(w.get("username") or "").strip()
|
||||
if not u:
|
||||
continue
|
||||
winner_month_counts[u] = int(winner_month_counts.get(u, 0)) + 1
|
||||
|
||||
top_champion = None
|
||||
if winner_month_counts:
|
||||
champion_username = sorted(winner_month_counts.items(), key=lambda kv: (-int(kv[1]), str(kv[0])))[0][0]
|
||||
champion_months = int(winner_month_counts.get(champion_username) or 0)
|
||||
row = contact_rows.get(champion_username)
|
||||
display = _pick_display_name(row, champion_username)
|
||||
top_champion = {
|
||||
"username": champion_username,
|
||||
"displayName": display,
|
||||
"maskedName": _mask_name(display),
|
||||
"monthsWon": champion_months,
|
||||
}
|
||||
|
||||
filled_months = [int(x.get("month") or 0) for x in months if isinstance(x.get("winner"), dict)]
|
||||
|
||||
return {
|
||||
"year": int(year),
|
||||
"months": months,
|
||||
"summary": {
|
||||
"monthsWithWinner": int(len(filled_months)),
|
||||
"topChampion": top_champion,
|
||||
"filledMonths": filled_months,
|
||||
},
|
||||
"settings": {
|
||||
"weights": {
|
||||
"interaction": float(weights["interaction"]),
|
||||
"speed": float(weights["speed"]),
|
||||
"continuity": float(weights["continuity"]),
|
||||
"coverage": float(weights["coverage"]),
|
||||
},
|
||||
"tauSeconds": int(tau_seconds),
|
||||
"gapCapSeconds": int(gap_cap_seconds),
|
||||
"eligibility": {
|
||||
"minTotalMessages": int(eligibility["minTotalMessages"]),
|
||||
"minInteraction": int(eligibility["minInteraction"]),
|
||||
"minReplyCount": int(eligibility["minReplyCount"]),
|
||||
"minActiveDays": int(eligibility["minActiveDays"]),
|
||||
},
|
||||
"usedIndex": bool(used_index),
|
||||
"indexStatus": index_status,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def build_card_04_monthly_best_friends_wall(*, account_dir: Path, year: int) -> dict[str, Any]:
|
||||
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=year)
|
||||
summary = dict(data.get("summary") or {})
|
||||
top_champion = summary.get("topChampion")
|
||||
months_with_winner = int(summary.get("monthsWithWinner") or 0)
|
||||
|
||||
if months_with_winner <= 0:
|
||||
narrative = "今年还没有足够的聊天互动数据来评选每月最佳好友(或搜索索引尚未就绪)。"
|
||||
elif isinstance(top_champion, dict) and top_champion.get("displayName"):
|
||||
champ_name = str(top_champion.get("displayName") or "")
|
||||
months_won = int(top_champion.get("monthsWon") or 0)
|
||||
narrative = f"{champ_name} 拿下了 {months_won} 个月的月度最佳好友;这一年你们的聊天默契很稳定。"
|
||||
else:
|
||||
narrative = f"你在 {months_with_winner} 个月里都出现了稳定的“月度最佳好友”。"
|
||||
|
||||
return {
|
||||
"id": 4,
|
||||
"title": "陪你走过每个月的人",
|
||||
"scope": "global",
|
||||
"category": "B",
|
||||
"status": "ok",
|
||||
"kind": "chat/monthly_best_friends_wall",
|
||||
"narrative": narrative,
|
||||
"data": data,
|
||||
}
|
||||
@@ -16,6 +16,7 @@ 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_02_message_chars import build_card_02_message_chars
|
||||
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
||||
from .cards.card_04_monthly_best_friends_wall import build_card_04_monthly_best_friends_wall
|
||||
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -23,9 +24,9 @@ logger = get_logger(__name__)
|
||||
|
||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||
# an older partial cache.
|
||||
_IMPLEMENTED_UPTO_ID = 4
|
||||
_IMPLEMENTED_UPTO_ID = 5
|
||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||
_CACHE_VERSION = 15
|
||||
_CACHE_VERSION = 18
|
||||
|
||||
|
||||
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
||||
@@ -61,6 +62,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"title": "这一年,每个月谁最懂你?",
|
||||
"scope": "global",
|
||||
"category": "B",
|
||||
"kind": "chat/monthly_best_friends_wall",
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"title": "这一年,你的表情包里藏了多少心情?",
|
||||
"scope": "global",
|
||||
"category": "B",
|
||||
@@ -282,7 +290,7 @@ def build_wrapped_annual_response(
|
||||
) -> dict[str, Any]:
|
||||
"""Build annual wrapped response for the given account/year.
|
||||
|
||||
For now we implement cards up to id=4 (plus a meta overview card id=0).
|
||||
For now we implement cards up to id=5 (plus a meta overview card id=0).
|
||||
"""
|
||||
|
||||
account_dir = _resolve_account_dir(account)
|
||||
@@ -325,7 +333,9 @@ def build_wrapped_annual_response(
|
||||
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))
|
||||
# Page 6: annual emoji universe / meme almanac.
|
||||
# Page 6: monthly best friends wall (photo wall).
|
||||
cards.append(build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y))
|
||||
# Page 7: annual emoji universe / meme almanac.
|
||||
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
|
||||
|
||||
obj: dict[str, Any] = {
|
||||
@@ -519,6 +529,8 @@ def build_wrapped_annual_card(
|
||||
elif cid == 3:
|
||||
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||
elif cid == 4:
|
||||
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
|
||||
elif cid == 5:
|
||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||
else:
|
||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||
|
||||
@@ -659,7 +659,7 @@ class TestWrappedEmojiUniverse(unittest.TestCase):
|
||||
self._seed_session_db(account_dir / "session.db", usernames=[])
|
||||
|
||||
card = build_card_04_emoji_universe(account_dir=account_dir, year=2025)
|
||||
self.assertEqual(card["id"], 4)
|
||||
self.assertEqual(card["id"], 5)
|
||||
self.assertEqual(card["status"], "ok")
|
||||
self.assertEqual(card["data"]["sentStickerCount"], 0)
|
||||
self.assertIn("几乎没用表情表达", card["narrative"])
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import sqlite3
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
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 TestWrappedMonthlyBestFriends(unittest.TestCase):
|
||||
def _ts(self, y: int, m: int, d: int, hh: int, mm: int, ss: int) -> int:
|
||||
return int(datetime(y, m, d, hh, mm, ss).timestamp())
|
||||
|
||||
def _seed_contact_db(self, path: Path, usernames: list[str]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS contact (
|
||||
username TEXT PRIMARY KEY,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
for u in usernames:
|
||||
conn.execute(
|
||||
"INSERT INTO contact(username, nick_name) VALUES(?, ?)",
|
||||
(u, f"Nick_{u}"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def _seed_index_db(self, path: Path, rows: list[dict]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_fts (
|
||||
username TEXT,
|
||||
sender_username TEXT,
|
||||
create_time INTEGER,
|
||||
sort_seq INTEGER,
|
||||
local_id INTEGER,
|
||||
local_type INTEGER,
|
||||
db_stem TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
for r in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO message_fts(
|
||||
username, sender_username, create_time, sort_seq, local_id, local_type, db_stem
|
||||
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
r["username"],
|
||||
r["sender_username"],
|
||||
int(r["create_time"]),
|
||||
int(r["sort_seq"]),
|
||||
int(r["local_id"]),
|
||||
int(r.get("local_type", 1)),
|
||||
str(r.get("db_stem", "message_0")),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_balanced_profile_can_beat_higher_volume(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||
compute_monthly_best_friends_wall_stats,
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account = "wxid_me"
|
||||
account_dir = Path(td) / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
user_volume = "wxid_volume"
|
||||
user_balanced = "wxid_balanced"
|
||||
self._seed_contact_db(account_dir / "contact.db", [user_volume, user_balanced])
|
||||
|
||||
rows: list[dict] = []
|
||||
lid = 1
|
||||
# High-volume user: more messages but consistently slow replies and low continuity.
|
||||
for d in [3, 18]:
|
||||
for i in range(6):
|
||||
t = self._ts(2025, 1, d, 21, i * 3, 0)
|
||||
rows.append(
|
||||
{
|
||||
"username": user_volume,
|
||||
"sender_username": user_volume,
|
||||
"create_time": t,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
rows.append(
|
||||
{
|
||||
"username": user_volume,
|
||||
"sender_username": account,
|
||||
"create_time": t + 7200,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
|
||||
# Balanced user: slightly fewer interactions, but much faster and spread over more days/hours.
|
||||
day_hour = [
|
||||
(2, 1),
|
||||
(6, 8),
|
||||
(9, 13),
|
||||
(13, 19),
|
||||
(20, 10),
|
||||
(24, 22),
|
||||
(27, 7),
|
||||
(29, 16),
|
||||
(30, 12),
|
||||
(31, 20),
|
||||
]
|
||||
for d, hh in day_hour:
|
||||
t = self._ts(2025, 1, d, hh, 10, 0)
|
||||
rows.append(
|
||||
{
|
||||
"username": user_balanced,
|
||||
"sender_username": user_balanced,
|
||||
"create_time": t,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
rows.append(
|
||||
{
|
||||
"username": user_balanced,
|
||||
"sender_username": account,
|
||||
"create_time": t + 20,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows)
|
||||
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=2025)
|
||||
jan = data["months"][0]
|
||||
self.assertIsNotNone(jan["winner"])
|
||||
self.assertEqual(jan["winner"]["username"], user_balanced)
|
||||
|
||||
def test_allows_consecutive_month_wins(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||
compute_monthly_best_friends_wall_stats,
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account = "wxid_me"
|
||||
account_dir = Path(td) / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
buddy = "wxid_best"
|
||||
self._seed_contact_db(account_dir / "contact.db", [buddy])
|
||||
|
||||
rows: list[dict] = []
|
||||
lid = 1
|
||||
for month in [1, 2]:
|
||||
for d in [3, 8, 12, 18]:
|
||||
t = self._ts(2025, month, d, 12, 0, 0)
|
||||
rows.append(
|
||||
{
|
||||
"username": buddy,
|
||||
"sender_username": buddy,
|
||||
"create_time": t,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
rows.append(
|
||||
{
|
||||
"username": buddy,
|
||||
"sender_username": account,
|
||||
"create_time": t + 30,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows)
|
||||
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=2025)
|
||||
jan = data["months"][0]
|
||||
feb = data["months"][1]
|
||||
self.assertEqual(jan["winner"]["username"], buddy)
|
||||
self.assertEqual(feb["winner"]["username"], buddy)
|
||||
|
||||
def test_month_without_enough_activity_is_empty(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||
compute_monthly_best_friends_wall_stats,
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account = "wxid_me"
|
||||
account_dir = Path(td) / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
user = "wxid_low"
|
||||
self._seed_contact_db(account_dir / "contact.db", [user])
|
||||
|
||||
rows = []
|
||||
lid = 1
|
||||
# Only 3 reply pairs in March -> total 6 messages, below minTotalMessages=8.
|
||||
for d in [5, 11, 25]:
|
||||
t = self._ts(2025, 3, d, 10, 0, 0)
|
||||
rows.append(
|
||||
{
|
||||
"username": user,
|
||||
"sender_username": user,
|
||||
"create_time": t,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
rows.append(
|
||||
{
|
||||
"username": user,
|
||||
"sender_username": account,
|
||||
"create_time": t + 40,
|
||||
"sort_seq": lid,
|
||||
"local_id": lid,
|
||||
}
|
||||
)
|
||||
lid += 1
|
||||
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", rows)
|
||||
data = compute_monthly_best_friends_wall_stats(account_dir=account_dir, year=2025)
|
||||
march = data["months"][2]
|
||||
self.assertIsNone(march["winner"])
|
||||
self.assertEqual(march["reason"], "insufficient_data")
|
||||
|
||||
def test_card_shape_and_kind(self):
|
||||
from wechat_decrypt_tool.wrapped.cards.card_04_monthly_best_friends_wall import (
|
||||
build_card_04_monthly_best_friends_wall,
|
||||
)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account = "wxid_me"
|
||||
account_dir = Path(td) / account
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
self._seed_contact_db(account_dir / "contact.db", [])
|
||||
self._seed_index_db(account_dir / "chat_search_index.db", [])
|
||||
|
||||
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=2025)
|
||||
self.assertEqual(card["id"], 4)
|
||||
self.assertEqual(card["kind"], "chat/monthly_best_friends_wall")
|
||||
self.assertEqual(card["status"], "ok")
|
||||
self.assertEqual(len(card["data"]["months"]), 12)
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user