improvement(wrapped-ui): 年度总结新增隐私模式开关(昵称/头像/消息/词云可模糊)

- Wrapped 页面右上角新增隐私模式开关,并在 deck 根节点挂载 wrapped-privacy

- Tailwind 新增 wrapped-privacy-* 细粒度模糊样式:仅模糊昵称/消息/关键词/头像,hover 可临时显示

- Wrapped 各卡片/可视化统一接入新 class,逐步替换原 privacy-blur 用法(词云/聊天回放/气泡等)
This commit is contained in:
2977094657
2026-02-27 16:43:28 +08:00
parent 6b24daf7bf
commit 43104ef2c4
12 changed files with 166 additions and 86 deletions
+40
View File
@@ -90,6 +90,46 @@
.privacy-blur:hover {
filter: none;
}
/* Wrapped 隐私模式:仅模糊“用户名文本”,头像不模糊(避免把头像也 blur 掉) */
.wrapped-privacy .wrapped-privacy-name {
filter: blur(9px);
transition: filter 0.2s ease;
}
.wrapped-privacy .wrapped-privacy-name:hover {
filter: none;
}
/* Wrapped 隐私模式:模糊“消息内容文本”(仅在被标记为 message 的节点上生效) */
.wrapped-privacy .wrapped-privacy-message {
filter: blur(9px);
transition: filter 0.2s ease;
}
.wrapped-privacy .wrapped-privacy-message:hover {
filter: none;
}
/* Wrapped 隐私模式:模糊“词云关键词” */
.wrapped-privacy .wrapped-privacy-keyword {
filter: blur(9px);
transition: filter 0.2s ease;
}
.wrapped-privacy .wrapped-privacy-keyword:hover {
filter: none;
}
/* Wrapped 隐私模式:模糊头像(含 fallback 字符) */
.wrapped-privacy .wrapped-privacy-avatar {
filter: blur(9px);
transition: filter 0.2s ease;
}
.wrapped-privacy .wrapped-privacy-avatar:hover {
filter: none;
}
/* 按钮样式 */
.btn {
@apply px-6 py-3 rounded-full font-medium transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 transform active:scale-95;
@@ -36,11 +36,8 @@
<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">
<span class="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 wrapped-privacy-avatar">
<img
v-if="topContactAvatarUrl && avatarOk.topContact"
:src="topContactAvatarUrl"
@@ -52,18 +49,15 @@
{{ avatarFallback(topContact.displayName) }}
</span>
</span>
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topContact.displayName }}</span>
<span class="wrapped-privacy-name 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">
<span class="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 wrapped-privacy-avatar">
<img
v-if="topGroupAvatarUrl && avatarOk.topGroup"
:src="topGroupAvatarUrl"
@@ -75,7 +69,7 @@
{{ avatarFallback(topGroup.displayName) }}
</span>
</span>
<span class="inline-block max-w-[10rem] truncate align-bottom">{{ topGroup.displayName }}</span>
<span class="wrapped-privacy-name 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>
@@ -87,10 +81,7 @@
</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>
你说得最多的一句话是<span class="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>
@@ -66,39 +66,39 @@
<!-- 最早最晚消息描述按一天中的时刻 -->
<template v-if="earliestSent && latestSent && totalMessages > 0">
<template v-if="sameMomentTarget">
最先想起的是<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最后放不下的也还是<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最先想起的是<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
最后放不下的也还是<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
</template>
<template v-else>
<template v-if="sameMomentDate">
{{ earliestDateLabel }}最早的一条发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
{{ earliestDateLabel }}最早的一条发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
最晚的一条发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="!hasMomentDates">
最早的一条发给了
<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
最晚的一条发给了
<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="momentVariant === 0">
最早的一条{{ earliestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条{{ latestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
最早的一条{{ earliestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
最晚的一条{{ latestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="momentVariant === 1">
最早的收件人是<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>{{ earliestDateLabel }}
最晚的收件人是<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>{{ latestDateLabel }}
最早的收件人是<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>{{ earliestDateLabel }}
最晚的收件人是<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>{{ latestDateLabel }}
</template>
<template v-else-if="momentVariant === 2">
{{ earliestDateLabel }}你把消息发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
{{ latestDateLabel }}你又发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
{{ earliestDateLabel }}你把消息发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
{{ latestDateLabel }}你又发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>
</template>
<template v-else-if="momentVariant === 3">
最早与最晚分别写给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>{{ earliestDateLabel }}
<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>{{ latestDateLabel }}
最早与最晚分别写给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>{{ earliestDateLabel }}
<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>{{ latestDateLabel }}
</template>
<template v-else>
最早的一条落在 {{ earliestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ earliestSent.displayName }}</span>
最晚的一条落在 {{ latestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold">{{ latestSent.displayName }}</span>
最早的一条落在 {{ earliestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ earliestSent.displayName }}</span>
最晚的一条落在 {{ latestDateLabel }}发给了<span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ latestSent.displayName }}</span>
</template>
</template>
</template>
@@ -111,15 +111,15 @@
v-if="yearFirstSent.avatarUrl"
:src="yearFirstSent.avatarUrl"
:alt="yearFirstSent.displayName"
class="inline-block w-5 h-5 rounded align-middle mx-0.5"
/><span class="wrapped-number text-[#07C160] font-semibold">{{ yearFirstSent.displayName }}</span>{{ yearFirstSent.content || '...' }}<template v-if="yearLastSent">
class="inline-block w-5 h-5 rounded align-middle mx-0.5 wrapped-privacy-avatar"
/><span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ yearFirstSent.displayName }}</span><span class="wrapped-privacy-message">{{ yearFirstSent.content || '...' }}</span><template v-if="yearLastSent">
最后一条消息<span class="wrapped-number text-[#07C160] font-semibold">{{ yearLastDateLabel }} {{ yearLastSent.time }}</span>发给了
<img
v-if="yearLastSent.avatarUrl"
:src="yearLastSent.avatarUrl"
:alt="yearLastSent.displayName"
class="inline-block w-5 h-5 rounded align-middle mx-0.5"
/><span class="wrapped-number text-[#07C160] font-semibold">{{ yearLastSent.displayName }}</span>{{ yearLastSent.content || '...' }}</template>
class="inline-block w-5 h-5 rounded align-middle mx-0.5 wrapped-privacy-avatar"
/><span class="wrapped-number text-[#07C160] font-semibold wrapped-privacy-name">{{ yearLastSent.displayName }}</span><span class="wrapped-privacy-message">{{ yearLastSent.content || '...' }}</span></template>
<template v-if="sameYearTarget">
<span class="text-[#7F7F7F]">从年初到年末始终如一</span>
</template>
@@ -11,7 +11,7 @@
class="inline-flex items-center gap-2 align-bottom px-1.5 py-0.5 rounded-lg bg-[#00000008]"
:title="bestBuddy?.displayName || ''"
>
<span class="w-5 h-5 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<span class="w-5 h-5 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
<img
v-if="bestBuddyAvatarUrl && avatarOk.best"
:src="bestBuddyAvatarUrl"
@@ -23,7 +23,7 @@
{{ avatarFallback(bestBuddy?.displayName) }}
</span>
</span>
<span class="wrapped-body text-sm text-[#000000e6] max-w-[12rem] truncate">
<span class="wrapped-body text-sm text-[#000000e6] max-w-[12rem] truncate wrapped-privacy-name">
{{ bestBuddy?.displayName || '' }}
</span>
</span>
@@ -35,7 +35,7 @@
class="inline-flex items-center gap-1.5 align-bottom px-1.5 py-0.5 rounded-lg bg-[#00000008]"
:title="seg.contact?.displayName || ''"
>
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0">
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
<img
v-if="resolveMediaUrl(seg.contact?.avatarUrl) && avatarOk[seg.contact?.username] !== false"
:src="resolveMediaUrl(seg.contact?.avatarUrl)"
@@ -47,7 +47,7 @@
{{ avatarFallback(seg.contact?.displayName) }}
</span>
</span>
<span class="wrapped-body text-sm text-[#000000e6] max-w-[8rem] truncate">
<span class="wrapped-body text-sm text-[#000000e6] max-w-[8rem] truncate wrapped-privacy-name">
{{ seg.contact?.displayName || '' }}
</span>
</span>
@@ -95,7 +95,7 @@
<!-- 主内容抽奖揭晓 + 右侧年度 Top10 总消息 bar race -->
<div v-else class="w-full">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-center">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8 items-start">
<!-- Left: 抽奖区 -->
<div
class="reply-buddy-rail flex flex-col items-center justify-center transition-transform duration-500 will-change-transform"
@@ -109,7 +109,7 @@
<img
v-if="shownAvatarUrl && shownAvatarOk"
:src="shownAvatarUrl"
class="w-full h-full object-cover"
class="w-full h-full object-cover wrapped-privacy-avatar"
alt="avatar"
@error="onShownAvatarError"
/>
@@ -121,7 +121,7 @@
/>
<div
v-else
class="w-full h-full flex items-center justify-center"
class="w-full h-full flex items-center justify-center wrapped-privacy-avatar"
>
<span class="wrapped-number text-3xl text-[#00000066]">
{{ shownAvatarFallback }}
@@ -129,7 +129,7 @@
</div>
</div>
<div class="mt-4 min-h-[1.75rem] wrapped-body text-base text-[#000000e6] max-w-[18rem] truncate" :title="shownDisplayName">
<div class="mt-4 min-h-[1.75rem] wrapped-body text-base text-[#000000e6] max-w-[18rem] truncate wrapped-privacy-name" :title="shownDisplayName">
{{ shownDisplayName }}
</div>
@@ -210,7 +210,7 @@
</div>
<div
class="w-7 h-7 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0"
class="w-7 h-7 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar"
>
<img
v-if="item.avatarUrl && avatarOk[item.username] !== false"
@@ -227,7 +227,7 @@
<div class="min-w-0 flex-1">
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<div class="wrapped-body text-[#000000e6] text-sm truncate" :title="item.displayName">
<div class="wrapped-body text-[#000000e6] text-sm truncate wrapped-privacy-name" :title="item.displayName">
{{ item.displayName }}
</div>
</div>
@@ -58,7 +58,7 @@
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">
<span class="w-4 h-4 rounded-md overflow-hidden bg-[#0000000d] flex items-center justify-center flex-shrink-0 wrapped-privacy-avatar">
<img
v-if="heroStickerOwnerAvatarUrl && avatarOk.topStickerOwner"
:src="heroStickerOwnerAvatarUrl"
@@ -71,7 +71,7 @@
</span>
</span>
<span class="wrapped-body text-[11px] text-[#00000080] truncate">
常发送给 <span class="text-[#07C160] font-semibold">{{ heroStickerOwnerName || '未知' }}</span>
常发送给 <span class="text-[#07C160] font-semibold wrapped-privacy-name">{{ heroStickerOwnerName || '未知' }}</span>
</span>
</div>
@@ -30,7 +30,7 @@
<template v-if="item.winner">
<div class="flex items-start gap-1.5 pt-0.5 px-0.5">
<!-- 头像 -->
<div class="polaroid-photo flex-shrink-0">
<div class="polaroid-photo flex-shrink-0 wrapped-privacy-avatar">
<img
v-if="winnerAvatar(item) && avatarOk[item.winner.username] !== false"
:src="winnerAvatar(item)"
@@ -46,7 +46,7 @@
<div class="flex-1 min-w-0 pt-0.5 flex flex-col justify-between" style="height:70px">
<div>
<div class="flex items-center justify-between gap-1 min-w-0">
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight" :title="item.winner.displayName">
<div class="wrapped-body text-[10px] text-[#000000cc] truncate flex-1 leading-tight wrapped-privacy-name" :title="item.winner.displayName">
{{ item.winner.displayName }}
</div>
<!-- 月份徽章 -->
@@ -6,6 +6,7 @@
v-if="showOverlay"
ref="overlayEl"
class="kw-overlay fixed inset-0 overflow-hidden"
:class="{ 'wrapped-privacy': privacyMode }"
:style="{ zIndex: 9999 }"
@pointerdown="onStagePointerDown"
>
@@ -31,13 +32,15 @@
<div
class="px-3 py-2 text-sm max-w-sm relative msg-bubble whitespace-pre-wrap break-words leading-relaxed bg-[#95EC69] text-black bubble-tail-r"
>
<span v-if="Array.isArray(b.segments) && b.segments.length > 0">
<span v-for="(seg, idx) in b.segments" :key="`${b.id}-${idx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
<span class="wrapped-privacy-message">
<span v-if="Array.isArray(b.segments) && b.segments.length > 0">
<span v-for="(seg, idx) in b.segments" :key="`${b.id}-${idx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px" />
</span>
</span>
<span v-else>{{ b.text }}</span>
</span>
<span v-else>{{ b.text }}</span>
</div>
</div>
</div>
@@ -56,7 +59,7 @@
<template v-else>
这一年你一共发出了 <span class="font-medium text-[#07C160]">{{ card.data?.meta?.matchedCandidates || 0 }}</span> 句简短的表达其中 <span class="font-medium text-[#07C160]">{{ card.data?.meta?.uniquePhrases || 0 }}</span> 句话成了你的专属口头禅
<template v-if="card.data?.topKeyword">
<span class="font-medium text-[#07C160]">{{ card.data.topKeyword.word }}</span>是你最常说的话足足被你重复了 <span class="font-medium text-[#07C160]">{{ card.data.topKeyword.count }}</span>
<span class="font-medium text-[#07C160] wrapped-privacy-keyword">{{ card.data.topKeyword.word }}</span>是你最常说的话足足被你重复了 <span class="font-medium text-[#07C160]">{{ card.data.topKeyword.count }}</span>
</template>
点击气泡找回当时的心情
</template>
@@ -93,15 +96,20 @@
<script setup>
import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { gsap } from 'gsap'
import KeywordWordCloud from '~/components/wrapped/visualizations/KeywordWordCloud.vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { usePrivacyStore } from '~/stores/privacy'
const props = defineProps({
card: { type: Object, required: true },
variant: { type: String, default: 'panel' } // 'panel' | 'slide'
})
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
const cardRoot = ref(null)
const stageEl = ref(null)
const overlayEl = ref(null)
@@ -770,6 +778,7 @@ watch(
)
onMounted(() => {
privacyStore.init()
if (!import.meta.client) return
detectReducedMotion()
@@ -172,14 +172,14 @@
<template v-if="bestBuddyAvatarUrl && !broken.bestBuddy">
<img
:src="bestBuddyAvatarUrl"
class="w-full h-full rounded-full border-[3px] border-white object-cover relative z-10 shadow-lg bg-gray-50 filter drop-shadow-[0_4px_12px_rgba(244,114,182,0.3)] transition-transform duration-500 hover:scale-105"
class="w-full h-full rounded-full border-[3px] border-white object-cover relative z-10 shadow-lg bg-gray-50 filter drop-shadow-[0_4px_12px_rgba(244,114,182,0.3)] transition-transform duration-500 hover:scale-105 wrapped-privacy-avatar"
:alt="bestBuddyName"
@error="markBroken('bestBuddy')"
/>
</template>
<template v-else>
<div
class="w-full h-full rounded-full border-[3px] border-white relative z-10 shadow-lg bg-gray-50 flex items-center justify-center font-black text-xl text-pink-600"
class="w-full h-full rounded-full border-[3px] border-white relative z-10 shadow-lg bg-gray-50 flex items-center justify-center font-black text-xl text-pink-600 wrapped-privacy-avatar"
>
{{ avatarFallback(bestBuddyName) }}
</div>
@@ -192,7 +192,7 @@
</div>
</div>
<div class="text-lg sm:text-xl font-black tracking-tight text-gray-800 text-center truncate w-full px-1">
<div class="text-lg sm:text-xl font-black tracking-tight text-gray-800 text-center truncate w-full px-1 wrapped-privacy-name">
{{ bestBuddyName }}
</div>
</div>
@@ -267,7 +267,7 @@
<div class="shrink-0">
<template v-if="topGroupAvatarUrl && !broken.topGroup">
<img
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-2 border-white shadow-sm ring-1 ring-sky-500/20 bg-gray-50 object-cover"
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-2 border-white shadow-sm ring-1 ring-sky-500/20 bg-gray-50 object-cover wrapped-privacy-avatar"
:src="topGroupAvatarUrl"
:alt="topGroupName"
@error="markBroken('topGroup')"
@@ -275,7 +275,7 @@
</template>
<template v-else>
<div
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-2 border-white shadow-sm ring-1 ring-sky-500/20 bg-sky-100 flex items-center justify-center font-black text-sky-700"
class="w-16 h-16 sm:w-20 sm:h-20 rounded-full border-2 border-white shadow-sm ring-1 ring-sky-500/20 bg-sky-100 flex items-center justify-center font-black text-sky-700 wrapped-privacy-avatar"
>
{{ avatarFallback(topGroupName) }}
</div>
@@ -283,7 +283,7 @@
</div>
<div class="flex flex-col min-w-0 flex-1">
<div class="text-base sm:text-lg font-black tracking-tight text-gray-800 w-full truncate">{{ topGroupName }}</div>
<div class="text-base sm:text-lg font-black tracking-tight text-gray-800 w-full truncate wrapped-privacy-name">{{ topGroupName }}</div>
<div
class="mt-1 text-[10px] sm:text-xs font-semibold text-sky-600 bg-sky-500/10 px-3 py-1 rounded-full border border-sky-500/10 inline-flex items-center gap-1.5 max-w-full truncate self-start"
>
@@ -344,7 +344,7 @@
<div class="ml-auto shrink-0" :title="fastestContactName">
<template v-if="fastestAvatarUrl && !broken.fastestAvatar">
<img
class="w-7 h-7 rounded-full bg-white border border-emerald-500/20 shadow-sm object-cover"
class="w-7 h-7 rounded-full bg-white border border-emerald-500/20 shadow-sm object-cover wrapped-privacy-avatar"
:src="fastestAvatarUrl"
:alt="fastestContactName"
@error="markBroken('fastestAvatar')"
@@ -352,7 +352,7 @@
</template>
<template v-else>
<div
class="w-7 h-7 rounded-full bg-emerald-50 border border-emerald-500/15 shadow-sm flex items-center justify-center text-[11px] font-black text-emerald-700"
class="w-7 h-7 rounded-full bg-emerald-50 border border-emerald-500/15 shadow-sm flex items-center justify-center text-[11px] font-black text-emerald-700 wrapped-privacy-avatar"
>
{{ avatarFallback(fastestContactName) }}
</div>
@@ -372,7 +372,7 @@
<div class="ml-auto shrink-0" :title="slowestContactName">
<template v-if="slowestAvatarUrl && !broken.slowestAvatar">
<img
class="w-7 h-7 rounded-full bg-white border border-rose-500/20 shadow-sm object-cover"
class="w-7 h-7 rounded-full bg-white border border-rose-500/20 shadow-sm object-cover wrapped-privacy-avatar"
:src="slowestAvatarUrl"
:alt="slowestContactName"
@error="markBroken('slowestAvatar')"
@@ -380,7 +380,7 @@
</template>
<template v-else>
<div
class="w-7 h-7 rounded-full bg-rose-50 border border-rose-500/15 shadow-sm flex items-center justify-center text-[11px] font-black text-rose-700"
class="w-7 h-7 rounded-full bg-rose-50 border border-rose-500/15 shadow-sm flex items-center justify-center text-[11px] font-black text-rose-700 wrapped-privacy-avatar"
>
{{ avatarFallback(slowestContactName) }}
</div>
@@ -552,16 +552,16 @@
<div v-if="showMonthlyHero" class="monthly-hero">
<div class="monthly-avatar-lg">
<template v-if="monthlyMvpAvatarUrl && !broken.monthlyMvp">
<img :src="monthlyMvpAvatarUrl" alt="MVP" @error="markBroken('monthlyMvp')" />
<img :src="monthlyMvpAvatarUrl" class="wrapped-privacy-avatar" alt="MVP" @error="markBroken('monthlyMvp')" />
</template>
<template v-else>
<div class="monthly-avatar-fallback-lg">{{ avatarFallback(monthlyMvpName) }}</div>
<div class="monthly-avatar-fallback-lg wrapped-privacy-avatar">{{ avatarFallback(monthlyMvpName) }}</div>
</template>
</div>
<div class="monthly-hero-info">
<span
class="text-2xl sm:text-3xl font-black tracking-tight text-transparent bg-clip-text bg-gradient-to-br from-teal-600 to-emerald-500 leading-none truncate"
class="text-2xl sm:text-3xl font-black tracking-tight text-transparent bg-clip-text bg-gradient-to-br from-teal-600 to-emerald-500 leading-none truncate wrapped-privacy-name"
>
{{ monthlyMvpName }}
</span>
@@ -596,19 +596,19 @@
>
<template v-if="item._avatarUrl && !broken['m-' + item.month]">
<img
class="monthly-avatar-sm"
class="monthly-avatar-sm wrapped-privacy-avatar"
:src="item._avatarUrl"
:alt="item._nameLabel"
@error="markBroken('m-' + item.month)"
/>
</template>
<template v-else>
<div class="monthly-avatar-fallback-sm" :style="{ background: item._avatarFallbackBg }">
<div class="monthly-avatar-fallback-sm wrapped-privacy-avatar" :style="{ background: item._avatarFallbackBg }">
{{ avatarFallback(item._nameLabel) }}
</div>
</template>
<div class="monthly-cell-month">{{ item.month }}</div>
<div v-if="!showMonthlyHero" class="monthly-cell-name">{{ item._nameLabel }}</div>
<div v-if="!showMonthlyHero" class="monthly-cell-name wrapped-privacy-name">{{ item._nameLabel }}</div>
</div>
</div>
<div class="bento-watermark text-teal-600">📅</div>
@@ -11,12 +11,6 @@
<template v-if="variant === 'slide'">
<div class="h-full flex flex-col justify-between">
<div class="flex items-start justify-between gap-4">
<div class="wrapped-label text-xs text-[#00000080]">
WECHAT WRAPPED
</div>
<div class="wrapped-body text-xs text-[#00000055]">
年度回望
</div>
</div>
<div class="mt-10 sm:mt-14">
@@ -3,7 +3,7 @@
<!-- Top bar -->
<div class="wrapped-chat-replay__top">
<div class="wrapped-chat-replay__top-left">
<div :class="['wrapped-chat-replay__avatar', { 'privacy-blur': privacyMode }]">
<div class="wrapped-chat-replay__avatar wrapped-privacy-avatar">
<img
v-if="resolvedAvatarUrl && avatarOk"
:src="resolvedAvatarUrl"
@@ -17,7 +17,7 @@
<div class="min-w-0">
<div class="wrapped-label text-[10px] text-[#00000066]">{{ label }}</div>
<div class="wrapped-body text-sm text-[#000000e6] truncate" :title="displayName">
<div class="wrapped-body text-sm text-[#000000e6] truncate wrapped-privacy-name" :title="displayName">
{{ displayNameShown }}
</div>
</div>
@@ -42,7 +42,7 @@
<transition name="wrapped-chat-replay-slide">
<div v-if="showBubble" class="wrapped-chat-replay__bubble">
<div :class="['wrapped-chat-replay__bubble-text', { 'privacy-blur': privacyMode }]" :title="content">
<div class="wrapped-chat-replay__bubble-text wrapped-privacy-message" :title="content">
{{ typedText }}
</div>
</div>
@@ -18,7 +18,7 @@
:title="`${w.word} · ${formatInt(w.count)} 次`"
@pointerdown.stop="selectWord(w.word, $event)"
>
{{ w.word }}
<span class="wrapped-privacy-keyword">{{ w.word }}</span>
</button>
</div>
</div>
@@ -37,6 +37,7 @@
<div
v-if="selectedInfo"
class="kw-panel fixed z-[100] w-[min(92%,420px)] rounded-2xl border border-[#EDEDED] bg-white/80 backdrop-blur shadow-[0_16px_40px_rgba(0,0,0,0.14)] overflow-hidden"
:class="{ 'wrapped-privacy': privacyMode }"
:style="panelStyle"
data-no-accel
@pointerdown.stop
@@ -44,7 +45,7 @@
<div class="flex items-start justify-between gap-3 px-4 pt-4 pb-2 border-b border-[#F3F3F3]">
<div class="min-w-0">
<div class="wrapped-title text-base text-[#000000e6] truncate">
{{ selectedInfo.word }}
<span class="wrapped-privacy-keyword">{{ selectedInfo.word }}</span>
<span class="wrapped-number text-sm text-[#07C160] font-semibold">· {{ formatInt(selectedInfo.count) }} </span>
</div>
<div class="mt-0.5 wrapped-body text-xs text-[#7F7F7F]">
@@ -77,7 +78,7 @@
class="flex justify-end"
>
<div class="relative bubble-tail-r bg-[#95EC69] msg-radius px-3 py-2 shadow-[0_6px_16px_rgba(0,0,0,0.12)] max-w-[92%]">
<div class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words">
<div class="wrapped-body text-sm text-[#000000e6] leading-snug whitespace-pre-wrap break-words wrapped-privacy-message">
<span v-if="Array.isArray(m.segments) && m.segments.length > 0">
<span v-for="(seg, sidx) in m.segments" :key="`${selectedInfo.word}-${i}-${sidx}`">
<span v-if="seg.type === 'text'">{{ seg.content }}</span>
@@ -98,7 +99,9 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { usePrivacyStore } from '~/stores/privacy'
const props = defineProps({
keywords: { type: Array, default: () => [] }, // [{word,count,weight}]
@@ -107,6 +110,9 @@ const props = defineProps({
reducedMotion: { type: Boolean, default: false }
})
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
const nfInt = new Intl.NumberFormat('zh-CN', { maximumFractionDigits: 0 })
const formatInt = (n) => nfInt.format(Math.round(Number(n) || 0))
@@ -369,6 +375,7 @@ watch(
)
onMounted(() => {
privacyStore.init()
if (!import.meta.client) return
updateSize()
if (typeof ResizeObserver !== 'undefined' && rootEl.value) {
+41 -2
View File
@@ -2,6 +2,7 @@
<div
ref="deckEl"
class="wrapped-deck-root relative h-screen w-full overflow-hidden transition-colors duration-500"
:class="{ 'wrapped-privacy': privacyMode }"
:style="{ backgroundColor: currentBg }"
>
<!-- PPT 风格单张卡片占据全页面鼠标滚轮切换 -->
@@ -64,11 +65,43 @@
</div>
</div>
<!-- 右上角年份选择器主题化 -->
<!-- 右上角隐私模式 + 年份选择器主题化 -->
<div v-show="!deckChromeHidden" class="absolute top-6 right-6 z-20 pointer-events-auto select-none transition-opacity duration-300">
<div class="relative">
<div class="absolute -inset-6 rounded-full bg-[#07C160]/10 blur-2xl"></div>
<div class="relative flex justify-end">
<div class="relative flex items-center justify-end gap-3">
<button
type="button"
class="pointer-events-auto inline-flex items-center justify-center w-9 h-9 rounded-full bg-transparent text-[#07C160] hover:bg-[#07C160]/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#07C160]/30 transition"
:aria-label="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
@click="privacyStore.toggle"
>
<svg
class="w-4 h-4"
:class="privacyMode ? 'text-[#07C160]' : 'text-[#00000080]'"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
aria-hidden="true"
>
<path
v-if="privacyMode"
stroke-linecap="round"
stroke-linejoin="round"
d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z"
/>
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
</svg>
</button>
<WrappedYearSelector
v-if="yearOptions.length > 1"
v-model="year"
@@ -207,6 +240,8 @@
<script setup>
import { useApi } from '~/composables/useApi'
import { storeToRefs } from 'pinia'
import { usePrivacyStore } from '~/stores/privacy'
useHead({
title: '年度总结 · WeChat Wrapped',
@@ -217,6 +252,9 @@ const api = useApi()
const route = useRoute()
const router = useRouter()
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
const queryYear = Number(route.query?.year)
const defaultYear = new Date().getFullYear() - 1
const year = ref(Number.isFinite(queryYear) ? queryYear : defaultYear)
@@ -560,6 +598,7 @@ watch(activeIndex, (i) => {
})
onMounted(async () => {
privacyStore.init()
applyViewportBg()
updateViewport()
if (import.meta.client && typeof ResizeObserver !== 'undefined' && deckEl.value) {