improvement(wrapped): 全局概览改为年度日历热力图

- card_00_global_overview 输出 annualHeatmap(dailyCounts + highlights)

- 新增 AnnualCalendarHeatmap:横向滚动网格 + 气泡 tooltip + 高光日文案

- GlobalOverviewChart 从 Radar 重构为 Heatmap;Card00 slide 下微调间距

- MessageCharsChart 复用 msg-bubble 样式,统一气泡外观
This commit is contained in:
2977094657
2026-02-19 20:00:21 +08:00
parent 4c9260b781
commit 02bbf9d8e2
5 changed files with 966 additions and 293 deletions

View File

@@ -98,7 +98,9 @@
</div> </div>
</template> </template>
<div :class="variant === 'slide' ? 'w-full -mt-2 sm:-mt-4' : 'w-full'">
<GlobalOverviewChart :data="card.data || {}" /> <GlobalOverviewChart :data="card.data || {}" />
</div>
</WrappedCardShell> </WrappedCardShell>
</template> </template>

View File

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

View File

@@ -1,271 +1,44 @@
<template> <template>
<div class="w-full"> <div class="w-full">
<div class="overview-card"> <AnnualCalendarHeatmap
<div class="flex items-center justify-between gap-4"> :year="year"
<div class="wrapped-label text-xs text-[#00000066]">年度聊天画像</div> :daily-counts="annualDailyCounts"
<div class="wrapped-body text-xs text-[#00000066]">Radar</div> :days="daysInYear"
</div> :highlights="annualHighlights"
<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>
</div> </div>
</template> </template>
<script setup> <script setup>
import AnnualCalendarHeatmap from '~/components/wrapped/visualizations/AnnualCalendarHeatmap.vue'
const props = defineProps({ const props = defineProps({
data: { type: Object, default: () => ({}) } data: { type: Object, default: () => ({}) }
}) })
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 }) const year = computed(() => {
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0)) const v = props.data?.annualHeatmap?.year ?? props.data?.year ?? new Date().getFullYear()
const y = Number(v)
const formatFloat = (n, digits = 1) => { return Number.isFinite(y) ? y : new Date().getFullYear()
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 metrics = computed(() => [ const daysInYear = computed(() => {
{ const d = Number(props.data?.annualHeatmap?.days || 0)
key: 'totalMessages', if (Number.isFinite(d) && d > 0) return d
name: '发送消息', const y = Number(year.value)
label: '发送', const isLeap = y % 4 === 0 && (y % 100 !== 0 || y % 400 === 0)
display: `${formatInt(totalMessages.value)}`, return isLeap ? 366 : 365
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 gridPolygonPoints = (t) => { const annualDailyCounts = computed(() => {
const pts = axisPoints.value.map((p) => `${cx + (p.x - cx) * t},${cy + (p.y - cy) * t}`) const a = props.data?.annualHeatmap
return pts.join(' ') const arr = a?.dailyCounts
} return Array.isArray(arr) ? arr : []
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 dataPolygonPoints = computed(() => dataPoints.value.map((p) => `${p.x},${p.y}`).join(' ')) const annualHighlights = computed(() => {
const a = props.data?.annualHeatmap
const labels = computed(() => { const hs = a?.highlights
const out = [] return Array.isArray(hs) ? hs : []
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
}) })
</script> </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>

View File

@@ -4,14 +4,14 @@
<div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4"> <div class="rounded-2xl border border-[#00000010] bg-[#F5F5F5] p-3 sm:p-4">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
<!-- Received (left) --> <!-- Received (left) -->
<div class="flex items-end gap-2"> <div class="flex items-start gap-2">
<div class="avatar-box bg-white"> <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"> <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="M8 3h10a2 2 0 0 1 2 2v14H6V5a2 2 0 0 1 2-2z" />
<path d="M6 7H4a2 2 0 0 0-2 2v10h4" /> <path d="M6 7H4a2 2 0 0 0-2 2v10h4" />
</svg> </svg>
</div> </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="wrapped-label text-xs text-[#00000066]">你收到的字</div>
<div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]"> <div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6]">
{{ formatInt(receivedChars) }} {{ formatInt(receivedChars) }}
@@ -28,8 +28,8 @@
</div> </div>
<!-- Sent (right) --> <!-- Sent (right) -->
<div class="flex items-end gap-2 justify-end"> <div class="flex items-start gap-2 justify-end">
<div class="bubble-right"> <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="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"> <div class="mt-0.5 wrapped-number text-xl sm:text-2xl text-[#000000e6] text-right">
{{ formatInt(sentChars) }} {{ 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; @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 { .keyboard-outer {
@apply mt-3 rounded-2xl p-1; @apply mt-3 rounded-2xl p-1;

View File

@@ -6,7 +6,7 @@ import sqlite3
import time import time
from collections import Counter from collections import Counter
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@@ -28,6 +28,7 @@ logger = get_logger(__name__)
_MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}") _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. # Best-effort heuristics for "new friends added" detection: WeChat system messages vary by version.
_ADDED_FRIEND_PATTERNS: tuple[str, ...] = ( _ADDED_FRIEND_PATTERNS: tuple[str, ...] = (
"你已添加了", "你已添加了",
@@ -60,6 +61,13 @@ def _year_range_epoch_seconds(year: int) -> tuple[int, int]:
return start, end 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]: def _list_message_tables(conn: sqlite3.Connection) -> list[str]:
try: try:
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall() 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 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]: def _list_session_usernames(session_db_path: Path) -> list[str]:
if not session_db_path.exists(): if not session_db_path.exists():
return [] return []
@@ -769,6 +1265,22 @@ def build_card_00_global_overview(
"action": "你还在微信里发送消息", "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] = [] lines: list[str] = []
if heatmap.total_messages > 0: if heatmap.total_messages > 0:
lines.append(f"今年以来,你在微信里发送了 {heatmap.total_messages:,} 条消息,平均每天 {messages_per_day:.1f} 条。") 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), "totalMessages": int(heatmap.total_messages),
"activeDays": int(stats.active_days), "activeDays": int(stats.active_days),
"addedFriends": int(stats.added_friends), "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, "messagesPerDay": messages_per_day,
"mostActiveHour": most_active_hour, "mostActiveHour": most_active_hour,
"mostActiveWeekday": most_active_weekday, "mostActiveWeekday": most_active_weekday,
@@ -823,6 +1337,7 @@ def build_card_00_global_overview(
"topContact": top_contact_obj, "topContact": top_contact_obj,
"topGroup": top_group_obj, "topGroup": top_group_obj,
"topKind": top_kind, "topKind": top_kind,
"annualHeatmap": annual_heatmap,
"topPhrase": {"phrase": stats.top_phrase[0], "count": int(stats.top_phrase[1])} if stats.top_phrase else None, "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, "topEmoji": {"emoji": stats.top_emoji[0], "count": int(stats.top_emoji[1])} if stats.top_emoji else None,
"highlight": highlight, "highlight": highlight,