Compare commits

...

22 Commits

46 changed files with 4739 additions and 618 deletions
+1 -10
View File
@@ -6,6 +6,7 @@
<h1>WeChatDataAnalysis - 微信数据库解密与分析工具</h1>
<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>
<p>如需定制功能,请联系 QQ2977094657。</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" />
<img src="https://gh-down-badges.linkof.link/LifeArchiveProject/WeChatDataAnalysis" alt="Downloads" />
@@ -192,16 +193,6 @@ npm run dist
3. **数据隐私**: 解密后的数据包含个人隐私信息,请谨慎处理
4. **合法使用**: 请遵守相关法律法规,不得用于非法目的
## 修改消息
支持在聊天页对单条消息进行本地修改(如修改消息文本/字段、修复为我发送、反转本地气泡方向),并在“修改记录”页查看原始与当前对比,支持单条恢复或按会话一键恢复。
该功能只修改本机本地数据库(`db_storage` 与解密副本),不会调用远端回写接口。
<p align="center">
<img src="frontend/public/edit.gif" alt="本地消息修改" width="800" />
</p>
## 致谢
本项目的开发过程中参考了以下优秀的开源项目和资源:
+19 -1
View File
@@ -30,12 +30,18 @@
</template>
<script setup>
import { useThemeStore } from '~/stores/theme'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
const route = useRoute()
const desktopUpdate = useDesktopUpdate()
const { open: settingsDialogOpen, closeDialog: closeSettingsDialog } = useSettingsDialog()
const themeStore = useThemeStore()
if (process.client) {
themeStore.init()
}
// In Electron the server/pre-render doesn't know about `window.wechatDesktop`.
// If we render different DOM on server vs client, Vue hydration will keep the
@@ -71,6 +77,7 @@ onMounted(() => {
const privacy = usePrivacyStore()
void chatAccounts.ensureLoaded()
privacy.init()
themeStore.init()
})
onBeforeUnmount(() => {
@@ -78,7 +85,7 @@ onBeforeUnmount(() => {
})
const rootClass = computed(() => {
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
const base = 'theme-app-shell'
return isDesktop.value
? `wechat-desktop h-screen flex overflow-hidden ${base}`
: `h-screen flex overflow-hidden ${base}`
@@ -126,4 +133,15 @@ const showSidebar = computed(() => {
.wechat-desktop .wechat-desktop-content > .min-h-screen {
min-height: 100%;
}
.theme-app-shell {
background:
radial-gradient(circle at top left, rgba(7, 193, 96, 0.08), transparent 32%),
radial-gradient(circle at top right, rgba(16, 174, 239, 0.08), transparent 36%),
linear-gradient(135deg, #f0fdf4 0%, #ecfdf5 45%, #dcfce7 100%);
}
html[data-theme='dark'] .theme-app-shell {
background: var(--app-shell-bg);
}
</style>
+209 -52
View File
@@ -24,7 +24,7 @@
.wechat-link-card.wechat-link-card--disabled:hover,
.wechat-link-card-cover.wechat-link-card--disabled:hover {
background: #fff;
background: var(--merged-history-bg);
}
/* 滚动条样式 */
@@ -33,17 +33,17 @@
}
.overflow-y-auto::-webkit-scrollbar-track {
background: #f1f1f1;
background: var(--scrollbar-track);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background: #c1c1c1;
background: var(--scrollbar-thumb);
border-radius: 3px;
}
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
background: #a1a1a1;
background: var(--scrollbar-thumb-hover);
}
/* 会话列表宽度:按物理像素(px)配置,按 dpr 换算为 CSS px */
@@ -75,7 +75,25 @@
.session-list-resizer:hover::after,
.session-list-resizer-active::after {
background: rgba(0, 0, 0, 0.12);
background: var(--session-list-resizer);
}
.msg-bubble.bubble-tail-r {
background-color: var(--chat-bubble-sent) !important;
color: var(--chat-bubble-sent-text) !important;
}
.msg-bubble.bubble-tail-l {
background-color: var(--chat-bubble-received) !important;
color: var(--chat-bubble-received-text) !important;
}
.bubble-tail-r::after {
background: var(--chat-bubble-sent);
}
.bubble-tail-l::after {
background: var(--chat-bubble-received);
}
/* 消息气泡样式 */
@@ -87,7 +105,7 @@
/* 发送的消息(右侧绿色气泡) */
.sent-message {
background-color: #95EB69 !important;
background-color: var(--chat-bubble-sent) !important;
border-radius: var(--message-radius);
}
@@ -99,13 +117,13 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background-color: #95EB69;
background-color: var(--chat-bubble-sent);
border-radius: 2px;
}
/* 接收的消息(左侧白色气泡) */
.received-message {
background-color: white !important;
background-color: var(--chat-bubble-received) !important;
border-radius: var(--message-radius);
}
@@ -117,7 +135,7 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background-color: white;
background-color: var(--chat-bubble-received);
border-radius: 2px;
}
@@ -172,7 +190,7 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background-color: #95EC69;
background-color: var(--chat-bubble-sent);
border-radius: 2px;
}
@@ -188,7 +206,7 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background-color: white;
background-color: var(--chat-bubble-received);
border-radius: 2px;
}
@@ -216,7 +234,8 @@
}
.wechat-voice-sent {
background: #95EC69;
background: var(--chat-bubble-sent);
color: var(--chat-bubble-sent-text);
}
.wechat-voice-sent::after {
@@ -227,12 +246,13 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: #95EC69;
background: var(--chat-bubble-sent);
border-radius: 2px;
}
.wechat-voice-received {
background: white;
background: var(--chat-bubble-received);
color: var(--chat-bubble-received-text);
}
.wechat-voice-received::before {
@@ -243,7 +263,7 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: white;
background: var(--chat-bubble-received);
border-radius: 2px;
}
@@ -259,7 +279,7 @@
width: 18px;
height: 18px;
flex-shrink: 0;
color: #1a1a1a;
color: currentColor;
}
.wechat-quote-voice-icon {
@@ -293,7 +313,7 @@
.wechat-voice-duration {
font-size: 14px;
color: #1a1a1a;
color: inherit;
}
.wechat-voice-unread {
@@ -315,7 +335,8 @@
}
.wechat-voip-sent {
background: #95EC69;
background: var(--chat-bubble-sent);
color: var(--chat-bubble-sent-text);
}
.wechat-voip-sent::after {
@@ -326,12 +347,13 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: #95EC69;
background: var(--chat-bubble-sent);
border-radius: 2px;
}
.wechat-voip-received {
background: white;
background: var(--chat-bubble-received);
color: var(--chat-bubble-received-text);
}
.wechat-voip-received::before {
@@ -342,7 +364,7 @@
transform: translateY(-50%) rotate(45deg);
width: 10px;
height: 10px;
background: white;
background: var(--chat-bubble-received);
border-radius: 2px;
}
@@ -362,7 +384,7 @@
.wechat-voip-text {
font-size: 14px;
color: #1a1a1a;
color: inherit;
}
/* 统一特殊消息尾巴(红包 / 文件等) */
@@ -390,14 +412,14 @@
.wechat-chat-history-card {
width: 210px;
background: #ffffff;
background: var(--merged-history-bg);
border-radius: var(--message-radius);
cursor: pointer;
transition: background-color 0.15s ease;
}
.wechat-chat-history-card:hover {
background: #f5f5f5;
background: var(--merged-history-hover);
}
.wechat-chat-history-body {
@@ -407,13 +429,13 @@
.wechat-chat-history-title {
font-size: 14px;
font-weight: 400;
color: #161616;
color: var(--merged-history-title);
margin-bottom: 6px;
}
.wechat-chat-history-preview {
font-size: 12px;
color: #6b7280;
color: var(--merged-history-preview);
line-height: 1.4;
}
@@ -439,12 +461,17 @@
left: 13px;
right: 13px;
height: 1.5px;
background: #e8e8e8;
background: var(--merged-history-divider);
}
.wechat-chat-history-bottom span {
font-size: 12px;
color: #b2b2b2;
color: var(--merged-history-footer);
}
.wechat-quote-preview {
background: var(--quote-bubble-bg);
color: var(--quote-bubble-text);
}
/* 转账消息样式 - 微信风格 */
@@ -677,7 +704,7 @@
/* 文件消息样式 - 基于红包样式覆盖 */
.wechat-file-card {
width: 210px;
background: #fff;
background: var(--merged-history-bg);
cursor: pointer;
transition: background-color 0.15s ease;
}
@@ -701,11 +728,11 @@
left: 13px;
right: 13px;
height: 1.5px;
background: #e8e8e8;
background: var(--merged-history-divider);
}
.wechat-file-card:hover {
background: #f5f5f5;
background: var(--merged-history-hover);
}
.wechat-file-card .wechat-file-info {
@@ -715,7 +742,7 @@
.wechat-file-name {
font-size: 14px;
color: #1a1a1a;
color: var(--merged-history-title);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -726,7 +753,7 @@
.wechat-file-size {
font-size: 12px;
color: #b2b2b2;
color: var(--merged-history-footer);
margin-top: 4px;
}
@@ -738,12 +765,16 @@
}
.wechat-file-bottom {
border-top: 1px solid #e8e8e8;
border-top: 1px solid var(--merged-history-divider);
}
.wechat-file-bottom span {
font-size: 12px;
color: #b2b2b2;
color: var(--merged-history-footer);
}
.wechat-file-card :is(.text-gray-500, .text-gray-400) {
color: var(--merged-history-preview);
}
.wechat-file-logo {
@@ -758,7 +789,7 @@
width: 210px;
min-width: 210px;
max-width: 210px;
background: #fff;
background: var(--merged-history-bg);
display: flex;
flex-direction: column;
box-sizing: border-box;
@@ -771,7 +802,7 @@
}
.wechat-link-card:hover {
background: #f5f5f5;
background: var(--merged-history-hover);
}
.wechat-link-content {
@@ -792,7 +823,7 @@
.wechat-link-title {
font-size: 14px;
color: #1a1a1a;
color: var(--merged-history-title);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -803,7 +834,7 @@
.wechat-link-desc {
font-size: 12px;
color: #8c8c8c;
color: var(--merged-history-preview);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -820,7 +851,7 @@
flex: 0 0 auto;
border-radius: 0;
overflow: hidden;
background: #f2f2f2;
background: var(--app-surface-muted);
align-self: flex-start;
}
@@ -878,7 +909,7 @@
.wechat-link-mini-header-name {
font-size: 13px;
color: #7d7d7d;
color: var(--merged-history-preview);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -889,7 +920,7 @@
.wechat-link-mini-title {
font-size: 13px;
line-height: 1.45;
color: #1a1a1a;
color: var(--merged-history-title);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -903,12 +934,12 @@
min-height: 0;
flex: 1 1 auto;
overflow: hidden;
background: #f2f2f2;
background: var(--app-surface-muted);
margin-top: auto;
}
.wechat-link-mini-preview--empty {
background: #f7f7f7;
background: var(--app-surface-soft);
}
.wechat-link-mini-preview-img {
@@ -937,7 +968,7 @@
left: 12px;
right: 12px;
height: 1px;
background: #e8e8e8;
background: var(--merged-history-divider);
}
.wechat-link-mini-footer-icon {
@@ -949,7 +980,7 @@
.wechat-link-mini-footer-text {
font-size: 10px;
color: #8c8c8c;
color: var(--merged-history-preview);
}
.wechat-link-from {
@@ -969,7 +1000,7 @@
left: 11px;
right: 11px;
height: 1.5px;
background: #e8e8e8;
background: var(--merged-history-divider);
}
.wechat-link-from-avatar {
@@ -997,7 +1028,7 @@
.wechat-link-from-name {
font-size: 12px;
color: #b2b2b2;
color: var(--merged-history-footer);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -1008,7 +1039,7 @@
width: 137px;
min-width: 137px;
max-width: 137px;
background: #fff;
background: var(--merged-history-bg);
display: flex;
flex-direction: column;
box-sizing: border-box;
@@ -1021,7 +1052,7 @@
}
.wechat-link-card-cover:hover {
background: #f5f5f5;
background: var(--merged-history-hover);
}
.wechat-link-cover-image-wrap {
@@ -1030,7 +1061,7 @@
position: relative;
overflow: hidden;
border-radius: 4px 4px 0 0;
background: #f2f2f2;
background: var(--app-surface-muted);
flex-shrink: 0;
}
@@ -1099,7 +1130,7 @@
box-sizing: border-box;
font-size: 12px;
line-height: 1.24;
color: #1a1a1a;
color: var(--merged-history-title);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -1108,6 +1139,132 @@
flex-shrink: 0;
}
.wechat-link-card-finder {
width: 135px;
min-width: 135px;
max-width: 135px;
border: none;
box-shadow: none;
outline: none;
cursor: pointer;
text-decoration: none;
}
.wechat-link-card-finder.wechat-link-card--disabled {
cursor: default;
}
.wechat-link-finder-cover {
width: 135px;
height: 185px;
position: relative;
overflow: hidden;
border-radius: 4px;
background: var(--app-surface-muted);
}
.wechat-link-finder-cover--empty {
background: linear-gradient(180deg, #37cc6a 0%, #118f42 100%);
}
.wechat-link-finder-cover-img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
}
.wechat-link-finder-cover-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.92);
}
.wechat-link-finder-cover-placeholder svg {
width: 34px;
height: 34px;
}
.wechat-link-finder-cover-shade {
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.04) 0%, rgba(0, 0, 0, 0.12) 42%, rgba(0, 0, 0, 0.68) 100%);
}
.wechat-link-finder-play {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -66%);
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.42);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18);
}
.wechat-link-finder-play svg {
width: 20px;
height: 20px;
margin-left: 2px;
}
.wechat-link-finder-meta {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
display: flex;
flex-direction: column;
gap: 0;
}
.wechat-link-finder-author {
display: flex;
align-items: center;
gap: 5px;
min-width: 0;
padding: 5px 7px;
border-radius: 999px;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(6px);
}
.wechat-link-finder-author-avatar {
width: 18px;
height: 18px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.wechat-link-finder-author-avatar-img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.wechat-link-finder-author-name {
min-width: 0;
flex: 1 1 auto;
font-size: 10px;
color: rgba(255, 255, 255, 0.96);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
/* 隐私模式模糊效果 */
.privacy-blur {
filter: blur(9px);
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,6 +1,7 @@
<template>
<div v-if="appStore.apiStatus !== 'connected'"
class="fixed top-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm z-50">
<div v-if="appStore.apiStatus !== 'connected'"
class="api-status-banner fixed top-4 right-4 bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg max-w-sm z-50"
>
<div class="flex items-start">
<svg class="h-5 w-5 text-red-600 mr-2 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
+394
View File
@@ -0,0 +1,394 @@
<template>
<div class="biz-page h-full min-h-0 flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<div :class="['w-[300px] lg:w-[320px] border-r flex flex-col flex-shrink-0 z-10', isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-200']">
<div class="p-3 border-b" :class="isDark ? 'border-[#333]' : 'border-gray-200'" style="background-color: var(--app-surface-muted)">
<div class="contact-search-wrapper flex-1">
<input
v-model="searchQuery"
type="text"
class="contact-search-input"
placeholder="搜索服务号"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="loadingAccounts" class="flex justify-center py-4">
<span class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">加载中...</span>
</div>
<div v-else class="pb-4">
<div
v-for="item in filteredAccounts"
:key="item.username"
@click="selectAccount(item)"
class="flex items-center gap-3 px-4 py-3 cursor-pointer transition-colors border-b"
:class="[
isDark ? 'border-[#333]' : 'border-gray-50',
selectedBizAccount?.username === item.username
? (isDark ? 'bg-[#333]' : 'bg-[#E5E5E5]') // 选中状态
: item.username === 'gh_3dfda90e39d6'
? (isDark ? 'bg-[#2a2a2a] hover:bg-[#333]' : 'bg-[#F2F2F2] hover:bg-[#EAEAEA]') // 微信支付专门的底色
: (isDark ? 'hover:bg-[#252525]' : 'hover:bg-gray-50') // 普通悬浮色
]"
>
<img v-if="item.avatar" :src="api.getBizProxyImageUrl(item.avatar)" :class="['w-10 h-10 rounded-md object-cover flex-shrink-0', isDark ? 'bg-[#333]' : 'bg-gray-200']" alt=""/>
<div v-else class="w-10 h-10 rounded-md bg-[#03C160] text-white flex items-center justify-center text-lg font-medium flex-shrink-0 shadow-sm">
{{ (item.name || item.username).charAt(0).toUpperCase() }}
</div>
<div class="flex-1 min-w-0 flex flex-col justify-center gap-0.5">
<div class="flex justify-between items-center">
<h3 class="text-sm truncate" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ item.name || item.username }}</h3>
<span v-if="item.formatted_last_time" class="text-[11px] flex-shrink-0 ml-2" :class="isDark ? 'text-gray-500' : 'text-gray-400'">
{{ item.formatted_last_time }}
</span>
</div>
<div
class="text-[10px] px-1.5 py-0.5 rounded w-max mt-0.5"
:class="[
item.type === 1 ? (isDark ? 'text-[#03C160] bg-[#03C160]/20' : 'text-[#03C160] bg-[#03C160]/10') : // 服务号
item.type === 0 ? (isDark ? 'text-blue-400 bg-blue-900/40' : 'text-blue-500 bg-blue-50') : // 公众号
item.type === 2 ? (isDark ? 'text-orange-400 bg-orange-900/40' : 'text-orange-500 bg-orange-50') : // 企业号
(isDark ? 'text-gray-400 bg-gray-700/50' : 'text-gray-400 bg-gray-100') // 未知
]"
>
{{ {1: '服务号', 0: '公众号', 2: '企业号', 3: '未知'}[item.type] || '未知' }}
</div>
</div>
</div>
</div>
</div>
</div>
<div class="flex-1 flex flex-col min-h-0 min-w-0" :class="isDark ? 'bg-[#121212]' : 'bg-[#F5F5F5]'">
<div v-if="selectedBizAccount" class="flex-1 flex flex-col min-h-0 relative">
<div class="h-14 border-b flex items-center px-5 shrink-0 z-10" :class="isDark ? 'bg-[#121212] border-[#333]' : 'bg-[#F5F5F5] border-gray-200'">
<h2 class="text-base" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ selectedBizAccount.name }}</h2>
</div>
<div class="flex-1 overflow-y-auto px-4 py-6 flex flex-col-reverse" @scroll="handleScroll" ref="messageListRef">
<div class="h-4 shrink-0" aria-hidden="true"></div>
<div v-if="!hasMore" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">没有更多消息了</div>
<div v-if="loadingMessages" class="text-center text-xs py-4 w-full" :class="isDark ? 'text-gray-500' : 'text-gray-400'">正在加载...</div>
<div class="w-full max-w-[400px] mx-auto flex flex-col-reverse gap-6">
<div v-for="msg in messages" :key="msg.local_id" class="w-full">
<div v-if="selectedBizAccount.username === 'gh_3dfda90e39d6'" class="rounded-xl shadow-sm p-5 border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
<div class="flex items-center text-sm mb-5" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
<img v-if="msg.merchant_icon" :src="api.getBizProxyImageUrl(msg.merchant_icon)" class="w-6 h-6 rounded-full mr-2 object-cover" alt=""/>
<div v-else class="w-6 h-6 rounded-full mr-2 flex items-center justify-center" :class="isDark ? 'bg-green-900/40 text-green-400' : 'bg-green-100 text-green-600'">¥</div>
<span>{{ msg.merchant_name || '微信支付' }}</span>
</div>
<div class="text-center mb-6">
<h3 class="text-[22px] font-medium mb-1" :class="isDark ? 'text-gray-100' : 'text-gray-900'">{{ msg.title }}</h3>
</div>
<div class="text-[13px] whitespace-pre-wrap leading-relaxed" :class="isDark ? 'text-gray-400' : 'text-gray-500'">
{{ msg.description }}
</div>
<div class="mt-4 pt-3 border-t text-[12px] text-right" :class="isDark ? 'border-[#333] text-gray-500' : 'border-gray-100 text-gray-400'">
{{ msg.formatted_time }}
</div>
</div>
<div v-else class="rounded-xl shadow-sm overflow-hidden border" :class="isDark ? 'bg-[#1e1e1e] border-[#333]' : 'bg-white border-gray-100'">
<a :href="msg.url" target="_blank" class="block relative group cursor-pointer">
<img :src="msg.cover ? api.getBizProxyImageUrl(msg.cover) : defaultImage" :class="['w-full h-[180px] object-cover', isDark ? 'bg-[#333]' : 'bg-gray-100']" alt=""/>
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-3 pt-8">
<h3 class="text-white text-[15px] font-medium leading-snug line-clamp-2 group-hover:underline">
{{ msg.title }}
</h3>
</div>
</a>
<div v-if="msg.des" class="px-4 py-3 text-[13px] border-b" :class="isDark ? 'text-gray-400 border-[#333]' : 'text-gray-500 border-gray-50'">
{{ msg.des }}
</div>
<div v-if="msg.content_list && msg.content_list.length > 1" class="flex flex-col">
<a
v-for="(item, idx) in msg.content_list.slice(1)"
:key="idx"
:href="item.url"
target="_blank"
class="flex items-center justify-between p-3 border-t hover:bg-opacity-50 cursor-pointer group"
:class="isDark ? 'border-[#333] hover:bg-[#252525]' : 'border-gray-100 hover:bg-gray-50'"
>
<span class="text-[14px] leading-snug line-clamp-2 pr-3 group-hover:underline" :class="isDark ? 'text-gray-200' : 'text-gray-800'">
{{ item.title }}
</span>
<img :src="item.cover ? api.getBizProxyImageUrl(item.cover) : defaultImage" :class="['w-12 h-12 rounded object-cover flex-shrink-0 border', isDark ? 'bg-[#333] border-[#444]' : 'bg-gray-100 border-gray-100']" alt=""/>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl flex items-center justify-center" :class="isDark ? 'bg-[#2a2a2a]' : 'bg-gray-200/50'">
<svg class="w-10 h-10" :class="isDark ? 'text-gray-600' : 'text-gray-400'" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5L18.5 7H20" />
</svg>
</div>
<p class="text-sm" :class="isDark ? 'text-gray-500' : 'text-gray-400'">请选择一个服务号查看消息</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { useApi } from '~/composables/useApi'
const api = useApi()
import { storeToRefs } from 'pinia'
import { useThemeStore } from '~/stores/theme'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
const accounts = ref([])
const loadingAccounts = ref(false)
const searchQuery = ref('')
const selectedBizAccount = ref(null)
const themeStore = useThemeStore()
const chatAccountsStore = useChatAccountsStore()
const realtimeStore = useChatRealtimeStore()
const { isDark } = storeToRefs(themeStore)
const { selectedAccount: selectedDbAccount } = storeToRefs(chatAccountsStore)
const { enabled: realtimeEnabled, changeSeq } = storeToRefs(realtimeStore)
const messages = ref([])
const loadingMessages = ref(false)
const offset = ref(0)
const limit = 20
const hasMore = ref(true)
const messageListRef = ref(null)
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
// 默认占位图
// const defaultAvatar = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MCIgaGVpZ2h0PSI0MCIgdmlld0JveD0iMCAwIDQwIDQwIj48cmVjdCB3aWR0aD0iNDAiIGhlaWdodD0iNDAiIGZpbGw9IiNlNWU3ZWIiLz48L3N2Zz4='
const defaultImage = 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMTgwIj48cmVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjE4MCIgZmlsbD0iI2Y1ZjVmNSIvPjwvc3ZnPg=='
const getCurrentAccountParam = () => {
const account = String(selectedDbAccount.value || '').trim()
return account || undefined
}
const resetMessagesState = () => {
messages.value = []
offset.value = 0
hasMore.value = true
}
const fetchAccounts = async ({ preserveSelection = true } = {}) => {
loadingAccounts.value = true
const previousUsername = preserveSelection ? String(selectedBizAccount.value?.username || '').trim() : ''
try {
const res = await api.listBizAccounts({ account: getCurrentAccountParam() })
const nextAccounts = Array.isArray(res?.data) ? res.data : []
accounts.value = nextAccounts
if (previousUsername) {
selectedBizAccount.value = nextAccounts.find(item => item.username === previousUsername) || null
} else if (!selectedBizAccount.value?.username) {
selectedBizAccount.value = null
}
} catch (err) {
accounts.value = []
selectedBizAccount.value = null
console.error('获取服务号失败:', err)
} finally {
loadingAccounts.value = false
}
}
// 搜索过滤
const filteredAccounts = computed(() => {
if (!searchQuery.value) return accounts.value
const q = searchQuery.value.toLowerCase()
return accounts.value.filter(a =>
(a.name && a.name.toLowerCase().includes(q)) ||
(a.username && a.username.toLowerCase().includes(q))
)
})
// 点击选择服务号
const selectAccount = async (account) => {
if (selectedBizAccount.value?.username === account.username) return
selectedBizAccount.value = account
// 重置消息状态
resetMessagesState()
await loadMessages()
}
// 加载消息
const loadMessages = async () => {
if (loadingMessages.value || !hasMore.value || !selectedBizAccount.value) return
loadingMessages.value = true
try {
const username = selectedBizAccount.value.username
const params = {
account: getCurrentAccountParam(),
username,
offset: offset.value,
limit,
}
let res
if (username === 'gh_3dfda90e39d6') {
res = await api.listBizPayRecords(params)
} else {
res = await api.listBizMessages(params)
}
if (res && res.data) {
if (res.data.length < limit) {
hasMore.value = false
}
// 追加数据
messages.value.push(...res.data)
offset.value += limit
}
} catch (err) {
console.error('加载消息失败:', err)
} finally {
loadingMessages.value = false
}
}
const reloadSelectedMessages = async () => {
if (!selectedBizAccount.value) return
resetMessagesState()
await loadMessages()
}
const syncAllBizRealtime = async ({ forceReload = false } = {}) => {
const priorityUsername = String(selectedBizAccount.value?.username || '').trim()
if (!realtimeEnabled.value) {
if (forceReload) {
await reloadSelectedMessages()
}
return
}
try {
const result = await api.syncChatRealtimeAll({
account: getCurrentAccountParam(),
max_scan: 200,
priority_username: priorityUsername,
priority_max_scan: 400,
include_hidden: true,
include_official: true,
only_official: true,
backfill_limit: 0,
})
const hasDelta = Number(result?.insertedTotal || 0) > 0 || Number(result?.sessionsUpdated || 0) > 0
await fetchAccounts({ preserveSelection: true })
if (selectedBizAccount.value?.username) {
if (hasDelta || forceReload) {
await reloadSelectedMessages()
}
} else if (forceReload) {
resetMessagesState()
}
} catch (err) {
console.error('实时同步服务号失败:', err)
if (forceReload) {
await fetchAccounts({ preserveSelection: true })
await reloadSelectedMessages()
}
}
}
const queueRealtimeBizRefresh = () => {
if (!realtimeEnabled.value) return
if (realtimeRefreshFuture) {
realtimeRefreshQueued = true
return
}
realtimeRefreshFuture = syncAllBizRealtime().finally(() => {
realtimeRefreshFuture = null
if (realtimeRefreshQueued) {
realtimeRefreshQueued = false
queueRealtimeBizRefresh()
}
})
}
// 向上滚动加载逻辑
// 因为容器设置了 flex-col-reverse,所以 scrollTop 越靠近负值(或0取决于浏览器)越是到了历史消息端
// 但比较通用兼容的做法是监听 scroll,距离顶部或底部小于阈值时触发
const handleScroll = (e) => {
const target = e.target
// 针对 flex-col-reverse: 滚动到底部实际上是视觉上的最上方(历史消息)
// 当 scrollHeight - Math.abs(scrollTop) - clientHeight < 50 时加载
if (target.scrollHeight - Math.abs(target.scrollTop) - target.clientHeight < 50) {
loadMessages()
}
}
watch(selectedDbAccount, async (next, prev) => {
if (String(next || '').trim() === String(prev || '').trim()) return
selectedBizAccount.value = null
resetMessagesState()
searchQuery.value = ''
if (!String(next || '').trim()) {
accounts.value = []
return
}
await fetchAccounts({ preserveSelection: false })
if (realtimeEnabled.value) {
await syncAllBizRealtime({ forceReload: true })
}
})
watch(changeSeq, (next, prev) => {
if (!realtimeEnabled.value) return
if (next === prev) return
queueRealtimeBizRefresh()
})
watch(realtimeEnabled, async (enabled, wasEnabled) => {
if (enabled && !wasEnabled) {
await syncAllBizRealtime({ forceReload: true })
}
})
onMounted(async () => {
await chatAccountsStore.ensureLoaded()
await fetchAccounts({ preserveSelection: false })
if (realtimeEnabled.value) {
await syncAllBizRealtime({ forceReload: true })
}
})
</script>
<style scoped>
/* 隐藏滚动条但允许滚动(可选) */
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(0,0,0,0.1);
border-radius: 10px;
}
</style>
+23 -8
View File
@@ -136,10 +136,19 @@ const openLocation = () => {
<style scoped>
.wechat-location-card-wrap {
--location-card-bg: var(--chat-bubble-received);
--location-card-text: var(--chat-bubble-received-text);
--location-card-muted: var(--chat-sender-name);
position: relative;
display: inline-block;
}
.wechat-location-card-wrap--sent {
--location-card-bg: var(--chat-bubble-sent);
--location-card-text: var(--chat-bubble-sent-text);
--location-card-muted: rgba(255, 255, 255, 0.78);
}
.wechat-location-card-wrap--received::before,
.wechat-location-card-wrap--sent::after {
content: '';
@@ -147,7 +156,7 @@ const openLocation = () => {
top: 12px;
width: 12px;
height: 12px;
background: #fff;
background: var(--location-card-bg);
transform: rotate(45deg);
border-radius: 2px;
}
@@ -165,27 +174,27 @@ const openLocation = () => {
overflow: hidden;
border-radius: var(--message-radius);
border: none;
background: #fff;
background: var(--location-card-bg);
box-shadow: none;
cursor: pointer;
transition: opacity 0.15s ease;
}
.wechat-location-card--sent {
background: #fff;
background: var(--location-card-bg);
}
.wechat-location-card__text {
padding: 10px 12px 8px;
background: #fff;
background: var(--location-card-bg);
}
.wechat-location-card--sent .wechat-location-card__text {
background: #fff;
background: var(--location-card-bg);
}
.wechat-location-card__title {
color: #111827;
color: var(--location-card-text);
font-size: 13px;
font-weight: 500;
line-height: 1.4;
@@ -197,7 +206,7 @@ const openLocation = () => {
.wechat-location-card__subtitle {
margin-top: 4px;
color: #9ca3af;
color: var(--location-card-muted);
font-size: 11px;
line-height: 1.4;
white-space: nowrap;
@@ -206,7 +215,7 @@ const openLocation = () => {
}
.wechat-location-card--sent .wechat-location-card__subtitle {
color: #9ca3af;
color: var(--location-card-muted);
}
.wechat-location-card__map {
@@ -258,4 +267,10 @@ const openLocation = () => {
width: 100%;
height: 100%;
}
html[data-theme='dark'] .wechat-location-card-wrap {
--location-card-bg: var(--merged-history-bg);
--location-card-text: var(--merged-history-title);
--location-card-muted: var(--merged-history-preview);
}
</style>
+6 -6
View File
@@ -60,7 +60,7 @@ const closeWindow = () => {
<style scoped>
.desktop-titlebar {
height: var(--desktop-titlebar-height, 32px);
background: #ededed;
background: var(--desktop-titlebar-bg);
display: flex;
align-items: stretch;
flex-shrink: 0;
@@ -92,11 +92,11 @@ const closeWindow = () => {
}
.desktop-titlebar-btn:hover {
background: rgba(0, 0, 0, 0.06);
background: var(--desktop-titlebar-hover);
}
.desktop-titlebar-btn:active {
background: rgba(0, 0, 0, 0.1);
background: var(--desktop-titlebar-active);
}
.desktop-titlebar-btn-close:hover {
@@ -122,7 +122,7 @@ const closeWindow = () => {
/* Optical centering: the glyph was anchored to the bottom, so it looked low. */
top: 5px;
height: 1px;
background: #111;
background: var(--desktop-titlebar-icon);
}
.desktop-titlebar-icon-maximize::before {
@@ -132,7 +132,7 @@ const closeWindow = () => {
top: 2px;
right: 2px;
bottom: 2px;
border: 1px solid #111;
border: 1px solid var(--desktop-titlebar-icon);
box-sizing: border-box;
}
@@ -144,7 +144,7 @@ const closeWindow = () => {
right: 1px;
top: 50%;
height: 1px;
background: #111;
background: var(--desktop-titlebar-icon);
transform-origin: center;
}
+1 -1
View File
@@ -3,7 +3,7 @@
<div v-if="open && info" class="fixed inset-0 z-[9999] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="onBackdropClick" />
<div class="relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
<div class="desktop-update-dialog-panel relative w-[min(520px,calc(100vw-32px))] rounded-lg bg-white shadow-xl border border-gray-200">
<button
class="absolute right-3 top-3 h-8 w-8 rounded-md text-gray-500 hover:bg-gray-100 hover:text-gray-700"
type="button"
+2 -2
View File
@@ -1,10 +1,10 @@
<template>
<div
v-if="open"
class="fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
class="settings-dialog fixed inset-0 z-[120] flex items-center justify-center bg-black/40 px-4 py-4 backdrop-blur-md sm:py-8"
@click.self="handleClose"
>
<div class="flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<div class="settings-dialog-panel flex h-[80vh] min-h-[380px] w-full max-w-[760px] overflow-hidden rounded-[10px] border border-[#e2e2e2] bg-white shadow-2xl">
<!-- Sidebar -->
<aside class="flex w-[180px] shrink-0 flex-col bg-[#fcfcfc] border-r border-[#eeeeee]">
<div class="mt-4 mb-2 flex items-center px-4 gap-2">
+123 -58
View File
@@ -1,7 +1,6 @@
<template>
<div
class="border-r border-gray-200 flex flex-col"
:style="{ backgroundColor: '#e8e7e7', width: '60px', minWidth: '60px', maxWidth: '60px' }"
class="sidebar-rail border-r flex flex-col"
>
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- Avatar -->
@@ -25,12 +24,12 @@
<!-- Chat -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="聊天"
@click="goChat"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isChatRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isChatRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z" />
</svg>
@@ -40,12 +39,12 @@
<!-- Edits -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="修改记录"
@click="goEdits"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isEditsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isEditsRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
@@ -56,12 +55,12 @@
<!-- Moments -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="朋友圈"
@click="goSns"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isSnsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isSnsRoute }">
<svg
class="w-full h-full"
viewBox="0 0 24 24"
@@ -86,12 +85,12 @@
<!-- Contacts -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="联系人"
@click="goContacts"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isContactsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isContactsRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2" />
<circle cx="10" cy="7" r="4" />
@@ -102,14 +101,29 @@
</div>
</div>
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="服务号"
@click="goBiz"
>
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isBizRoute }">
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5L6 9H2v6h4l5 4V5z"></path>
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
</svg>
</div>
</div>
</div>
<!-- Wrapped -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="年度总结"
@click="goWrapped"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="isWrappedRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<div class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': isWrappedRoute }">
<svg
class="w-full h-full"
viewBox="0 0 24 24"
@@ -132,15 +146,15 @@
<!-- Realtime -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center group"
:class="realtimeBusy ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer'"
:title="realtimeTitle"
@click="toggleRealtime"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<svg
class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
:class="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'"
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
:class="{ 'sidebar-rail-icon-active': realtimeEnabled }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -156,12 +170,12 @@
<!-- Privacy -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="privacyStore.toggle"
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="privacyMode ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': privacyMode }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<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" />
@@ -169,15 +183,52 @@
</div>
</div>
<!-- Theme -->
<div
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
:title="themeStore.isDark ? '切换浅色模式' : '切换深色模式'"
@click="themeStore.toggle"
>
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<svg
v-if="themeStore.isDark"
class="sidebar-rail-icon sidebar-rail-icon-active w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4.5" />
<path d="M12 2.5v2.2M12 19.3v2.2M4.93 4.93l1.56 1.56M17.51 17.51l1.56 1.56M2.5 12h2.2M19.3 12h2.2M4.93 19.07l1.56-1.56M17.51 6.49l1.56-1.56" />
</svg>
<svg
v-else
class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M21 12.79A9 9 0 1 1 11.21 3c-.08.5-.12 1.01-.12 1.54a8.25 8.25 0 0 0 8.37 8.25c.52 0 1.03-.04 1.54-.12Z" />
</svg>
</div>
</div>
<div class="mt-auto">
<!-- Guide -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
title="引导页"
@click="goGuide"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#5d5d5d]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M3 10.5L12 3l9 7.5" />
<path d="M5 9.5V20h14V9.5" />
<path d="M10 20v-6h4v6" />
@@ -187,12 +238,12 @@
<!-- Settings -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
class="sidebar-rail-action w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="goSettings"
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="settingsDialogOpen ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<div class="sidebar-rail-plate w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent">
<svg class="sidebar-rail-icon w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="{ 'sidebar-rail-icon-active': settingsDialogOpen }" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path
stroke-linecap="round"
stroke-linejoin="round"
@@ -208,10 +259,10 @@
<div
v-if="accountDialogOpen"
class="fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
class="account-info-dialog fixed inset-0 z-[130] flex items-center justify-center bg-black/35 px-4"
@click.self="closeAccountDialog"
>
<div class="w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
<div class="account-info-dialog-panel w-full max-w-[440px] overflow-hidden rounded-[12px] border border-[#e7e7e7] bg-white shadow-2xl">
<div class="flex items-center justify-between border-b border-[#efefef] px-4 py-3">
<div class="text-[14px] font-semibold text-[#222]">当前账号信息</div>
<button
@@ -289,6 +340,7 @@ import { storeToRefs } from 'pinia'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
import { usePrivacyStore } from '~/stores/privacy'
import { useThemeStore } from '~/stores/theme'
const route = useRoute()
@@ -298,6 +350,9 @@ const { selectedAccount } = storeToRefs(chatAccounts)
const privacyStore = usePrivacyStore()
const { privacyMode } = storeToRefs(privacyStore)
const themeStore = useThemeStore()
themeStore.init()
const realtimeStore = useChatRealtimeStore()
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
const { open: settingsDialogOpen, openDialog: openSettingsDialog } = useSettingsDialog()
@@ -439,34 +494,17 @@ const isChatRoute = computed(() => route.path?.startsWith('/chat'))
const isEditsRoute = computed(() => route.path?.startsWith('/edits'))
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isBizRoute = computed(() => route.path?.startsWith('/biz')) // 新增
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const goChat = async () => {
await navigateTo('/chat')
}
const goEdits = async () => {
await navigateTo('/edits')
}
const goSns = async () => {
await navigateTo('/sns')
}
const goContacts = async () => {
await navigateTo('/contacts')
}
const goWrapped = async () => {
await navigateTo('/wrapped')
}
const goGuide = async () => {
await navigateTo('/')
}
const goSettings = () => {
openSettingsDialog()
}
const goChat = async () => { await navigateTo('/chat') }
const goEdits = async () => { await navigateTo('/edits') }
const goSns = async () => { await navigateTo('/sns') }
const goContacts = async () => { await navigateTo('/contacts') }
const goBiz = async () => { await navigateTo('/biz') }
const goWrapped = async () => { await navigateTo('/wrapped') }
const goGuide = async () => { await navigateTo('/') }
const goSettings = () => { openSettingsDialog() }
const onWindowKeydown = (event) => {
if (event?.key !== 'Escape') return
@@ -540,3 +578,30 @@ const toggleRealtime = async () => {
await realtimeStore.toggle({ silent: false })
}
</script>
<style scoped>
.sidebar-rail {
width: 60px;
min-width: 60px;
max-width: 60px;
background-color: var(--sidebar-rail-bg);
border-color: var(--sidebar-rail-border);
}
.sidebar-rail-plate {
transition: background-color 0.15s ease;
}
.sidebar-rail-action:hover .sidebar-rail-plate {
background-color: var(--sidebar-rail-hover);
}
.sidebar-rail-icon {
color: var(--sidebar-rail-icon-color);
transition: color 0.15s ease;
}
.sidebar-rail-icon-active {
color: var(--sidebar-rail-icon-active-color);
}
</style>
+38 -38
View File
@@ -258,7 +258,7 @@
<div
v-if="messageSearchSenderDropdownOpen"
class="absolute left-0 right-0 mt-1 bg-white border border-gray-200 rounded-md shadow-lg z-50 overflow-hidden"
class="chat-overlay-dropdown absolute left-0 right-0 mt-1 rounded-md z-50 overflow-hidden"
>
<div class="p-2 border-b border-gray-100">
<input
@@ -274,8 +274,8 @@
<div class="max-h-64 overflow-y-auto">
<button
type="button"
class="w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-gray-50"
:class="!messageSearchSender ? 'bg-gray-50' : ''"
class="chat-overlay-option w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs"
:class="!messageSearchSender ? 'chat-overlay-option--active' : ''"
@click="selectMessageSearchSender('')"
>
<span class="w-6 h-6 rounded-md overflow-hidden bg-gray-200 flex-shrink-0 flex items-center justify-center text-[10px] text-gray-500">
@@ -298,8 +298,8 @@
v-for="s in filteredMessageSearchSenderOptions"
:key="s.username"
type="button"
class="w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs hover:bg-gray-50"
:class="messageSearchSender === s.username ? 'bg-gray-50' : ''"
class="chat-overlay-option w-full flex items-center gap-2 px-2 py-1.5 text-left text-xs"
:class="messageSearchSender === s.username ? 'chat-overlay-option--active' : ''"
@click="selectMessageSearchSender(s.username)"
>
<div class="w-6 h-6 rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
@@ -560,29 +560,29 @@
@mousedown="focusFloatingWindow(win.id)"
>
<div
class="bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden border border-gray-200 flex flex-col"
class="chat-floating-window rounded-xl overflow-hidden flex flex-col"
:style="{ width: win.width + 'px', height: win.height + 'px' }"
>
<div
class="px-3 py-2 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between select-none cursor-move"
class="chat-floating-window__header px-3 py-2 flex items-center justify-between select-none cursor-move"
@mousedown.stop="startFloatingWindowDrag(win.id, $event)"
@touchstart.stop="startFloatingWindowDrag(win.id, $event)"
>
<div class="text-sm text-[#161616] truncate min-w-0">{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}</div>
<div class="chat-floating-window__title text-sm truncate min-w-0">{{ win.title || (win.kind === 'link' ? '链接' : '聊天记录') }}</div>
<button
type="button"
class="p-2 rounded hover:bg-black/5 flex-shrink-0"
class="chat-floating-window__close p-2 rounded flex-shrink-0"
@click.stop="closeFloatingWindow(win.id)"
aria-label="关闭"
title="关闭"
>
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-5 h-5" 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>
</button>
</div>
<div class="flex-1 overflow-auto bg-[#f7f7f7]">
<div class="chat-floating-window__body flex-1 overflow-auto">
<!-- Chat history window -->
<template v-if="win.kind === 'chatHistory'">
<div v-if="win.loading" class="text-xs text-gray-500 text-center py-2">加载中...</div>
@@ -593,7 +593,7 @@
<div
v-for="(rec, idx) in win.records"
:key="rec.id || idx"
class="px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]"
class="chat-floating-window__row px-4 py-3 flex gap-3"
>
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
@@ -826,51 +826,51 @@
<!-- 合并转发聊天记录弹窗 -->
<div
v-if="chatHistoryModalVisible"
class="fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
class="chat-history-modal-overlay fixed inset-0 z-50 bg-black/40 flex items-center justify-center"
@click="closeChatHistoryModal"
>
<div
class="w-[92vw] max-w-[560px] max-h-[80vh] bg-[#f7f7f7] rounded-xl shadow-xl overflow-hidden flex flex-col"
class="chat-history-modal-panel w-[92vw] max-w-[560px] max-h-[80vh] rounded-xl shadow-xl overflow-hidden flex flex-col"
@click.stop
>
<div class="px-4 py-3 bg-[#f7f7f7] border-b border-gray-200 flex items-center justify-between">
<div class="chat-history-modal-header px-4 py-3 border-b flex items-center justify-between">
<div class="flex items-center gap-2 min-w-0">
<button
v-if="chatHistoryModalStack.length"
type="button"
class="p-2 rounded hover:bg-black/5 flex-shrink-0"
class="chat-history-modal-icon-btn p-2 rounded flex-shrink-0"
@click="goBackChatHistoryModal"
aria-label="返回"
title="返回"
>
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="chat-history-modal-icon w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<div class="text-sm text-[#161616] truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
<div class="chat-history-modal-title text-sm truncate">{{ chatHistoryModalTitle || '聊天记录' }}</div>
</div>
<button
type="button"
class="p-2 rounded hover:bg-black/5"
class="chat-history-modal-icon-btn p-2 rounded"
@click="closeChatHistoryModal"
aria-label="关闭"
title="关闭"
>
<svg class="w-5 h-5 text-gray-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="chat-history-modal-icon w-5 h-5" 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>
</button>
</div>
<div class="flex-1 overflow-auto bg-[#f7f7f7]">
<div v-if="!chatHistoryModalRecords.length" class="text-sm text-gray-500 text-center py-10">
<div class="chat-history-modal-body flex-1 overflow-auto">
<div v-if="!chatHistoryModalRecords.length" class="chat-history-modal-empty text-sm text-center py-10">
没有可显示的聊天记录
</div>
<template v-else>
<div
v-for="(rec, idx) in chatHistoryModalRecords"
:key="rec.id || idx"
class="px-4 py-3 flex gap-3 border-b border-gray-100 bg-[#f7f7f7]"
class="chat-history-modal-row px-4 py-3 flex gap-3 border-b"
>
<div class="w-9 h-9 rounded-md overflow-hidden bg-gray-200 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<img
@@ -892,12 +892,12 @@
<div class="min-w-0 flex-1">
<div
v-if="chatHistoryModalInfo?.isChatRoom && (rec.senderDisplayName || rec.sourcename)"
class="text-xs text-gray-500 leading-none truncate mb-1"
class="chat-history-modal-sender text-xs leading-none truncate mb-1"
>
{{ rec.senderDisplayName || rec.sourcename }}
</div>
</div>
<div v-if="rec.fullTime || rec.sourcetime" class="text-xs text-gray-400 flex-shrink-0 leading-none">
<div v-if="rec.fullTime || rec.sourcetime" class="chat-history-modal-time text-xs flex-shrink-0 leading-none">
{{ rec.fullTime || rec.sourcetime }}
</div>
</div>
@@ -1087,19 +1087,19 @@
<div
v-if="contextMenu.visible"
ref="contextMenuElement"
class="fixed z-[12000] max-h-[calc(100vh-16px)] overflow-y-auto bg-white border border-gray-200 rounded-md shadow-lg text-sm"
class="chat-context-menu fixed z-[12000] max-h-[calc(100vh-16px)] overflow-y-auto rounded-md text-sm"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
@click.stop
>
<button
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
@click="onCopyMessageTextClick"
>
复制文本
</button>
<button
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
@click="onCopyMessageJsonClick"
>
@@ -1107,14 +1107,14 @@
</button>
<button
v-if="contextMenu.message?.renderType === 'quote' && contextMenu.message?.quoteServerId"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
@click="onLocateQuotedMessageClick"
>
定位引用消息
</button>
<button
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
:disabled="contextMenu.disabled"
:class="contextMenu.disabled ? 'opacity-50 cursor-not-allowed' : ''"
@@ -1127,7 +1127,7 @@
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
@click="onEditMessageClick"
>
@@ -1135,7 +1135,7 @@
</button>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
@click="onEditMessageFieldsClick"
>
@@ -1143,7 +1143,7 @@
</button>
<button
v-if="contextMenu.editStatus?.modified"
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-red-600"
class="chat-context-menu__item block w-full text-left px-3 py-2 text-red-600"
type="button"
@click="onResetEditedMessageClick"
>
@@ -1151,7 +1151,7 @@
</button>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100"
class="chat-context-menu__item block w-full text-left px-3 py-2"
type="button"
@click="onRepairMessageSenderAsMeClick"
>
@@ -1159,7 +1159,7 @@
</button>
<button
v-if="contextMenu.message?.id"
class="block w-full text-left px-3 py-2 hover:bg-gray-100 text-orange-600"
class="chat-context-menu__item block w-full text-left px-3 py-2 text-orange-600"
type="button"
@click="onFlipWechatMessageDirectionClick"
>
@@ -1171,7 +1171,7 @@
<!-- 修改消息弹窗 -->
<div v-if="messageEditModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeMessageEditModal"></div>
<div class="relative w-[860px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="chat-edit-modal relative w-[860px] max-w-[95vw] rounded-lg overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
<div class="text-base font-medium text-gray-900">{{ messageEditModal.mode === 'content' ? '修改消息' : '编辑源码' }}</div>
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageEditModal">
@@ -1218,7 +1218,7 @@
<!-- 字段编辑弹窗 -->
<div v-if="messageFieldsModal.open" class="fixed inset-0 z-[11000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeMessageFieldsModal"></div>
<div class="relative w-[920px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="chat-edit-modal relative w-[920px] max-w-[95vw] rounded-lg overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
<div class="text-base font-medium text-gray-900">字段编辑</div>
<button class="ml-auto text-gray-400 hover:text-gray-600" type="button" @click="closeMessageFieldsModal">
@@ -1273,7 +1273,7 @@
<!-- 导出弹窗 -->
<div v-if="exportModalOpen" class="fixed inset-0 z-[11000] flex items-center justify-center">
<div class="absolute inset-0 bg-black/40" @click="closeExportModal"></div>
<div class="relative w-[960px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="chat-export-modal relative w-[960px] max-w-[95vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden">
<div class="px-5 py-4 border-b border-gray-200 flex items-center">
<div class="text-base font-medium text-gray-900">导出聊天记录离线 ZIP</div>
<button class="ml-auto text-gray-400 hover:text-gray-700" type="button" @click="closeExportModal">
@@ -1,9 +1,9 @@
<template>
<div class="flex-1 flex flex-col min-h-0 min-w-0">
<div class="conversation-pane flex-1 flex flex-col min-h-0 min-w-0">
<div v-if="selectedContact" class="flex-1 flex flex-col min-h-0 relative">
<div class="chat-header">
<div class="flex items-center gap-3">
<h2 class="text-base font-medium text-gray-900" :class="{ 'privacy-blur': privacyMode }">
<h2 class="chat-header-title text-base font-medium" :class="{ 'privacy-blur': privacyMode }">
{{ selectedContact ? selectedContact.name : '' }}
</h2>
</div>
@@ -71,7 +71,7 @@
<button
v-if="showJumpToBottom"
type="button"
class="absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full bg-white/90 border border-gray-200 shadow hover:bg-white flex items-center justify-center"
class="jump-to-bottom-btn absolute bottom-6 right-6 z-20 w-10 h-10 rounded-full border shadow flex items-center justify-center"
title="回到最新"
@click="scrollToBottom"
>
@@ -81,15 +81,15 @@
</button>
</div>
<div v-else class="flex-1 flex items-center justify-center">
<div v-else class="conversation-empty flex-1 flex items-center justify-center">
<div class="text-center">
<div class="w-20 h-20 mx-auto mb-5 rounded-2xl bg-gradient-to-br from-[#03C160]/10 to-[#03C160]/5 flex items-center justify-center">
<svg class="w-10 h-10 text-[#03C160]/60" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 19.8C17.52 19.8 22 15.99 22 11.3C22 6.6 17.52 2.8 12 2.8C6.48 2.8 2 6.6 2 11.3C2 13.29 2.8 15.12 4.15 16.57C4.6 17.05 4.82 17.29 4.92 17.44C5.14 17.79 5.21 17.99 5.23 18.4C5.24 18.59 5.22 18.81 5.16 19.26C5.1 19.75 5.07 19.99 5.13 20.16C5.23 20.49 5.53 20.71 5.87 20.72C6.04 20.72 6.27 20.63 6.72 20.43L8.07 19.86C8.43 19.71 8.61 19.63 8.77 19.59C8.95 19.55 9.04 19.54 9.22 19.54C9.39 19.53 9.64 19.57 10.14 19.65C10.74 19.75 11.37 19.8 12 19.8Z"/>
</svg>
</div>
<h3 class="text-base font-medium text-gray-700 mb-1.5">选择一个会话</h3>
<p class="text-sm text-gray-400">
<h3 class="conversation-empty-title text-base font-medium mb-1.5">选择一个会话</h3>
<p class="conversation-empty-text text-sm">
从左侧列表选择联系人查看聊天记录
</p>
</div>
+71 -6
View File
@@ -2,6 +2,8 @@
import { defineComponent, h, ref, watch } from 'vue'
import miniProgramIconUrl from '~/assets/images/wechat/mini-program.svg'
const finderLogoUrl = '/assets/images/wechat/channels-logo.svg'
export default defineComponent({
name: 'LinkCard',
props: {
@@ -51,7 +53,11 @@ export default defineComponent({
return text ? (Array.from(text)[0] || '') : ''
})()
const fromAvatarUrl = String(props.fromAvatar || '').trim()
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
const isMiniProgram = String(props.linkType || '').trim() === 'mini_program'
const isFinder = String(props.linkType || '').trim() === 'finder'
const isCoverVariant = !isMiniProgram && String(props.variant || '').trim() === 'cover'
const Tag = canNavigate ? 'a' : 'div'
@@ -116,7 +122,7 @@ export default defineComponent({
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
background: 'var(--merged-history-bg)',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
@@ -140,9 +146,68 @@ export default defineComponent({
)
}
const headingText = String(props.heading || href || '').trim()
let abstractText = String(props.abstract || '').trim()
if (abstractText && headingText && abstractText === headingText) abstractText = ''
if (isFinder) {
return h(
Tag,
{
...(canNavigate ? { href, target: '_blank', rel: 'noreferrer' } : { role: 'group', 'aria-disabled': 'true' }),
class: [
'wechat-link-card-finder',
!canNavigate ? 'wechat-link-card--disabled' : '',
'wechat-special-card',
'msg-radius',
props.isSent ? 'wechat-special-sent-side' : ''
].filter(Boolean).join(' '),
style: {
width: '135px',
minWidth: '135px',
maxWidth: '135px',
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
outline: 'none'
}
},
[
h('div', { class: ['wechat-link-finder-cover', !props.preview ? 'wechat-link-finder-cover--empty' : ''].filter(Boolean).join(' ') }, [
props.preview
? h('img', {
src: props.preview,
alt: props.heading || '视频号封面',
class: 'wechat-link-finder-cover-img',
referrerpolicy: 'no-referrer'
})
: h('div', { class: 'wechat-link-finder-cover-placeholder', 'aria-hidden': 'true' }, [
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M8 5v14l11-7z' })
])
]),
h('div', { class: 'wechat-link-finder-cover-shade', 'aria-hidden': 'true' }),
h('div', { class: 'wechat-link-finder-play', 'aria-hidden': 'true' }, [
h('svg', { viewBox: '0 0 24 24', fill: 'currentColor' }, [
h('path', { d: 'M8 5v14l11-7z' })
])
]),
h('div', { class: 'wechat-link-finder-meta' }, [
h('div', { class: 'wechat-link-finder-author' }, [
h('div', { class: 'wechat-link-finder-author-avatar', 'aria-hidden': 'true' }, [
h('img', {
src: finderLogoUrl,
alt: '',
class: 'wechat-link-finder-author-avatar-img'
})
]),
h('div', { class: 'wechat-link-finder-author-name' }, fromText || '视频号')
])
])
])
]
)
}
if (isMiniProgram) {
return h(
@@ -167,7 +232,7 @@ export default defineComponent({
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
background: 'var(--merged-history-bg)',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
@@ -236,7 +301,7 @@ export default defineComponent({
flexDirection: 'column',
boxSizing: 'border-box',
flex: '0 0 auto',
background: '#fff',
background: 'var(--merged-history-bg)',
border: 'none',
boxShadow: 'none',
textDecoration: 'none',
+1 -1
View File
@@ -127,7 +127,7 @@
</div>
<div
v-if="message.quoteTitle || message.quoteContent"
class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start bg-[#e1e1e1]">
class="wechat-quote-preview mt-[5px] px-2 text-xs rounded max-w-[404px] max-h-[65px] overflow-hidden flex items-start">
<div class="py-2 min-w-0 flex-1">
<div v-if="isQuotedVoice(message)" class="flex items-center gap-1 min-w-0">
<span v-if="message.quoteTitle" class="truncate flex-shrink-0">{{ message.quoteTitle }}:</span>
+41 -4
View File
@@ -10,13 +10,13 @@
:data-create-time="message.createTime"
>
<div v-if="message.showTimeDivider" class="flex justify-center mb-4">
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
<div class="message-time-divider px-3 py-1 text-xs">
{{ message.timeDivider }}
</div>
</div>
<div v-if="message.renderType === 'system'" class="flex justify-center">
<div class="px-3 py-1 text-xs text-[#9e9e9e]">
<div class="message-time-divider px-3 py-1 text-xs">
{{ message.content }}
</div>
</div>
@@ -49,7 +49,7 @@
<div
v-if="contactProfileCardOpen && contactProfileCardMessageId === String(message.id ?? '')"
class="absolute z-40 w-[360px] max-w-[88vw] bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden"
class="chat-contact-card absolute z-40 w-[360px] max-w-[88vw] rounded-lg overflow-hidden"
:class="message.isSent ? 'right-0 top-[calc(100%+8px)]' : 'left-0 top-[calc(100%+8px)]'"
@mouseenter="onContactCardMouseEnter"
@mouseleave="onMessageAvatarMouseLeave"
@@ -110,7 +110,7 @@
:class="[message.isSent ? 'items-end' : 'items-start', { 'privacy-blur': privacyMode }]"
@contextmenu="openMediaContextMenu($event, message, 'message')"
>
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="text-[11px] text-gray-500 mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
<div v-if="message.isGroup && !message.isSent && message.senderDisplayName" class="message-sender-name text-[11px] mb-1" :class="message.isSent ? 'text-right' : 'text-left'">
{{ message.senderDisplayName }}
</div>
<div
@@ -146,3 +146,40 @@ export default defineComponent({
}
})
</script>
<style scoped>
.chat-contact-card {
background-color: var(--app-surface-bg);
border: 1px solid var(--app-border);
color: var(--app-text-primary);
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.16);
}
html[data-theme='dark'] .chat-contact-card {
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.42);
}
.chat-contact-card .bg-white {
background-color: var(--app-surface-bg);
}
.chat-contact-card [class*='bg-[#F6F6F6]'] {
background-color: var(--app-surface-soft);
}
.chat-contact-card .bg-gray-200 {
background-color: var(--app-border-soft);
}
.chat-contact-card :is(.border-gray-100, .border-gray-200, .border-gray-300) {
border-color: var(--app-border);
}
.chat-contact-card :is(.text-gray-900, .text-gray-800, .text-gray-700) {
color: var(--app-text-primary);
}
.chat-contact-card :is(.text-gray-600, .text-gray-500, .text-gray-400) {
color: var(--app-text-muted);
}
</style>
+4 -4
View File
@@ -1,8 +1,8 @@
<template>
<div ref="messageContainerRef" class="flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
<div ref="messageContainerRef" class="message-list flex-1 overflow-y-auto p-4 min-h-0" @scroll="onMessageScroll">
<div v-if="selectedContact && hasMoreMessages" class="flex justify-center mb-4">
<div
class="text-xs px-3 py-1 rounded-md bg-white border border-gray-200 text-gray-700 select-none"
class="message-list-load-more text-xs px-3 py-1 rounded-md border select-none"
:class="isLoadingMessages ? 'opacity-60' : 'hover:bg-gray-50 cursor-pointer'"
@click="!isLoadingMessages && loadMoreMessages()"
>
@@ -10,13 +10,13 @@
</div>
</div>
<div v-if="isLoadingMessages && messages.length === 0" class="text-center text-sm text-gray-500 py-6">
<div v-if="isLoadingMessages && messages.length === 0" class="message-list-status text-center text-sm py-6">
加载中...
</div>
<div v-else-if="messagesError" class="text-center text-sm text-red-500 py-6 whitespace-pre-wrap">
{{ messagesError }}
</div>
<div v-else-if="messages.length === 0" class="text-center text-sm text-gray-500 py-6">
<div v-else-if="messages.length === 0" class="message-list-status text-center text-sm py-6">
暂无聊天记录
</div>
+16 -19
View File
@@ -1,7 +1,7 @@
<template>
<div
class="session-list-panel border-r border-gray-200 flex flex-col min-h-0 shrink-0 relative"
:style="{ backgroundColor: '#F7F7F7', '--session-list-width': sessionListWidth + 'px' }"
class="session-list-panel border-r flex flex-col min-h-0 shrink-0 relative"
:style="{ '--session-list-width': sessionListWidth + 'px' }"
>
<!-- 拖动调整会话列表宽度 -->
<div
@@ -14,7 +14,7 @@
<!-- 聊天列表 -->
<div class="h-full flex flex-col min-h-0">
<!-- 搜索栏 -->
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
<div class="session-list-search p-3 border-b">
<div class="flex items-center gap-2">
<div class="contact-search-wrapper flex-1">
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
@@ -53,7 +53,7 @@
</div>
<!-- 联系人列表 -->
<div class="flex-1 overflow-y-auto min-h-0">
<div class="session-list-scroll flex-1 overflow-y-auto min-h-0">
<div v-if="isLoadingContacts" class="px-3 py-4 h-full overflow-hidden">
<div v-for="i in 15" :key="i" class="flex items-center space-x-3 h-[calc(80px/var(--dpr))]">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md bg-gray-200 skeleton-pulse"></div>
@@ -63,22 +63,19 @@
</div>
</div>
</div>
<div v-else-if="contactsError" class="px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
<div v-else-if="contactsError" class="session-list-status px-3 py-2 text-sm text-red-500 whitespace-pre-wrap">
{{ contactsError }}
</div>
<div v-else-if="contacts.length === 0" class="px-3 py-2 text-sm text-gray-500">
<div v-else-if="contacts.length === 0" class="session-list-status px-3 py-2 text-sm">
暂无会话
</div>
<template v-else>
<div v-else class="pb-4">
<div v-for="contact in filteredContacts" :key="contact.id"
class="px-3 cursor-pointer transition-colors duration-150 border-b border-gray-100 h-[calc(80px/var(--dpr))] flex items-center"
:class="contact.isTop
? (selectedContact?.id === contact.id
? 'bg-[#D2D2D2] hover:bg-[#C7C7C7]'
: 'bg-[#EAEAEA] hover:bg-[#DEDEDE]')
: (selectedContact?.id === contact.id
? 'bg-[#DEDEDE] hover:bg-[#d3d3d3]'
: 'hover:bg-[#eaeaea]')"
class="session-list-item px-3 cursor-pointer transition-colors duration-150 h-[calc(80px/var(--dpr))] flex items-center"
:class="{
'session-list-item--top': contact.isTop,
'session-list-item--selected': selectedContact?.id === contact.id
}"
@click="selectContact(contact)">
<div class="flex items-center space-x-3 w-full">
<!-- 联系人头像 -->
@@ -101,12 +98,12 @@
<!-- 联系人信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-900 truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<h3 class="session-list-item-name text-sm truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
<div class="flex items-center flex-shrink-0 ml-2">
<span class="text-xs text-gray-500">{{ contact.lastMessageTime }}</span>
<span class="session-list-item-time text-xs">{{ contact.lastMessageTime }}</span>
</div>
</div>
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
<p class="session-list-item-preview text-xs truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
<span
v-for="(seg, idx) in parseTextWithEmoji(
(contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '') +
@@ -121,7 +118,7 @@
</div>
</div>
</div>
</template>
</div>
</div>
</div>
+40 -26
View File
@@ -35,16 +35,20 @@ export const useChatMessages = ({
}
const logMessagePhase = (phase, details = {}) => {
if (!isDesktopRenderer()) return
try {
window.wechatDesktop?.logDebug?.('chat-messages', phase, details)
} catch {}
console.info(`[chat-messages] ${phase}`, {
const payload = {
account: String(selectedAccount.value || '').trim(),
selectedUsername: String(selectedContact.value?.username || '').trim(),
activeMessagesFor: String(activeMessagesFor.value || '').trim(),
...details
})
}
if (isDesktopRenderer()) {
try {
window.wechatDesktop?.logDebug?.('chat-messages', phase, payload)
} catch {}
}
console.info(`[chat-messages] ${phase}`, payload)
}
const summarizeRenderTypes = (list) => {
@@ -556,28 +560,38 @@ export const useChatMessages = ({
params.render_types = messageTypeFilter.value
}
const response = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
try {
const response = await api.listChatMessages(params)
if (selectedContact.value?.username !== username) return
const latest = (response?.messages || []).map(normalizeMessage)
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
const newOnes = []
for (const message of latest) {
const id = String(message?.id || '')
if (!id || seenIds.has(id)) continue
seenIds.add(id)
newOnes.push(message)
const rawMessages = response?.messages || []
const latest = rawMessages.map(normalizeMessage)
const seenIds = new Set(existing.map((message) => String(message?.id || '')))
const newOnes = []
for (const message of latest) {
const id = String(message?.id || '')
if (!id || seenIds.has(id)) continue
seenIds.add(id)
newOnes.push(message)
}
if (!newOnes.length) return
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
await nextTick()
const nextContainer = messageContainerRef.value
if (nextContainer && atBottom) {
nextContainer.scrollTop = nextContainer.scrollHeight
}
updateJumpToBottomState()
} catch (error) {
console.error('[chat-messages] refreshRealtimeIncremental:error', {
account: String(selectedAccount.value || '').trim(),
username: String(username || '').trim(),
error
})
}
if (!newOnes.length) return
allMessages.value = { ...allMessages.value, [username]: [...existing, ...newOnes] }
await nextTick()
const nextContainer = messageContainerRef.value
if (nextContainer && atBottom) {
nextContainer.scrollTop = nextContainer.scrollHeight
}
updateJumpToBottomState()
}
let realtimeRefreshFuture = null
@@ -250,7 +250,11 @@ export const useChatSessions = ({ chatAccounts, selectedAccount, realtimeEnabled
isLoadingContacts.value = true
contactsError.value = ''
try {
const hadLoadedAccountSnapshot = !!chatAccounts.loaded
await chatAccounts.ensureLoaded()
if (!selectedAccount.value && hadLoadedAccountSnapshot) {
await chatAccounts.ensureLoaded({ force: true })
}
if (!selectedAccount.value) {
clearContactsState(chatAccounts.error || '未检测到已解密账号,请先解密数据库。')
+44
View File
@@ -205,6 +205,8 @@ export const useApi = () => {
if (params && params.priority_max_scan != null) query.set('priority_max_scan', String(params.priority_max_scan))
if (params && params.include_hidden != null) query.set('include_hidden', String(!!params.include_hidden))
if (params && params.include_official != null) query.set('include_official', String(!!params.include_official))
if (params && params.only_official != null) query.set('only_official', String(!!params.only_official))
if (params && params.backfill_limit != null) query.set('backfill_limit', String(params.backfill_limit))
const url = '/chat/realtime/sync_all' + (query.toString() ? `?${query.toString()}` : '')
return await request(url, { method: 'POST' })
}
@@ -561,6 +563,44 @@ export const useApi = () => {
return await request('/get_image_key')
}
// 枚举服务号信息
const listBizAccounts = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
const url = '/biz/list' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取普通服务号消息
const listBizMessages = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.username) query.set('username', params.username)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/biz/messages' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
// 获取微信支付记录
const listBizPayRecords = async (params = {}) => {
const query = new URLSearchParams()
if (params && params.account) query.set('account', params.account)
if (params && params.limit != null) query.set('limit', String(params.limit))
if (params && params.offset != null) query.set('offset', String(params.offset))
const url = '/biz/pay_records' + (query.toString() ? `?${query.toString()}` : '')
return await request(url)
}
const getBizProxyImageUrl = (url) => {
if (!url) return ''
if (url.startsWith('data:')) return url // 如果已经是 base64,不处理
const query = new URLSearchParams()
query.set('url', url)
const base = baseURL ? baseURL.replace(/\/$/, '') : ''
return `${base}/biz/proxy_image?${query.toString()}`
}
return {
detectWechat,
detectCurrentAccount,
@@ -616,5 +656,9 @@ export const useApi = () => {
getKeys,
getImageKey,
getWxStatus,
listBizAccounts,
listBizMessages,
listBizPayRecords,
getBizProxyImageUrl,
}
}
+2 -1
View File
@@ -178,8 +178,10 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
return {
id: msg.id,
localId: Number(msg.localId || 0),
serverId: msg.serverId || 0,
serverIdStr,
type: Number(msg.type || 0),
sender,
senderUsername: msg.senderUsername || '',
senderDisplayName: msg.senderDisplayName || '',
@@ -188,7 +190,6 @@ export const createMessageNormalizer = ({ apiBase, getSelectedAccount, getSelect
fullTime: formatMessageFullTime(msg.createTime),
createTime: Number(msg.createTime || 0),
isSent,
type: 'text',
renderType: msg.renderType || 'text',
voipType: msg.voipType || '',
title: msg.title || '',
+36
View File
@@ -0,0 +1,36 @@
export const UI_THEME_KEY = 'ui.theme'
export const UI_THEME_LIGHT = 'light'
export const UI_THEME_DARK = 'dark'
export const normalizeUiTheme = (value, fallback = UI_THEME_LIGHT) => {
const normalized = String(value || '').trim().toLowerCase()
if (normalized === UI_THEME_DARK) return UI_THEME_DARK
if (normalized === UI_THEME_LIGHT) return UI_THEME_LIGHT
return fallback === UI_THEME_DARK ? UI_THEME_DARK : UI_THEME_LIGHT
}
export const readUiTheme = (fallback = UI_THEME_LIGHT) => {
if (!process.client) return normalizeUiTheme(fallback)
try {
const raw = localStorage.getItem(UI_THEME_KEY)
return normalizeUiTheme(raw, fallback)
} catch {
return normalizeUiTheme(fallback)
}
}
export const writeUiTheme = (theme) => {
if (!process.client) return
try {
localStorage.setItem(UI_THEME_KEY, normalizeUiTheme(theme))
} catch {}
}
export const applyUiTheme = (theme) => {
if (!process.client || typeof document === 'undefined') return
const normalized = normalizeUiTheme(theme)
const root = document.documentElement
root.dataset.theme = normalized
root.classList.toggle('theme-dark', normalized === UI_THEME_DARK)
root.style.colorScheme = normalized === UI_THEME_DARK ? 'dark' : 'light'
}
+16
View File
@@ -0,0 +1,16 @@
<template>
<div class="h-full min-h-0 flex overflow-hidden bg-white">
<div class="flex-1 min-w-0">
<BizMessages />
</div>
</div>
</template>
<script setup>
import BizMessages from "../components/BizMessages.vue";
useHead({
title: '服务号消息 - WeChatDataAnalysis'
})
</script>
+4 -4
View File
@@ -1,8 +1,8 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<div class="chat-page-shell h-screen flex overflow-hidden">
<SessionListPanel :state="chatState" />
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="chat-page-main flex-1 flex flex-col min-h-0">
<div class="flex-1 flex min-h-0">
<ConversationPane :state="chatState" />
</div>
@@ -46,7 +46,7 @@ definePageMeta({
})
useHead({
title: '??????? - ????????'
title: '聊天记录 - 微信数据库解密工具'
})
const route = useRoute()
@@ -508,7 +508,7 @@ const onAccountChange = async () => {
contactsError.value = ''
await loadSessionsForSelectedAccount()
} catch (error) {
contactsError.value = error?.message || '???????'
contactsError.value = error?.message || '加载会话失败'
} finally {
isLoadingContacts.value = false
}
+4 -4
View File
@@ -1,10 +1,10 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="contacts-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
<div class="flex-1 min-h-0 overflow-hidden p-4">
<div class="h-full grid grid-cols-1 lg:grid-cols-[400px_minmax(0,1fr)] gap-4">
<div class="bg-white border border-gray-200 rounded-lg flex flex-col min-h-0 overflow-hidden">
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
<div class="flex items-center gap-2">
<div class="contact-search-wrapper flex-1" :class="{ 'privacy-blur': privacyMode }">
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
@@ -80,7 +80,7 @@
</div>
</div>
<div class="bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
<div class="contacts-export-panel bg-white border border-gray-200 rounded-lg p-4 flex flex-col gap-3">
<div>
<div class="text-base font-medium text-gray-900">导出联系人</div>
<div class="text-xs text-gray-500 mt-1">支持 JSON / CSV默认包含头像链接</div>
+2 -2
View File
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen relative overflow-hidden flex items-center justify-center">
<div class="decrypt-result-page min-h-screen relative overflow-hidden flex items-center justify-center">
<!-- 网格背景 -->
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
@@ -171,4 +171,4 @@ onMounted(() => {
linear-gradient(90deg, rgba(7, 193, 96, 0.1) 1px, transparent 1px);
background-size: 50px 50px;
}
</style>
</style>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex items-center justify-center py-8">
<div class="decrypt-page min-h-screen flex items-center justify-center py-8">
<div class="max-w-4xl mx-auto px-6 w-full">
<!-- 步骤指示器 -->
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen relative overflow-hidden flex items-center">
<div class="detection-result-page min-h-screen relative overflow-hidden flex items-center">
<!-- 网格背景 -->
<div class="absolute inset-0 bg-grid-pattern opacity-5 pointer-events-none"></div>
+3 -3
View File
@@ -1,9 +1,9 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<div class="edits-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<!-- 左侧会话列表与聊天页统一风格 -->
<div class="edits-sidebar border-r border-gray-200 flex flex-col">
<!-- 搜索栏区域 -->
<div class="p-3 border-b border-gray-200" style="background-color: #F7F7F7">
<div class="p-3 border-b border-gray-200" style="background-color: var(--app-surface-muted)">
<div class="flex items-center gap-2">
<div class="contact-search-wrapper flex-1">
<svg class="contact-search-icon" fill="none" stroke="currentColor" viewBox="0 0 16 16">
@@ -137,7 +137,7 @@
</div>
<!-- 内容区 -->
<div class="flex-1 overflow-y-auto" style="background-color: #EDEDED">
<div class="flex-1 overflow-y-auto" style="background-color: var(--app-shell-bg)">
<!-- 错误提示 -->
<div v-if="itemsError" class="mx-5 mt-4 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg px-4 py-3 whitespace-pre-wrap">{{ itemsError }}</div>
+1 -1
View File
@@ -1,5 +1,5 @@
<template>
<div class="min-h-screen flex items-center justify-center relative overflow-hidden">
<div class="landing-page min-h-screen flex items-center justify-center relative overflow-hidden">
<!-- 网格背景 -->
<div class="absolute inset-0 bg-grid-pattern opacity-5"></div>
+3 -3
View File
@@ -1,7 +1,7 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<div class="sns-page h-screen flex overflow-hidden" style="background-color: var(--app-shell-bg)">
<!-- 左侧朋友圈联系人 -->
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]">
<div class="w-[280px] flex flex-col min-h-0 border-r border-gray-200 bg-[#EDEDED]" style="background-color: var(--app-shell-bg)">
<div class="p-3">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-gray-700">朋友圈联系人</div>
@@ -104,7 +104,7 @@
</div>
<!-- 右侧朋友圈区域 -->
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<div class="flex-1 flex flex-col min-h-0" style="background-color: var(--app-shell-bg)">
<div ref="timelineScrollEl" class="flex-1 overflow-auto min-h-0 bg-white" @scroll="onScroll">
<div class="max-w-2xl mx-auto px-4 py-4">
<div class="relative w-full mb-12 -mt-4 bg-white">
+3 -4
View File
@@ -303,14 +303,13 @@ const slides = computed(() => {
return out
})
const currentBg = computed(() => '#F3FFF8')
const currentBg = '#F3FFF8'
const deckTrackClass = computed(() => 'z-10')
const applyViewportBg = () => {
if (!import.meta.client) return
const bg = currentBg.value
document.documentElement.style.backgroundColor = bg
document.body.style.backgroundColor = bg
document.documentElement.style.backgroundColor = currentBg
document.body.style.backgroundColor = currentBg
}
const slideStyle = computed(() => (
@@ -0,0 +1,5 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1774499781741" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="7897" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200">
<path d="M118.81813333 106.3936c87.27893333-26.2144 192.03413333 75.1616 358.46826667 346.9312 18.70506667 30.5152 34.7136 55.46666667 35.60106667 55.5008 0.88746667 0 17.74933333-26.4192 37.41013333-58.70933333 165.51253333-271.53066667 270.336-371.3024 359.35573333-342.08426667 56.55893333 18.56853333 80.41813333 73.18186667 80.21333334 183.63733333-0.4096 214.86933333-103.69706667 551.59466667-188.0064 612.89813334-69.4272 50.44906667-173.1584-13.07306667-269.85813334-165.30773334-9.59146667-15.1552-18.36373333-27.57973333-19.42186666-27.61386666-1.05813333 0-9.59146667 12.32213333-18.944 27.4432-50.96106667 82.39786667-113.4592 146.26133333-167.04853334 170.7008-26.04373333 11.8784-71.33866667 13.5168-90.45333333 3.24266666-52.08746667-27.98933333-110.72853333-149.504-156.16-323.72053333C7.3728 310.95466667 21.504 135.68 118.81813333 106.3936zM848.31573333 217.088c-55.26186667 42.93973333-126.49813333 138.58133333-230.8096 309.93066667l-49.2544 80.82773333 16.86186667 30.17386667c42.35946667 75.94666667 91.30666667 139.81013333 130.79893333 170.66666666 26.76053333 20.95786667 35.60106667 16.55466667 58.9824-29.4912 73.5232-144.55466667 136.192-440.7296 115.712-547.19146666-6.144-32.0512-15.80373333-35.46453333-42.2912-14.91626667zM143.73546667 207.9744c-19.72906667 19.49013333-14.60906667 145.8176 10.99093333 271.90613333 30.89066667 152.23466667 95.91466667 329.3184 124.5184 339.2512 27.81866667 9.65973333 104.31146667-77.824 164.38613333-188.0064l13.14133334-24.13226666-42.15466667-69.18826667c-112.98133333-185.344-186.64106667-284.3648-240.8448-323.72053333-16.65706667-12.0832-22.7328-13.312-30.03733333-6.10986667z" fill="#FF9908" p-id="7898"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

+46
View File
@@ -0,0 +1,46 @@
import { defineStore } from 'pinia'
import {
UI_THEME_DARK,
UI_THEME_LIGHT,
applyUiTheme,
normalizeUiTheme,
readUiTheme,
writeUiTheme,
} from '~/lib/ui-theme'
export const useThemeStore = defineStore('theme', () => {
const theme = ref(UI_THEME_LIGHT)
const initialized = ref(false)
const isDark = computed(() => theme.value === UI_THEME_DARK)
const set = (nextTheme) => {
theme.value = normalizeUiTheme(nextTheme, UI_THEME_LIGHT)
writeUiTheme(theme.value)
applyUiTheme(theme.value)
}
const init = () => {
if (initialized.value) {
applyUiTheme(theme.value)
return
}
initialized.value = true
theme.value = readUiTheme(UI_THEME_LIGHT)
applyUiTheme(theme.value)
}
const toggle = () => {
set(isDark.value ? UI_THEME_LIGHT : UI_THEME_DARK)
}
return {
theme,
initialized,
isDark,
init,
set,
toggle,
}
})
+2
View File
@@ -36,6 +36,7 @@ from .routers.wrapped import router as _wrapped_router
from .request_logging import log_server_errors_middleware
from .sns_stage_timing import add_sns_stage_timing_headers
from .wcdb_realtime import WCDB_REALTIME, shutdown as _wcdb_shutdown
from .routers.biz import router as _biz_router
app = FastAPI(
title="微信数据库解密工具",
@@ -96,6 +97,7 @@ app.include_router(_chat_media_router)
app.include_router(_sns_router)
app.include_router(_sns_export_router)
app.include_router(_wrapped_router)
app.include_router(_biz_router)
class _SPAStaticFiles(StaticFiles):
+17 -2
View File
@@ -19,7 +19,7 @@ import zipfile
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Literal, Optional
from typing import Any, Callable, Iterable, Literal, Optional
from urllib.parse import urljoin, urlparse
import requests
@@ -3386,6 +3386,7 @@ def _parse_message_for_export(
resource_conn: Optional[sqlite3.Connection],
resource_chat_id: Optional[int],
sender_alias: str = "",
resolve_display_name: Optional[Callable[[str], str]] = None,
) -> dict[str, Any]:
raw_text = row.raw_text or ""
sender_username = str(row.sender_username or "").strip()
@@ -3449,7 +3450,18 @@ def _parse_message_for_export(
if local_type == 10000:
render_type = "system"
content_text = _parse_system_message_content(raw_text)
system_display_name_resolver = None
if resolve_display_name is not None:
def system_display_name_resolver(username: str, fallback_display_name: str) -> str:
resolved = str(resolve_display_name(username) or "").strip()
if resolved and resolved != username:
return resolved
fallback = str(fallback_display_name or "").strip()
return fallback or resolved or username
content_text = _parse_system_message_content(
raw_text,
resolve_display_name=system_display_name_resolver,
)
elif local_type == 49:
parsed = _parse_app_message(raw_text)
render_type = str(parsed.get("renderType") or "text")
@@ -3923,6 +3935,7 @@ def _write_conversation_json(
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
sender_alias=sender_alias,
resolve_display_name=resolve_display_name,
)
if not _is_render_type_selected(msg.get("renderType"), want_types):
continue
@@ -4101,6 +4114,7 @@ def _write_conversation_txt(
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
sender_alias=sender_alias,
resolve_display_name=resolve_display_name,
)
if not _is_render_type_selected(msg.get("renderType"), want_types):
continue
@@ -4859,6 +4873,7 @@ def _write_conversation_html(
resource_conn=resource_conn,
resource_chat_id=resource_chat_id,
sender_alias="",
resolve_display_name=resolve_display_name,
)
if not _is_render_type_selected(msg.get("renderType"), want_types):
continue
+177 -2
View File
@@ -7,7 +7,7 @@ import sqlite3
from collections import Counter
from datetime import datetime
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional
from urllib.parse import parse_qs, quote, urlparse
from fastapi import HTTPException
@@ -787,7 +787,112 @@ def _parse_location_message(text: str) -> dict[str, Any]:
}
def _parse_system_message_content(raw_text: str) -> str:
def _extract_chatroom_top_message_metadata(raw_text: str) -> dict[str, str]:
text = str(raw_text or "").strip()
if not text:
return {}
lower_text = text.lower()
if "<mmchatroomtopmsg" in lower_text or "<sysmsg" in lower_text:
chatroom_id = str(_extract_xml_tag_text(text, "chatroomname") or "").strip()
operation = str(_extract_xml_tag_text(text, "op") or "").strip()
operator_username = str(_extract_xml_tag_text(text, "username") or "").strip()
operator_display_name = str(_extract_xml_tag_text(text, "nickname") or "").strip()
if chatroom_id.endswith("@chatroom") and operation in {"1", "2"} and operator_username:
return {
"operation": operation,
"operatorUsername": operator_username,
"operatorDisplayName": operator_display_name,
}
def _is_int_token(value: str) -> bool:
candidate = str(value or "").strip()
if not candidate:
return False
if candidate[0] in {"+", "-"}:
candidate = candidate[1:]
return candidate.isdigit()
normalized = re.sub(r"<!--\s*ChatRoomTopMsgRequest\s*-->", " ", text, flags=re.IGNORECASE)
normalized = re.sub(r"<!--\s*ChatRoomTopMsgResponse\s*-->", " ", normalized, flags=re.IGNORECASE)
normalized = re.sub(r"\s+", " ", normalized).strip()
if not normalized:
return {}
parts = normalized.split(" ")
has_markers = ("chatroomtopmsgrequest" in lower_text) or ("chatroomtopmsgresponse" in lower_text)
if len(parts) < 5:
return {}
chatroom_id = str(parts[0] or "").strip()
operation = str(parts[1] or "").strip()
if not chatroom_id.endswith("@chatroom"):
return {}
if operation not in {"1", "2"}:
return {}
if not has_markers:
if len(parts) < 6:
return {}
if not _is_int_token(parts[2]) or not _is_int_token(parts[3]) or not _is_int_token(parts[5]):
return {}
operator_username = str(parts[4] or "").strip()
if not operator_username:
return {}
operator_display_name = ""
if len(parts) >= 6 and _is_int_token(parts[5]):
response_tokens = parts[6:]
if len(response_tokens) >= 2 and _is_int_token(response_tokens[-1]):
response_tokens = response_tokens[:-1]
operator_display_name = " ".join(response_tokens).strip()
return {
"operation": operation,
"operatorUsername": operator_username,
"operatorDisplayName": operator_display_name,
}
def _parse_chatroom_top_message(
raw_text: str,
resolve_display_name: Optional[Callable[[str, str], str]] = None,
) -> str:
meta = _extract_chatroom_top_message_metadata(raw_text)
if not meta:
return ""
operation = str(meta.get("operation") or "").strip()
operator_username = str(meta.get("operatorUsername") or "").strip()
operator_display_name = str(meta.get("operatorDisplayName") or "").strip()
if resolve_display_name is not None and operator_username:
try:
resolved = str(resolve_display_name(operator_username, operator_display_name) or "").strip()
except Exception:
resolved = ""
if resolved:
operator_display_name = resolved
if not operator_display_name:
operator_display_name = operator_username or "有人"
action_map = {
"1": "置顶了一条消息",
"2": "移除了一条置顶消息",
}
action = action_map.get(operation)
if not action:
return ""
return f"{operator_display_name}{action}"
def _parse_system_message_content(
raw_text: str,
resolve_display_name: Optional[Callable[[str, str], str]] = None,
) -> str:
text = str(raw_text or "").strip()
if not text:
return "[系统消息]"
@@ -801,12 +906,17 @@ def _parse_system_message_content(raw_text: str) -> str:
if nested_content:
candidate = nested_content
candidate = re.sub(r"<!--.*?-->", " ", candidate, flags=re.IGNORECASE | re.DOTALL)
candidate = re.sub(r"<!\[CDATA\[", "", candidate, flags=re.IGNORECASE)
candidate = re.sub(r"\]\]>", "", candidate)
candidate = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", candidate)
candidate = re.sub(r"\s+", " ", candidate).strip()
return candidate
top_message_text = _parse_chatroom_top_message(text, resolve_display_name=resolve_display_name)
if top_message_text:
return top_message_text
if "revokemsg" in text.lower():
replace_msg = _extract_xml_tag_text(text, "replacemsg")
cleaned_replace_msg = _clean_system_text(replace_msg)
@@ -1110,6 +1220,70 @@ def _parse_app_message(text: str) -> dict[str, Any]:
"linkStyle": link_style,
}
if app_type == 51:
# 视频号分享(Finder / Channels
# 常见特征:
# - title 是「当前版本不支持展示该内容,请升级至最新版本。」
# - 真正标题在 <finderFeed><desc> 或其它 finder 节点里
finder_feed = _extract_xml_tag_text(text, "finderFeed")
finder_desc = (
(_extract_xml_tag_text(finder_feed, "desc") if finder_feed else "")
or _extract_xml_tag_text(text, "finderdesc")
or des
)
finder_nickname = (
_extract_xml_tag_text(text, "findernickname")
or _extract_xml_tag_text(text, "finder_nickname")
or (_extract_xml_tag_text(finder_feed, "nickname") if finder_feed else "")
or (_extract_xml_tag_text(finder_feed, "findernickname") if finder_feed else "")
)
finder_username = (
_extract_xml_tag_text(text, "finderusername")
or _extract_xml_tag_text(text, "finder_username")
or (_extract_xml_tag_text(finder_feed, "username") if finder_feed else "")
or (_extract_xml_tag_text(finder_feed, "finderusername") if finder_feed else "")
)
thumb_url = _normalize_xml_url(
_extract_xml_tag_or_attr(text, "thumburl")
or _extract_xml_tag_or_attr(text, "cdnthumburl")
or _extract_xml_tag_or_attr(text, "coverurl")
or _extract_xml_tag_or_attr(text, "cover")
or (_extract_xml_tag_or_attr(finder_feed, "thumbUrl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "thumburl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "coverUrl") if finder_feed else "")
or (_extract_xml_tag_or_attr(finder_feed, "coverurl") if finder_feed else "")
)
finder_url = url or _normalize_xml_url(
(_extract_xml_tag_text(finder_feed, "url") if finder_feed else "")
or (_extract_xml_tag_text(text, "playurl"))
or (_extract_xml_tag_text(text, "dataurl"))
)
display_title = str(title or "").strip()
if (not display_title) or ("不支持" in display_title):
display_title = str(finder_desc or "").strip()
if not display_title:
display_title = str(des or "").strip()
display_title = display_title or "[视频号]"
summary_text = str(finder_desc or "").strip() or display_title
from_display = str(finder_nickname or source_display_name or "").strip() or "视频号"
from_u = str(finder_username or source_username or "").strip()
return {
"renderType": "link",
"content": summary_text,
"title": display_title,
"url": finder_url or "",
"thumbUrl": thumb_url or "",
"from": from_display,
"fromUsername": from_u,
"linkType": "finder",
"linkStyle": "finder",
}
if app_type in (33, 36):
# 小程序分享(WeChat v4 常见:local_type = 49 + (33<<32) / 49 + (36<<32)
# 注:部分 payload 的 <url> 为空;前端会按需渲染为不可点击卡片。
@@ -2334,4 +2508,5 @@ def _row_to_search_hit(
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
}
+64 -8
View File
@@ -41,6 +41,22 @@ class ColoredFormatter(logging.Formatter):
return formatted
def _can_use_logging_stream(stream) -> bool:
try:
if stream is None or getattr(stream, "closed", False):
return False
except Exception:
return False
try:
stream.write("")
stream.flush()
except Exception:
return False
return True
class WeChatLogger:
"""微信解密工具统一日志管理器"""
@@ -64,6 +80,12 @@ class WeChatLogger:
if env_level:
log_level = env_level
console_logging_env = str(os.environ.get("WECHAT_TOOL_ENABLE_CONSOLE_LOG", "") or "").strip().lower()
console_logging_forced = console_logging_env in {"1", "true", "yes", "on"}
console_logging_disabled = console_logging_env in {"0", "false", "no", "off"}
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
# 创建日志目录
now = datetime.now()
from .app_paths import get_output_dir
@@ -73,10 +95,41 @@ class WeChatLogger:
# 设置日志文件名
date_str = now.strftime("%d")
self.log_file = log_dir / f"{date_str}_wechat_tool.log"
desired_log_file = log_dir / f"{date_str}_wechat_tool.log"
root_logger = logging.getLogger()
wants_console_handler = _can_use_logging_stream(sys.stdout)
if getattr(sys, "frozen", False) and not console_logging_forced:
wants_console_handler = False
if console_logging_disabled:
wants_console_handler = False
if WeChatLogger._initialized:
current_log_file = Path(getattr(self, "log_file", desired_log_file))
has_expected_file_handler = False
has_stream_handler = False
for handler in root_logger.handlers:
if isinstance(handler, logging.FileHandler):
try:
if Path(handler.baseFilename).resolve() == desired_log_file.resolve():
has_expected_file_handler = True
except Exception:
if Path(handler.baseFilename) == desired_log_file:
has_expected_file_handler = True
elif isinstance(handler, logging.StreamHandler):
has_stream_handler = True
if (
current_log_file == desired_log_file
and root_logger.level == level
and has_expected_file_handler
and (has_stream_handler or not wants_console_handler)
):
self.log_file = desired_log_file
return self.log_file
self.log_file = desired_log_file
# 清除现有的处理器
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
try:
@@ -100,18 +153,20 @@ class WeChatLogger:
# 文件处理器
file_handler = logging.FileHandler(self.log_file, encoding='utf-8')
file_handler.setFormatter(file_formatter)
level = getattr(logging, str(log_level or "INFO").upper(), logging.INFO)
file_handler.setLevel(level)
# 控制台处理器
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
console_handler = None
if wants_console_handler:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(console_formatter)
console_handler.setLevel(level)
# 配置根日志器
root_logger.setLevel(level)
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
if console_handler is not None:
root_logger.addHandler(console_handler)
# 只为uvicorn日志器添加文件处理器,保持其原有的控制台处理器(带颜色)
uvicorn_logger = logging.getLogger("uvicorn")
@@ -158,7 +213,8 @@ class WeChatLogger:
except Exception:
pass
fastapi_logger.addHandler(file_handler)
fastapi_logger.addHandler(console_handler)
if console_handler is not None:
fastapi_logger.addHandler(console_handler)
fastapi_logger.setLevel(level)
# 记录初始化信息
+370
View File
@@ -0,0 +1,370 @@
import hashlib
import sqlite3
import time
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Optional, Any, Dict, List
import urllib
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from ..chat_helpers import _resolve_account_dir
from ..path_fix import PathFixRoute
from ..logging_config import get_logger
try:
import zstandard as zstd
except Exception:
zstd = None
logger = get_logger(__name__)
router = APIRouter(route_class=PathFixRoute)
def decompress_zstd_content(data: bytes, source_id: str, local_id: int) -> Optional[bytes]:
"""Zstandard 解压逻辑"""
if not data or not data.startswith(b'\x28\xb5\x2f\xfd'):
return None
try:
if zstd:
dctx = zstd.ZstdDecompressor()
return dctx.decompress(data, max_output_size=10 * 1024 * 1024)
except Exception as e:
error_msg = f"❌ [解压失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def extract_xml_from_db_content(content: Any, source_id: str, local_id: int) -> str:
"""提取并解压数据库内容"""
if not content:
return ""
if isinstance(content, memoryview):
content = content.tobytes()
elif isinstance(content, str):
content = content.encode('utf-8', errors='ignore')
if isinstance(content, bytes):
decompressed = decompress_zstd_content(content, source_id, local_id)
if decompressed:
return decompressed.decode('utf-8', errors='ignore')
# 若不是 zstd 压缩或解压失败,尝试直接 decode
try:
return content.decode('utf-8', errors='ignore')
except Exception:
return ""
return ""
def parse_wechat_xml_to_struct(xml_str: str, source_id: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信服务号 XML 到 Dict"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_tag_text(element, path, default=""):
node = element.find(path)
return node.text if node is not None and node.text else default
main_cover = get_tag_text(root, ".//appmsg/thumburl")
if not main_cover:
main_cover = get_tag_text(root, ".//topnew/cover")
result = {
"title": get_tag_text(root, ".//appmsg/title"),
"des": get_tag_text(root, ".//appmsg/des"),
"url": get_tag_text(root, ".//appmsg/url"),
"cover": main_cover,
"content_list": []
}
items = root.findall(".//mmreader/category/item")
for item in items:
item_struct = {
"title": get_tag_text(item, "title"),
"url": get_tag_text(item, "url"),
"cover": get_tag_text(item, "cover"),
"summary": get_tag_text(item, "summary")
}
if item_struct["title"]:
result["content_list"].append(item_struct)
return result
except Exception as e:
error_msg = f"❌ [解析XML失败] 服务号id: {source_id}, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
def parse_pay_xml(xml_str: str, local_id: int) -> Optional[Dict[str, Any]]:
"""解析微信支付 XML"""
if not xml_str.strip():
return None
try:
root = ET.fromstring(xml_str)
def get_text(path):
node = root.find(path)
return node.text if node is not None else ""
record = {
"title": get_text(".//appmsg/title"),
"description": get_text(".//appmsg/des"),
"merchant_name": get_text(".//template_header/display_name"),
"merchant_icon": get_text(".//template_header/icon_url"),
"timestamp": int(get_text(".//pub_time") or 0),
"formatted_time": ""
}
return record
except Exception as e:
error_msg = f"❌ [解析微信支付XML失败] 支付id: gh_3dfda90e39d6, local_id: {local_id} -> {e}"
print(error_msg)
logger.error(error_msg)
return None
@router.get("/api/biz/proxy_image", summary="代理请求微信服务号图片")
def proxy_biz_image(url: str):
if not url:
return Response(status_code=400)
try:
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
})
with urllib.request.urlopen(req, timeout=10) as response:
content = response.read()
content_type = response.headers.get('Content-Type', 'image/jpeg')
return Response(content=content, media_type=content_type)
except Exception as e:
logger.error(f"[biz] 代理图片失败: {url} -> {e}")
return Response(status_code=500)
# 接口 1:获取全部的服务号/公众号的信息
@router.get("/api/biz/list", summary="获取全部服务号/公众号列表")
def get_biz_account_list(account: Optional[str] = None):
account_dir = _resolve_account_dir(account)
biz_ids = set()
biz_latest_time = {}
# 1. 遍历 biz_message_*.db
for db_file in account_dir.glob("biz_message*.db"):
try:
conn = sqlite3.connect(str(db_file))
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(Name2Id)")
cols = [row[1].lower() for row in cursor.fetchall()]
user_col = "username" if "username" in cols else "user_name" if "user_name" in cols else ""
if user_col:
rows = cursor.execute(f"SELECT {user_col} FROM Name2Id").fetchall()
for r in rows:
if r[0]:
uname = r[0]
biz_ids.add(uname)
# 顺便查询该号的最后一条消息时间
md5_id = hashlib.md5(uname.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
try:
time_res = conn.execute(f"SELECT MAX(create_time) FROM {table_name}").fetchone()
if time_res and time_res[0]:
current_max = biz_latest_time.get(uname, 0)
biz_latest_time[uname] = max(current_max, time_res[0])
except Exception:
pass
conn.close()
except Exception as e:
logger.warning(f"读取 Name2Id 失败 {db_file}: {e}")
contact_db_path = account_dir / "contact.db"
contact_info = {}
if contact_db_path.exists() and biz_ids:
try:
conn = sqlite3.connect(str(contact_db_path))
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(biz_ids))
# 先查 contact 表
query_contact = f"SELECT username, remark, nick_name, alias, big_head_url FROM contact WHERE username IN ({placeholders})"
rows_contact = cursor.execute(query_contact, list(biz_ids)).fetchall()
for r in rows_contact:
uname = r[0]
name = r[1] or r[2] or r[3] or uname
contact_info[uname] = {
"username": uname,
"name": name,
"avatar": r[4],
"type": 3 # 默认给个 3(未知)
}
# 再查 biz_info 表获取类型
try:
query_biz = f"SELECT username, type FROM biz_info WHERE username IN ({placeholders})"
rows_biz = cursor.execute(query_biz, list(biz_ids)).fetchall()
for r in rows_biz:
uname = r[0]
biz_type = r[1]
# 如果查到了且是 0, 1, 2,就更新进去,否则保留 3
if uname in contact_info:
if biz_type in (0, 1, 2):
contact_info[uname]["type"] = biz_type
else:
contact_info[uname]["type"] = 3
except Exception as e:
logger.warning(f"读取 biz_info 失败: {e}")
conn.close()
except Exception as e:
logger.warning(f"读取 contact.db 失败: {e}")
# 3. 组装结果(不在 contact_info 里的直接丢弃)
result = []
for uid in biz_ids:
if uid in contact_info:
info = contact_info[uid]
info["last_time"] = biz_latest_time.get(uid, 0)
if info["last_time"]:
# 格式化日期给前端展示用
info["formatted_last_time"] = time.strftime("%Y-%m-%d", time.localtime(info["last_time"]))
else:
info["formatted_last_time"] = ""
result.append(info)
# 4. 按最后一条消息的时间降序排列
result.sort(key=lambda x: x.get("last_time", 0), reverse=True)
return {"status": "success", "total": len(result), "data": result}
# 接口 2:获取普通服务号/公众号的 json 消息 (已修复表名比对 bug)
@router.get("/api/biz/messages", summary="获取指定服务号的消息")
def get_biz_messages(username: str, account: Optional[str] = None, limit: int = 50, offset: int = 0):
if username == "gh_3dfda90e39d6":
raise HTTPException(status_code=400, detail="微信支付记录请请求 /api/biz/pay_records 接口")
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
# 必须用 table_name.lower(),否则永远匹配不上
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name.lower(),)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": f"未找到 {username} 的消息历史"}
# ... (后续数据库查询逻辑保持不变) ...
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
struct_data = parse_wechat_xml_to_struct(raw_xml, username, local_id)
if struct_data:
struct_data["local_id"] = local_id
struct_data["create_time"] = c_time
messages.append(struct_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 数据库查询出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
# 接口 3:返回微信支付的 json 消息 (已修复表名比对 bug)
@router.get("/api/biz/pay_records", summary="获取微信支付记录")
def get_wechat_pay_records(account: Optional[str] = None, limit: int = 50, offset: int = 0):
username = "gh_3dfda90e39d6"
account_dir = _resolve_account_dir(account)
md5_id = hashlib.md5(username.encode('utf-8')).hexdigest().lower()
table_name = f"Msg_{md5_id}"
target_db = None
for db_file in account_dir.glob("biz_message*.db"):
conn = sqlite3.connect(str(db_file))
try:
# 必须用 table_name.lower()
res = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND lower(name)=?",
(table_name.lower(),)).fetchone()
if res:
target_db = db_file
break
except Exception:
pass
finally:
conn.close()
if not target_db:
return {"status": "success", "data": [], "message": "未找到微信支付的消息历史"}
messages = []
try:
conn = sqlite3.connect(str(target_db))
cursor = conn.cursor()
query = f"""
SELECT local_id, create_time, message_content
FROM [{table_name}]
WHERE local_type = 21474836529 OR local_type != 1
ORDER BY create_time DESC
LIMIT ? OFFSET ?
"""
rows = cursor.execute(query, (limit, offset)).fetchall()
for local_id, c_time, content in rows:
raw_xml = extract_xml_from_db_content(content, username, local_id)
if not raw_xml:
continue
parsed_data = parse_pay_xml(raw_xml, local_id)
if parsed_data:
parsed_data["local_id"] = local_id
parsed_data["create_time"] = c_time
if not parsed_data["timestamp"]:
parsed_data["timestamp"] = c_time
parsed_data["formatted_time"] = time.strftime(
"%Y-%m-%d %H:%M:%S", time.localtime(parsed_data["timestamp"])
)
messages.append(parsed_data)
conn.close()
except Exception as e:
logger.error(f"[biz] 查询微信支付数据库出错: {e}")
return {"status": "error", "message": str(e)}
return {"status": "success", "data": messages}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -26,6 +26,32 @@ _DEFAULT_WCDB_API_DLL = _NATIVE_DIR / "wcdb_api.dll"
_WCDB_API_DLL_SELECTED: Optional[Path] = None
def _iter_runtime_wcdb_api_dll_paths() -> tuple[Path, ...]:
candidates: list[Path] = []
seen: set[str] = set()
def add_anchor(anchor: str | Path | None) -> None:
if not anchor:
return
try:
base = Path(anchor).resolve()
except Exception:
base = Path(anchor)
candidate = base / "native" / "wcdb_api.dll"
key = str(candidate).replace("/", "\\").rstrip("\\").lower()
if key in seen:
return
seen.add(key)
candidates.append(candidate)
add_anchor(os.environ.get("WECHAT_TOOL_DATA_DIR", "").strip())
add_anchor(Path.cwd())
if getattr(sys, "frozen", False):
add_anchor(Path(sys.executable).resolve().parent)
return tuple(candidates)
def _is_project_wcdb_api_dll_path(path: Path) -> bool:
try:
resolved = path.resolve(strict=False)
@@ -40,6 +66,14 @@ def _is_project_wcdb_api_dll_path(path: Path) -> bool:
if resolved == default_resolved:
return True
for candidate in _iter_runtime_wcdb_api_dll_paths():
try:
if resolved == candidate.resolve(strict=False):
return True
except Exception:
if resolved == candidate:
return True
parts = tuple(str(part).lower() for part in resolved.parts)
allowed_suffixes = (
("backend", "native", "wcdb_api.dll"),
+236
View File
@@ -0,0 +1,236 @@
import hashlib
import sqlite3
import sys
import threading
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import chat as chat_router
class _DummyConn:
def __init__(self) -> None:
self.handle = 1
self.lock = threading.Lock()
class TestChatRealtimeName2IdSync(unittest.TestCase):
def test_sync_repairs_name2id_even_without_new_messages(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_friend"
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
msg_db_path = account_dir / "message_0.db"
conn = sqlite3.connect(str(msg_db_path))
try:
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
conn.execute(
"""
CREATE TABLE "{table_name}" (
local_id INTEGER PRIMARY KEY,
server_id INTEGER,
local_type INTEGER,
sort_seq INTEGER,
real_sender_id INTEGER,
create_time INTEGER,
message_content TEXT,
compress_content BLOB,
packed_info_data BLOB
)
""".format(table_name=table_name)
)
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", ("wxid_old",))
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (5, ?, 1)", ("wxid_gap_tail",))
conn.execute(
f'INSERT INTO "{table_name}" '
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(10, 10010, 1, 10, 3, 1710000010, "hello", None, None),
)
conn.commit()
finally:
conn.close()
live_rows = [
{"rowid": 1, "user_name": "acc", "is_session": 1},
{"rowid": 2, "user_name": "wxid_old", "is_session": 1},
{"rowid": 3, "user_name": "wxid_missing_a", "is_session": 1},
{"rowid": 4, "user_name": "wxid_missing_b", "is_session": 1},
{"rowid": 5, "user_name": "wxid_gap_tail", "is_session": 1},
]
def _fake_exec_query(_handle, *, kind, path, sql):
self.assertEqual(kind, "message")
self.assertTrue(str(path).endswith("message_0.db"))
if "COUNT(1)" in sql:
return [{"c": len(live_rows), "mx": 5}]
if "ORDER BY rowid ASC" in sql:
return list(live_rows)
raise AssertionError(f"Unexpected SQL: {sql}")
with (
patch.object(chat_router, "_resolve_db_storage_message_paths", return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db")),
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
patch.object(chat_router, "_wcdb_get_messages", return_value=[]),
):
result = chat_router._sync_chat_realtime_messages_for_table(
account_dir=account_dir,
rt_conn=_DummyConn(),
username=username,
msg_db_path=msg_db_path,
table_name=table_name,
max_scan=50,
backfill_limit=0,
)
self.assertEqual(result.get("inserted"), 0)
conn = sqlite3.connect(str(msg_db_path))
try:
rows = conn.execute("SELECT rowid, user_name FROM Name2Id ORDER BY rowid ASC").fetchall()
finally:
conn.close()
self.assertEqual(
rows,
[
(1, "acc"),
(2, "wxid_old"),
(3, "wxid_missing_a"),
(4, "wxid_missing_b"),
(5, "wxid_gap_tail"),
],
)
def test_sync_still_inserts_new_messages_when_name2id_is_up_to_date(self):
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
username = "wxid_friend"
table_name = f"Msg_{hashlib.md5(username.encode('utf-8')).hexdigest()}"
msg_db_path = account_dir / "message_0.db"
conn = sqlite3.connect(str(msg_db_path))
try:
conn.execute("CREATE TABLE Name2Id (user_name TEXT, is_session INTEGER DEFAULT 1)")
conn.execute(
"""
CREATE TABLE "{table_name}" (
local_id INTEGER PRIMARY KEY,
server_id INTEGER,
local_type INTEGER,
sort_seq INTEGER,
real_sender_id INTEGER,
create_time INTEGER,
message_content TEXT,
compress_content BLOB,
packed_info_data BLOB
)
""".format(table_name=table_name)
)
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (1, ?, 1)", ("acc",))
conn.execute("INSERT INTO Name2Id(rowid, user_name, is_session) VALUES (2, ?, 1)", (username,))
conn.execute(
f'INSERT INTO "{table_name}" '
"(local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content, packed_info_data) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(10, 10010, 1, 10, 2, 1710000010, "old", None, None),
)
conn.commit()
finally:
conn.close()
session_conn = sqlite3.connect(str(account_dir / "session.db"))
try:
session_conn.execute(
"""
CREATE TABLE SessionTable (
username TEXT PRIMARY KEY,
summary TEXT DEFAULT '',
last_timestamp INTEGER DEFAULT 0,
sort_timestamp INTEGER DEFAULT 0,
last_msg_locald_id INTEGER DEFAULT 0,
last_msg_type INTEGER DEFAULT 0,
last_msg_sub_type INTEGER DEFAULT 0,
last_msg_sender TEXT DEFAULT ''
)
"""
)
session_conn.commit()
finally:
session_conn.close()
def _fake_exec_query(_handle, *, kind, path, sql):
self.assertEqual(kind, "message")
self.assertTrue(str(path).endswith("message_0.db"))
if "COUNT(1)" in sql:
return [{"c": 2, "mx": 2}]
raise AssertionError(f"Unexpected SQL: {sql}")
live_messages = [
{
"local_id": 11,
"server_id": 10011,
"local_type": 1,
"sort_seq": 11,
"real_sender_id": 2,
"create_time": 1710000011,
"message_content": "new message",
"compress_content": None,
"sender_username": username,
}
]
with (
patch.object(
chat_router,
"_resolve_db_storage_message_paths",
return_value=(Path(td) / "live_message_0.db", Path(td) / "message_resource.db"),
),
patch.object(chat_router, "_wcdb_exec_query", side_effect=_fake_exec_query),
patch.object(chat_router, "_wcdb_get_messages", side_effect=[list(live_messages)]),
patch.object(chat_router, "_best_effort_upsert_output_name2id_rows") as mock_upsert_name2id,
):
result = chat_router._sync_chat_realtime_messages_for_table(
account_dir=account_dir,
rt_conn=_DummyConn(),
username=username,
msg_db_path=msg_db_path,
table_name=table_name,
max_scan=50,
backfill_limit=0,
)
self.assertEqual(result.get("inserted"), 1)
mock_upsert_name2id.assert_not_called()
conn = sqlite3.connect(str(msg_db_path))
try:
rows = conn.execute(
f'SELECT local_id, server_id, real_sender_id, create_time, message_content FROM "{table_name}" ORDER BY local_id ASC'
).fetchall()
finally:
conn.close()
self.assertEqual(
rows,
[
(10, 10010, 2, 1710000010, "old"),
(11, 10011, 2, 1710000011, "new message"),
],
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,95 @@
import sys
import threading
import unittest
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.routers import chat as chat_router
class _DummyRequest:
base_url = "http://testserver/"
class _DummyConn:
def __init__(self) -> None:
self.handle = 1
self.lock = threading.Lock()
class TestChatRealtimeSystemMessageDisplayName(unittest.TestCase):
def test_realtime_chatroom_top_message_prefers_remark_name(self):
raw_text = (
"17990148862@chatroom 2 3546361838777087323 0 "
"wxid_k7zhjk9xvzsk22 21 A 69"
)
wcdb_rows = [
{
"localId": 1,
"serverId": 123,
"localType": 10000,
"sortSeq": 1700000000000,
"realSenderId": 0,
"createTime": 1700000000,
"messageContent": raw_text,
"compressContent": None,
"packedInfoData": None,
"senderUsername": "",
"isSent": False,
}
]
with TemporaryDirectory() as td:
account_dir = Path(td) / "acc"
account_dir.mkdir(parents=True, exist_ok=True)
conn = _DummyConn()
with (
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
patch.object(chat_router, "_wcdb_get_messages", return_value=wcdb_rows),
patch.object(
chat_router,
"_load_contact_rows",
return_value={
"wxid_k7zhjk9xvzsk22": {
"remark": "周鑫",
"nick_name": "A",
"alias": "",
}
},
),
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
patch.object(chat_router, "_load_usernames_by_display_names", return_value={}),
patch.object(chat_router, "_load_group_nickname_map", return_value={}),
):
resp = chat_router.list_chat_messages(
_DummyRequest(),
username="17990148862@chatroom",
account="acc",
limit=50,
offset=0,
order="asc",
render_types=None,
source="realtime",
)
self.assertEqual(resp.get("status"), "success")
messages = resp.get("messages") or []
self.assertEqual(len(messages), 1)
msg = messages[0]
self.assertEqual(msg.get("renderType"), "system")
self.assertEqual(msg.get("content"), "周鑫移除了一条置顶消息")
self.assertNotIn("_rawText", msg)
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -37,6 +37,83 @@ class TestChatSystemMessageParsing(unittest.TestCase):
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
def test_chatroom_top_message_uses_response_name_by_default(self):
raw_text = (
"<!-- ChatRoomTopMsgRequest --> 17990148862@chatroom 1 3546361838777087323 49 "
"wxid_7iazcmpjn90k22 <!-- ChatRoomTopMsgResponse --> 21 新青年 68"
)
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
def test_chatroom_top_message_prefers_resolved_display_name(self):
raw_text = (
"<!-- ChatRoomTopMsgRequest --> 17990148862@chatroom 2 3546361838777087323 0 "
"wxid_k7zhjk9xvzsk22 <!-- ChatRoomTopMsgResponse --> 21 A 69"
)
def resolve_display_name(username: str, fallback: str) -> str:
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
self.assertEqual(fallback, "A")
return "周鑫"
self.assertEqual(
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
"周鑫移除了一条置顶消息",
)
def test_chatroom_top_message_without_comment_markers_still_parses(self):
raw_text = "17990148862@chatroom 1 3546361838777087323 49 wxid_7iazcmpjn90k22 21 新青年 68"
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
def test_chatroom_top_message_without_comment_markers_still_prefers_resolved_name(self):
raw_text = "17990148862@chatroom 2 3546361838777087323 0 wxid_k7zhjk9xvzsk22 21 A 69"
def resolve_display_name(username: str, fallback: str) -> str:
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
self.assertEqual(fallback, "A")
return "周鑫"
self.assertEqual(
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
"周鑫移除了一条置顶消息",
)
def test_chatroom_top_message_xml_payload_still_parses(self):
raw_text = (
'<sysmsg type="mmchatroomtopmsg"><mmchatroomtopmsg>'
'<chatroomname><![CDATA[17990148862@chatroom]]></chatroomname>'
'<op><![CDATA[1]]></op>'
'<newmsgid><![CDATA[3546361838777087323]]></newmsgid>'
'<msgtype><![CDATA[49]]></msgtype>'
'<username><![CDATA[wxid_7iazcmpjn90k22]]></username>'
'<id><![CDATA[21]]></id>'
'<nickname><![CDATA[新青年]]></nickname>'
'</mmchatroomtopmsg><chatroominfoversion><![CDATA[68]]></chatroominfoversion></sysmsg>'
)
self.assertEqual(_parse_system_message_content(raw_text), "新青年置顶了一条消息")
def test_chatroom_top_message_xml_payload_prefers_resolved_name(self):
raw_text = (
'<sysmsg type="mmchatroomtopmsg"><mmchatroomtopmsg>'
'<chatroomname><![CDATA[17990148862@chatroom]]></chatroomname>'
'<op><![CDATA[2]]></op>'
'<newmsgid><![CDATA[3546361838777087323]]></newmsgid>'
'<msgtype><![CDATA[0]]></msgtype>'
'<username><![CDATA[wxid_k7zhjk9xvzsk22]]></username>'
'<id><![CDATA[21]]></id>'
'<nickname><![CDATA[A]]></nickname>'
'</mmchatroomtopmsg><chatroominfoversion><![CDATA[69]]></chatroominfoversion></sysmsg>'
)
def resolve_display_name(username: str, fallback: str) -> str:
self.assertEqual(username, "wxid_k7zhjk9xvzsk22")
self.assertEqual(fallback, "A")
return "周鑫"
self.assertEqual(
_parse_system_message_content(raw_text, resolve_display_name=resolve_display_name),
"周鑫移除了一条置顶消息",
)
if __name__ == "__main__":
unittest.main()
+30
View File
@@ -118,6 +118,36 @@ class TestParseAppMessage(unittest.TestCase):
self.assertEqual(parsed.get("linkType"), "official_article")
self.assertEqual(parsed.get("linkStyle"), "cover")
def test_finder_type_51_uses_nested_desc_and_cover(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'
'<title>当前版本不支持展示该内容,请升级至最新版本。</title>'
'<des></des>'
'<type>51</type>'
'<url></url>'
'<finderFeed>'
'<nickname><![CDATA[央视新闻]]></nickname>'
'<username><![CDATA[finder_cctv_news]]></username>'
'<desc><![CDATA[微信视频号全金融行业今公布发布]]></desc>'
'<mediaList><media>'
'<coverUrl><![CDATA[https://finder.video.qq.com/cover.jpg]]></coverUrl>'
'<url><![CDATA[https://channels.weixin.qq.com/web/pages/feed?feedid=abc]]></url>'
'</media></mediaList>'
'</finderFeed>'
'</appmsg></msg>'
)
parsed = _parse_app_message(raw_text)
self.assertEqual(parsed.get("renderType"), "link")
self.assertEqual(parsed.get("linkType"), "finder")
self.assertEqual(parsed.get("title"), "微信视频号全金融行业今公布发布")
self.assertEqual(parsed.get("content"), "微信视频号全金融行业今公布发布")
self.assertEqual(parsed.get("from"), "央视新闻")
self.assertEqual(parsed.get("fromUsername"), "finder_cctv_news")
self.assertEqual(parsed.get("thumbUrl"), "https://finder.video.qq.com/cover.jpg")
self.assertEqual(parsed.get("url"), "https://channels.weixin.qq.com/web/pages/feed?feedid=abc")
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
raw_text = (
'<msg><appmsg appid="" sdkver="0">'