mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(wrapped): 新增梗图年鉴(Emoji Universe)卡片
- 后端新增 card_04_emoji_universe:统计表情包/emoji 使用与画像 - 前端新增 Card04EmojiUniverse + VueBits Stack/ImageTrail 交互展示 - 更新 Wrapped manifest/Hero 预览与用例覆盖
This commit is contained in:
793
frontend/components/wrapped/cards/Card04EmojiUniverse.vue
Normal file
793
frontend/components/wrapped/cards/Card04EmojiUniverse.vue
Normal file
@@ -0,0 +1,793 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="cardRoot" class="h-full w-full">
|
||||||
|
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
|
||||||
|
<template #narrative>
|
||||||
|
<div class="mt-1 wrapped-body text-sm sm:text-base text-[#6F6F6F] leading-relaxed">
|
||||||
|
<p class="whitespace-normal">
|
||||||
|
<template v-for="(seg, idx) in narrativeSegments" :key="`n-${idx}`">
|
||||||
|
<img
|
||||||
|
v-if="seg.type === 'emoji'"
|
||||||
|
:src="seg.src"
|
||||||
|
class="inline-block align-[-0.18em] rounded-[3px] wx-inline-emoji"
|
||||||
|
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
|
||||||
|
:alt="seg.alt || 'emoji'"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
v-else-if="seg.type === 'sticker'"
|
||||||
|
:src="seg.src"
|
||||||
|
class="inline-block align-[-0.16em] rounded-[4px] wx-inline-emoji"
|
||||||
|
:style="{ width: `${seg.sizeEm}em`, height: `${seg.sizeEm}em` }"
|
||||||
|
:alt="seg.alt || 'sticker'"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-else-if="seg.type === 'num'"
|
||||||
|
class="wrapped-number text-[#07C160] font-semibold"
|
||||||
|
>
|
||||||
|
{{ seg.content }}
|
||||||
|
</span>
|
||||||
|
<span v-else>{{ seg.content }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="w-full -mt-1 sm:-mt-2">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-2">
|
||||||
|
<div class="lg:col-span-7 space-y-2 sm:space-y-2.5">
|
||||||
|
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
|
||||||
|
<div class="wrapped-label text-xs text-[#00000066]">高频表情卡堆(Vue Bits)</div>
|
||||||
|
<div v-if="stackCardsData.length > 0" class="mt-2">
|
||||||
|
<div class="relative h-[9.4rem] sm:h-[9.8rem] rounded-xl overflow-visible">
|
||||||
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Stack
|
||||||
|
:randomRotation="true"
|
||||||
|
:sensitivity="180"
|
||||||
|
:sendToBackOnClick="false"
|
||||||
|
:cardDimensions="stackCardDimensions"
|
||||||
|
:cardsData="stackCardsData"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex flex-col items-center justify-center text-center">
|
||||||
|
<div class="wrapped-number text-base text-[#07C160] font-semibold leading-tight">
|
||||||
|
{{ formatInt(Number(stackTopCount || 0)) }} 次
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="mt-0.5 inline-flex items-center gap-1.5 rounded-md bg-[#00000008] px-1.5 py-1 max-w-full"
|
||||||
|
:title="heroStickerOwnerName ? `常发送给 ${heroStickerOwnerName}` : '常发送给:未知'"
|
||||||
|
>
|
||||||
|
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
|
||||||
|
<img
|
||||||
|
v-if="heroStickerOwnerAvatarUrl && avatarOk.topStickerOwner"
|
||||||
|
:src="heroStickerOwnerAvatarUrl"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt="avatar"
|
||||||
|
@error="avatarOk.topStickerOwner = false"
|
||||||
|
/>
|
||||||
|
<span v-else class="wrapped-number text-[10px] text-[#00000066]">
|
||||||
|
{{ avatarFallback(heroStickerOwnerName) }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="wrapped-body text-[11px] text-[#00000080] truncate">
|
||||||
|
常发送给 <span class="text-[#07C160] font-semibold">{{ heroStickerOwnerName || '未知' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-1 wrapped-label text-[10px] text-[#00000055]">拖动表情卡片翻一翻</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-2 wrapped-body text-xs text-[#00000055]">
|
||||||
|
暂无可展示的高频表情图片。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="wrapped-label text-xs text-[#00000066]">Emoji Top(小黄脸 + Unicode)</div>
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#07C160]/10 text-[#07C160] border-[#07C160]/25">
|
||||||
|
小黄脸
|
||||||
|
</span>
|
||||||
|
<span class="wrapped-label text-[10px] px-2 py-0.5 rounded-md border bg-[#0EA5E9]/10 text-[#0EA5E9] border-[#0EA5E9]/25">
|
||||||
|
Unicode
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="emojiBubbleRows.length > 0" class="mt-2">
|
||||||
|
<div class="grid grid-cols-4 sm:grid-cols-8 gap-2 sm:gap-2.5 place-items-end">
|
||||||
|
<div
|
||||||
|
v-for="row in emojiBubbleRows"
|
||||||
|
:key="row.id"
|
||||||
|
class="min-w-0 flex flex-col items-center gap-1"
|
||||||
|
:title="`${row.label} · ${formatInt(row.count)}次`"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center rounded-full shadow-sm"
|
||||||
|
:class="row.kind === 'wechat' ? 'bg-[#07C160]/14' : 'bg-[#0EA5E9]/12'"
|
||||||
|
:style="{ width: `${row.size}px`, height: `${row.size}px` }"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="row.kind === 'wechat' && row.assetPath && emojiAssetOk[row.key] !== false"
|
||||||
|
:src="resolveEmojiAsset(row.assetPath)"
|
||||||
|
class="w-3/4 h-3/4 object-contain"
|
||||||
|
alt="emoji"
|
||||||
|
@error="emojiAssetOk[row.key] = false"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-xl leading-none">
|
||||||
|
{{ row.kind === 'unicode' ? row.label : '🙂' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="wrapped-number text-[10px] font-semibold leading-none"
|
||||||
|
:class="row.kind === 'wechat' ? 'text-[#07C160]' : 'text-[#0EA5E9]'"
|
||||||
|
>
|
||||||
|
{{ formatInt(row.count) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="mt-2 wrapped-body text-xs text-[#00000066]">
|
||||||
|
今年没有统计到可识别的 Emoji(小黄脸/Unicode)。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lg:col-span-5 h-full min-h-[20rem] sm:min-h-[21.5rem] rounded-2xl border border-[#EDEDED] bg-white/65 p-2.5 sm:p-3 relative overflow-hidden">
|
||||||
|
<div class="relative z-[1] h-full flex flex-col">
|
||||||
|
<div class="wrapped-label text-xs text-[#00000066]">斗图热力时段</div>
|
||||||
|
<div class="mt-1 wrapped-body text-sm text-[#000000e6]">
|
||||||
|
<template v-if="peakHour !== null && peakWeekdayName">
|
||||||
|
高峰在
|
||||||
|
<span class="wrapped-number text-[#07C160] font-semibold">{{ peakWeekdayName }} {{ peakHour }}:00</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
今年没有明显的斗图高峰时段
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2.5 h-16 sm:h-20 flex items-end gap-[2px]">
|
||||||
|
<div
|
||||||
|
v-for="item in hourBars"
|
||||||
|
:key="`hour-${item.hour}`"
|
||||||
|
class="flex-1 min-w-0 rounded-sm bg-[#07C160]/20"
|
||||||
|
:style="{ height: `${item.heightPct}%` }"
|
||||||
|
:title="`${item.hour}:00 · ${formatInt(item.count)}次`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex items-center justify-between wrapped-label text-[10px] text-[#00000055]">
|
||||||
|
<span>00</span>
|
||||||
|
<span>06</span>
|
||||||
|
<span>12</span>
|
||||||
|
<span>18</span>
|
||||||
|
<span>23</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 rounded-xl border border-[#EDEDED] bg-white/60 p-2.5 flex-1 flex flex-col">
|
||||||
|
<div class="wrapped-label text-[11px] text-[#00000066]">表情新鲜度</div>
|
||||||
|
<div class="mt-2 grid grid-cols-1 gap-2.5">
|
||||||
|
<div class="rounded-lg bg-[#07C160]/10 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="wrapped-label text-[11px] text-[#00000066]">年度新解锁</div>
|
||||||
|
<div class="mt-1.5 wrapped-number text-lg text-[#07C160] font-semibold leading-tight">
|
||||||
|
{{ formatInt(newStickerCountThisYear) }}
|
||||||
|
</div>
|
||||||
|
<div class="wrapped-label text-[11px] text-[#00000055]">
|
||||||
|
占类型 {{ newStickerSharePct }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="newStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="item in newStickerDecorDisplayItems"
|
||||||
|
:key="`new-chip-${item.id}`"
|
||||||
|
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.src && stickerDecorOk[item.id] !== false"
|
||||||
|
:src="item.src"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt=""
|
||||||
|
@error="stickerDecorOk[item.id] = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-lg bg-[#0EA5E9]/10 px-3 py-2.5 flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="wrapped-label text-[11px] text-[#00000066]">回温表情</div>
|
||||||
|
<div class="mt-1.5 wrapped-number text-lg text-[#0EA5E9] font-semibold leading-tight">
|
||||||
|
{{ formatInt(revivedStickerCount) }}
|
||||||
|
</div>
|
||||||
|
<div class="wrapped-label text-[11px] text-[#00000055] leading-tight">
|
||||||
|
间隔≥{{ formatInt(revivedMinGapDays) }}天,最长 {{ formatInt(revivedMaxGapDays) }} 天
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="revivedStickerDecorDisplayItems.length > 0" class="grid grid-cols-3 gap-2 flex-shrink-0">
|
||||||
|
<div
|
||||||
|
v-for="item in revivedStickerDecorDisplayItems"
|
||||||
|
:key="`rev-chip-${item.id}`"
|
||||||
|
class="w-11 h-11 sm:w-12 sm:h-12 rounded-[10px] overflow-hidden ring-1 ring-[#00000014] bg-white/60"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="item.src && stickerDecorOk[item.id] !== false"
|
||||||
|
:src="item.src"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
alt=""
|
||||||
|
@error="stickerDecorOk[item.id] = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 wrapped-body text-[11px] text-[#00000066]">
|
||||||
|
今年共用过 <span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(uniqueStickerTypeCount) }}</span> 种表情,
|
||||||
|
回温占比 <span class="wrapped-number text-[#0EA5E9] font-semibold">{{ revivedStickerSharePct }}</span>%。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</WrappedCardShell>
|
||||||
|
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="cursorFxEnabled && isCardVisible && cursorTrails.length > 0" class="emoji-cursor-layer" aria-hidden="true">
|
||||||
|
<img
|
||||||
|
v-for="item in cursorTrails"
|
||||||
|
:key="item.id"
|
||||||
|
class="emoji-cursor-item"
|
||||||
|
:style="{
|
||||||
|
left: `${item.x}px`,
|
||||||
|
top: `${item.y}px`,
|
||||||
|
width: `${item.size}px`,
|
||||||
|
height: `${item.size}px`,
|
||||||
|
'--drift-x': `${item.driftX}px`,
|
||||||
|
'--drift-y': `${item.driftY}px`
|
||||||
|
}"
|
||||||
|
:src="item.src"
|
||||||
|
alt=""
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
|
||||||
|
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
card: { type: Object, required: true },
|
||||||
|
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
|
||||||
|
})
|
||||||
|
|
||||||
|
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
|
||||||
|
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
|
||||||
|
|
||||||
|
const mediaBase = process.client ? 'http://localhost:8000' : ''
|
||||||
|
const resolveMediaUrl = (value, opts = { backend: false }) => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
if (opts.backend || raw.startsWith('/api/')) {
|
||||||
|
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
|
||||||
|
}
|
||||||
|
return raw.startsWith('/') ? raw : `/${raw}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveEmojiAsset = (value) => {
|
||||||
|
const raw = String(value || '').trim()
|
||||||
|
if (!raw) return ''
|
||||||
|
if (/^https?:\/\//i.test(raw)) return raw
|
||||||
|
if (raw.startsWith('/wxemoji/')) return raw
|
||||||
|
if (raw.startsWith('/')) return raw
|
||||||
|
return `/wxemoji/${raw}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveStickerUrl = (st) => {
|
||||||
|
const remote = resolveMediaUrl(st?.emojiUrl, { backend: true })
|
||||||
|
if (remote) return remote
|
||||||
|
const asset = resolveEmojiAsset(st?.emojiAssetPath)
|
||||||
|
return asset || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitTextByNumbers = (text) => {
|
||||||
|
const raw = String(text || '')
|
||||||
|
if (!raw) return []
|
||||||
|
const parts = raw.split(/(\d[\d,.]*)/g)
|
||||||
|
const out = []
|
||||||
|
for (const p of parts) {
|
||||||
|
if (!p) continue
|
||||||
|
if (/^\d[\d,.]*$/.test(p)) out.push({ type: 'num', content: p })
|
||||||
|
else out.push({ type: 'text', content: p })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendParsedText = (segments, text) => {
|
||||||
|
const parsed = parseTextWithEmoji(String(text || ''))
|
||||||
|
for (const seg of parsed) {
|
||||||
|
if (seg?.type === 'emoji' && seg.emojiSrc) {
|
||||||
|
segments.push({
|
||||||
|
type: 'emoji',
|
||||||
|
src: resolveEmojiAsset(seg.emojiSrc),
|
||||||
|
alt: seg.content || 'emoji',
|
||||||
|
sizeEm: 1.12
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const inner = splitTextByNumbers(seg?.content || '')
|
||||||
|
for (const x of inner) segments.push(x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentStickerCount = computed(() => Number(props.card?.data?.sentStickerCount || 0))
|
||||||
|
const stickerActiveDays = computed(() => Number(props.card?.data?.stickerActiveDays || 0))
|
||||||
|
const peakHour = computed(() => {
|
||||||
|
const h = props.card?.data?.peakHour
|
||||||
|
return Number.isFinite(Number(h)) ? Number(h) : null
|
||||||
|
})
|
||||||
|
const peakWeekdayName = computed(() => String(props.card?.data?.peakWeekdayName || '').trim())
|
||||||
|
|
||||||
|
const stickerPerActiveDayText = computed(() => {
|
||||||
|
const v = Number(props.card?.data?.stickerPerActiveDay || 0)
|
||||||
|
return Number.isFinite(v) ? v.toFixed(1) : '0.0'
|
||||||
|
})
|
||||||
|
const uniqueStickerTypeCount = computed(() => Number(props.card?.data?.uniqueStickerTypeCount || 0))
|
||||||
|
const newStickerCountThisYear = computed(() => Number(props.card?.data?.newStickerCountThisYear || 0))
|
||||||
|
const revivedStickerCount = computed(() => Number(props.card?.data?.revivedStickerCount || 0))
|
||||||
|
const revivedMinGapDays = computed(() => Number(props.card?.data?.revivedMinGapDays || 60))
|
||||||
|
const revivedMaxGapDays = computed(() => Number(props.card?.data?.revivedMaxGapDays || 0))
|
||||||
|
const newStickerSharePct = computed(() => {
|
||||||
|
const v = Number(props.card?.data?.newStickerShare)
|
||||||
|
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
|
||||||
|
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
|
||||||
|
if (total <= 0) return 0
|
||||||
|
return Math.max(0, Math.min(100, Math.round((Number(newStickerCountThisYear.value || 0) / total) * 100)))
|
||||||
|
})
|
||||||
|
const revivedStickerSharePct = computed(() => {
|
||||||
|
const v = Number(props.card?.data?.revivedStickerShare)
|
||||||
|
if (Number.isFinite(v) && v >= 0) return Math.max(0, Math.min(100, Math.round(v * 100)))
|
||||||
|
const total = Math.max(0, Number(uniqueStickerTypeCount.value || 0))
|
||||||
|
if (total <= 0) return 0
|
||||||
|
return Math.max(0, Math.min(100, Math.round((Number(revivedStickerCount.value || 0) / total) * 100)))
|
||||||
|
})
|
||||||
|
const newStickerSamples = computed(() => {
|
||||||
|
const arr = props.card?.data?.newStickerSamples
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
})
|
||||||
|
const revivedStickerSamples = computed(() => {
|
||||||
|
const arr = props.card?.data?.revivedStickerSamples
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildDecorStickerItems = (rows, prefix) => {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (let idx = 0; idx < rows.length; idx += 1) {
|
||||||
|
const st = rows[idx] || {}
|
||||||
|
const rawId = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || `${prefix}-${idx}`).trim()
|
||||||
|
if (!rawId || seen.has(rawId)) continue
|
||||||
|
seen.add(rawId)
|
||||||
|
out.push({
|
||||||
|
id: `${prefix}-${rawId}`,
|
||||||
|
src: resolveStickerUrl(st),
|
||||||
|
count: Math.max(0, Number(st?.count || 0)),
|
||||||
|
gapDays: Math.max(0, Number(st?.gapDays || 0))
|
||||||
|
})
|
||||||
|
if (out.length >= 4) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
const topStickers = computed(() => {
|
||||||
|
const arr = props.card?.data?.topStickers
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const newStickerDecorItems = computed(() => buildDecorStickerItems(newStickerSamples.value, 'new'))
|
||||||
|
const revivedStickerDecorItems = computed(() => buildDecorStickerItems(revivedStickerSamples.value, 'revived'))
|
||||||
|
const topStickerDecorItems = computed(() => buildDecorStickerItems(topStickers.value, 'top'))
|
||||||
|
const toRenderableDecor = (items) => items.filter((x) => String(x?.src || '').trim())
|
||||||
|
const decorFallbackPool = computed(() => {
|
||||||
|
const out = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const item of [
|
||||||
|
...toRenderableDecor(newStickerDecorItems.value),
|
||||||
|
...toRenderableDecor(revivedStickerDecorItems.value),
|
||||||
|
...toRenderableDecor(topStickerDecorItems.value)
|
||||||
|
]) {
|
||||||
|
const key = String(item?.src || '').trim()
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push(item)
|
||||||
|
if (out.length >= 6) break
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
const newStickerDecorDisplayItems = computed(() => {
|
||||||
|
const own = toRenderableDecor(newStickerDecorItems.value).slice(0, 3)
|
||||||
|
if (own.length > 0) return own
|
||||||
|
return decorFallbackPool.value.slice(0, 3)
|
||||||
|
})
|
||||||
|
const revivedStickerDecorDisplayItems = computed(() => {
|
||||||
|
const own = toRenderableDecor(revivedStickerDecorItems.value).slice(0, 3)
|
||||||
|
if (own.length > 0) return own
|
||||||
|
const fallback = decorFallbackPool.value.slice(3, 6)
|
||||||
|
return fallback.length > 0 ? fallback : decorFallbackPool.value.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroSticker = computed(() => {
|
||||||
|
const arr = topStickers.value
|
||||||
|
return Array.isArray(arr) && arr.length > 0 ? arr[0] : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const heroStickerUrl = computed(() => resolveStickerUrl(heroSticker.value))
|
||||||
|
const heroStickerOwnerName = computed(() => String(heroSticker.value?.sampleDisplayName || heroSticker.value?.sampleUsername || '').trim())
|
||||||
|
const heroStickerOwnerAvatarUrl = computed(() => resolveMediaUrl(heroSticker.value?.sampleAvatarUrl, { backend: true }))
|
||||||
|
|
||||||
|
const stackCardDimensions = { width: 140, height: 140 }
|
||||||
|
|
||||||
|
const stackCardsData = computed(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const st of topStickers.value) {
|
||||||
|
const key = String(st?.md5 || st?.emojiAssetPath || st?.emojiUrl || '').trim()
|
||||||
|
if (!key || seen.has(key)) continue
|
||||||
|
const src = resolveStickerUrl(st)
|
||||||
|
if (!src) continue
|
||||||
|
seen.add(key)
|
||||||
|
out.push({ id: key, img: src })
|
||||||
|
if (out.length >= 4) break
|
||||||
|
}
|
||||||
|
if (out.length > 0) return out
|
||||||
|
return allWechatEmojiAssets.value.slice(0, 4).map((img, idx) => ({ id: `wx-${idx}`, img }))
|
||||||
|
})
|
||||||
|
|
||||||
|
const stackTopCount = computed(() => Number(heroSticker.value?.count || 0))
|
||||||
|
|
||||||
|
const hourBars = computed(() => {
|
||||||
|
const raw = Array.isArray(props.card?.data?.stickerHourCounts) ? props.card.data.stickerHourCounts : []
|
||||||
|
const counts = Array.from({ length: 24 }, (_, i) => Math.max(0, Number(raw[i] || 0)))
|
||||||
|
const maxV = Math.max(1, ...counts)
|
||||||
|
return counts.map((count, hour) => ({
|
||||||
|
hour,
|
||||||
|
count,
|
||||||
|
heightPct: Math.max(8, Math.round((count / maxV) * 100))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const topTextEmojis = computed(() => {
|
||||||
|
const arr = props.card?.data?.topTextEmojis
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
})
|
||||||
|
const topWechatEmojis = computed(() => {
|
||||||
|
const arr = props.card?.data?.topWechatEmojis
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
})
|
||||||
|
const topUnicodeEmojis = computed(() => {
|
||||||
|
const arr = props.card?.data?.topUnicodeEmojis
|
||||||
|
return Array.isArray(arr) ? arr : []
|
||||||
|
})
|
||||||
|
|
||||||
|
const smallWechatEmojiChips = computed(() => {
|
||||||
|
if (topWechatEmojis.value.length > 0) {
|
||||||
|
return topWechatEmojis.value.map((x) => ({
|
||||||
|
key: String(x?.key || ''),
|
||||||
|
count: Number(x?.count || 0),
|
||||||
|
assetPath: String(x?.assetPath || '')
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
return topTextEmojis.value.map((x) => ({
|
||||||
|
key: String(x?.key || ''),
|
||||||
|
count: Number(x?.count || 0),
|
||||||
|
assetPath: String(x?.assetPath || '')
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const emojiAssetOk = reactive({})
|
||||||
|
watch(
|
||||||
|
smallWechatEmojiChips,
|
||||||
|
(arr) => {
|
||||||
|
for (const k of Object.keys(emojiAssetOk)) delete emojiAssetOk[k]
|
||||||
|
if (!Array.isArray(arr)) return
|
||||||
|
for (const em of arr) {
|
||||||
|
const key = String(em?.key || '').trim()
|
||||||
|
if (key) emojiAssetOk[key] = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const emojiChartRows = computed(() => {
|
||||||
|
const wechat = smallWechatEmojiChips.value
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((x, idx) => ({
|
||||||
|
id: `w-${String(x?.key || idx)}`,
|
||||||
|
kind: 'wechat',
|
||||||
|
key: String(x?.key || idx),
|
||||||
|
label: String(x?.key || '').trim() || '微信表情',
|
||||||
|
count: Math.max(0, Number(x?.count || 0)),
|
||||||
|
assetPath: String(x?.assetPath || '').trim()
|
||||||
|
}))
|
||||||
|
|
||||||
|
const unicode = topUnicodeEmojis.value
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((x, idx) => ({
|
||||||
|
id: `u-${String(x?.emoji || idx)}`,
|
||||||
|
kind: 'unicode',
|
||||||
|
key: String(x?.emoji || idx),
|
||||||
|
label: String(x?.emoji || '').trim() || '😀',
|
||||||
|
count: Math.max(0, Number(x?.count || 0)),
|
||||||
|
assetPath: ''
|
||||||
|
}))
|
||||||
|
|
||||||
|
const rows = [...wechat, ...unicode].filter((x) => x.count > 0 && x.label)
|
||||||
|
const maxV = Math.max(1, ...rows.map((x) => x.count))
|
||||||
|
return rows
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.map((x) => ({
|
||||||
|
...x,
|
||||||
|
pct: Math.max(8, Math.round((x.count / maxV) * 100))
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const emojiBubbleRows = computed(() => {
|
||||||
|
const rows = emojiChartRows.value
|
||||||
|
if (!rows.length) return []
|
||||||
|
const maxV = Math.max(1, ...rows.map((x) => x.count))
|
||||||
|
return rows.map((x) => {
|
||||||
|
const ratio = Math.max(0, Math.min(1, x.count / maxV))
|
||||||
|
const size = Math.round(32 + Math.sqrt(ratio) * 36)
|
||||||
|
return { ...x, size: Math.max(28, Math.min(72, size)) }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const stickerDecorOk = reactive({})
|
||||||
|
watch(
|
||||||
|
() => [...newStickerDecorItems.value, ...revivedStickerDecorItems.value],
|
||||||
|
(arr) => {
|
||||||
|
for (const k of Object.keys(stickerDecorOk)) delete stickerDecorOk[k]
|
||||||
|
if (!Array.isArray(arr)) return
|
||||||
|
for (const x of arr) {
|
||||||
|
const key = String(x?.id || '').trim()
|
||||||
|
if (key) stickerDecorOk[key] = true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
const avatarOk = reactive({ topStickerOwner: true })
|
||||||
|
watch(heroStickerOwnerAvatarUrl, () => { avatarOk.topStickerOwner = true })
|
||||||
|
|
||||||
|
const avatarFallback = (name) => {
|
||||||
|
const s = String(name || '').trim()
|
||||||
|
return s ? s[0] : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrativeSegments = computed(() => {
|
||||||
|
const out = []
|
||||||
|
|
||||||
|
if (sentStickerCount.value > 0) {
|
||||||
|
appendParsedText(
|
||||||
|
out,
|
||||||
|
`这一年,你用 ${formatInt(sentStickerCount.value)} 张表情包把聊天变得更有温度;在 ${formatInt(
|
||||||
|
stickerActiveDays.value
|
||||||
|
)} 个活跃日里,日均 ${stickerPerActiveDayText.value} 张。`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
appendParsedText(out, '这一年你几乎没发过表情包。')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (peakHour.value !== null && peakWeekdayName.value) {
|
||||||
|
appendParsedText(out, `你最活跃的时刻是 ${peakWeekdayName.value} ${peakHour.value}:00。`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasTail = false
|
||||||
|
if (heroSticker.value) {
|
||||||
|
appendParsedText(out, '年度 C 位表情是 ')
|
||||||
|
if (heroStickerUrl.value) {
|
||||||
|
out.push({ type: 'sticker', src: heroStickerUrl.value, alt: '年度 C 位表情', sizeEm: 1.22 })
|
||||||
|
} else {
|
||||||
|
appendParsedText(out, '(图片缺失)')
|
||||||
|
}
|
||||||
|
appendParsedText(out, `(${formatInt(Number(heroSticker.value?.count || 0))} 次)`)
|
||||||
|
hasTail = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const topWechat = topWechatEmojis.value[0]
|
||||||
|
const topText = topTextEmojis.value[0]
|
||||||
|
if (topWechat) {
|
||||||
|
appendParsedText(out, `${hasTail ? ',' : ''}你最常用的小黄脸是 `)
|
||||||
|
if (topWechat.assetPath) {
|
||||||
|
out.push({
|
||||||
|
type: 'emoji',
|
||||||
|
src: resolveEmojiAsset(topWechat.assetPath),
|
||||||
|
alt: topWechat.key || 'emoji',
|
||||||
|
sizeEm: 1.16
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
appendParsedText(out, topWechat.key || '')
|
||||||
|
}
|
||||||
|
appendParsedText(out, `(${formatInt(Number(topWechat.count || 0))} 次)`)
|
||||||
|
hasTail = true
|
||||||
|
} else if (topText) {
|
||||||
|
appendParsedText(out, `${hasTail ? ',' : ''}在文字聊天里,你最常打的小黄脸是 `)
|
||||||
|
if (topText.assetPath) {
|
||||||
|
out.push({
|
||||||
|
type: 'emoji',
|
||||||
|
src: resolveEmojiAsset(topText.assetPath),
|
||||||
|
alt: topText.key || 'emoji',
|
||||||
|
sizeEm: 1.16
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
appendParsedText(out, topText.key || '')
|
||||||
|
}
|
||||||
|
appendParsedText(out, `(${formatInt(Number(topText.count || 0))} 次)`)
|
||||||
|
hasTail = true
|
||||||
|
} else {
|
||||||
|
appendParsedText(out, `${hasTail ? ',' : ''}今年没有命中可识别的小黄脸`)
|
||||||
|
hasTail = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const topUnicode = topUnicodeEmojis.value[0]
|
||||||
|
if (topUnicode) {
|
||||||
|
appendParsedText(
|
||||||
|
out,
|
||||||
|
`${hasTail ? ',' : ''}普通 Emoji 最常用 ${topUnicode.emoji}(${formatInt(Number(topUnicode.count || 0))} 次)。`
|
||||||
|
)
|
||||||
|
} else if (hasTail) {
|
||||||
|
appendParsedText(out, '。')
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const cardRoot = ref(null)
|
||||||
|
const isCardVisible = ref(false)
|
||||||
|
const cursorTrails = ref([])
|
||||||
|
const cursorFxEnabled = ref(true)
|
||||||
|
const allWechatEmojiAssets = computed(() => {
|
||||||
|
const values = Object.values(WechatEmojiTable || {})
|
||||||
|
const uniq = new Set()
|
||||||
|
for (const x of values) {
|
||||||
|
const p = resolveEmojiAsset(String(x || '').trim())
|
||||||
|
if (p) uniq.add(p)
|
||||||
|
}
|
||||||
|
return Array.from(uniq)
|
||||||
|
})
|
||||||
|
|
||||||
|
let trailSeq = 0
|
||||||
|
let lastSpawnAt = 0
|
||||||
|
let lastSpawnX = -9999
|
||||||
|
let lastSpawnY = -9999
|
||||||
|
const TRAIL_LIFETIME_MS = 780
|
||||||
|
const MIN_SPAWN_INTERVAL_MS = 28
|
||||||
|
const MIN_SPAWN_DISTANCE = 10
|
||||||
|
const MAX_TRAIL_COUNT = 32
|
||||||
|
|
||||||
|
const checkCardVisible = () => {
|
||||||
|
if (!process.client) return false
|
||||||
|
const rect = cardRoot.value?.getBoundingClientRect?.()
|
||||||
|
if (!rect) return false
|
||||||
|
const vh = window.innerHeight || 0
|
||||||
|
const vw = window.innerWidth || 0
|
||||||
|
return rect.bottom > vh * 0.12 && rect.top < vh * 0.88 && rect.right > 0 && rect.left < vw
|
||||||
|
}
|
||||||
|
|
||||||
|
const spawnCursorTrail = (x, y) => {
|
||||||
|
const pool = allWechatEmojiAssets.value
|
||||||
|
if (!pool.length) return
|
||||||
|
const src = pool[Math.floor(Math.random() * pool.length)]
|
||||||
|
const item = {
|
||||||
|
id: ++trailSeq,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
src,
|
||||||
|
size: 18 + Math.floor(Math.random() * 8),
|
||||||
|
driftX: Math.round((Math.random() - 0.5) * 30),
|
||||||
|
driftY: 20 + Math.floor(Math.random() * 20)
|
||||||
|
}
|
||||||
|
cursorTrails.value = [...cursorTrails.value.slice(-MAX_TRAIL_COUNT), item]
|
||||||
|
setTimeout(() => {
|
||||||
|
cursorTrails.value = cursorTrails.value.filter((t) => t.id !== item.id)
|
||||||
|
}, TRAIL_LIFETIME_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWindowPointerMove = (e) => {
|
||||||
|
if (!process.client || !cursorFxEnabled.value) return
|
||||||
|
if (e?.pointerType === 'touch') return
|
||||||
|
const visible = checkCardVisible()
|
||||||
|
isCardVisible.value = visible
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
const x = Number(e.clientX)
|
||||||
|
const y = Number(e.clientY)
|
||||||
|
if (!Number.isFinite(x) || !Number.isFinite(y)) return
|
||||||
|
|
||||||
|
const now = performance.now()
|
||||||
|
const dx = x - lastSpawnX
|
||||||
|
const dy = y - lastSpawnY
|
||||||
|
const dist = Math.hypot(dx, dy)
|
||||||
|
if ((now - lastSpawnAt) < MIN_SPAWN_INTERVAL_MS && dist < MIN_SPAWN_DISTANCE) return
|
||||||
|
|
||||||
|
lastSpawnAt = now
|
||||||
|
lastSpawnX = x
|
||||||
|
lastSpawnY = y
|
||||||
|
spawnCursorTrail(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWindowPointerLeave = () => {
|
||||||
|
lastSpawnX = -9999
|
||||||
|
lastSpawnY = -9999
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!process.client) return
|
||||||
|
try {
|
||||||
|
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||||
|
cursorFxEnabled.value = !mq.matches
|
||||||
|
} catch {
|
||||||
|
cursorFxEnabled.value = true
|
||||||
|
}
|
||||||
|
isCardVisible.value = checkCardVisible()
|
||||||
|
window.addEventListener('pointermove', onWindowPointerMove, { passive: true })
|
||||||
|
window.addEventListener('pointerleave', onWindowPointerLeave, { passive: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (process.client) {
|
||||||
|
window.removeEventListener('pointermove', onWindowPointerMove)
|
||||||
|
window.removeEventListener('pointerleave', onWindowPointerLeave)
|
||||||
|
}
|
||||||
|
cursorTrails.value = []
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wx-inline-emoji {
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-cursor-layer {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji-cursor-item {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
opacity: 0.94;
|
||||||
|
animation: emoji-cursor-float 780ms ease-out forwards;
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes emoji-cursor-float {
|
||||||
|
0% {
|
||||||
|
opacity: 0.94;
|
||||||
|
transform: translate(-50%, -50%) scale(0.88);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(calc(-50% + var(--drift-x)), calc(-50% - var(--drift-y))) scale(1.16);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1225
frontend/components/wrapped/shared/VueBitsImageTrail.vue
Normal file
1225
frontend/components/wrapped/shared/VueBitsImageTrail.vue
Normal file
File diff suppressed because it is too large
Load Diff
294
frontend/components/wrapped/shared/VueBitsStack.vue
Normal file
294
frontend/components/wrapped/shared/VueBitsStack.vue
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
<template>
|
||||||
|
<div class="vb-stack relative select-none" :style="stackStyle">
|
||||||
|
<div
|
||||||
|
v-for="(card, index) in cards"
|
||||||
|
:key="String(card.id)"
|
||||||
|
class="vb-stack-card absolute top-1/2 left-1/2 overflow-hidden rounded-2xl bg-white/80 shadow-sm"
|
||||||
|
:style="{
|
||||||
|
width: `${dims.width}px`,
|
||||||
|
height: `${dims.height}px`,
|
||||||
|
touchAction: index === cards.length - 1 ? 'none' : 'auto',
|
||||||
|
pointerEvents: index === cards.length - 1 ? 'auto' : 'none'
|
||||||
|
}"
|
||||||
|
:ref="(el) => onCardRef(card.id, el)"
|
||||||
|
@pointerdown="(e) => onPointerDown(e, card.id, index)"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerCancel"
|
||||||
|
>
|
||||||
|
<img :src="card.img" class="w-full h-full object-cover" alt="" draggable="false" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
|
import { gsap } from 'gsap'
|
||||||
|
|
||||||
|
type StackCard = {
|
||||||
|
id: string | number
|
||||||
|
img: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
randomRotation?: boolean
|
||||||
|
sensitivity?: number
|
||||||
|
sendToBackOnClick?: boolean
|
||||||
|
cardDimensions?: { width?: number; height?: number }
|
||||||
|
cardsData?: StackCard[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
randomRotation: false,
|
||||||
|
sensitivity: 180,
|
||||||
|
sendToBackOnClick: true,
|
||||||
|
cardDimensions: () => ({ width: 200, height: 200 }),
|
||||||
|
cardsData: () => []
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const dims = computed(() => {
|
||||||
|
const w = Number(props.cardDimensions?.width)
|
||||||
|
const h = Number(props.cardDimensions?.height)
|
||||||
|
return {
|
||||||
|
width: Number.isFinite(w) && w > 0 ? Math.floor(w) : 200,
|
||||||
|
height: Number.isFinite(h) && h > 0 ? Math.floor(h) : 200
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stackStyle = computed(() => ({
|
||||||
|
width: `${dims.value.width}px`,
|
||||||
|
height: `${dims.value.height}px`
|
||||||
|
}))
|
||||||
|
|
||||||
|
const cards = ref<StackCard[]>([])
|
||||||
|
const elMap = new Map<string, HTMLDivElement>()
|
||||||
|
const rotationMap = ref(new Map<string, number>())
|
||||||
|
const mounted = ref(false)
|
||||||
|
|
||||||
|
const ROT_RANGE_DEG = 7
|
||||||
|
const STACK_OFFSET_X = 2
|
||||||
|
const STACK_OFFSET_Y = 4
|
||||||
|
const STACK_SCALE_STEP = 0.03
|
||||||
|
const MAX_TILT_DEG = 22
|
||||||
|
|
||||||
|
function normalizeCards(data: unknown): StackCard[] {
|
||||||
|
const raw = Array.isArray(data) ? data : []
|
||||||
|
const out: StackCard[] = []
|
||||||
|
for (const item of raw) {
|
||||||
|
const id = (item as any)?.id
|
||||||
|
const img = String((item as any)?.img || '').trim()
|
||||||
|
if (id === null || id === undefined) continue
|
||||||
|
if (!img) continue
|
||||||
|
out.push({ id, img })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRotations(list: StackCard[]) {
|
||||||
|
const next = new Map(rotationMap.value)
|
||||||
|
if (!props.randomRotation) {
|
||||||
|
for (const c of list) next.set(String(c.id), 0)
|
||||||
|
rotationMap.value = next
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for (const c of list) {
|
||||||
|
const key = String(c.id)
|
||||||
|
if (next.has(key)) continue
|
||||||
|
next.set(key, Math.round((Math.random() * 2 - 1) * ROT_RANGE_DEG))
|
||||||
|
}
|
||||||
|
rotationMap.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCardRef(id: StackCard['id'], el: Element | null) {
|
||||||
|
const key = String(id)
|
||||||
|
if (el && el instanceof HTMLDivElement) elMap.set(key, el)
|
||||||
|
else elMap.delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLayout(animate: boolean) {
|
||||||
|
if (!mounted.value) return
|
||||||
|
const total = cards.value.length
|
||||||
|
if (total === 0) return
|
||||||
|
|
||||||
|
cards.value.forEach((card, idx) => {
|
||||||
|
const key = String(card.id)
|
||||||
|
const el = elMap.get(key)
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const orderFromTop = total - 1 - idx
|
||||||
|
const x = orderFromTop * STACK_OFFSET_X
|
||||||
|
const y = orderFromTop * STACK_OFFSET_Y
|
||||||
|
const scale = Math.max(0.88, 1 - orderFromTop * STACK_SCALE_STEP)
|
||||||
|
const rotation = rotationMap.value.get(key) ?? 0
|
||||||
|
|
||||||
|
const tweenVars: gsap.TweenVars = {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
rotation,
|
||||||
|
rotationX: 0,
|
||||||
|
rotationY: 0,
|
||||||
|
scale,
|
||||||
|
xPercent: -50,
|
||||||
|
yPercent: -50,
|
||||||
|
zIndex: idx + 1,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
ease: 'power3.out',
|
||||||
|
duration: animate ? 0.32 : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
gsap.killTweensOf(el)
|
||||||
|
if (animate) gsap.to(el, tweenVars)
|
||||||
|
else gsap.set(el, tweenVars)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.cardsData,
|
||||||
|
(val) => {
|
||||||
|
const nextCards = normalizeCards(val)
|
||||||
|
cards.value = nextCards
|
||||||
|
ensureRotations(nextCards)
|
||||||
|
nextTick(() => applyLayout(false))
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.randomRotation, props.cardDimensions?.width, props.cardDimensions?.height] as const,
|
||||||
|
() => {
|
||||||
|
ensureRotations(cards.value)
|
||||||
|
nextTick(() => applyLayout(false))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
mounted.value = true
|
||||||
|
applyLayout(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
const clamp = (v: number, min: number, max: number) => Math.min(max, Math.max(min, v))
|
||||||
|
|
||||||
|
let activePointerId: number | null = null
|
||||||
|
let activeCardId: string | null = null
|
||||||
|
let startClientX = 0
|
||||||
|
let startClientY = 0
|
||||||
|
let startX = 0
|
||||||
|
let startY = 0
|
||||||
|
let startRotationZ = 0
|
||||||
|
let startScale = 1
|
||||||
|
let lastDx = 0
|
||||||
|
let lastDy = 0
|
||||||
|
|
||||||
|
function sendToBack(id: string) {
|
||||||
|
if (cards.value.length < 2) return
|
||||||
|
const list = cards.value.slice()
|
||||||
|
const idx = list.findIndex((c) => String(c.id) === id)
|
||||||
|
if (idx < 0) return
|
||||||
|
const [card] = list.splice(idx, 1)
|
||||||
|
if (!card) return
|
||||||
|
cards.value = [card, ...list]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent, id: StackCard['id'], index: number) {
|
||||||
|
if (activePointerId !== null) return
|
||||||
|
if (index !== cards.value.length - 1) return
|
||||||
|
|
||||||
|
const key = String(id)
|
||||||
|
const el = elMap.get(key)
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
activePointerId = e.pointerId
|
||||||
|
activeCardId = key
|
||||||
|
startClientX = e.clientX
|
||||||
|
startClientY = e.clientY
|
||||||
|
startX = Number(gsap.getProperty(el, 'x')) || 0
|
||||||
|
startY = Number(gsap.getProperty(el, 'y')) || 0
|
||||||
|
startRotationZ = Number(gsap.getProperty(el, 'rotation')) || 0
|
||||||
|
startScale = Number(gsap.getProperty(el, 'scale')) || 1
|
||||||
|
lastDx = 0
|
||||||
|
lastDy = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
el.setPointerCapture(e.pointerId)
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
gsap.killTweensOf(el)
|
||||||
|
gsap.set(el, { zIndex: 999 })
|
||||||
|
gsap.to(el, { scale: startScale * 1.03, duration: 0.12, ease: 'power2.out' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||||
|
const key = activeCardId
|
||||||
|
if (!key) return
|
||||||
|
const el = elMap.get(key)
|
||||||
|
if (!el) return
|
||||||
|
|
||||||
|
const dx = e.clientX - startClientX
|
||||||
|
const dy = e.clientY - startClientY
|
||||||
|
lastDx = dx
|
||||||
|
lastDy = dy
|
||||||
|
|
||||||
|
const w = Math.max(1, dims.value.width)
|
||||||
|
const h = Math.max(1, dims.value.height)
|
||||||
|
const nx = dx / (w * 0.55)
|
||||||
|
const ny = dy / (h * 0.55)
|
||||||
|
const tiltY = clamp(Math.tanh(nx) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
|
||||||
|
const tiltX = clamp(-Math.tanh(ny) * MAX_TILT_DEG, -MAX_TILT_DEG, MAX_TILT_DEG)
|
||||||
|
|
||||||
|
gsap.set(el, {
|
||||||
|
x: startX + dx,
|
||||||
|
y: startY + dy,
|
||||||
|
rotation: startRotationZ + dx / 18,
|
||||||
|
rotationX: tiltX,
|
||||||
|
rotationY: tiltY
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishPointer(id: number, shouldSendBack: boolean) {
|
||||||
|
const key = activeCardId
|
||||||
|
activePointerId = null
|
||||||
|
activeCardId = null
|
||||||
|
|
||||||
|
const el = key ? elMap.get(key) : null
|
||||||
|
if (el) {
|
||||||
|
try {
|
||||||
|
el.releasePointerCapture(id)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldSendBack && key) sendToBack(key)
|
||||||
|
applyLayout(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp(e: PointerEvent) {
|
||||||
|
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||||
|
const dist = Math.hypot(lastDx, lastDy)
|
||||||
|
const clickLike = dist < 6
|
||||||
|
const shouldSendBack = dist > Number(props.sensitivity || 0) || (props.sendToBackOnClick && clickLike)
|
||||||
|
finishPointer(e.pointerId, shouldSendBack)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerCancel(e: PointerEvent) {
|
||||||
|
if (activePointerId === null || e.pointerId !== activePointerId) return
|
||||||
|
finishPointer(e.pointerId, false)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.vb-stack {
|
||||||
|
perspective: 1000px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vb-stack-card {
|
||||||
|
will-change: transform;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
backface-visibility: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vb-stack-card:active {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -329,6 +329,10 @@ const PREVIEW_BY_KIND = {
|
|||||||
'chat/reply_speed': {
|
'chat/reply_speed': {
|
||||||
summary: '回复速度',
|
summary: '回复速度',
|
||||||
question: '谁是你愿意秒回的那个人?'
|
question: '谁是你愿意秒回的那个人?'
|
||||||
|
},
|
||||||
|
'emoji/annual_universe': {
|
||||||
|
summary: '梗图年鉴',
|
||||||
|
question: '你这一年最常丢出的表情包是哪张?'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -163,6 +163,12 @@
|
|||||||
variant="slide"
|
variant="slide"
|
||||||
class="h-full w-full"
|
class="h-full w-full"
|
||||||
/>
|
/>
|
||||||
|
<Card04EmojiUniverse
|
||||||
|
v-else-if="c && (c.kind === 'emoji/annual_universe' || c.id === 4)"
|
||||||
|
:card="c"
|
||||||
|
variant="slide"
|
||||||
|
class="h-full w-full"
|
||||||
|
/>
|
||||||
<WrappedCardShell
|
<WrappedCardShell
|
||||||
v-else
|
v-else
|
||||||
:card-id="Number(c?.id || (idx + 1))"
|
:card-id="Number(c?.id || (idx + 1))"
|
||||||
|
|||||||
1265
src/wechat_decrypt_tool/wrapped/cards/card_04_emoji_universe.py
Normal file
1265
src/wechat_decrypt_tool/wrapped/cards/card_04_emoji_universe.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,15 +16,16 @@ from .cards.card_00_global_overview import build_card_00_global_overview
|
|||||||
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
from .cards.card_01_cyber_schedule import WeekdayHourHeatmap, build_card_01_cyber_schedule, compute_weekday_hour_heatmap
|
||||||
from .cards.card_02_message_chars import build_card_02_message_chars
|
from .cards.card_02_message_chars import build_card_02_message_chars
|
||||||
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
from .cards.card_03_reply_speed import build_card_03_reply_speed
|
||||||
|
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
# We use this number to version the cache filename so adding more cards won't accidentally serve
|
||||||
# an older partial cache.
|
# an older partial cache.
|
||||||
_IMPLEMENTED_UPTO_ID = 3
|
_IMPLEMENTED_UPTO_ID = 4
|
||||||
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
|
||||||
_CACHE_VERSION = 9
|
_CACHE_VERSION = 15
|
||||||
|
|
||||||
|
|
||||||
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
|
||||||
@@ -58,6 +59,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
|
|||||||
"category": "B",
|
"category": "B",
|
||||||
"kind": "chat/reply_speed",
|
"kind": "chat/reply_speed",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "这一年,你的表情包里藏了多少心情?",
|
||||||
|
"scope": "global",
|
||||||
|
"category": "B",
|
||||||
|
"kind": "emoji/annual_universe",
|
||||||
|
},
|
||||||
)
|
)
|
||||||
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
|
||||||
|
|
||||||
@@ -274,7 +282,7 @@ def build_wrapped_annual_response(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Build annual wrapped response for the given account/year.
|
"""Build annual wrapped response for the given account/year.
|
||||||
|
|
||||||
For now we implement cards up to id=3 (plus a meta overview card id=0).
|
For now we implement cards up to id=4 (plus a meta overview card id=0).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
account_dir = _resolve_account_dir(account)
|
account_dir = _resolve_account_dir(account)
|
||||||
@@ -317,6 +325,8 @@ def build_wrapped_annual_response(
|
|||||||
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
|
||||||
# Page 5: reply speed / best chat buddy.
|
# Page 5: reply speed / best chat buddy.
|
||||||
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
|
||||||
|
# Page 6: annual emoji universe / meme almanac.
|
||||||
|
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
|
||||||
|
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
"account": account_dir.name,
|
"account": account_dir.name,
|
||||||
@@ -508,6 +518,8 @@ def build_wrapped_annual_card(
|
|||||||
card = build_card_02_message_chars(account_dir=account_dir, year=y)
|
card = build_card_02_message_chars(account_dir=account_dir, year=y)
|
||||||
elif cid == 3:
|
elif cid == 3:
|
||||||
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
card = build_card_03_reply_speed(account_dir=account_dir, year=y)
|
||||||
|
elif cid == 4:
|
||||||
|
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
|
||||||
else:
|
else:
|
||||||
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
|
||||||
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
raise ValueError(f"Unknown Wrapped card id: {cid}")
|
||||||
|
|||||||
773
tests/test_wrapped_emoji_universe.py
Normal file
773
tests/test_wrapped_emoji_universe.py
Normal file
@@ -0,0 +1,773 @@
|
|||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / "src"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestWrappedEmojiUniverse(unittest.TestCase):
|
||||||
|
def _ts(self, y: int, m: int, d: int, h: int = 0, mi: int = 0, s: int = 0) -> int:
|
||||||
|
return int(datetime(y, m, d, h, mi, s).timestamp())
|
||||||
|
|
||||||
|
def _seed_contact_db(self, path: Path, *, account: str, usernames: list[str]) -> None:
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE contact (
|
||||||
|
username TEXT,
|
||||||
|
remark TEXT,
|
||||||
|
nick_name TEXT,
|
||||||
|
alias TEXT,
|
||||||
|
local_type INTEGER,
|
||||||
|
verify_flag INTEGER,
|
||||||
|
big_head_url TEXT,
|
||||||
|
small_head_url TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE stranger (
|
||||||
|
username TEXT,
|
||||||
|
remark TEXT,
|
||||||
|
nick_name TEXT,
|
||||||
|
alias TEXT,
|
||||||
|
local_type INTEGER,
|
||||||
|
verify_flag INTEGER,
|
||||||
|
big_head_url TEXT,
|
||||||
|
small_head_url TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(account, "", "我", "", 1, 0, "", ""),
|
||||||
|
)
|
||||||
|
for idx, username in enumerate(usernames):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||||
|
(username, "", f"好友{idx + 1}", "", 1, 0, "", ""),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _seed_session_db(self, path: Path, *, usernames: list[str]) -> None:
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE SessionTable (
|
||||||
|
username TEXT,
|
||||||
|
is_hidden INTEGER,
|
||||||
|
sort_timestamp INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for username in usernames:
|
||||||
|
conn.execute("INSERT INTO SessionTable VALUES (?, ?, ?)", (username, 0, 1735689600))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _seed_message_db(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
account: str,
|
||||||
|
username: str,
|
||||||
|
rows: list[dict[str, object]],
|
||||||
|
) -> None:
|
||||||
|
table_name = f"msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute("CREATE TABLE Name2Id (rowid INTEGER PRIMARY KEY, user_name TEXT)")
|
||||||
|
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (1, account))
|
||||||
|
conn.execute("INSERT INTO Name2Id(rowid, user_name) VALUES (?, ?)", (2, username))
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
CREATE TABLE {table_name} (
|
||||||
|
local_id INTEGER,
|
||||||
|
server_id INTEGER,
|
||||||
|
local_type INTEGER,
|
||||||
|
sort_seq INTEGER,
|
||||||
|
real_sender_id INTEGER,
|
||||||
|
create_time INTEGER,
|
||||||
|
message_content TEXT,
|
||||||
|
compress_content BLOB,
|
||||||
|
packed_info_data BLOB
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
f"""
|
||||||
|
INSERT INTO {table_name}
|
||||||
|
(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
int(row.get("local_id", 0)),
|
||||||
|
int(row.get("server_id", 0)),
|
||||||
|
int(row.get("local_type", 0)),
|
||||||
|
int(row.get("sort_seq", row.get("local_id", 0))),
|
||||||
|
int(row.get("real_sender_id", 1)),
|
||||||
|
int(row.get("create_time", 0)),
|
||||||
|
str(row.get("message_content", "")),
|
||||||
|
row.get("compress_content"),
|
||||||
|
row.get("packed_info_data"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _seed_index_db(self, path: Path, *, rows: list[dict[str, object]]) -> None:
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE message_fts (
|
||||||
|
text TEXT,
|
||||||
|
username TEXT,
|
||||||
|
render_type TEXT,
|
||||||
|
create_time INTEGER,
|
||||||
|
sort_seq INTEGER,
|
||||||
|
local_id INTEGER,
|
||||||
|
server_id INTEGER,
|
||||||
|
local_type INTEGER,
|
||||||
|
db_stem TEXT,
|
||||||
|
table_name TEXT,
|
||||||
|
sender_username TEXT,
|
||||||
|
is_hidden INTEGER,
|
||||||
|
is_official INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO message_fts (
|
||||||
|
text, username, render_type, create_time, sort_seq, local_id, server_id, local_type,
|
||||||
|
db_stem, table_name, sender_username, is_hidden, is_official
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(row.get("text", "")),
|
||||||
|
str(row.get("username", "")),
|
||||||
|
str(row.get("render_type", "")),
|
||||||
|
int(row.get("create_time", 0)),
|
||||||
|
int(row.get("sort_seq", 0)),
|
||||||
|
int(row.get("local_id", 0)),
|
||||||
|
int(row.get("server_id", 0)),
|
||||||
|
int(row.get("local_type", 0)),
|
||||||
|
str(row.get("db_stem", "message_0")),
|
||||||
|
str(row.get("table_name", "")),
|
||||||
|
str(row.get("sender_username", "")),
|
||||||
|
int(row.get("is_hidden", 0)),
|
||||||
|
int(row.get("is_official", 0)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def _seed_resource_db(
|
||||||
|
self,
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
md5: str,
|
||||||
|
server_id: int,
|
||||||
|
local_id: int,
|
||||||
|
create_time: int,
|
||||||
|
) -> None:
|
||||||
|
conn = sqlite3.connect(str(path))
|
||||||
|
try:
|
||||||
|
conn.execute("CREATE TABLE ChatName2Id (user_name TEXT)")
|
||||||
|
conn.execute("INSERT INTO ChatName2Id (rowid, user_name) VALUES (?, ?)", (7, username))
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE MessageResourceInfo (
|
||||||
|
message_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_svr_id INTEGER,
|
||||||
|
chat_id INTEGER,
|
||||||
|
message_local_type INTEGER,
|
||||||
|
packed_info BLOB,
|
||||||
|
message_local_id INTEGER,
|
||||||
|
message_create_time INTEGER
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
packed = f"/tmp/{md5}.dat".encode("utf-8")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO MessageResourceInfo
|
||||||
|
(message_svr_id, chat_id, message_local_type, packed_info, message_local_id, message_create_time)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(int(server_id), 7, 47, packed, int(local_id), int(create_time)),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def test_only_sticker_messages_outputs_core_stats(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_a"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
md5_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||||
|
md5_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"server_id": 1001,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 1, 1, 10, 5, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_a}" cdnurl="http://cdn/a.gif"/></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 2,
|
||||||
|
"server_id": 1002,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 1, 1, 10, 30, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_a}" cdnurl="http://cdn/a2.gif"/></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 3,
|
||||||
|
"server_id": 1003,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 1, 2, 22, 10, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_b}" cdnurl="http://cdn/b.gif"/></msg>',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||||
|
|
||||||
|
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
|
||||||
|
fts_rows = []
|
||||||
|
for row in rows:
|
||||||
|
fts_rows.append(
|
||||||
|
{
|
||||||
|
"text": "[表情]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "emoji",
|
||||||
|
"create_time": row["create_time"],
|
||||||
|
"sort_seq": row["local_id"],
|
||||||
|
"local_id": row["local_id"],
|
||||||
|
"server_id": row["server_id"],
|
||||||
|
"local_type": 47,
|
||||||
|
"db_stem": "message_0",
|
||||||
|
"table_name": table_name,
|
||||||
|
"sender_username": account,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
|
||||||
|
self.assertTrue(data["settings"]["usedIndex"])
|
||||||
|
self.assertEqual(data["sentStickerCount"], 3)
|
||||||
|
self.assertEqual(data["peakHour"], 10)
|
||||||
|
self.assertIsNotNone(data["peakWeekday"])
|
||||||
|
self.assertEqual(data["topBattlePartner"]["username"], friend)
|
||||||
|
self.assertEqual(data["topBattlePartner"]["stickerCount"], 3)
|
||||||
|
self.assertEqual(data["topBattlePartner"]["maskedName"], data["topBattlePartner"]["displayName"])
|
||||||
|
self.assertEqual(data["topStickers"][0]["md5"], md5_a)
|
||||||
|
self.assertEqual(data["topStickers"][0]["count"], 2)
|
||||||
|
self.assertTrue(str(data["topStickers"][0].get("sampleDisplayName") or "").strip())
|
||||||
|
self.assertTrue(str(data["topStickers"][0].get("sampleAvatarUrl") or "").startswith("/api/chat/avatar"))
|
||||||
|
|
||||||
|
def test_fallback_to_resource_md5_when_xml_missing(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_b"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
ts = self._ts(2025, 3, 8, 21, 0, 0)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"local_id": 11,
|
||||||
|
"server_id": 220011,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": ts,
|
||||||
|
"message_content": '<msg><emoji cdnurl="http://cdn/no_md5.gif"/></msg>',
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||||
|
|
||||||
|
md5_fallback = "cccccccccccccccccccccccccccccccc"
|
||||||
|
self._seed_resource_db(
|
||||||
|
account_dir / "message_resource.db",
|
||||||
|
username=friend,
|
||||||
|
md5=md5_fallback,
|
||||||
|
server_id=220011,
|
||||||
|
local_id=11,
|
||||||
|
create_time=ts,
|
||||||
|
)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
|
||||||
|
self.assertFalse(data["settings"]["usedIndex"])
|
||||||
|
self.assertEqual(data["sentStickerCount"], 1)
|
||||||
|
self.assertEqual(data["topStickers"][0]["md5"], md5_fallback)
|
||||||
|
self.assertEqual(data["topStickers"][0]["count"], 1)
|
||||||
|
|
||||||
|
def test_text_emoji_mapping_from_wechat_emojis_ts(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_c"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
|
||||||
|
fts_rows = [
|
||||||
|
{
|
||||||
|
"text": "早上好[微笑][微笑]🙂🙂",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "text",
|
||||||
|
"create_time": self._ts(2025, 4, 1, 9, 0, 0),
|
||||||
|
"local_id": 1,
|
||||||
|
"server_id": 901,
|
||||||
|
"local_type": 1,
|
||||||
|
"db_stem": "message_0",
|
||||||
|
"table_name": table_name,
|
||||||
|
"sender_username": account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "晚上见[微笑][发呆]😂",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "text",
|
||||||
|
"create_time": self._ts(2025, 4, 1, 22, 0, 0),
|
||||||
|
"local_id": 2,
|
||||||
|
"server_id": 902,
|
||||||
|
"local_type": 1,
|
||||||
|
"db_stem": "message_0",
|
||||||
|
"table_name": table_name,
|
||||||
|
"sender_username": account,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
self.assertTrue(data["settings"]["usedIndex"])
|
||||||
|
self.assertGreaterEqual(len(data["topTextEmojis"]), 1)
|
||||||
|
self.assertEqual(data["topTextEmojis"][0]["key"], "[微笑]")
|
||||||
|
self.assertEqual(data["topTextEmojis"][0]["count"], 3)
|
||||||
|
self.assertTrue(data["topTextEmojis"][0]["assetPath"].endswith("Expression_1@2x.png"))
|
||||||
|
self.assertGreaterEqual(len(data["topUnicodeEmojis"]), 1)
|
||||||
|
self.assertEqual(data["topUnicodeEmojis"][0]["emoji"], "🙂")
|
||||||
|
self.assertEqual(data["topUnicodeEmojis"][0]["count"], 2)
|
||||||
|
|
||||||
|
def test_wechat_builtin_emoji_from_packed_info_data(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_e"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
# packed_info_data protobuf varints:
|
||||||
|
# 08 04 => field#1=4
|
||||||
|
# 10 33 => field#2=51 (Expression_51@2x)
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"server_id": 501,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 10, 0, 0),
|
||||||
|
"message_content": "binary_emoji_payload_a",
|
||||||
|
"packed_info_data": bytes.fromhex("08041033"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 2,
|
||||||
|
"server_id": 502,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 10, 1, 0),
|
||||||
|
"message_content": "binary_emoji_payload_b",
|
||||||
|
"packed_info_data": bytes.fromhex("08041033"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 3,
|
||||||
|
"server_id": 503,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 11, 0, 0),
|
||||||
|
"message_content": "binary_emoji_payload_c",
|
||||||
|
"packed_info_data": bytes.fromhex("0804104a"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
|
||||||
|
self.assertFalse(data["settings"]["usedIndex"])
|
||||||
|
self.assertEqual(data["sentStickerCount"], 3)
|
||||||
|
self.assertGreaterEqual(len(data["topWechatEmojis"]), 1)
|
||||||
|
self.assertEqual(data["topWechatEmojis"][0]["id"], 51)
|
||||||
|
self.assertEqual(data["topWechatEmojis"][0]["count"], 2)
|
||||||
|
self.assertTrue(data["topWechatEmojis"][0]["assetPath"].endswith("Expression_51@2x.png"))
|
||||||
|
self.assertGreaterEqual(len(data["topStickers"]), 1)
|
||||||
|
self.assertEqual(data["topStickers"][0]["emojiId"], 51)
|
||||||
|
self.assertEqual(data["topStickers"][0]["count"], 2)
|
||||||
|
self.assertTrue(str(data["topStickers"][0].get("emojiAssetPath") or "").endswith("Expression_51@2x.png"))
|
||||||
|
|
||||||
|
def test_index_counts_only_sent_messages(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_sent_only"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"text": "[ 微 笑 ]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "text",
|
||||||
|
"create_time": self._ts(2025, 6, 2, 9, 0, 0),
|
||||||
|
"local_id": 101,
|
||||||
|
"server_id": 4001,
|
||||||
|
"local_type": 1,
|
||||||
|
"table_name": "msg_dummy",
|
||||||
|
"sender_username": account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "[ 发 呆 ]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "text",
|
||||||
|
"create_time": self._ts(2025, 6, 2, 9, 1, 0),
|
||||||
|
"local_id": 102,
|
||||||
|
"server_id": 4002,
|
||||||
|
"local_type": 1,
|
||||||
|
"table_name": "msg_dummy",
|
||||||
|
"sender_username": friend,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "[表情]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "emoji",
|
||||||
|
"create_time": self._ts(2025, 6, 2, 9, 2, 0),
|
||||||
|
"local_id": 201,
|
||||||
|
"server_id": 5001,
|
||||||
|
"local_type": 47,
|
||||||
|
"table_name": "msg_dummy",
|
||||||
|
"sender_username": account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "[表情]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "emoji",
|
||||||
|
"create_time": self._ts(2025, 6, 2, 9, 3, 0),
|
||||||
|
"local_id": 202,
|
||||||
|
"server_id": 5002,
|
||||||
|
"local_type": 47,
|
||||||
|
"table_name": "msg_dummy",
|
||||||
|
"sender_username": friend,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows=rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
self.assertTrue(data["settings"]["usedIndex"])
|
||||||
|
|
||||||
|
self.assertEqual(data["sentStickerCount"], 1)
|
||||||
|
|
||||||
|
keys = {x.get("key") for x in data.get("topTextEmojis") or []}
|
||||||
|
self.assertIn("[微笑]", keys)
|
||||||
|
self.assertNotIn("[发呆]", keys)
|
||||||
|
|
||||||
|
def test_raw_db_counts_only_sent_messages(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_raw_dir"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"server_id": 1001,
|
||||||
|
"local_type": 1,
|
||||||
|
"real_sender_id": 1,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 8, 0, 0),
|
||||||
|
"message_content": "/::B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 2,
|
||||||
|
"server_id": 1002,
|
||||||
|
"local_type": 1,
|
||||||
|
"real_sender_id": 2,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 8, 1, 0),
|
||||||
|
"message_content": "/::B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 3,
|
||||||
|
"server_id": 1101,
|
||||||
|
"local_type": 47,
|
||||||
|
"real_sender_id": 1,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 9, 0, 0),
|
||||||
|
"message_content": "binary_emoji_payload_a",
|
||||||
|
"packed_info_data": bytes.fromhex("08031033"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 4,
|
||||||
|
"server_id": 1102,
|
||||||
|
"local_type": 47,
|
||||||
|
"real_sender_id": 2,
|
||||||
|
"create_time": self._ts(2025, 7, 1, 9, 1, 0),
|
||||||
|
"message_content": "binary_emoji_payload_b",
|
||||||
|
"packed_info_data": bytes.fromhex("08031033"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
|
||||||
|
self.assertFalse(data["settings"]["usedIndex"])
|
||||||
|
self.assertEqual(data["sentStickerCount"], 1)
|
||||||
|
self.assertEqual(data["topWechatEmojis"][0]["id"], 51)
|
||||||
|
self.assertEqual(data["topWechatEmojis"][0]["count"], 1)
|
||||||
|
|
||||||
|
self.assertGreaterEqual(len(data["topTextEmojis"]), 1)
|
||||||
|
self.assertEqual(data["topTextEmojis"][0]["key"], "[色]")
|
||||||
|
self.assertEqual(data["topTextEmojis"][0]["count"], 1)
|
||||||
|
self.assertTrue(data["topTextEmojis"][0]["assetPath"].endswith("Expression_3@2x.png"))
|
||||||
|
|
||||||
|
def test_new_and_revived_sticker_metrics(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_new_revived"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
md5_revived = "dddddddddddddddddddddddddddddddd"
|
||||||
|
md5_recent = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
|
||||||
|
md5_new = "ffffffffffffffffffffffffffffffff"
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"server_id": 5001,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2024, 1, 1, 9, 0, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_revived}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 2,
|
||||||
|
"server_id": 5002,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2024, 12, 28, 10, 0, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_recent}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 3,
|
||||||
|
"server_id": 5003,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 1, 5, 11, 0, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_recent}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 4,
|
||||||
|
"server_id": 5004,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 3, 15, 12, 0, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_revived}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 5,
|
||||||
|
"server_id": 5005,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 5, 10, 13, 0, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_new}" /></msg>',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
|
||||||
|
self.assertEqual(data["sentStickerCount"], 3)
|
||||||
|
self.assertEqual(data["uniqueStickerTypeCount"], 3)
|
||||||
|
self.assertEqual(data["newStickerCountThisYear"], 1)
|
||||||
|
self.assertEqual(data["revivedStickerCount"], 1)
|
||||||
|
self.assertEqual(data["revivedMinGapDays"], 60)
|
||||||
|
self.assertGreaterEqual(int(data.get("revivedMaxGapDays") or 0), 400)
|
||||||
|
new_samples = list(data.get("newStickerSamples") or [])
|
||||||
|
revived_samples = list(data.get("revivedStickerSamples") or [])
|
||||||
|
self.assertTrue(any(str(x.get("md5") or "") == md5_new for x in new_samples))
|
||||||
|
self.assertTrue(any(str(x.get("md5") or "") == md5_revived for x in revived_samples))
|
||||||
|
revived_item = next((x for x in revived_samples if str(x.get("md5") or "") == md5_revived), {})
|
||||||
|
self.assertGreaterEqual(int(revived_item.get("gapDays") or 0), 400)
|
||||||
|
|
||||||
|
def test_empty_year_returns_safe_empty_state(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import build_card_04_emoji_universe
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[])
|
||||||
|
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["status"], "ok")
|
||||||
|
self.assertEqual(card["data"]["sentStickerCount"], 0)
|
||||||
|
self.assertIn("几乎没用表情表达", card["narrative"])
|
||||||
|
self.assertIsInstance(card["data"]["lines"], list)
|
||||||
|
self.assertGreaterEqual(len(card["data"]["lines"]), 1)
|
||||||
|
self.assertEqual(card["data"].get("topUnicodeEmojis"), [])
|
||||||
|
|
||||||
|
def test_tie_break_is_stable_by_key(self):
|
||||||
|
from wechat_decrypt_tool.wrapped.cards.card_04_emoji_universe import compute_emoji_universe_stats
|
||||||
|
|
||||||
|
with TemporaryDirectory() as td:
|
||||||
|
root = Path(td)
|
||||||
|
account = "wxid_me"
|
||||||
|
friend = "wxid_friend_d"
|
||||||
|
account_dir = root / account
|
||||||
|
account_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
self._seed_contact_db(account_dir / "contact.db", account=account, usernames=[friend])
|
||||||
|
self._seed_session_db(account_dir / "session.db", usernames=[friend])
|
||||||
|
|
||||||
|
md5_a = "11111111111111111111111111111111"
|
||||||
|
md5_b = "22222222222222222222222222222222"
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
"local_id": 1,
|
||||||
|
"server_id": 301,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 6, 1, 8, 0, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_a}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 2,
|
||||||
|
"server_id": 302,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 6, 1, 8, 1, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_b}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 3,
|
||||||
|
"server_id": 303,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 6, 1, 8, 2, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_a}" /></msg>',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"local_id": 4,
|
||||||
|
"server_id": 304,
|
||||||
|
"local_type": 47,
|
||||||
|
"create_time": self._ts(2025, 6, 1, 8, 3, 0),
|
||||||
|
"message_content": f'<msg><emoji md5="{md5_b}" /></msg>',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
self._seed_message_db(account_dir / "message_0.db", account=account, username=friend, rows=rows)
|
||||||
|
|
||||||
|
table_name = f"msg_{hashlib.md5(friend.encode('utf-8')).hexdigest()}"
|
||||||
|
fts_rows = []
|
||||||
|
for row in rows:
|
||||||
|
fts_rows.append(
|
||||||
|
{
|
||||||
|
"text": "[表情]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "emoji",
|
||||||
|
"create_time": row["create_time"],
|
||||||
|
"local_id": row["local_id"],
|
||||||
|
"server_id": row["server_id"],
|
||||||
|
"local_type": 47,
|
||||||
|
"db_stem": "message_0",
|
||||||
|
"table_name": table_name,
|
||||||
|
"sender_username": account,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
fts_rows.extend(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
# `chat_search_index` stores text as char-tokens: "[微笑][发呆]" -> "[ 微 笑 ] [ 发 呆 ]"
|
||||||
|
"text": "[ 微 笑 ] [ 发 呆 ]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "text",
|
||||||
|
"create_time": self._ts(2025, 6, 2, 9, 0, 0),
|
||||||
|
"local_id": 101,
|
||||||
|
"server_id": 4001,
|
||||||
|
"local_type": 1,
|
||||||
|
"db_stem": "message_0",
|
||||||
|
"table_name": table_name,
|
||||||
|
"sender_username": account,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "[ 发 呆 ] [ 微 笑 ]",
|
||||||
|
"username": friend,
|
||||||
|
"render_type": "text",
|
||||||
|
"create_time": self._ts(2025, 6, 2, 9, 1, 0),
|
||||||
|
"local_id": 102,
|
||||||
|
"server_id": 4002,
|
||||||
|
"local_type": 1,
|
||||||
|
"db_stem": "message_0",
|
||||||
|
"table_name": table_name,
|
||||||
|
"sender_username": account,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._seed_index_db(account_dir / "chat_search_index.db", rows=fts_rows)
|
||||||
|
|
||||||
|
data = compute_emoji_universe_stats(account_dir=account_dir, year=2025)
|
||||||
|
|
||||||
|
self.assertEqual(data["topStickers"][0]["md5"], md5_a)
|
||||||
|
expected_emoji_key = sorted(["[微笑]", "[发呆]"])[0]
|
||||||
|
self.assertEqual(data["topTextEmojis"][0]["key"], expected_emoji_key)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user