mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
- 后端 Card#0 增加 addedFriends 字段(基于系统消息关键字 best-effort 识别新好友) - 前端概览文案在 addedFriends>0 时展示新增好友数 - bump wrapped cache version,避免旧缓存导致字段缺失
201 lines
8.1 KiB
Vue
201 lines
8.1 KiB
Vue
<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>
|