mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 22:30:49 +08:00
feat(wrapped-ui): 年度总结页支持懒加载与复古模式,新增概览/字数卡片
- wrapped 页面改为:先拉 meta/年份列表,再按页请求单张卡片,首屏更快 - 新增 Card#0 全局概览页(含图表) - 新增 Card#2 消息字数页(含键盘敲击统计与图表) - 新增复古模式:像素字体资源 + CRT Overlay,支持一键开关 - 调整 shared 组件、types/useApi,更新前端依赖与 lock
This commit is contained in:
194
frontend/components/wrapped/cards/Card00GlobalOverview.vue
Normal file
194
frontend/components/wrapped/cards/Card00GlobalOverview.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<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>
|
||||
@@ -1,11 +1,5 @@
|
||||
<template>
|
||||
<WrappedCardShell :card-id="card.id" :title="card.title" :narrative="card.narrative" :variant="variant">
|
||||
<template #badge>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-xs bg-[#07C160]/10 text-[#07C160] border border-[#07C160]/20">
|
||||
作息规律
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<WeekdayHourHeatmap
|
||||
:weekday-labels="card.data?.weekdayLabels"
|
||||
:hour-labels="card.data?.hourLabels"
|
||||
|
||||
42
frontend/components/wrapped/cards/Card02MessageChars.vue
Normal file
42
frontend/components/wrapped/cards/Card02MessageChars.vue
Normal file
@@ -0,0 +1,42 @@
|
||||
<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="sentChars > 0">
|
||||
这一年,你在微信里敲下了
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(sentChars) }}</span>
|
||||
个字。
|
||||
</template>
|
||||
<template v-else>
|
||||
这一年,你还没有发出文字消息。
|
||||
</template>
|
||||
|
||||
<template v-if="receivedChars > 0">
|
||||
你也收到了
|
||||
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(receivedChars) }}</span>
|
||||
个字。
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<MessageCharsChart :data="card.data || {}" />
|
||||
</WrappedCardShell>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import MessageCharsChart from '~/components/wrapped/visualizations/MessageCharsChart.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 sentChars = computed(() => Number(props.card?.data?.sentChars || 0))
|
||||
const receivedChars = computed(() => Number(props.card?.data?.receivedChars || 0))
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user