mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
improvement(wrapped-ui): 年度总结新增隐私模式开关(昵称/头像/消息/词云可模糊)
- Wrapped 页面右上角新增隐私模式开关,并在 deck 根节点挂载 wrapped-privacy - Tailwind 新增 wrapped-privacy-* 细粒度模糊样式:仅模糊昵称/消息/关键词/头像,hover 可临时显示 - Wrapped 各卡片/可视化统一接入新 class,逐步替换原 privacy-blur 用法(词云/聊天回放/气泡等)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user