Files
WeChatDataAnalysis/frontend/components/wrapped/cards/Card00GlobalOverview.vue
2977094657 645dc1cff1 feat(wrapped-ui): 年度总结页支持懒加载与复古模式,新增概览/字数卡片
- wrapped 页面改为:先拉 meta/年份列表,再按页请求单张卡片,首屏更快
- 新增 Card#0 全局概览页(含图表)
- 新增 Card#2 消息字数页(含键盘敲击统计与图表)
- 新增复古模式:像素字体资源 + CRT Overlay,支持一键开关
- 调整 shared 组件、types/useApi,更新前端依赖与 lock
2026-01-31 14:54:43 +08:00

195 lines
7.8 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="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 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>