Compare commits

...

7 Commits

36 changed files with 4329 additions and 187 deletions
+38 -36
View File
@@ -4,7 +4,7 @@
<div align="center">
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
<p>一个专门用于微信4.x版本数据解密的工具(支持聊天记录实时更新)</p>
<p>微信4.x数据解密并生成年度总结,高仿微信,支持实时更新,导出聊天记录,朋友圈等大量便捷功能</p>
<p><b>特别致谢</b><a href="https://github.com/H3CoF6">H3CoF6</a>(密钥与朋友圈等核心内容的技术支持)、<a href="https://github.com/ycccccccy/echotrace">echotrace</a>、<a href="https://github.com/hicccc77/WeFlow">WeFlow</a>(本项目大量功能参考其实现)</p>
<img src="https://img.shields.io/github/v/tag/LifeArchiveProject/WeChatDataAnalysis" alt="Version" />
<img src="https://img.shields.io/github/stars/LifeArchiveProject/WeChatDataAnalysis" alt="Stars" />
@@ -16,39 +16,45 @@
<img src="https://img.shields.io/badge/SQLite-003B57?logo=SQLite&logoColor=white" alt="SQLite" />
</div>
## 年度总结
<table>
<tr>
<td align="center" colspan="2"><img src="frontend/public/style1.png" alt="年度总结 Modern" width="800"/></td>
</tr>
<tr>
<td><img src="frontend/public/AnnualSummary1.png" alt="AnnualSummary 1" width="400"/></td>
<td><img src="frontend/public/AnnualSummary2.png" alt="AnnualSummary 2" width="400"/></td>
</tr>
<tr>
<td><img src="frontend/public/AnnualSummary3.png" alt="AnnualSummary 3" width="400"/></td>
<td><img src="frontend/public/AnnualSummary4.gif" alt="AnnualSummary 4" width="400"/></td>
</tr>
<tr>
<td><img src="frontend/public/AnnualSummary5.gif" alt="AnnualSummary 5" width="400"/></td>
<td><img src="frontend/public/AnnualSummary6.png" alt="AnnualSummary 6" width="400"/></td>
</tr>
<tr>
<td><img src="frontend/public/AnnualSummary7.png" alt="AnnualSummary 7" width="400"/></td>
<td><img src="frontend/public/AnnualSummary8.png" alt="AnnualSummary 8" width="400"/></td>
</tr>
</table>
## 界面预览
<table>
<tr>
<td align="center"><b>首页</b></td>
<td align="center"><b>检测页面</b></td>
</tr>
<tr>
<td><img src="frontend/public/home.png" alt="首页" width="400"/></td>
<td><img src="frontend/public/detection.png" alt="微信检测页面" width="400"/></td>
</tr>
<tr>
<td align="center"><b>解密页面</b></td>
<td align="center"><b>图片密钥(填写)</b></td>
</tr>
<tr>
<td><img src="frontend/public/decrypt.png" alt="数据库解密页面" width="400"/></td>
<td><img src="frontend/public/imageAES.png" alt="图片密钥(填写)" width="400"/></td>
</tr>
<tr>
<td align="center"><b>图片解密页面</b></td>
<td align="center"><b>解密成功页面</b></td>
</tr>
<tr>
<td><img src="frontend/public/imageSucces.png" alt="图片解密页面" width="400"/></td>
<td><img src="frontend/public/success.png" alt="解密成功页面" width="400"/></td>
</tr>
<tr>
<td align="center" colspan="2"><b>聊天记录页面</b></td>
<td align="center" colspan="2"><b>聊天记录页面</b>(支持多种消息类型展示,样式尽可能与微信保持一致)</td>
</tr>
<tr>
<td colspan="2" align="center"><img src="frontend/public/message.png" alt="聊天记录页面" width="800"/></td>
</tr>
<tr>
<td align="center" colspan="2"><b>朋友圈</b>(支持查看用户之前朋友圈的背景图及时间;本地查看过的朋友圈即使后续不可见也可以查看)</td>
</tr>
<tr>
<td colspan="2" align="center"><img src="frontend/public/sns.png" alt="朋友圈" width="800"/></td>
</tr>
<tr>
<td align="center" colspan="2"><b>聊天记录搜索</b></td>
</tr>
@@ -61,22 +67,18 @@
<tr>
<td colspan="2" align="center"><img src="frontend/public/export.png" alt="聊天记录导出" width="800"/></td>
</tr>
</table>
## 年度总结
> ⚠️ **提醒**:年度总结目前还不是最终版本,后续还会增加新总结或新内容。
也欢迎加入下方 QQ 群一起讨论。
<table>
<tr>
<td align="center"><img src="frontend/public/style1.png" alt="年度总结 Modern"/></td>
<td align="center" colspan="2"><b>联系人导出</b></td>
</tr>
<tr>
<td colspan="2" align="center"><img src="frontend/public/Contact.png" alt="联系人导出" width="800"/></td>
</tr>
</table>
## 加入群聊
也欢迎加入下方 QQ 群一起讨论。
<p align="center">
<a href="https://qm.qq.com/q/VQEQ7PcGkk">
<img src="frontend/public/QQImage_1770190010691_1103312318341691201.jpg" alt="WeChatDataAnalysis 加群二维码" width="360" />
+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;
+93 -2
View File
@@ -27,8 +27,26 @@
<div class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3">
<div class="text-xs font-medium text-gray-700">更新内容</div>
<div class="mt-2 text-xs text-gray-700 whitespace-pre-wrap break-words">
{{ info.releaseNotes || '修复了一些已知问题,提升了稳定性。' }}
<div
ref="notesViewportRef"
class="mt-2 max-h-48 overflow-y-auto pr-1 text-xs text-gray-700"
@scroll="onNotesScroll"
>
<div class="relative" :style="{ height: `${virtualTotalHeight}px` }">
<div
class="absolute left-0 right-0 top-0"
:style="{ transform: `translateY(${virtualOffsetTop}px)` }"
>
<div
v-for="item in virtualVisibleItems"
:key="item.key"
class="h-6 leading-6 truncate"
:title="item.text"
>
{{ item.text }}
</div>
</div>
</div>
</div>
</div>
@@ -113,6 +131,79 @@ const props = defineProps({
const emit = defineEmits(["close", "update", "install", "ignore"]);
const DEFAULT_RELEASE_NOTE = "修复了一些已知问题,提升了稳定性。";
const NOTE_ROW_HEIGHT = 24;
const NOTE_OVERSCAN = 6;
const NOTE_FALLBACK_VIEWPORT_HEIGHT = 192; // 8 rows * 24px
const notesViewportRef = ref(null);
const notesScrollTop = ref(0);
const sanitizeReleaseNotes = (input) => {
const raw = String(input || "").replace(/\r\n?/g, "\n");
if (!raw.trim()) return "";
return raw
.replace(/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/gi, "$1")
.replace(/\s*\((https?:\/\/[^)]+)\)/gi, "")
.replace(/<https?:\/\/[^>]+>/gi, "")
.replace(/https?:\/\/\S+/gi, "")
.replace(/[ \t]+$/gm, "")
.replace(/\n{3,}/g, "\n\n")
.trim();
};
const releaseNoteLines = computed(() => {
const sanitized = sanitizeReleaseNotes(props.info?.releaseNotes || "");
const lines = sanitized
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter((line) => !/^更新内容\s*(\(|)/.test(line))
.filter((line) => !/^完整变更[:]?\s*$/.test(line));
if (!lines.length) return [DEFAULT_RELEASE_NOTE];
return lines;
});
const viewportHeight = computed(() => {
const h = Number(notesViewportRef.value?.clientHeight || 0);
return h > 0 ? h : NOTE_FALLBACK_VIEWPORT_HEIGHT;
});
const virtualStartIndex = computed(() => {
const start = Math.floor(notesScrollTop.value / NOTE_ROW_HEIGHT) - NOTE_OVERSCAN;
return Math.max(0, start);
});
const virtualEndIndex = computed(() => {
const count = Math.ceil(viewportHeight.value / NOTE_ROW_HEIGHT) + NOTE_OVERSCAN * 2;
return Math.min(releaseNoteLines.value.length, virtualStartIndex.value + count);
});
const virtualVisibleItems = computed(() => {
const start = virtualStartIndex.value;
return releaseNoteLines.value.slice(start, virtualEndIndex.value).map((text, idx) => ({
key: `${start + idx}-${text}`,
text,
}));
});
const virtualOffsetTop = computed(() => virtualStartIndex.value * NOTE_ROW_HEIGHT);
const virtualTotalHeight = computed(() => releaseNoteLines.value.length * NOTE_ROW_HEIGHT);
const onNotesScroll = (event) => {
notesScrollTop.value = Number(event?.target?.scrollTop || 0);
};
watch(
() => [props.open, props.info?.version, props.info?.releaseNotes],
() => {
notesScrollTop.value = 0;
if (notesViewportRef.value) {
notesViewportRef.value.scrollTop = 0;
}
}
);
const safeProgress = computed(() => {
if (typeof props.progress === "number") return { percent: props.progress };
if (props.progress && typeof props.progress === "object") return props.progress;
@@ -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()
File diff suppressed because it is too large Load Diff
@@ -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) {
+50 -3
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"
@@ -105,7 +138,7 @@
:style="slideStyle"
>
<WrappedCardShell
v-if="!c || c.status !== 'ok'"
v-if="!c || (c.status !== 'ok' && !(c.kind === 'global/bento_summary' || c.id === 7))"
:card-id="Number(c?.id || (idx + 1))"
:title="c?.title || '正在生成…'"
:narrative="c?.status === 'error' ? '生成失败' : (c?.status === 'loading' ? '正在生成本页数据…' : '进入该页后将开始生成')"
@@ -181,6 +214,12 @@
variant="slide"
class="h-full w-full"
/>
<Card07BentoSummary
v-else-if="c && (c.kind === 'global/bento_summary' || c.id === 7)"
:card="c"
variant="slide"
class="h-full w-full"
/>
<WrappedCardShell
v-else
:card-id="Number(c?.id || (idx + 1))"
@@ -201,6 +240,8 @@
<script setup>
import { useApi } from '~/composables/useApi'
import { storeToRefs } from 'pinia'
import { usePrivacyStore } from '~/stores/privacy'
useHead({
title: '年度总结 · WeChat Wrapped',
@@ -211,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)
@@ -478,6 +522,8 @@ const retryCard = async (cardId) => {
await ensureCardLoaded(cardId)
}
provide('wrappedRetryCard', retryCard)
const reload = async (forceRefresh = false, preserveIndex = false) => {
const token = ++reportToken
const keepIndex = preserveIndex ? activeIndex.value : 0
@@ -552,6 +598,7 @@ watch(activeIndex, (i) => {
})
onMounted(async () => {
privacyStore.init()
applyViewportBg()
updateViewport()
if (import.meta.client && typeof ResizeObserver !== 'undefined' && deckEl.value) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 914 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 978 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 385 KiB

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 404 KiB

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 562 KiB

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 509 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

+600 -66
View File
@@ -167,8 +167,8 @@ _VUE_SCOPED_ATTR_RE = re.compile(r"\[data-v-[0-9a-f]{8}\]", flags=re.IGNORECASE)
_CHAT_HISTORY_MD5_TAG_RE = re.compile(
r"(?i)<(?:fullmd5|thumbfullmd5|md5|emoticonmd5|emojimd5|cdnthumbmd5)>([0-9a-f]{32})<"
)
_CHAT_HISTORY_URL_TAG_RE = re.compile(r"(?i)<(?:sourceheadurl|cdnurlstring|encrypturlstring|externurl)>(https?://[^<\\s]+)<")
_CHAT_HISTORY_SERVER_ID_TAG_RE = re.compile(r"(?i)<fromnewmsgid>\\s*(\\d+)\\s*<")
_CHAT_HISTORY_URL_TAG_RE = re.compile(r"(?i)<(?:sourceheadurl|cdnurlstring|encrypturlstring|externurl)>(https?://[^<\s]+)<")
_CHAT_HISTORY_SERVER_ID_TAG_RE = re.compile(r"(?i)<fromnewmsgid>\s*(\d+)\s*<")
def _strip_vue_scoped_attrs(css: str) -> str:
@@ -187,10 +187,13 @@ def _load_ui_css_bundle(*, ui_public_dir: Optional[Path], report: dict[str, Any]
Includes:
- `_nuxt/entry.*.css` (base + tailwind utilities)
- All `_nuxt/*.css` chunks (scoped selectors stripped; chat chunk appended last)
- Chat page chunks `_nuxt/*_username_*.css` (scoped selectors stripped)
- `_HTML_EXPORT_CSS_PATCH` appended last
Falls back to `_HTML_EXPORT_CSS_FALLBACK` when entry css is missing.
Note: We only bundle chat-related chunks because stripping Vue SFC scoped selectors (`[data-v-...]`) can
otherwise leak scoped utility overrides (e.g. `.text-sm[data-v-...]`) into global rules in the export.
"""
if ui_public_dir is None:
@@ -211,15 +214,12 @@ def _load_ui_css_bundle(*, ui_public_dir: Optional[Path], report: dict[str, Any]
entry_css = _strip_vue_scoped_attrs(entry_css)
nuxt_dir = Path(ui_public_dir) / "_nuxt"
extra_css_paths: list[Path] = []
chat_css_paths: list[Path] = []
try:
extra_css_paths = [p for p in nuxt_dir.glob("*.css") if (p.is_file() and (not p.name.startswith("entry.")))]
chat_css_paths = [p for p in nuxt_dir.glob("*_username_*.css") if p.is_file()]
except Exception:
extra_css_paths = []
chat_css_paths = []
chat_css_paths = [p for p in extra_css_paths if "_username_" in p.name]
other_css_paths = [p for p in extra_css_paths if p not in chat_css_paths]
other_css_paths.sort(key=lambda p: p.name)
chat_css_paths.sort(key=lambda p: p.name)
if not chat_css_paths:
@@ -231,7 +231,7 @@ def _load_ui_css_bundle(*, ui_public_dir: Optional[Path], report: dict[str, Any]
pass
extra_chunks: list[str] = []
for p in [*other_css_paths, *chat_css_paths]:
for p in chat_css_paths:
try:
extra_chunks.append(_strip_vue_scoped_attrs(p.read_text(encoding="utf-8")))
except Exception:
@@ -520,9 +520,9 @@ a { color: inherit; }
_HTML_EXPORT_CSS_PATCH = """
/* Offline HTML viewer patch */
:root {
/* Keep aligned with frontend defaults (see `frontend/app.vue`). */
--dpr: 1;
--message-radius: 4px;
/* Keep consistent with `frontend/app.vue`. */
--sidebar-rail-step: 48px;
--sidebar-rail-btn: 32px;
--sidebar-rail-icon: 24px;
@@ -537,23 +537,23 @@ body { background: #EDEDED; }
.wce-chat-area { flex: 1; display: flex; flex-direction: column; min-height: 0; background: #EDEDED; }
.wce-chat-main { flex: 1; display: flex; min-height: 0; }
.wce-chat-col { flex: 1; display: flex; flex-direction: column; min-height: 0; min-width: 0; position: relative; }
.wce-chat-header { height: 56px; padding: 0 20px; display: flex; align-items: center; border-bottom: 1px solid #e5e7eb; background: #EDEDED; }
.wce-chat-title { font-size: 16px; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wce-filter-select { font-size: 12px; padding: 6px 8px; border: 0; border-radius: 8px; background: transparent; color: #374151; }
.wce-chat-header { height: calc(56px / var(--dpr)); padding: 0 calc(20px / var(--dpr)); display: flex; align-items: center; border-bottom: 1px solid #e5e7eb; background: #EDEDED; }
.wce-chat-title { font-size: 1rem; font-weight: 500; color: #111827; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wce-filter-select { font-size: 0.75rem; padding: calc(6px / var(--dpr)) calc(8px / var(--dpr)); border: 0; border-radius: calc(8px / var(--dpr)); background: transparent; color: #374151; }
.wce-message-container { flex: 1; overflow: auto; padding: 16px; min-height: 0; }
.wce-pager { display: flex; align-items: center; justify-content: center; gap: 12px; padding: 6px 0 12px; }
.wce-pager-btn { font-size: 12px; padding: 6px 10px; border-radius: 8px; border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; }
.wce-pager { display: flex; align-items: center; justify-content: center; gap: calc(12px / var(--dpr)); padding: calc(6px / var(--dpr)) 0 calc(12px / var(--dpr)); }
.wce-pager-btn { font-size: 0.75rem; padding: calc(6px / var(--dpr)) calc(10px / var(--dpr)); border-radius: calc(8px / var(--dpr)); border: 1px solid #e5e7eb; background: #fff; color: #374151; cursor: pointer; }
.wce-pager-btn:hover { background: #f9fafb; }
.wce-pager-btn:disabled { opacity: 0.6; cursor: not-allowed; }
.wce-pager-status { font-size: 12px; color: #6b7280; }
.wce-pager-status { font-size: 0.75rem; color: #6b7280; }
/* Single session item (middle column). */
.wce-session-item { display: flex; align-items: center; gap: 12px; padding: 0 12px; height: 80px; border-bottom: 1px solid #f3f4f6; background: #DEDEDE; text-decoration: none; color: inherit; }
.wce-session-avatar { width: 45px; height: 45px; border-radius: 6px; overflow: hidden; background: #d1d5db; flex-shrink: 0; }
.wce-session-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.wce-session-meta { min-width: 0; flex: 1; }
.wce-session-name { font-size: 14px; font-weight: 600; color: #111827; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wce-session-sub { font-size: 12px; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 2px; }
.wce-session-name { font-size: 0.875rem; font-weight: 600; color: #111827; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.wce-session-sub { font-size: 0.75rem; color: #6b7280; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: calc(2px / var(--dpr)); }
/* Message rows (right column). */
.wce-msg-row { display: flex; align-items: flex-start; margin-bottom: 24px; }
@@ -565,10 +565,10 @@ body { background: #EDEDED; }
.wce-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.wce-avatar-sent { margin-left: 12px; }
.wce-avatar-received { margin-right: 12px; }
.wce-sender-name { font-size: 11px; color: #6b7280; margin-bottom: 4px; max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.wce-sender-name { font-size: 0.75rem; color: #6b7280; margin-bottom: calc(4px / var(--dpr)); max-width: calc(320px / var(--dpr)); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* Bubble basics (tailwind classes may override when Nuxt CSS is present). */
.wce-bubble { padding: 8px 12px; border-radius: var(--message-radius); font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-width: 320px; position: relative; }
.wce-bubble { padding: calc(8px / var(--dpr)) calc(12px / var(--dpr)); border-radius: var(--message-radius); font-size: 0.875rem; line-height: 1.6; white-space: pre-wrap; word-break: break-word; max-width: calc(320px / var(--dpr)); position: relative; }
.wce-bubble-sent { background: #95EC69; color: #000; }
.wce-bubble-received { background: #fff; color: #1f2937; }
@@ -599,7 +599,7 @@ body { background: #EDEDED; }
/* System messages. */
.wce-system { display: flex; justify-content: center; margin: 16px 0; }
.wce-system > div { font-size: 12px; color: #9e9e9e; padding: 4px 0; }
.wce-system > div { font-size: 0.75rem; color: #9e9e9e; padding: calc(4px / var(--dpr)) 0; }
/* Media blocks. */
.wce-media-img { max-width: 240px; max-height: 240px; border-radius: var(--message-radius); display: block; object-fit: cover; }
@@ -610,15 +610,15 @@ body { background: #EDEDED; }
.wce-video-play > div { width: 48px; height: 48px; border-radius: 9999px; background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center; }
.wce-file { border: 1px solid #e5e7eb; border-radius: 10px; padding: 10px 12px; background: #fff; max-width: 320px; }
.wce-file-name { font-size: 13px; color: #111827; word-break: break-all; }
.wce-file-meta { font-size: 12px; color: #6b7280; margin-top: 4px; }
.wce-file-name { font-size: 0.8125rem; color: #111827; word-break: break-all; }
.wce-file-meta { font-size: 0.75rem; color: #6b7280; margin-top: calc(4px / var(--dpr)); }
.wce-file-actions { margin-top: 8px; }
.wce-file-actions a { font-size: 12px; color: #07c160; text-decoration: none; }
.wce-file-actions a { font-size: 0.75rem; color: #07c160; text-decoration: none; }
.wce-file-actions a:hover { text-decoration: underline; }
.wce-audio { width: 260px; max-width: 92vw; }
.wce-audio-actions { margin-top: 6px; }
.wce-audio-actions a { font-size: 12px; color: #07c160; text-decoration: none; }
.wce-audio-actions a { font-size: 0.75rem; color: #07c160; text-decoration: none; }
.wce-audio-actions a:hover { text-decoration: underline; }
/* Index page helpers. */
@@ -628,8 +628,8 @@ body { background: #EDEDED; }
.wce-index-item { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid #f3f4f6; text-decoration: none; color: inherit; }
.wce-index-item:last-child { border-bottom: 0; }
.wce-index-item:hover { background: #f9fafb; }
.wce-index-title { font-size: 18px; font-weight: 700; color: #111827; margin: 0 0 6px 0; }
.wce-index-sub { font-size: 12px; color: #6b7280; margin: 0 0 16px 0; }
.wce-index-title { font-size: 1.125rem; font-weight: 700; color: #111827; margin: 0 0 calc(6px / var(--dpr)) 0; }
.wce-index-sub { font-size: 0.75rem; color: #6b7280; margin: 0 0 calc(16px / var(--dpr)) 0; }
"""
@@ -637,8 +637,14 @@ _HTML_EXPORT_JS = r"""
(() => {
const updateDprVar = () => {
try {
const dpr = window.devicePixelRatio || 1
document.documentElement.style.setProperty('--dpr', String(dpr))
document.documentElement.style.setProperty('--dpr', '1')
} catch {}
}
const hideJsMissingBanner = () => {
try {
const el = document.getElementById('wceJsMissing')
if (el) el.style.display = 'none'
} catch {}
}
@@ -1039,7 +1045,8 @@ _HTML_EXPORT_JS = r"""
const local = index && index.remote && index.remote[u]
if (local) return String(local || '')
} catch {}
if (/^https?:\\/\\//i.test(u)) return u
const ul = String(u || '').trim().toLowerCase()
if (ul.startsWith('http://') || ul.startsWith('https://')) return u
}
return ''
}
@@ -1154,7 +1161,8 @@ _HTML_EXPORT_JS = r"""
const fullmd5 = getText(node, 'fullmd5')
const thumbfullmd5 = getText(node, 'thumbfullmd5')
const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojiMd5')
const md5 = getText(node, 'md5') || getText(node, 'emoticonmd5') || getText(node, 'emojimd5') || getText(node, 'emojiMd5')
const cdnthumbmd5 = getText(node, 'cdnthumbmd5')
const cdnurlstring = normalizeChatHistoryUrl(getText(node, 'cdnurlstring'))
const encrypturlstring = normalizeChatHistoryUrl(getText(node, 'encrypturlstring'))
const externurl = normalizeChatHistoryUrl(getText(node, 'externurl'))
@@ -1162,7 +1170,14 @@ _HTML_EXPORT_JS = r"""
const fromnewmsgid = getText(node, 'fromnewmsgid')
const srcMsgLocalid = getText(node, 'srcMsgLocalid')
const srcMsgCreateTime = getText(node, 'srcMsgCreateTime')
const nestedRecordItem = getAnyXml(node, 'recorditem') || getDirectChildXml(node, 'recorditem') || getText(node, 'recorditem')
const nestedRecordItem = (
getAnyXml(node, 'recorditem')
|| getDirectChildXml(node, 'recorditem')
|| getText(node, 'recorditem')
|| getAnyXml(node, 'recordxml')
|| getDirectChildXml(node, 'recordxml')
|| getText(node, 'recordxml')
)
let content = datatitle || datadesc
if (!content) {
@@ -1224,6 +1239,7 @@ _HTML_EXPORT_JS = r"""
fullmd5,
thumbfullmd5,
md5,
cdnthumbmd5,
cdnurlstring,
encrypturlstring,
externurl,
@@ -1323,7 +1339,8 @@ _HTML_EXPORT_JS = r"""
const name0 = String(rec?.sourcename || '').trim() || '?'
const avatarUrlRaw = normalizeChatHistoryUrl(rec?.sourceheadurl)
const avatarLocal = (mediaIndex && mediaIndex.remote && mediaIndex.remote[avatarUrlRaw]) ? String(mediaIndex.remote[avatarUrlRaw] || '') : ''
const avatarUrl = avatarLocal || ((avatarUrlRaw && /^https?:\\/\\//i.test(avatarUrlRaw)) ? avatarUrlRaw : '')
const avatarUrlLower = String(avatarUrlRaw || '').trim().toLowerCase()
const avatarUrl = avatarLocal || ((avatarUrlLower.startsWith('http://') || avatarUrlLower.startsWith('https://')) ? avatarUrlRaw : '')
if (avatarUrl) {
const img = document.createElement('img')
img.src = avatarUrl
@@ -1428,7 +1445,7 @@ _HTML_EXPORT_JS = r"""
const heading = String(rec?.title || '').trim() || content || href || '链接'
const desc = String(rec?.content || '').trim()
const thumbMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.md5)
const thumbMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.md5, rec?.id)
let previewUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!previewUrl && serverMd5) previewUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!previewUrl) previewUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
@@ -1494,8 +1511,8 @@ _HTML_EXPORT_JS = r"""
body.appendChild(card)
} else if (rt === 'video') {
const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5) || videoMd5
const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5, rec?.id)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5, rec?.cdnthumbmd5) || videoMd5
let videoUrl = resolveMd5Any(mediaIndex, videoMd5)
if (!videoUrl && serverMd5) videoUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!videoUrl) videoUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
@@ -1537,7 +1554,7 @@ _HTML_EXPORT_JS = r"""
body.appendChild(wrap)
} else if (rt === 'image') {
const imageMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.md5)
const imageMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.md5, rec?.id)
let imgUrl = resolveMd5Any(mediaIndex, imageMd5)
if (!imgUrl && serverMd5) imgUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!imgUrl) imgUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
@@ -1562,7 +1579,7 @@ _HTML_EXPORT_JS = r"""
body.appendChild(t)
}
} else if (rt === 'emoji') {
const emojiMd5 = pickFirstMd5(rec?.md5, rec?.fullmd5, rec?.thumbfullmd5)
const emojiMd5 = pickFirstMd5(rec?.md5, rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.id)
let emojiUrl = resolveMd5Any(mediaIndex, emojiMd5)
if (!emojiUrl && serverMd5) emojiUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!emojiUrl) emojiUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
@@ -1670,7 +1687,15 @@ _HTML_EXPORT_JS = r"""
document.addEventListener('keydown', (ev) => {
const key = String(ev?.key || '')
if (key === 'Escape' && !modal.classList.contains('hidden')) close()
})
if ((key === 'Enter' || key === ' ') && modal.classList.contains('hidden')) {
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
openFromCard(card)
}
}, true)
document.addEventListener('click', (ev) => {
const target = ev && ev.target
@@ -1678,10 +1703,527 @@ _HTML_EXPORT_JS = r"""
if (!card) return
try { ev.preventDefault() } catch {}
openFromCard(card)
})
}, true)
}
const initChatHistoryFloatingWindows = () => {
const mediaIndex = readMediaIndex()
let zIndex = 1000
let cascade = 0
let idSeed = 0
const clampNumber = (value, min, max) => {
const n = Number(value)
if (!Number.isFinite(n)) return min
return Math.min(max, Math.max(min, n))
}
const getViewport = () => {
const w = Math.max(320, window.innerWidth || 0)
const h = Math.max(240, window.innerHeight || 0)
return { w, h }
}
const getPoint = (ev) => {
try {
return (ev && ev.touches && ev.touches[0]) ? ev.touches[0] : ev
} catch {
return ev
}
}
const buildChatHistoryState = (payload) => {
const title = String(payload?.title || '聊天记录').trim() || '聊天记录'
const xml = String(payload?.recordItem || '').trim()
const parsed = parseChatHistoryRecord(xml)
const info = (parsed && parsed.info) ? parsed.info : { isChatRoom: false }
let records = (parsed && Array.isArray(parsed.items)) ? parsed.items : []
if (!records.length) {
const lines = Array.isArray(payload?.fallbackLines)
? payload.fallbackLines
: String(payload?.content || '').trim().split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean)
records = lines.map((line, idx) => ({ id: String(idx), renderType: 'text', content: line, sourcename: '', sourcetime: '' }))
}
return { title, info, records }
}
const renderRecordRow = (rec, info, onOpenNested) => {
const row = document.createElement('div')
row.className = 'px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]'
const avatarWrap = document.createElement('div')
avatarWrap.className = 'w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0'
const name0 = String(rec?.sourcename || '').trim() || '?'
const avatarUrlRaw = normalizeChatHistoryUrl(rec?.sourceheadurl)
const avatarLocal = (mediaIndex && mediaIndex.remote && mediaIndex.remote[avatarUrlRaw]) ? String(mediaIndex.remote[avatarUrlRaw] || '') : ''
const avatarUrlLower = String(avatarUrlRaw || '').trim().toLowerCase()
const avatarUrl = avatarLocal || ((avatarUrlLower.startsWith('http://') || avatarUrlLower.startsWith('https://')) ? avatarUrlRaw : '')
if (avatarUrl) {
const img = document.createElement('img')
img.src = avatarUrl
img.alt = '头像'
img.className = 'w-full h-full object-cover'
try { img.referrerPolicy = 'no-referrer' } catch {}
img.onerror = () => {
try { avatarWrap.textContent = '' } catch {}
const fb = document.createElement('div')
fb.className = 'w-full h-full flex items-center justify-center text-xs font-bold text-gray-600'
fb.textContent = String(name0.charAt(0) || '?')
avatarWrap.appendChild(fb)
}
avatarWrap.appendChild(img)
} else {
const fb = document.createElement('div')
fb.className = 'w-full h-full flex items-center justify-center text-xs font-bold text-gray-600'
fb.textContent = String(name0.charAt(0) || '?')
avatarWrap.appendChild(fb)
}
const main = document.createElement('div')
main.className = 'min-w-0 flex-1'
const header = document.createElement('div')
header.className = 'flex items-start gap-2'
const headerLeft = document.createElement('div')
headerLeft.className = 'min-w-0 flex-1'
const senderName = String(rec?.sourcename || '').trim()
if (info && info.isChatRoom && senderName) {
const sn = document.createElement('div')
sn.className = 'text-xs text-gray-500 leading-none truncate mb-1'
sn.textContent = senderName
headerLeft.appendChild(sn)
}
const headerRight = document.createElement('div')
headerRight.className = 'text-xs text-gray-400 flex-shrink-0 leading-none'
const timeText = String(rec?.sourcetime || '').trim()
headerRight.textContent = timeText
header.appendChild(headerLeft)
if (timeText) header.appendChild(headerRight)
const body = document.createElement('div')
body.className = 'mt-1'
const rt = String(rec?.renderType || 'text')
const content = String(rec?.content || '').trim()
const serverId = String(rec?.fromnewmsgid || '').trim()
const serverMd5 = resolveServerMd5(mediaIndex, serverId)
if (rt === 'chatHistory') {
const card = document.createElement('div')
card.className = 'wechat-chat-history-card wechat-special-card msg-radius'
const chBody = document.createElement('div')
chBody.className = 'wechat-chat-history-body'
const chTitle = document.createElement('div')
chTitle.className = 'wechat-chat-history-title'
chTitle.textContent = String(rec?.title || '聊天记录')
chBody.appendChild(chTitle)
const raw = String(rec?.content || '').trim()
const lines = raw ? raw.split(/\r?\n/).map((x) => String(x || '').trim()).filter(Boolean).slice(0, 4) : []
if (lines.length) {
const preview = document.createElement('div')
preview.className = 'wechat-chat-history-preview'
for (const line of lines) {
const el = document.createElement('div')
el.className = 'wechat-chat-history-line'
el.textContent = line
preview.appendChild(el)
}
chBody.appendChild(preview)
}
card.appendChild(chBody)
const bottom = document.createElement('div')
bottom.className = 'wechat-chat-history-bottom'
const label = document.createElement('span')
label.textContent = '聊天记录'
bottom.appendChild(label)
card.appendChild(bottom)
const nestedXml = String(rec?.recordItem || '').trim()
if (nestedXml) {
card.classList.add('cursor-pointer')
card.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
try { ev.stopPropagation() } catch {}
if (typeof onOpenNested === 'function') onOpenNested(rec)
})
}
body.appendChild(card)
} else if (rt === 'link') {
const href = normalizeChatHistoryUrl(rec?.url) || normalizeChatHistoryUrl(rec?.externurl)
const heading = String(rec?.title || '').trim() || content || href || '链接'
const desc = String(rec?.content || '').trim()
const thumbMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.md5, rec?.id)
let previewUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!previewUrl && serverMd5) previewUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!previewUrl) previewUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
const card = document.createElement(href ? 'a' : 'div')
card.className = 'wechat-link-card wechat-special-card msg-radius cursor-pointer'
if (href) {
card.href = href
card.target = '_blank'
card.rel = 'noreferrer noopener'
}
try { card.style.textDecoration = 'none' } catch {}
try { card.style.outline = 'none' } catch {}
const linkContent = document.createElement('div')
linkContent.className = 'wechat-link-content'
const linkInfo = document.createElement('div')
linkInfo.className = 'wechat-link-info'
const titleEl = document.createElement('div')
titleEl.className = 'wechat-link-title'
titleEl.textContent = heading
linkInfo.appendChild(titleEl)
if (desc) {
const descEl = document.createElement('div')
descEl.className = 'wechat-link-desc'
descEl.textContent = desc
linkInfo.appendChild(descEl)
}
linkContent.appendChild(linkInfo)
if (previewUrl) {
const thumb = document.createElement('div')
thumb.className = 'wechat-link-thumb'
const img = document.createElement('img')
img.src = previewUrl
img.alt = heading || '链接预览'
img.className = 'wechat-link-thumb-img'
try { img.referrerPolicy = 'no-referrer' } catch {}
thumb.appendChild(img)
linkContent.appendChild(thumb)
}
card.appendChild(linkContent)
const fromRow = document.createElement('div')
fromRow.className = 'wechat-link-from'
const fromAvatar = document.createElement('div')
fromAvatar.className = 'wechat-link-from-avatar'
const fromUrlRaw = normalizeChatHistoryUrl(rec?.sourceheadurl)
const fromLocal = (mediaIndex && mediaIndex.remote && mediaIndex.remote[fromUrlRaw]) ? String(mediaIndex.remote[fromUrlRaw] || '') : ''
const fromLower = String(fromUrlRaw || '').trim().toLowerCase()
const fromUrl = fromLocal || ((fromLower.startsWith('http://') || fromLower.startsWith('https://')) ? fromUrlRaw : '')
const fromText = String(rec?.sourcename || '').trim()
if (fromUrl) {
const img = document.createElement('img')
img.src = fromUrl
img.alt = ''
img.className = 'wechat-link-from-avatar-img'
try { img.referrerPolicy = 'no-referrer' } catch {}
img.onerror = () => {
try { fromAvatar.textContent = '' } catch {}
const span = document.createElement('span')
span.textContent = String(fromText ? fromText.charAt(0) : '\u200B')
fromAvatar.appendChild(span)
}
fromAvatar.appendChild(img)
} else {
const span = document.createElement('span')
span.textContent = String(fromText ? fromText.charAt(0) : '\u200B')
fromAvatar.appendChild(span)
}
const fromName = document.createElement('div')
fromName.className = 'wechat-link-from-name'
fromName.textContent = fromText || '\u200B'
fromRow.appendChild(fromAvatar)
fromRow.appendChild(fromName)
card.appendChild(fromRow)
body.appendChild(card)
} else if (rt === 'video') {
const videoMd5 = pickFirstMd5(rec?.fullmd5, rec?.md5, rec?.id)
const thumbMd5 = pickFirstMd5(rec?.thumbfullmd5, rec?.cdnthumbmd5) || videoMd5
let videoUrl = resolveMd5Any(mediaIndex, videoMd5)
if (!videoUrl && serverMd5) videoUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!videoUrl) videoUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
let thumbUrl = resolveMd5Any(mediaIndex, thumbMd5)
if (!thumbUrl && serverMd5) thumbUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!thumbUrl) thumbUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
const wrap = document.createElement('div')
wrap.className = 'msg-radius overflow-hidden relative bg-black/5 inline-block'
if (thumbUrl) {
const img = document.createElement('img')
img.src = thumbUrl
img.alt = '视频'
img.className = 'block w-[220px] max-w-[260px] h-auto max-h-[260px] object-cover'
wrap.appendChild(img)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700'
t.textContent = content || '[视频]'
wrap.appendChild(t)
}
if (thumbUrl) {
const overlay = document.createElement(videoUrl ? 'a' : 'div')
if (videoUrl) {
overlay.href = videoUrl
overlay.target = '_blank'
overlay.rel = 'noreferrer noopener'
}
overlay.className = 'absolute inset-0 flex items-center justify-center'
const btn = document.createElement('div')
btn.className = 'w-12 h-12 rounded-full bg-black/45 flex items-center justify-center'
btn.innerHTML = '<svg class=\"w-6 h-6 text-white\" fill=\"currentColor\" viewBox=\"0 0 24 24\"><path d=\"M8 5v14l11-7z\"/></svg>'
overlay.appendChild(btn)
wrap.appendChild(overlay)
}
body.appendChild(wrap)
} else if (rt === 'image') {
const imageMd5 = pickFirstMd5(rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.md5, rec?.id)
let imgUrl = resolveMd5Any(mediaIndex, imageMd5)
if (!imgUrl && serverMd5) imgUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!imgUrl) imgUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
if (imgUrl) {
const outer = document.createElement('div')
outer.className = 'msg-radius overflow-hidden cursor-pointer inline-block'
const a = document.createElement('a')
a.href = imgUrl
a.target = '_blank'
a.rel = 'noreferrer noopener'
const img = document.createElement('img')
img.src = imgUrl
img.alt = '图片'
img.className = 'max-w-[240px] max-h-[240px] object-cover'
a.appendChild(img)
outer.appendChild(a)
body.appendChild(outer)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || '[图片]'
body.appendChild(t)
}
} else if (rt === 'emoji') {
const emojiMd5 = pickFirstMd5(rec?.md5, rec?.fullmd5, rec?.thumbfullmd5, rec?.cdnthumbmd5, rec?.id)
let emojiUrl = resolveMd5Any(mediaIndex, emojiMd5)
if (!emojiUrl && serverMd5) emojiUrl = resolveMd5Any(mediaIndex, serverMd5)
if (!emojiUrl) emojiUrl = resolveRemoteAny(mediaIndex, rec?.externurl, rec?.cdnurlstring, rec?.encrypturlstring)
if (emojiUrl) {
const img = document.createElement('img')
img.src = emojiUrl
img.alt = '表情'
img.className = 'w-24 h-24 object-contain'
body.appendChild(img)
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || '[表情]'
body.appendChild(t)
}
} else {
const t = document.createElement('div')
t.className = 'px-3 py-2 text-sm text-gray-700 whitespace-pre-wrap break-words'
t.textContent = content || ''
body.appendChild(t)
}
main.appendChild(header)
main.appendChild(body)
row.appendChild(avatarWrap)
row.appendChild(main)
return row
}
const focusWindow = (wrap) => {
zIndex += 1
try { wrap.style.zIndex = String(zIndex) } catch {}
}
const openChatHistoryWindow = (payload, opts) => {
const state = buildChatHistoryState(payload || {})
const info = state.info || { isChatRoom: false }
const records = Array.isArray(state.records) ? state.records : []
const vp = getViewport()
const width = Math.min(560, Math.max(320, Math.floor(vp.w * 0.92)))
const height = Math.min(560, Math.max(240, Math.floor(vp.h * 0.8)))
let x = Math.max(8, Math.floor((vp.w - width) / 2))
let y = Math.max(8, Math.floor((vp.h - height) / 2))
const spawnFrom = opts && opts.spawnFrom
if (spawnFrom) {
x = Number(spawnFrom.x || x) + 24
y = Number(spawnFrom.y || y) + 24
} else {
x += cascade
y += cascade
cascade = (cascade + 24) % 120
}
x = clampNumber(x, 8, Math.max(8, vp.w - width - 8))
y = clampNumber(y, 8, Math.max(8, vp.h - height - 8))
const win = { id: String(++idSeed), x, y, width, height }
const wrap = document.createElement('div')
wrap.className = 'fixed'
wrap.style.left = `${win.x}px`
wrap.style.top = `${win.y}px`
wrap.style.zIndex = String(++zIndex)
const box = document.createElement('div')
box.className = 'bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden border border-gray-200 flex flex-col'
box.style.width = `${win.width}px`
box.style.height = `${win.height}px`
wrap.appendChild(box)
const header = document.createElement('div')
header.className = 'px-3 py-2 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between select-none cursor-move'
box.appendChild(header)
const titleEl = document.createElement('div')
titleEl.className = 'text-sm text-[#161616] truncate min-w-0'
titleEl.textContent = String(state.title || '聊天记录')
header.appendChild(titleEl)
const closeBtn = document.createElement('button')
closeBtn.type = 'button'
closeBtn.className = 'p-2 rounded hover:bg-black/5 flex-shrink-0'
try { closeBtn.setAttribute('aria-label', '关闭') } catch {}
try { closeBtn.setAttribute('title', '关闭') } catch {}
closeBtn.innerHTML = '<svg class=\"w-5 h-5 text-gray-700\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"2\" d=\"M6 18L18 6M6 6l12 12\"/></svg>'
header.appendChild(closeBtn)
const body = document.createElement('div')
body.className = 'flex-1 overflow-auto bg-[#f7f7f7]'
box.appendChild(body)
if (!records.length) {
const empty = document.createElement('div')
empty.className = 'text-sm text-gray-500 text-center py-10'
empty.textContent = '没有可显示的聊天记录'
body.appendChild(empty)
} else {
const onOpenNested = (rec) => {
const xml = String(rec?.recordItem || '').trim()
if (!xml) return
openChatHistoryWindow({
title: String(rec?.title || '聊天记录'),
recordItem: xml,
content: String(rec?.content || ''),
}, { spawnFrom: win })
}
for (const rec of records) {
try {
body.appendChild(renderRecordRow(rec, info, onOpenNested))
} catch {}
}
}
const updatePos = () => {
try { wrap.style.left = `${win.x}px` } catch {}
try { wrap.style.top = `${win.y}px` } catch {}
}
closeBtn.addEventListener('click', (ev) => {
try { ev.preventDefault() } catch {}
try { ev.stopPropagation() } catch {}
try { wrap.remove() } catch {
try { if (wrap.parentElement) wrap.parentElement.removeChild(wrap) } catch {}
}
})
const startDrag = (ev) => {
const t = ev && ev.target
if (t && t.closest && t.closest('button')) return
focusWindow(wrap)
const p0 = getPoint(ev)
const ox = Number(p0?.clientX || 0) - win.x
const oy = Number(p0?.clientY || 0) - win.y
const onMove = (e2) => {
const p = getPoint(e2)
if (!p) return
try { if (e2 && typeof e2.preventDefault === 'function') e2.preventDefault() } catch {}
const vp2 = getViewport()
const nx = Number(p.clientX || 0) - ox
const ny = Number(p.clientY || 0) - oy
win.x = clampNumber(nx, 8, Math.max(8, vp2.w - win.width - 8))
win.y = clampNumber(ny, 8, Math.max(8, vp2.h - win.height - 8))
updatePos()
}
const stop = () => {
try { document.removeEventListener('mousemove', onMove) } catch {}
try { document.removeEventListener('touchmove', onMove) } catch {}
}
try { document.addEventListener('mousemove', onMove) } catch {}
try { document.addEventListener('mouseup', () => stop(), { once: true }) } catch {}
try { document.addEventListener('touchmove', onMove, { passive: false }) } catch {}
try { document.addEventListener('touchend', () => stop(), { once: true }) } catch {}
try { ev.preventDefault() } catch {}
}
header.addEventListener('mousedown', startDrag)
header.addEventListener('touchstart', startDrag, { passive: false })
wrap.addEventListener('mousedown', () => focusWindow(wrap))
wrap.addEventListener('touchstart', () => focusWindow(wrap), { passive: true })
try { document.body.appendChild(wrap) } catch {}
return win
}
document.addEventListener('keydown', (ev) => {
const key = String(ev?.key || '')
if (key !== 'Enter' && key !== ' ') return
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
const title = String(card?.getAttribute('data-title') || '聊天记录').trim() || '聊天记录'
const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim()
const xml = decodeBase64Utf8(b64)
const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || [])
.map((el) => String(el?.textContent || '').trim())
.filter(Boolean)
openChatHistoryWindow({ title, recordItem: xml, fallbackLines: lines })
}, true)
document.addEventListener('click', (ev) => {
const target = ev && ev.target
const card = target && target.closest ? target.closest('[data-wce-chat-history=\"1\"]') : null
if (!card) return
try { ev.preventDefault() } catch {}
const title = String(card?.getAttribute('data-title') || '聊天记录').trim() || '聊天记录'
const b64 = String(card?.getAttribute('data-record-item-b64') || '').trim()
const xml = decodeBase64Utf8(b64)
const lines = Array.from(card.querySelectorAll('.wechat-chat-history-line') || [])
.map((el) => String(el?.textContent || '').trim())
.filter(Boolean)
openChatHistoryWindow({ title, recordItem: xml, fallbackLines: lines })
}, true)
}
document.addEventListener('DOMContentLoaded', () => {
hideJsMissingBanner()
updateDprVar()
try {
window.addEventListener('resize', updateDprVar)
@@ -1689,7 +2231,7 @@ _HTML_EXPORT_JS = r"""
initSessionSearch()
initVoicePlayback()
initChatHistoryModal()
initChatHistoryFloatingWindows()
initPagedMessageLoading()
const select = document.getElementById('messageTypeFilter')
@@ -1708,6 +2250,9 @@ _HTML_EXPORT_JS = r"""
})
} catch {}
})
// Best-effort: defer scripts execute after the DOM is parsed, so we can hide the banner immediately.
hideJsMissingBanner()
})()
"""
@@ -2413,6 +2958,10 @@ class ChatExportManager:
parts.append(' <script defer src="assets/wechat-chat-export.js"></script>\n')
parts.append("</head>\n")
parts.append("<body>\n")
parts.append(
' <div id="wceJsMissing" style="position:fixed;top:0;left:0;right:0;z-index:9999;background:#FEF3C7;color:#92400E;border-bottom:1px solid #F59E0B;padding:8px 12px;font-size:12px;line-height:1.4">'
"提示:此页面需要 JavaScript 才能使用“合并聊天记录”等交互功能。若该提示一直存在,请确认已完整解压导出目录,并检查 wechat-chat-export.js 是否能加载(位于 assets/)。</div>\n"
)
parts.append('<div class="wce-index">\n')
parts.append(' <div class="wce-index-container">\n')
parts.append(' <h1 class="wce-index-title">聊天记录导出(HTML</h1>\n')
@@ -3614,7 +4163,7 @@ def _write_conversation_html(
except Exception:
pass
try:
u = re.sub(r"\\s+", "", u)
u = re.sub(r"\s+", "", u)
except Exception:
pass
if not is_http_url(u):
@@ -3992,6 +4541,10 @@ def _write_conversation_html(
tw.write(f' <script defer src="{esc_attr(js_src)}"></script>\n')
tw.write("</head>\n")
tw.write("<body>\n")
tw.write(
' <div id="wceJsMissing" style="position:fixed;top:0;left:0;right:0;z-index:9999;background:#FEF3C7;color:#92400E;border-bottom:1px solid #F59E0B;padding:8px 12px;font-size:12px;line-height:1.4">'
"提示:此页面需要 JavaScript 才能使用“合并聊天记录”等交互功能。若该提示一直存在,请确认已完整解压导出目录,并检查 wechat-chat-export.js 是否能加载(位于 assets/)。</div>\n"
)
# Root
tw.write('<div class="wce-root h-screen flex overflow-hidden" style="background-color:#EDEDED">\n')
@@ -4144,19 +4697,19 @@ def _write_conversation_html(
tw.write(' <div class="wce-chat-col flex-1 flex flex-col min-h-0 min-w-0">\n')
tw.write(' <div class="flex-1 flex flex-col min-h-0 relative">\n')
tw.write(' <div class="chat-header wce-chat-header">\n')
tw.write(' <div class="chat-header">\n')
tw.write(' <div class="flex items-center gap-3 min-w-0">\n')
tw.write(f' <h2 class="wce-chat-title text-base font-medium text-gray-900">{esc_text(chat_title)}</h2>\n')
tw.write(f' <h2 class="text-base font-medium text-gray-900">{esc_text(chat_title)}</h2>\n')
tw.write(" </div>\n")
tw.write(' <div class="ml-auto flex items-center gap-2">\n')
tw.write(f' <select id="messageTypeFilter" class="message-filter-select wce-filter-select" title="筛选消息类型">\n')
tw.write(f' <select id="messageTypeFilter" class="message-filter-select" title="筛选消息类型">\n')
for value, label in options:
tw.write(f' <option value="{esc_attr(value)}">{esc_text(label)}</option>\n')
tw.write(" </select>\n")
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write(' <div id="messageContainer" class="wce-message-container flex-1 overflow-y-auto p-4 min-h-0">\n')
tw.write(' <div id="messageContainer" class="flex-1 overflow-y-auto p-4 min-h-0">\n')
tw.write(' <div id="wcePager" class="wce-pager" style="display:none">\n')
tw.write(' <button id="wceLoadPrevBtn" type="button" class="wce-pager-btn">加载更早消息</button>\n')
tw.write(' <span id="wceLoadPrevStatus" class="wce-pager-status"></span>\n')
@@ -4910,25 +5463,6 @@ def _write_conversation_html(
media_index_payload = media_index_payload.replace("</", "<\\/")
tw.write(f'<script type="application/json" id="wceMediaIndex">{media_index_payload}</script>\n')
tw.write(
'<div id="chatHistoryModal" class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center hidden" style="display:none" aria-hidden="true">\n'
)
tw.write(' <div class="w-[92vw] max-w-[560px] max-h-[80vh] bg-white rounded-xl shadow-xl overflow-hidden flex flex-col" role="dialog" aria-modal="true">\n')
tw.write(' <div class="px-4 py-3 bg-neutral-100 border-b border-gray-200 flex items-center justify-between">\n')
tw.write(' <div id="chatHistoryModalTitle" class="text-sm text-[#161616] truncate">聊天记录</div>\n')
tw.write(' <button type="button" id="chatHistoryModalClose" class="p-2 rounded hover:bg-black/5" aria-label="关闭" title="关闭">\n')
tw.write(' <svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">\n')
tw.write(' <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>\n')
tw.write(" </svg>\n")
tw.write(" </button>\n")
tw.write(" </div>\n")
tw.write(' <div class="flex-1 overflow-auto bg-white">\n')
tw.write(' <div id="chatHistoryModalEmpty" class="text-sm text-gray-500 text-center py-10">没有可显示的聊天记录</div>\n')
tw.write(' <div id="chatHistoryModalList"></div>\n')
tw.write(" </div>\n")
tw.write(" </div>\n")
tw.write("</div>\n")
tw.write("</body>\n")
tw.write("</html>\n")
tw.flush()
@@ -65,6 +65,136 @@ def _format_duration_zh(seconds: int | None) -> str:
return f"{d}{hh}小时" if hh else f"{d}"
def _compute_streak_days(doys: list[int]) -> int:
if not doys:
return 0
doys_sorted = sorted({int(x) for x in doys if int(x) > 0})
if not doys_sorted:
return 0
best = 1
cur = 1
prev = doys_sorted[0]
for d in doys_sorted[1:]:
if d == prev + 1:
cur += 1
else:
cur = 1
if cur > best:
best = cur
prev = d
return int(best)
def _compute_best_buddy_extras_from_index(*, account_dir: Path, year: int, buddy_username: str) -> dict[str, Any]:
"""Compute a few extra fields for Card07 Bento summary.
- longestStreakDays: longest consecutive days with any interaction
- peakHour/peakHourLabel: most active hour of day with this buddy
Best-effort: returns empty dict on any failure.
"""
buddy = str(buddy_username or "").strip()
if not buddy:
return {}
index_path = get_chat_search_index_db_path(account_dir)
if not index_path.exists():
return {}
start_ts, end_ts = _year_range_epoch_seconds(int(year))
ts_expr = (
"CASE "
"WHEN CAST(create_time AS INTEGER) > 1000000000000 "
"THEN CAST(CAST(create_time AS INTEGER)/1000 AS INTEGER) "
"ELSE CAST(create_time AS INTEGER) "
"END"
)
where = (
f"{ts_expr} >= ? AND {ts_expr} < ? "
"AND db_stem NOT LIKE 'biz_message%' "
"AND CAST(local_type AS INTEGER) != 10000 "
"AND username = ? "
"AND username NOT LIKE '%@chatroom'"
)
sql_days = (
"SELECT DISTINCT "
"CAST(strftime('%j', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS doy "
"FROM ("
f" SELECT {ts_expr} AS ts "
" FROM message_fts "
f" WHERE {where}"
") sub "
"WHERE ts > 0 "
"ORDER BY doy ASC"
)
sql_peak_hour = (
"SELECT "
"CAST(strftime('%H', datetime(ts, 'unixepoch', 'localtime')) AS INTEGER) AS h, "
"COUNT(1) AS cnt "
"FROM ("
f" SELECT {ts_expr} AS ts "
" FROM message_fts "
f" WHERE {where}"
") sub "
"WHERE ts > 0 "
"GROUP BY h "
"ORDER BY cnt DESC, h ASC "
"LIMIT 1"
)
conn = sqlite3.connect(str(index_path))
try:
has_fts = (
conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name='message_fts' LIMIT 1").fetchone()
is not None
)
if not has_fts:
return {}
params = (start_ts, end_ts, buddy)
doys: list[int] = []
try:
rows = conn.execute(sql_days, params).fetchall()
except Exception:
rows = []
for r in rows:
if not r or r[0] is None:
continue
try:
doys.append(int(r[0]))
except Exception:
continue
longest_streak_days = _compute_streak_days(doys)
peak_hour: int | None = None
try:
row = conn.execute(sql_peak_hour, params).fetchone()
if row and row[0] is not None:
peak_hour = int(row[0])
except Exception:
peak_hour = None
out: dict[str, Any] = {"longestStreakDays": int(longest_streak_days)}
if peak_hour is not None and 0 <= peak_hour <= 23:
out["peakHour"] = int(peak_hour)
out["peakHourLabel"] = f"{int(peak_hour):02d}:00"
return out
except Exception:
return {}
finally:
try:
conn.close()
except Exception:
pass
@dataclass
class _ConvAgg:
username: str
@@ -125,6 +255,9 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
global_slowest: int | None = None
global_slowest_u: str | None = None
reply_gaps: list[int] = []
reply_stats: dict[str, Any] | None = None
best_score = -1.0
best_agg: _ConvAgg | None = None
@@ -287,6 +420,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
total_replies += 1
sum_gap += gap
sum_gap_capped += min(gap, gap_cap_seconds)
reply_gaps.append(int(gap))
if replies == 1 or gap < min_gap:
min_gap = gap
@@ -323,6 +457,20 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
except Exception:
pass
if reply_gaps:
try:
reply_gaps.sort()
n = int(len(reply_gaps))
# Nearest-rank quantiles (deterministic, integer seconds).
p50_idx = max(0, min(n - 1, int(math.ceil(0.50 * n) - 1)))
p90_idx = max(0, min(n - 1, int(math.ceil(0.90 * n) - 1)))
reply_stats = {
"p50Seconds": int(reply_gaps[p50_idx]),
"p90Seconds": int(reply_gaps[p90_idx]),
}
except Exception:
reply_stats = None
# -------- Fallback path: no index --------
# Best-effort: if the index doesn't exist / isn't ready, auto-start building it (async) so user can
# retry this page later. We intentionally do NOT block here.
@@ -406,6 +554,14 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
best_buddy_obj = None
if best_agg is not None:
best_buddy_obj = conv_to_obj(best_score, best_agg)
if used_index and isinstance(best_buddy_obj, dict) and best_buddy_obj.get("username"):
extras = _compute_best_buddy_extras_from_index(
account_dir=account_dir,
year=int(year),
buddy_username=str(best_buddy_obj.get("username") or ""),
)
if extras:
best_buddy_obj.update(extras)
fastest_obj = None
if global_fastest is not None and global_fastest_u:
@@ -645,6 +801,7 @@ def compute_reply_speed_stats(*, account_dir: Path, year: int) -> dict[str, Any]
"year": int(year),
"sentToContacts": int(len(sent_to_contacts)),
"replyEvents": int(total_replies),
"replyStats": reply_stats,
"fastestReplySeconds": int(global_fastest) if global_fastest is not None else None,
"longestReplySeconds": int(global_slowest) if global_slowest is not None else None,
"bestBuddy": best_buddy_obj,
@@ -0,0 +1,292 @@
from __future__ import annotations
from typing import Any
def _as_data(obj: Any) -> dict[str, Any]:
if not isinstance(obj, dict):
return {}
data = obj.get("data")
if isinstance(data, dict):
return data
return obj
def _pick_int(x: Any, default: int = 0) -> int:
try:
return int(x)
except Exception:
return int(default)
def _pick_float(x: Any, default: float = 0.0) -> float:
try:
v = float(x)
return v if v == v else float(default) # NaN guard
except Exception:
return float(default)
def _pick_str(x: Any, default: str = "") -> str:
s = str(x or "").strip()
return s if s else str(default)
def _pick_obj(d: Any, keys: tuple[str, ...]) -> dict[str, Any] | None:
if not isinstance(d, dict):
return None
out: dict[str, Any] = {}
for k in keys:
if k in d:
out[k] = d.get(k)
return out if out else None
def build_card_07_bento_summary_from_sources(
*,
year: int,
overview: dict[str, Any],
heatmap: dict[str, Any],
message_chars: dict[str, Any],
reply_speed: dict[str, Any],
monthly: dict[str, Any],
emoji: dict[str, Any],
) -> dict[str, Any]:
"""Card #7: Bento Summary (prototype style merged into Wrapped deck).
The frontend expects a stable `data.snapshot` object to render without running extra JS.
"""
overview_d = _as_data(overview)
heatmap_d = _as_data(heatmap)
message_chars_d = _as_data(message_chars)
reply_speed_d = _as_data(reply_speed)
monthly_d = _as_data(monthly)
emoji_d = _as_data(emoji)
top_group_raw = overview_d.get("topGroup")
top_group = None
if isinstance(top_group_raw, dict):
display = _pick_str(top_group_raw.get("displayName"), "--")
top_group = {
"displayName": display,
"maskedName": display,
"avatarUrl": _pick_str(top_group_raw.get("avatarUrl"), ""),
"messages": _pick_int(top_group_raw.get("messages"), 0),
}
best_buddy_raw = reply_speed_d.get("bestBuddy")
best_buddy = None
if isinstance(best_buddy_raw, dict):
display = _pick_str(best_buddy_raw.get("displayName"), "--")
best_buddy = {
"displayName": display,
"maskedName": display,
"avatarUrl": _pick_str(best_buddy_raw.get("avatarUrl"), ""),
"totalMessages": _pick_int(best_buddy_raw.get("totalMessages"), 0),
"longestStreakDays": _pick_int(best_buddy_raw.get("longestStreakDays"), 0),
"peakHour": best_buddy_raw.get("peakHour"),
"peakHourLabel": _pick_str(best_buddy_raw.get("peakHourLabel"), ""),
}
fastest_raw = reply_speed_d.get("fastest")
fastest = None
if isinstance(fastest_raw, dict):
display = _pick_str(fastest_raw.get("displayName"), "--")
fastest = {
"displayName": display,
"maskedName": display,
"avatarUrl": _pick_str(fastest_raw.get("avatarUrl"), ""),
"seconds": _pick_int(fastest_raw.get("seconds"), 0),
}
slowest_raw = reply_speed_d.get("slowest")
slowest = None
if isinstance(slowest_raw, dict):
display = _pick_str(slowest_raw.get("displayName"), "--")
slowest = {
"displayName": display,
"maskedName": display,
"avatarUrl": _pick_str(slowest_raw.get("avatarUrl"), ""),
"seconds": _pick_int(slowest_raw.get("seconds"), 0),
}
reply_stats_raw = reply_speed_d.get("replyStats")
reply_stats = None
if isinstance(reply_stats_raw, dict):
reply_stats = {
"p50Seconds": reply_stats_raw.get("p50Seconds"),
"p90Seconds": reply_stats_raw.get("p90Seconds"),
}
top_phrase_raw = overview_d.get("topPhrase")
top_phrase = None
if isinstance(top_phrase_raw, dict):
phrase = _pick_str(top_phrase_raw.get("phrase"), "")
count = _pick_int(top_phrase_raw.get("count"), 0)
if phrase and count > 0:
top_phrase = {"phrase": phrase, "count": count}
sent_sticker_count = _pick_int(emoji_d.get("sentStickerCount"), _pick_int(overview_d.get("sentStickerCount"), 0))
top_sticker = None
top_stickers = emoji_d.get("topStickers")
if isinstance(top_stickers, list) and top_stickers:
x0 = top_stickers[0] if isinstance(top_stickers[0], dict) else None
if x0:
url = _pick_str(x0.get("emojiUrl") or x0.get("imageUrl") or x0.get("url"), "")
cnt = _pick_int(x0.get("count"), 0)
if url:
top_sticker = {"imageUrl": url, "count": cnt}
top_unicode_emoji = ""
top_unicode_emoji_count = 0
top_unicode_emojis = emoji_d.get("topUnicodeEmojis")
if isinstance(top_unicode_emojis, list) and top_unicode_emojis:
x0 = top_unicode_emojis[0] if isinstance(top_unicode_emojis[0], dict) else None
if x0:
top_unicode_emoji = _pick_str(x0.get("emoji"), "")
top_unicode_emoji_count = _pick_int(x0.get("count"), 0)
# "Top emoji" should be picked across both unicode emoji and WeChat built-in emoji.
# The deck has a separate "sticker" card; here we focus on emoji-like items.
top_emoji: dict[str, Any] | None = None
emoji_candidates: list[dict[str, Any]] = []
top_wechat_emojis = emoji_d.get("topWechatEmojis")
if isinstance(top_wechat_emojis, list) and top_wechat_emojis:
for item in top_wechat_emojis:
if not isinstance(item, dict):
continue
key = _pick_str(item.get("key"), "")
cnt = _pick_int(item.get("count"), 0)
if key and cnt > 0:
emoji_candidates.append(
{
"kind": "wechat",
"key": key,
"count": cnt,
"assetPath": _pick_str(item.get("assetPath"), ""),
}
)
top_text_emojis = emoji_d.get("topTextEmojis")
if isinstance(top_text_emojis, list) and top_text_emojis:
for item in top_text_emojis:
if not isinstance(item, dict):
continue
key = _pick_str(item.get("key"), "")
cnt = _pick_int(item.get("count"), 0)
if key and cnt > 0:
emoji_candidates.append(
{
"kind": "wechat",
"key": key,
"count": cnt,
"assetPath": _pick_str(item.get("assetPath"), ""),
}
)
if isinstance(top_unicode_emojis, list) and top_unicode_emojis:
for item in top_unicode_emojis:
if not isinstance(item, dict):
continue
emo = _pick_str(item.get("emoji"), "")
cnt = _pick_int(item.get("count"), 0)
if emo and cnt > 0:
emoji_candidates.append({"kind": "unicode", "emoji": emo, "count": cnt})
if emoji_candidates:
best = max(
emoji_candidates,
key=lambda x: (
_pick_int(x.get("count"), 0),
1 if str(x.get("kind")) == "wechat" else 0,
_pick_str(x.get("key") or x.get("emoji"), ""),
),
)
if str(best.get("kind")) == "wechat":
top_emoji = {
"kind": "wechat",
"key": _pick_str(best.get("key"), ""),
"count": _pick_int(best.get("count"), 0),
"assetPath": _pick_str(best.get("assetPath"), ""),
}
else:
top_emoji = {
"kind": "unicode",
"emoji": _pick_str(best.get("emoji"), ""),
"count": _pick_int(best.get("count"), 0),
}
monthly_best_buddies: list[dict[str, Any]] = []
months = monthly_d.get("months")
if isinstance(months, list) and months:
for item in months:
if not isinstance(item, dict):
continue
m = _pick_int(item.get("month"), 0)
winner = item.get("winner") if isinstance(item.get("winner"), dict) else None
metrics = item.get("metrics") if isinstance(item.get("metrics"), dict) else None
raw = item.get("raw") if isinstance(item.get("raw"), dict) else None
monthly_best_buddies.append(
{
"month": m,
"displayName": _pick_str((winner or {}).get("displayName"), "--"),
"maskedName": _pick_str((winner or {}).get("displayName"), "--"),
"avatarUrl": _pick_str((winner or {}).get("avatarUrl"), ""),
"messages": _pick_int((raw or {}).get("totalMessages"), 0),
"metrics": metrics if metrics else None,
}
)
# Ensure we always return 12 items for the grid.
if len(monthly_best_buddies) != 12:
fixed = {int(x.get("month") or 0): x for x in monthly_best_buddies if isinstance(x, dict)}
monthly_best_buddies = []
for m in range(1, 13):
monthly_best_buddies.append(
fixed.get(m)
or {
"month": m,
"displayName": "--",
"maskedName": "--",
"avatarUrl": "",
"messages": 0,
"metrics": None,
}
)
snapshot: dict[str, Any] = {
"year": _pick_int(year),
"totalMessages": _pick_int(overview_d.get("totalMessages"), _pick_int(heatmap_d.get("totalMessages"), 0)),
"messagesPerDay": _pick_float(overview_d.get("messagesPerDay"), 0.0),
"sentChars": _pick_int(message_chars_d.get("sentChars"), 0),
"addedFriends": _pick_int(overview_d.get("addedFriends"), 0),
"mostActiveHour": overview_d.get("mostActiveHour"),
"topGroup": top_group,
"bestBuddy": best_buddy,
"fastest": fastest,
"slowest": slowest,
"replyStats": reply_stats,
"topPhrase": top_phrase,
"sentStickerCount": int(sent_sticker_count),
"topSticker": top_sticker,
"topEmoji": top_emoji,
"topUnicodeEmoji": top_unicode_emoji,
"topUnicodeEmojiCount": int(top_unicode_emoji_count),
"monthlyBestBuddies": monthly_best_buddies,
"weekdayLabels": heatmap_d.get("weekdayLabels") or [],
"hourLabels": heatmap_d.get("hourLabels") or [],
"weekdayHourMatrix": heatmap_d.get("matrix") or [],
}
return {
"id": 7,
"title": "便当总览:一屏看完这一年",
"scope": "global",
"category": "A",
"status": "ok",
"kind": "global/bento_summary",
"narrative": "把这一年的关键信息装进一份便当。",
"data": {"snapshot": snapshot},
}
+52 -9
View File
@@ -19,15 +19,16 @@ from .cards.card_05_keywords_wordcloud import build_card_05_keywords_wordcloud
from .cards.card_03_reply_speed import build_card_03_reply_speed
from .cards.card_04_monthly_best_friends_wall import build_card_04_monthly_best_friends_wall
from .cards.card_04_emoji_universe import build_card_04_emoji_universe
from .cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
logger = get_logger(__name__)
# We use this number to version the cache filename so adding more cards won't accidentally serve
# an older partial cache.
_IMPLEMENTED_UPTO_ID = 6
_IMPLEMENTED_UPTO_ID = 7
# Bump this when we change card payloads/ordering while keeping the same implemented_upto.
_CACHE_VERSION = 24
_CACHE_VERSION = 26
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.
@@ -82,6 +83,13 @@ _WRAPPED_CARD_MANIFEST: tuple[dict[str, Any], ...] = (
"category": "B",
"kind": "emoji/annual_universe",
},
{
"id": 7,
"title": "便当总览:一屏看完这一年",
"scope": "global",
"category": "A",
"kind": "global/bento_summary",
},
)
_WRAPPED_CARD_ID_SET = {int(c["id"]) for c in _WRAPPED_CARD_MANIFEST}
@@ -300,7 +308,7 @@ def build_wrapped_annual_response(
) -> dict[str, Any]:
"""Build annual wrapped response for the given account/year.
For now we implement cards up to id=6 (plus a meta overview card id=0).
For now we implement cards up to id=7 (plus a meta overview card id=0).
"""
account_dir = _resolve_account_dir(account)
@@ -345,19 +353,37 @@ def build_wrapped_annual_response(
# in first-person narratives like "你最常...".
heatmap_sent = _get_or_compute_heatmap_sent(account_dir=account_dir, scope=scope, year=y, refresh=refresh)
# Page 2: global overview (page 1 is the frontend cover slide).
cards.append(build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent))
card_overview = build_card_00_global_overview(account_dir=account_dir, year=y, heatmap=heatmap_sent)
cards.append(card_overview)
# Page 3: cyber schedule heatmap.
cards.append(build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent))
card_heatmap = build_card_01_cyber_schedule(account_dir=account_dir, year=y, heatmap=heatmap_sent)
cards.append(card_heatmap)
# Page 4: message char counts (sent vs received).
cards.append(build_card_02_message_chars(account_dir=account_dir, year=y))
card_message_chars = build_card_02_message_chars(account_dir=account_dir, year=y)
cards.append(card_message_chars)
# Page 5: annual keywords (bubble storm -> word cloud).
cards.append(build_card_05_keywords_wordcloud(account_dir=account_dir, year=y))
# Page 6: reply speed / best chat buddy.
cards.append(build_card_03_reply_speed(account_dir=account_dir, year=y))
card_reply_speed = build_card_03_reply_speed(account_dir=account_dir, year=y)
cards.append(card_reply_speed)
# Page 7: monthly best friends wall (photo wall).
cards.append(build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y))
card_monthly = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
cards.append(card_monthly)
# Page 8: annual emoji universe / meme almanac.
cards.append(build_card_04_emoji_universe(account_dir=account_dir, year=y))
card_emoji = build_card_04_emoji_universe(account_dir=account_dir, year=y)
cards.append(card_emoji)
# Page 9: bento summary (prototype). Build from prior cards for consistency.
cards.append(
build_card_07_bento_summary_from_sources(
year=y,
overview=card_overview,
heatmap=card_heatmap,
message_chars=card_message_chars,
reply_speed=card_reply_speed,
monthly=card_monthly,
emoji=card_emoji,
)
)
obj: dict[str, Any] = {
"account": account_dir.name,
@@ -557,6 +583,23 @@ def build_wrapped_annual_card(
card = build_card_04_monthly_best_friends_wall(account_dir=account_dir, year=y)
elif cid == 5:
card = build_card_04_emoji_universe(account_dir=account_dir, year=y)
elif cid == 7:
# Build from already-implemented cards so we can reuse their caches if available.
overview = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=0, refresh=refresh)
heatmap = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=1, refresh=refresh)
message_chars = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=2, refresh=refresh)
reply_speed = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=3, refresh=refresh)
monthly = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=4, refresh=refresh)
emoji = build_wrapped_annual_card(account=account_dir.name, year=y, card_id=5, refresh=refresh)
card = build_card_07_bento_summary_from_sources(
year=y,
overview=overview,
heatmap=heatmap,
message_chars=message_chars,
reply_speed=reply_speed,
monthly=monthly,
emoji=emoji,
)
else:
# Should be unreachable due to _WRAPPED_CARD_ID_SET check.
raise ValueError(f"Unknown Wrapped card id: {cid}")
@@ -206,7 +206,6 @@ class TestChatExportChatHistoryModal(unittest.TestCase):
html_path = next((n for n in names if n.endswith("/messages.html")), "")
self.assertTrue(html_path)
html_text = zf.read(html_path).decode("utf-8")
self.assertIn('id="chatHistoryModal"', html_text)
self.assertIn('data-wce-chat-history="1"', html_text)
self.assertIn('data-record-item-b64="', html_text)
self.assertIn('id="wceMediaIndex"', html_text)
+1 -2
View File
@@ -307,7 +307,6 @@ class TestChatExportHtmlFormat(unittest.TestCase):
self.assertIn('data-wce-time-divider="1"', html_text)
self.assertIn('id="messageTypeFilter"', html_text)
self.assertIn('value="chatHistory"', html_text)
self.assertIn('id="chatHistoryModal"', html_text)
self.assertIn('data-wce-chat-history="1"', html_text)
self.assertIn('data-record-item-b64="', html_text)
self.assertIn('id="wceMediaIndex"', html_text)
@@ -334,6 +333,7 @@ class TestChatExportHtmlFormat(unittest.TestCase):
css_text = zf.read("assets/wechat-chat-export.css").decode("utf-8", errors="ignore")
self.assertIn("wechat-transfer-card", css_text)
self.assertNotIn("wechat-transfer-card[data-v-", css_text)
self.assertNotIn("bento-container", css_text)
js_text = zf.read("assets/wechat-chat-export.js").decode("utf-8", errors="ignore")
self.assertIn("wechat-voice-bubble", js_text)
@@ -343,7 +343,6 @@ class TestChatExportHtmlFormat(unittest.TestCase):
self.assertIn("assets/images/wechat/wechat-trans-icon1.png", names)
self.assertIn("assets/images/wechat/zip.png", names)
self.assertIn("assets/images/wechat/WeChat-Icon-Logo.wine.svg", names)
self.assertTrue(any(n.startswith("fonts/") and n.endswith(".woff2") for n in names))
self.assertIn("wxemoji/Expression_1@2x.png", names)
self.assertIn("../../wxemoji/Expression_1@2x.png", html_text)
finally:
@@ -0,0 +1,116 @@
import sys
import unittest
from pathlib import Path
# Ensure "src/" is importable when running tests from repo root.
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestWrappedBentoSummaryTopEmoji(unittest.TestCase):
def _build_sources(self, *, emoji_data):
# Keep sources minimal: card_07_bento_summary only needs a handful of keys.
overview = {"data": {"totalMessages": 100, "addedFriends": 0}}
heatmap = {"data": {"totalMessages": 100, "weekdayLabels": [], "hourLabels": [], "matrix": []}}
message_chars = {"data": {"sentChars": 0}}
reply_speed = {"data": {}}
monthly = {"data": {"months": []}}
emoji = {"data": emoji_data}
return overview, heatmap, message_chars, reply_speed, monthly, emoji
def test_top_emoji_prefers_wechat_when_count_higher(self):
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
emoji_data={
"topWechatEmojis": [{"key": "[微笑]", "count": 5, "assetPath": "/wxemoji/Expression_1@2x.png"}],
"topTextEmojis": [],
"topUnicodeEmojis": [{"emoji": "🙂", "count": 2}],
}
)
card = build_card_07_bento_summary_from_sources(
year=2025,
overview=overview,
heatmap=heatmap,
message_chars=message_chars,
reply_speed=reply_speed,
monthly=monthly,
emoji=emoji,
)
snap = card["data"]["snapshot"]
self.assertEqual(snap["topEmoji"]["kind"], "wechat")
self.assertEqual(snap["topEmoji"]["key"], "[微笑]")
self.assertEqual(snap["topEmoji"]["count"], 5)
self.assertTrue(str(snap["topEmoji"]["assetPath"]).startswith("/wxemoji/"))
def test_top_emoji_prefers_unicode_when_count_higher(self):
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
emoji_data={
"topWechatEmojis": [{"key": "[微笑]", "count": 5, "assetPath": "/wxemoji/Expression_1@2x.png"}],
"topTextEmojis": [],
"topUnicodeEmojis": [{"emoji": "🙂", "count": 9}],
}
)
card = build_card_07_bento_summary_from_sources(
year=2025,
overview=overview,
heatmap=heatmap,
message_chars=message_chars,
reply_speed=reply_speed,
monthly=monthly,
emoji=emoji,
)
snap = card["data"]["snapshot"]
self.assertEqual(snap["topEmoji"]["kind"], "unicode")
self.assertEqual(snap["topEmoji"]["emoji"], "🙂")
self.assertEqual(snap["topEmoji"]["count"], 9)
def test_top_emoji_includes_top_text_emojis(self):
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
emoji_data={
"topWechatEmojis": [{"key": "[表情1]", "count": 2, "assetPath": "/wxemoji/Expression_1@2x.png"}],
"topTextEmojis": [{"key": "[嘿哈]", "count": 4, "assetPath": "/wxemoji/Expression_99@2x.png"}],
"topUnicodeEmojis": [{"emoji": "🙂", "count": 3}],
}
)
card = build_card_07_bento_summary_from_sources(
year=2025,
overview=overview,
heatmap=heatmap,
message_chars=message_chars,
reply_speed=reply_speed,
monthly=monthly,
emoji=emoji,
)
snap = card["data"]["snapshot"]
self.assertEqual(snap["topEmoji"]["kind"], "wechat")
self.assertEqual(snap["topEmoji"]["key"], "[嘿哈]")
self.assertEqual(snap["topEmoji"]["count"], 4)
self.assertTrue(str(snap["topEmoji"]["assetPath"]).endswith("Expression_99@2x.png"))
def test_top_emoji_none_when_no_emoji_stats(self):
from wechat_decrypt_tool.wrapped.cards.card_07_bento_summary import build_card_07_bento_summary_from_sources
overview, heatmap, message_chars, reply_speed, monthly, emoji = self._build_sources(
emoji_data={"topWechatEmojis": [], "topTextEmojis": [], "topUnicodeEmojis": []}
)
card = build_card_07_bento_summary_from_sources(
year=2025,
overview=overview,
heatmap=heatmap,
message_chars=message_chars,
reply_speed=reply_speed,
monthly=monthly,
emoji=emoji,
)
snap = card["data"]["snapshot"]
self.assertIsNone(snap.get("topEmoji"))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,28 @@
import sys
import unittest
from pathlib import Path
# Ensure "src/" is importable when running tests from repo root.
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
class TestWrappedManifestBentoSummary(unittest.TestCase):
def test_manifest_appends_bento_summary(self):
try:
from wechat_decrypt_tool.wrapped.service import _WRAPPED_CARD_MANIFEST
except ModuleNotFoundError as e:
# Some dev/test environments may not have optional deps installed (e.g. pypinyin).
# The manifest itself doesn't depend on them, but importing the service module does.
if getattr(e, "name", "") == "pypinyin":
self.skipTest("pypinyin is not installed")
raise
self.assertTrue(len(_WRAPPED_CARD_MANIFEST) > 0)
last = _WRAPPED_CARD_MANIFEST[-1]
self.assertEqual(int(last.get("id")), 7)
self.assertEqual(str(last.get("kind")), "global/bento_summary")
if __name__ == "__main__":
unittest.main()