Files
WeChatDataAnalysis/frontend/components/wrapped/cards/Card00GlobalOverview.vue
2977094657 94e6e89f35 improvement(wrapped): 概览卡片补充年度新增好友统计
- 后端 Card#0 增加 addedFriends 字段(基于系统消息关键字 best-effort 识别新好友)

- 前端概览文案在 addedFriends>0 时展示新增好友数

- bump wrapped cache version,避免旧缓存导致字段缺失
2026-02-04 16:25:29 +08:00

201 lines
8.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="''" :variant="variant">
<template #narrative>
<div class="mt-2 wrapped-body text-sm text-[#7F7F7F] leading-relaxed">
<p>
<template v-if="totalMessages > 0">
这一年你在微信里发送了
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(totalMessages) }}</span>
条消息平均每天
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatFloat(messagesPerDay, 1) }}</span>
</template>
<template v-else>
这一年你在微信里还没有发出聊天消息也许你把时间留给了更重要的人和事
</template>
<template v-if="activeDays > 0">
在与你相伴的
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(activeDays) }}</span>
天里
<template v-if="addedFriends > 0">
你总共加了
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(addedFriends) }}</span>
位好友
</template>
<template v-if="mostActiveHour !== null && mostActiveWeekdayName">
你最常在 {{ mostActiveWeekdayName }}
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveHour }}</span>
点出现
</template>
<template v-else>
你留下了不少对话的痕迹
</template>
</template>
<template v-if="topContact || topGroup">
<template v-if="topContact">
你发消息最多的人是
<span
class="privacy-blur inline-flex items-center gap-2 align-bottom max-w-[12rem]"
:title="topContact.displayName"
>
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<img
v-if="topContactAvatarUrl && avatarOk.topContact"
:src="topContactAvatarUrl"
class="w-full h-full object-cover"
alt="avatar"
@error="avatarOk.topContact = false"
/>
<span v-else class="wrapped-number text-[11px] text-[#00000066]">
{{ avatarFallback(topContact.displayName) }}
</span>
</span>
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topContact.displayName }}</span>
</span>
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topContact.messages) }}</span>
</template>
<template v-if="topContact && topGroup"></template>
<template v-if="topGroup">
你最常发言的群是
<span
class="privacy-blur inline-flex items-center gap-2 align-bottom max-w-[12rem]"
:title="topGroup.displayName"
>
<span class="w-6 h-6 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<img
v-if="topGroupAvatarUrl && avatarOk.topGroup"
:src="topGroupAvatarUrl"
class="w-full h-full object-cover"
alt="avatar"
@error="avatarOk.topGroup = false"
/>
<span v-else class="wrapped-number text-[11px] text-[#00000066]">
{{ avatarFallback(topGroup.displayName) }}
</span>
</span>
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topGroup.displayName }}</span>
</span>
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topGroup.messages) }}</span>
</template>
</template>
<template v-if="topKind && topKindPct > 0">
你更常用 {{ topKind.label }} 来表达<span class="wrapped-number text-[#07C160] font-semibold">{{ topKindPct }}</span>%
</template>
<template v-if="topPhrase && topPhrase.phrase && topPhrase.count > 0">
你说得最多的一句话是<span
class="privacy-blur inline-block max-w-[12rem] truncate align-bottom"
:title="topPhrase.phrase"
>{{ topPhrase.phrase }}</span><span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(topPhrase.count) }}</span>
</template>
<span class="hidden sm:inline text-[#00000055]">愿你的每一句分享都有人回应</span>
</p>
</div>
</template>
<GlobalOverviewChart :data="card.data || {}" />
</WrappedCardShell>
</template>
<script setup>
import GlobalOverviewChart from '~/components/wrapped/visualizations/GlobalOverviewChart.vue'
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 formatFloat = (n, digits = 1) => {
const v = Number(n)
if (!Number.isFinite(v)) return '0'
return v.toFixed(digits)
}
const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0))
const activeDays = computed(() => Number(props.card?.data?.activeDays || 0))
const addedFriends = computed(() => Number(props.card?.data?.addedFriends || 0))
const messagesPerDay = computed(() => Number(props.card?.data?.messagesPerDay || 0))
const mostActiveHour = computed(() => {
const h = props.card?.data?.mostActiveHour
return Number.isFinite(Number(h)) ? Number(h) : null
})
const mostActiveWeekdayName = computed(() => {
const s = props.card?.data?.mostActiveWeekdayName
return typeof s === 'string' && s.trim() ? s.trim() : ''
})
const topContact = computed(() => {
const o = props.card?.data?.topContact
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
const topGroup = computed(() => {
const o = props.card?.data?.topGroup
return o && typeof o === 'object' && typeof o.displayName === 'string' ? o : null
})
// Keep the same behavior as the chat page: media (including avatars) comes from backend :8000 in dev.
const mediaBase = process.client ? 'http://localhost:8000' : ''
const resolveMediaUrl = (value) => {
const raw = String(value || '').trim()
if (!raw) return ''
if (/^https?:\/\//i.test(raw)) {
// qpic/qlogo are often hotlink-protected; proxy via backend (same as chat page).
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
}
// Most backend fields are like "/api/...", so just prefix.
return `${mediaBase}${raw.startsWith('/') ? '' : '/'}${raw}`
}
const topContactAvatarUrl = computed(() => {
return resolveMediaUrl(topContact.value?.avatarUrl)
})
const topGroupAvatarUrl = computed(() => {
return resolveMediaUrl(topGroup.value?.avatarUrl)
})
const avatarOk = reactive({ topContact: true, topGroup: true })
const avatarFallback = (name) => {
const s = String(name || '').trim()
if (!s) return '?'
return s[0]
}
watch(topContactAvatarUrl, () => { avatarOk.topContact = true })
watch(topGroupAvatarUrl, () => { avatarOk.topGroup = true })
const topKind = computed(() => {
const o = props.card?.data?.topKind
return o && typeof o === 'object' && typeof o.label === 'string' ? o : null
})
const topKindPct = computed(() => {
const r = Number(topKind.value?.ratio || 0)
if (!Number.isFinite(r) || r <= 0) return 0
return Math.max(0, Math.min(100, Math.round(r * 100)))
})
const topPhrase = computed(() => {
const o = props.card?.data?.topPhrase
return o && typeof o === 'object' ? o : null
})
</script>