Compare commits

...

5 Commits

39 changed files with 3160 additions and 415 deletions
+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>
+83 -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;
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"/>
+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">
+99 -32
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" />
@@ -104,12 +103,12 @@
<!-- 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 +131,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 +155,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 +168,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 +223,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 +244,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 +325,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 +335,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()
@@ -540,3 +580,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>
+3 -3
View File
@@ -116,7 +116,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',
@@ -167,7 +167,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 +236,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>
+14 -17
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-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 font-medium 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}条] ` : '') +
+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 || '未检测到已解密账号,请先解密数据库。')
+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'
}
+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>
+4 -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">
<!-- 步骤指示器 -->
@@ -73,6 +73,9 @@
</svg>
点击按钮将自动获取数据库图片双重密钥您也可以手动输入已知的64位密钥使用<a href="https://github.com/ycccccccy/wx_key" target="_blank" class="text-[#07C160] hover:text-[#06AD56]">wx_key</a>等工具获取
</p>
<div class="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-900">
提示数据库密钥跟随账号 + 设备下发同一账号在另一台电脑生成的聊天记录复制到当前设备后通常无法在当前设备重新获取原设备对应的密钥因此也无法直接解密
</div>
</div>
<!-- 数据库路径输入 -->
+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(() => (
+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,
}
})
+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
+113 -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)
@@ -2334,4 +2444,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 "",
}
+168 -50
View File
@@ -26,6 +26,7 @@ from ..chat_helpers import (
_build_fts_query,
_decode_message_content,
_decode_sqlite_text,
_extract_chatroom_top_message_metadata,
_extract_md5_from_packed_info,
_extract_sender_from_group_xml,
_extract_xml_attr,
@@ -514,6 +515,61 @@ def _resolve_sender_display_name(
return display_name
def _resolve_system_message_display_name(
*,
sender_username: str,
fallback_display_name: str,
sender_contact_rows: dict[str, sqlite3.Row],
wcdb_display_names: dict[str, str],
) -> str:
su = str(sender_username or "").strip()
fallback = str(fallback_display_name or "").strip()
if not su:
return fallback or "有人"
row = sender_contact_rows.get(su)
display_name = _pick_display_name(row, su)
if display_name != su:
return display_name
if fallback and fallback != su:
return fallback
wd = str(wcdb_display_names.get(su) or "").strip()
if wd and wd != su:
return wd
return fallback or wd or su
def _postprocess_special_message_content(
*,
message: dict[str, Any],
sender_contact_rows: dict[str, sqlite3.Row],
wcdb_display_names: dict[str, str],
) -> None:
raw = str(message.get("_rawText") or "")
if not raw:
message.pop("_rawText", None)
return
local_type = int(message.get("type") or 0)
if local_type == 266287972401:
message["content"] = _parse_pat_message(raw, sender_contact_rows)
elif local_type == 10000:
message["content"] = _parse_system_message_content(
raw,
resolve_display_name=lambda sender_username, fallback_display_name="": _resolve_system_message_display_name(
sender_username=sender_username,
fallback_display_name=fallback_display_name,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
),
)
message.pop("_rawText", None)
def _realtime_sync_lock(account: str, username: str) -> threading.Lock:
key = (str(account or "").strip(), str(username or "").strip())
with _REALTIME_SYNC_MU:
@@ -3034,7 +3090,7 @@ def _append_full_messages_from_rows(
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
"_rawText": raw_text if local_type == 266287972401 else "",
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
}
)
@@ -3271,9 +3327,20 @@ def _postprocess_full_messages(
if fn and fn in name_to_username:
m["fromUsername"] = name_to_username[fn]
system_usernames: set[str] = set()
for m in merged:
if int(m.get("type") or 0) != 10000:
continue
meta = _extract_chatroom_top_message_metadata(str(m.get("_rawText") or ""))
operator_username = str(meta.get("operatorUsername") or "").strip()
if operator_username:
system_usernames.add(operator_username)
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
uniq_senders = list(
dict.fromkeys([u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u])
dict.fromkeys(
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames + list(system_usernames)) if u]
)
)
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
local_sender_avatars = _query_head_image_usernames(head_image_db_path, uniq_senders)
@@ -3327,20 +3394,19 @@ def _postprocess_full_messages(
m["from"] = wd
su = str(m.get("senderUsername") or "")
if not su:
continue
m["senderDisplayName"] = _resolve_sender_display_name(
sender_username=su,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
group_nicknames=group_nicknames,
)
avatar_url = base_url + _avatar_url_unified(
account_dir=account_dir,
username=su,
local_avatar_usernames=local_sender_avatars,
)
m["senderAvatar"] = avatar_url
if su:
m["senderDisplayName"] = _resolve_sender_display_name(
sender_username=su,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
group_nicknames=group_nicknames,
)
avatar_url = base_url + _avatar_url_unified(
account_dir=account_dir,
username=su,
local_avatar_usernames=local_sender_avatars,
)
m["senderAvatar"] = avatar_url
qu = str(m.get("quoteUsername") or "").strip()
if qu:
@@ -3471,13 +3537,11 @@ def _postprocess_full_messages(
except Exception:
pass
if int(m.get("type") or 0) == 266287972401:
raw = str(m.get("_rawText") or "")
if raw:
m["content"] = _parse_pat_message(raw, sender_contact_rows)
if "_rawText" in m:
m.pop("_rawText", None)
_postprocess_special_message_content(
message=m,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
)
@router.get("/api/chat/accounts", summary="列出已解密账号")
@@ -4526,7 +4590,7 @@ def _collect_chat_messages(
"locationLng": location_lng,
"locationPoiname": location_poiname,
"locationLabel": location_label,
"_rawText": raw_text if local_type == 266287972401 else "",
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
}
)
finally:
@@ -5409,7 +5473,7 @@ def list_chat_messages(
"paySubType": pay_sub_type,
"transferStatus": transfer_status,
"transferId": transfer_id,
"_rawText": raw_text if local_type == 266287972401 else "",
"_rawText": raw_text if local_type in (10000, 266287972401) else "",
}
)
finally:
@@ -5498,6 +5562,15 @@ def list_chat_messages(
continue
pat_usernames_in_page.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)})
system_usernames_in_page: set[str] = set()
for m in messages_window:
if int(m.get("type") or 0) != 10000:
continue
meta = _extract_chatroom_top_message_metadata(str(m.get("_rawText") or ""))
operator_username = str(meta.get("operatorUsername") or "").strip()
if operator_username:
system_usernames_in_page.add(operator_username)
from_usernames = [str(m.get("fromUsername") or "").strip() for m in messages_window]
sender_usernames_in_page = [str(m.get("senderUsername") or "").strip() for m in messages_window]
quote_usernames_in_page = [str(m.get("quoteUsername") or "").strip() for m in messages_window]
@@ -5510,6 +5583,7 @@ def list_chat_messages(
+ list(pat_usernames_in_page)
+ quote_usernames_in_page
+ from_usernames
+ list(system_usernames_in_page)
)
if u
]
@@ -5567,20 +5641,19 @@ def list_chat_messages(
m["from"] = wd
su = str(m.get("senderUsername") or "")
if not su:
continue
m["senderDisplayName"] = _resolve_sender_display_name(
sender_username=su,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
group_nicknames=group_nicknames,
)
avatar_url = base_url + _avatar_url_unified(
account_dir=account_dir,
username=su,
local_avatar_usernames=local_sender_avatars,
)
m["senderAvatar"] = avatar_url
if su:
m["senderDisplayName"] = _resolve_sender_display_name(
sender_username=su,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
group_nicknames=group_nicknames,
)
avatar_url = base_url + _avatar_url_unified(
account_dir=account_dir,
username=su,
local_avatar_usernames=local_sender_avatars,
)
m["senderAvatar"] = avatar_url
qu = str(m.get("quoteUsername") or "").strip()
if qu:
@@ -5706,13 +5779,11 @@ def list_chat_messages(
except Exception:
pass
if int(m.get("type") or 0) == 266287972401:
raw = str(m.get("_rawText") or "")
if raw:
m["content"] = _parse_pat_message(raw, sender_contact_rows)
if "_rawText" in m:
m.pop("_rawText", None)
_postprocess_special_message_content(
message=m,
sender_contact_rows=sender_contact_rows,
wcdb_display_names=wcdb_display_names,
)
return {
"status": "success",
@@ -6032,7 +6103,14 @@ async def _search_chat_messages_via_fts(
scope = "conversation" if username else "global"
if username:
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits]))
system_usernames = [
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
for x in hits
if int(x.get("type") or 0) == 10000
]
uniq_usernames = list(
dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in hits] + system_usernames)
)
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, uniq_usernames)
@@ -6099,10 +6177,22 @@ async def _search_chat_messages_via_fts(
local_avatar_usernames=local_avatar_usernames,
)
h["senderAvatar"] = avatar_url
_postprocess_special_message_content(
message=h,
sender_contact_rows=contact_rows,
wcdb_display_names=wcdb_display_names,
)
else:
system_usernames = [
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
for x in hits
if int(x.get("type") or 0) == 10000
]
uniq_contacts = list(
dict.fromkeys(
[str(x.get("username") or "") for x in hits] + [str(x.get("senderUsername") or "") for x in hits]
[str(x.get("username") or "") for x in hits]
+ [str(x.get("senderUsername") or "") for x in hits]
+ system_usernames
)
)
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
@@ -6182,6 +6272,11 @@ async def _search_chat_messages_via_fts(
local_avatar_usernames=local_avatar_usernames,
)
h["senderAvatar"] = avatar_url
_postprocess_special_message_content(
message=h,
sender_contact_rows=contact_rows,
wcdb_display_names=wcdb_display_names,
)
return {
"status": "success",
@@ -6434,7 +6529,14 @@ async def search_chat_messages(
total_in_scan = len(conv_hits)
page = conv_hits[int(offset) : int(offset) + int(limit)]
uniq_usernames = list(dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in page]))
system_usernames = [
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
for x in page
if int(x.get("type") or 0) == 10000
]
uniq_usernames = list(
dict.fromkeys([username] + [str(x.get("senderUsername") or "") for x in page] + system_usernames)
)
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
conv_row = contact_rows.get(username)
conv_name = _pick_display_name(conv_row, username)
@@ -6455,6 +6557,11 @@ async def search_chat_messages(
wcdb_display_names={},
group_nicknames=group_nicknames,
)
_postprocess_special_message_content(
message=h,
sender_contact_rows=contact_rows,
wcdb_display_names={},
)
return {
"status": "success",
@@ -6531,7 +6638,13 @@ async def search_chat_messages(
uniq_contacts = list(
dict.fromkeys(
[str(x.get("username") or "") for x in page] + [str(x.get("senderUsername") or "") for x in page]
[str(x.get("username") or "") for x in page]
+ [str(x.get("senderUsername") or "") for x in page]
+ [
str(_extract_chatroom_top_message_metadata(str(x.get("_rawText") or "")).get("operatorUsername") or "").strip()
for x in page
if int(x.get("type") or 0) == 10000
]
)
)
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
@@ -6566,6 +6679,11 @@ async def search_chat_messages(
wcdb_display_names={},
group_nicknames=group_nickname_cache.get(cu, {}),
)
_postprocess_special_message_content(
message=h,
sender_contact_rows=contact_rows,
wcdb_display_names={},
)
return {
"status": "success",
+27 -3
View File
@@ -14,7 +14,12 @@ from ..app_paths import get_output_databases_dir
from ..logging_config import get_logger
from ..path_fix import PathFixRoute
from ..key_store import upsert_account_keys_in_store
from ..wechat_decrypt import WeChatDatabaseDecryptor, decrypt_wechat_databases, scan_account_databases_from_path
from ..wechat_decrypt import (
WeChatDatabaseDecryptor,
build_decrypt_result_message,
decrypt_wechat_databases,
scan_account_databases_from_path,
)
logger = get_logger(__name__)
@@ -76,6 +81,7 @@ async def decrypt_databases(request: DecryptRequest):
"message": results["message"],
"processed_files": results["processed_files"],
"failed_files": results["failed_files"],
"failure_details": results.get("failure_details", []),
"account_results": results.get("account_results", {}),
}
@@ -159,6 +165,7 @@ async def decrypt_databases_stream(
fail_count = 0
processed_files: list[str] = []
failed_files: list[str] = []
failure_details: list[dict] = []
account_results: dict = {}
overall_current = 0
@@ -181,6 +188,7 @@ async def decrypt_databases_stream(
account_success = 0
account_processed: list[str] = []
account_failed: list[str] = []
account_failure_details: list[dict] = []
for db_info in dbs:
if await request.is_disconnected():
@@ -232,11 +240,20 @@ async def decrypt_databases_stream(
status = "success"
msg = "解密成功"
else:
failure_detail = {
"account": account,
"file": db_path,
"name": db_name,
"code": str(decryptor.last_error_code or "").strip(),
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
}
account_failed.append(db_path)
account_failure_details.append(failure_detail)
failed_files.append(db_path)
failure_details.append(failure_detail)
fail_count += 1
status = "fail"
msg = "解密失败"
msg = failure_detail["reason"]
yield _sse(
{
@@ -261,6 +278,7 @@ async def decrypt_databases_stream(
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed,
"failure_details": account_failure_details,
}
# Build cache table (keep behavior consistent with the POST endpoint).
@@ -307,9 +325,15 @@ async def decrypt_databases_stream(
"success_count": success_count,
"failure_count": total_databases - success_count,
"output_directory": str(base_output_dir.absolute()),
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"message": build_decrypt_result_message(
total_databases=total_databases,
success_count=success_count,
failed_count=total_databases - success_count,
failure_details=failure_details,
),
"processed_files": processed_files,
"failed_files": failed_files,
"failure_details": failure_details,
"account_results": account_results,
}
+210 -131
View File
@@ -13,12 +13,12 @@ import hashlib
import hmac
import os
import json
import shutil
import tempfile
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from .app_paths import get_output_databases_dir
@@ -26,6 +26,94 @@ from .app_paths import get_output_databases_dir
# SQLite文件头
SQLITE_HEADER = b"SQLite format 3\x00"
PAGE_SIZE = 4096
KEY_SIZE = 32
SALT_SIZE = 16
IV_SIZE = 16
HMAC_SIZE = 64
RESERVE_SIZE = 80
KEY_MISMATCH_GUIDANCE = (
"请在当前设备登录该账号后重新获取密钥;"
"如果聊天记录是从另一台设备复制过来的,当前设备通常无法获取原设备对应的密钥。"
)
def _derive_mac_key(raw_key: bytes, salt: bytes) -> bytes:
mac_salt = bytes(b ^ 0x3A for b in salt)
return hashlib.pbkdf2_hmac("sha512", raw_key, mac_salt, 2, dklen=KEY_SIZE)
def _compute_page_hmac(mac_key: bytes, page: bytes, page_num: int) -> bytes:
offset = SALT_SIZE if page_num == 1 else 0
data_end = PAGE_SIZE - RESERVE_SIZE + IV_SIZE
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(page[offset:data_end])
mac.update(page_num.to_bytes(4, "little"))
return mac.digest()
def _decrypt_page(raw_key: bytes, page: bytes, page_num: int) -> bytes:
iv = page[PAGE_SIZE - RESERVE_SIZE : PAGE_SIZE - RESERVE_SIZE + IV_SIZE]
offset = SALT_SIZE if page_num == 1 else 0
encrypted = page[offset : PAGE_SIZE - RESERVE_SIZE]
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
decryptor = cipher.decryptor()
decrypted = decryptor.update(encrypted) + decryptor.finalize()
if page_num == 1:
return SQLITE_HEADER + decrypted + (b"\x00" * RESERVE_SIZE)
return decrypted + (b"\x00" * RESERVE_SIZE)
def _failure_matches_key_mismatch(detail: dict | None) -> bool:
if not isinstance(detail, dict):
return False
code = str(detail.get("code") or "").strip().lower()
reason = str(detail.get("reason") or "").strip()
if code == "key_mismatch":
return True
return ("密钥" in reason and "不匹配" in reason) or ("当前数据库密钥不正确" in reason)
def build_decrypt_result_message(
total_databases: int,
success_count: int,
failed_count: int,
failure_details: list[dict] | None = None,
) -> str:
total = max(int(total_databases or 0), 0)
success = max(int(success_count or 0), 0)
failed = max(int(failed_count or 0), 0)
details = list(failure_details or [])
if total == 0:
return "未找到可解密的数据库文件"
if failed == 0:
return f"解密完成: 成功 {success}/{total}"
key_mismatch_count = sum(1 for item in details if _failure_matches_key_mismatch(item))
if success == 0 and failed == total:
if key_mismatch_count == failed:
return (
f"解密失败:当前数据库密钥不正确,或该密钥不属于当前账号/当前设备(0/{total} 成功)。"
+ KEY_MISMATCH_GUIDANCE
)
return f"解密失败:0/{total} 个数据库解密成功,请检查密钥、账号与数据库路径是否匹配。"
if key_mismatch_count > 0:
return (
f"解密完成:成功 {success}/{total},失败 {failed}/{total}"
"失败文件中包含密钥不匹配的数据库,请确认使用的是当前账号在当前设备上的密钥。"
)
return f"解密完成:成功 {success}/{total},失败 {failed}/{total}"
def _normalize_account_name(name: str) -> str:
@@ -221,153 +309,123 @@ class WeChatDatabaseDecryptor:
self.key_bytes = bytes.fromhex(key_hex)
except ValueError:
raise ValueError("密钥必须是有效的十六进制字符串")
self.last_error_code = ""
self.last_error_message = ""
def _set_last_error(self, code: str, message: str) -> None:
self.last_error_code = str(code or "").strip()
self.last_error_message = str(message or "").strip()
def _clear_last_error(self) -> None:
self.last_error_code = ""
self.last_error_message = ""
def decrypt_database(self, db_path: str, output_path: str) -> bool:
"""解密微信4.x版本数据库
使用SQLCipher 4.0参数:
- PBKDF2-SHA512, 256000轮迭代
- AES-256-CBC加密
- HMAC-SHA512验证
- 页面大小4096字节
这里传入的 key 已经是从微信进程内存提取出的 raw enc_key
不是 SQLCipher 的口令因此不能再做一轮 PBKDF2
"""
from .logging_config import get_logger
logger = get_logger(__name__)
logger.info(f"开始解密数据库: {db_path}")
try:
with open(db_path, 'rb') as f:
encrypted_data = f.read()
logger.info(f"读取文件大小: {len(encrypted_data)} bytes")
if len(encrypted_data) < 4096:
logger.warning(f"文件太小,跳过解密: {db_path}")
tmp_output_path = ""
self._clear_last_error()
try:
file_size = os.path.getsize(db_path)
logger.info(f"读取文件大小: {file_size} bytes")
if file_size < PAGE_SIZE:
message = f"数据库文件过小,无法解密: {db_path}"
self._set_last_error("file_too_small", message)
logger.warning(message)
return False
output_dir = Path(output_path).parent
output_dir.mkdir(parents=True, exist_ok=True)
with open(db_path, "rb") as source:
page1 = source.read(PAGE_SIZE)
if len(page1) < PAGE_SIZE:
message = f"数据库首页大小不足,无法解密: {db_path}"
self._set_last_error("page_too_small", message)
logger.warning(message)
return False
# 检查是否已经是解密的数据库
if encrypted_data.startswith(SQLITE_HEADER):
if page1.startswith(SQLITE_HEADER):
logger.info(f"文件已是SQLite格式,直接复制: {db_path}")
with open(output_path, 'wb') as f:
f.write(encrypted_data)
fd, tmp_output_path = tempfile.mkstemp(
prefix=f".{Path(output_path).name}.",
suffix=".tmp",
dir=str(output_dir),
)
os.close(fd)
with open(db_path, "rb") as src, open(tmp_output_path, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
os.replace(tmp_output_path, output_path)
tmp_output_path = ""
return True
# 提取salt (前16字节)
salt = encrypted_data[:16]
# 计算mac_salt (salt XOR 0x3a)
mac_salt = bytes(b ^ 0x3a for b in salt)
# 使用PBKDF2-SHA512派生密钥
kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=salt,
iterations=256000,
backend=default_backend()
)
derived_key = kdf.derive(self.key_bytes)
# 派生MAC密钥
mac_kdf = PBKDF2HMAC(
algorithm=hashes.SHA512(),
length=32,
salt=mac_salt,
iterations=2,
backend=default_backend()
)
mac_key = mac_kdf.derive(derived_key)
# 解密数据
decrypted_data = bytearray()
decrypted_data.extend(SQLITE_HEADER)
page_size = 4096
iv_size = 16
hmac_size = 64 # SHA512的HMAC是64字节
# 计算保留区域大小 (对齐到AES块大小)
reserve_size = iv_size + hmac_size
if reserve_size % 16 != 0:
reserve_size = ((reserve_size // 16) + 1) * 16
total_pages = len(encrypted_data) // page_size
salt = page1[:SALT_SIZE]
mac_key = _derive_mac_key(self.key_bytes, salt)
expected_page1_hmac = _compute_page_hmac(mac_key, page1, 1)
stored_page1_hmac = page1[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
if stored_page1_hmac != expected_page1_hmac:
message = f"当前数据库密钥不正确,或该密钥不属于当前账号/当前设备: {db_path}"
self._set_last_error("key_mismatch", message)
logger.error(f"页面 1 HMAC验证失败,密钥与数据库不匹配: {db_path}")
return False
total_pages = (file_size + PAGE_SIZE - 1) // PAGE_SIZE
successful_pages = 0
failed_pages = 0
# 逐页解密
for cur_page in range(total_pages):
start = cur_page * page_size
end = start + page_size
page = encrypted_data[start:end]
page_num = cur_page + 1 # 页面编号从1开始
if len(page) < page_size:
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes")
break
# 确定偏移量:第一页(cur_page == 0)需要跳过salt
offset = 16 if cur_page == 0 else 0 # SALT_SIZE = 16
# 提取存储的HMAC
hmac_start = page_size - reserve_size + iv_size
hmac_end = hmac_start + hmac_size
stored_hmac = page[hmac_start:hmac_end]
# 按照wechat-dump-rs的方式验证HMAC
data_end = page_size - reserve_size + iv_size
hmac_data = page[offset:data_end]
# 分步计算HMAC:先更新数据,再更新页面编号
mac = hmac.new(mac_key, digestmod=hashlib.sha512)
mac.update(hmac_data) # 包含加密数据+IV
mac.update(page_num.to_bytes(4, 'little')) # 页面编号(小端序)
expected_hmac = mac.digest()
if stored_hmac != expected_hmac:
logger.warning(f"页面 {page_num} HMAC验证失败")
failed_pages += 1
continue
# 提取IV和加密数据用于AES解密
iv = page[page_size - reserve_size:page_size - reserve_size + iv_size]
encrypted_page = page[offset:page_size - reserve_size]
# AES-CBC解密
try:
cipher = Cipher(
algorithms.AES(derived_key),
modes.CBC(iv),
backend=default_backend()
)
decryptor = cipher.decryptor()
decrypted_page = decryptor.update(encrypted_page) + decryptor.finalize()
# 按照wechat-dump-rs的方式重组页面数据
decrypted_data.extend(decrypted_page)
decrypted_data.extend(page[page_size - reserve_size:]) # 保留区域
fd, tmp_output_path = tempfile.mkstemp(
prefix=f".{Path(output_path).name}.",
suffix=".tmp",
dir=str(output_dir),
)
os.close(fd)
with open(db_path, "rb") as source, open(tmp_output_path, "wb") as target:
for page_num in range(1, total_pages + 1):
page = source.read(PAGE_SIZE)
if not page:
break
if len(page) < PAGE_SIZE:
logger.warning(f"页面 {page_num} 大小不足: {len(page)} bytes,自动补齐到 {PAGE_SIZE} bytes")
page = page + (b"\x00" * (PAGE_SIZE - len(page)))
stored_hmac = page[PAGE_SIZE - HMAC_SIZE : PAGE_SIZE]
expected_hmac = _compute_page_hmac(mac_key, page, page_num)
if stored_hmac != expected_hmac:
message = f"数据库校验失败,文件可能损坏或密钥不匹配: {db_path}"
self._set_last_error("page_hmac_mismatch", message)
logger.error(f"页面 {page_num} HMAC验证失败,终止解密: {db_path}")
return False
target.write(_decrypt_page(self.key_bytes, page, page_num))
successful_pages += 1
except Exception as e:
logger.error(f"页面 {page_num} AES解密失败: {e}")
failed_pages += 1
continue
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 {failed_pages}")
# 写入解密后的文件
with open(output_path, 'wb') as f:
f.write(decrypted_data)
logger.info(f"解密文件大小: {len(decrypted_data)} bytes")
logger.info(f"解密完成: 成功 {successful_pages} 页, 失败 0")
os.replace(tmp_output_path, output_path)
tmp_output_path = ""
logger.info(f"解密文件大小: {os.path.getsize(output_path)} bytes")
self._clear_last_error()
return True
except Exception as e:
self._set_last_error("exception", f"解密过程中发生异常: {e}")
logger.error(f"解密失败: {db_path}, 错误: {e}")
return False
finally:
if tmp_output_path:
try:
os.remove(tmp_output_path)
except OSError:
pass
def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> dict:
"""
@@ -492,6 +550,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
success_count = 0
processed_files = []
failed_files = []
failure_details = []
account_results = {}
for account_name, databases in account_databases.items():
@@ -523,6 +582,7 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
account_success = 0
account_processed = []
account_failed = []
account_failure_details = []
for db_info in databases:
db_path = db_info['path']
@@ -542,7 +602,16 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
else:
account_failed.append(db_path)
failed_files.append(db_path)
logger.error(f"解密失败: {account_name}/{db_name}")
failure_detail = {
"account": account_name,
"file": db_path,
"name": db_name,
"code": str(decryptor.last_error_code or "").strip(),
"reason": str(decryptor.last_error_message or "").strip() or "解密失败",
}
account_failure_details.append(failure_detail)
failure_details.append(failure_detail)
logger.error(f"解密失败: {account_name}/{db_name} reason={failure_detail['reason']}")
# 记录账号解密结果
account_results[account_name] = {
@@ -551,7 +620,8 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
"failed": len(databases) - account_success,
"output_dir": str(account_output_dir),
"processed_files": account_processed,
"failed_files": account_failed
"failed_files": account_failed,
"failure_details": account_failure_details,
}
# 构建“会话最后一条消息”缓存表:把耗时挪到解密阶段,后续会话列表直接查表
@@ -575,15 +645,23 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info(f"账号 {account_name} 解密完成: 成功 {account_success}/{len(databases)}")
# 返回结果
failed_count = total_databases - success_count
message = build_decrypt_result_message(
total_databases=total_databases,
success_count=success_count,
failed_count=failed_count,
failure_details=failure_details,
)
result = {
"status": "success" if success_count > 0 else "error",
"message": f"解密完成: 成功 {success_count}/{total_databases}",
"message": message,
"total_databases": total_databases,
"successful_count": success_count,
"failed_count": total_databases - success_count,
"failed_count": failed_count,
"output_directory": str(base_output_dir.absolute()),
"processed_files": processed_files,
"failed_files": failed_files,
"failure_details": failure_details,
"account_results": account_results, # 新增:按账号的详细结果
"detected_accounts": detected_accounts,
}
@@ -591,8 +669,9 @@ def decrypt_wechat_databases(db_storage_path: str = None, key: str = None) -> di
logger.info("=" * 60)
logger.info("解密任务完成!")
logger.info(f"成功: {success_count}/{total_databases}")
logger.info(f"失败: {total_databases - success_count}/{total_databases}")
logger.info(f"失败: {failed_count}/{total_databases}")
logger.info(f"输出目录: {base_output_dir.absolute()}")
logger.info(f"结果说明: {message}")
logger.info("=" * 60)
return result
@@ -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()
+100
View File
@@ -3,14 +3,44 @@ import os
import sys
import unittest
import importlib
import hashlib
import hmac
from pathlib import Path
from tempfile import TemporaryDirectory
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes:
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SALT_SIZE, _derive_mac_key
if page_num == 1:
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
prefix = salt
else:
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
prefix = b""
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
page_without_hmac = prefix + encrypted + iv
mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512)
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
mac.update(page_num.to_bytes(4, "little"))
return page_without_hmac + mac.digest()
class TestDecryptStreamSSE(unittest.TestCase):
def test_decrypt_stream_reports_progress(self):
from fastapi import FastAPI
@@ -85,6 +115,76 @@ class TestDecryptStreamSSE(unittest.TestCase):
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
def test_decrypt_stream_reports_key_scope_error_for_wrong_key(self):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from wechat_decrypt_tool.wechat_decrypt import PAGE_SIZE, RESERVE_SIZE, SQLITE_HEADER
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
plain_page = SQLITE_HEADER + (b"A" * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))) + (b"\x00" * RESERVE_SIZE)
encrypted_db = _encrypt_page(good_key, plain_page, 1, salt, iv1)
with TemporaryDirectory() as td:
root = Path(td)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
prev_build_cache = os.environ.get("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = "0"
import wechat_decrypt_tool.app_paths as app_paths
import wechat_decrypt_tool.routers.decrypt as decrypt_router
importlib.reload(app_paths)
importlib.reload(decrypt_router)
db_storage = root / "xwechat_files" / "wxid_wrong_key_user" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
(db_storage / "MSG0.db").write_bytes(encrypted_db)
app = FastAPI()
app.include_router(decrypt_router.router)
client = TestClient(app)
events: list[dict] = []
with client.stream(
"GET",
"/api/decrypt_stream",
params={"key": bad_key, "db_storage_path": str(db_storage)},
) as resp:
self.assertEqual(resp.status_code, 200)
for line in resp.iter_lines():
if not line:
continue
if isinstance(line, bytes):
line = line.decode("utf-8", errors="ignore")
line = str(line)
if line.startswith(":") or not line.startswith("data: "):
continue
payload = json.loads(line[len("data: ") :])
events.append(payload)
if payload.get("type") in {"complete", "error"}:
break
self.assertEqual(events[-1].get("type"), "complete")
self.assertEqual(events[-1].get("status"), "failed")
self.assertIn("当前数据库密钥不正确", events[-1].get("message", ""))
self.assertIn("另一台设备复制", events[-1].get("message", ""))
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
if prev_build_cache is None:
os.environ.pop("WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE", None)
else:
os.environ["WECHAT_TOOL_BUILD_SESSION_LAST_MESSAGE"] = prev_build_cache
if __name__ == "__main__":
unittest.main()
+128
View File
@@ -0,0 +1,128 @@
import hashlib
import hmac
import os
import sys
import tempfile
import unittest
from pathlib import Path
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from wechat_decrypt_tool.wechat_decrypt import (
PAGE_SIZE,
RESERVE_SIZE,
SALT_SIZE,
SQLITE_HEADER,
WeChatDatabaseDecryptor,
_derive_mac_key,
decrypt_wechat_databases,
)
def _encrypt_page(raw_key: bytes, plain_page: bytes, page_num: int, salt: bytes, iv: bytes) -> bytes:
if page_num == 1:
encrypted_input = plain_page[SALT_SIZE : PAGE_SIZE - RESERVE_SIZE]
prefix = salt
else:
encrypted_input = plain_page[: PAGE_SIZE - RESERVE_SIZE]
prefix = b""
cipher = Cipher(
algorithms.AES(raw_key),
modes.CBC(iv),
backend=default_backend(),
)
encryptor = cipher.encryptor()
encrypted = encryptor.update(encrypted_input) + encryptor.finalize()
page_without_hmac = prefix + encrypted + iv
mac = hmac.new(_derive_mac_key(raw_key, salt), digestmod=hashlib.sha512)
mac.update(page_without_hmac[SALT_SIZE if page_num == 1 else 0 :])
mac.update(page_num.to_bytes(4, "little"))
return page_without_hmac + mac.digest()
def _build_plain_page(body_byte: int, *, first_page: bool) -> bytes:
if first_page:
payload = SQLITE_HEADER + bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE - len(SQLITE_HEADER))
else:
payload = bytes([body_byte]) * (PAGE_SIZE - RESERVE_SIZE)
return payload + (b"\x00" * RESERVE_SIZE)
class WeChatDecryptRawKeyTests(unittest.TestCase):
def test_decrypt_database_uses_raw_enc_key(self):
raw_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
iv2 = bytes.fromhex("1112131415161718191a1b1c1d1e1f20")
page1 = _build_plain_page(0x41, first_page=True)
page2 = _build_plain_page(0x42, first_page=False)
encrypted_db = _encrypt_page(raw_key, page1, 1, salt, iv1) + _encrypt_page(raw_key, page2, 2, salt, iv2)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
decryptor = WeChatDatabaseDecryptor(raw_key.hex())
self.assertTrue(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), page1 + page2)
def test_decrypt_database_keeps_existing_output_on_hmac_failure(self):
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
page1 = _build_plain_page(0x41, first_page=True)
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
with tempfile.TemporaryDirectory() as tmpdir:
src = Path(tmpdir) / "source.db"
dst = Path(tmpdir) / "out.db"
src.write_bytes(encrypted_db)
dst.write_bytes(b"keep-existing-output")
decryptor = WeChatDatabaseDecryptor(bad_key_hex)
self.assertFalse(decryptor.decrypt_database(str(src), str(dst)))
self.assertEqual(dst.read_bytes(), b"keep-existing-output")
def test_decrypt_wechat_databases_reports_key_scope_message(self):
good_key = bytes.fromhex("00112233445566778899aabbccddeefffedcba98765432100123456789abcdef")
bad_key_hex = "ffeeddccbbaa998877665544332211000123456789abcdeffedcba9876543210"
salt = bytes.fromhex("11223344556677889900aabbccddeeff")
iv1 = bytes.fromhex("0102030405060708090a0b0c0d0e0f10")
page1 = _build_plain_page(0x41, first_page=True)
encrypted_db = _encrypt_page(good_key, page1, 1, salt, iv1)
with tempfile.TemporaryDirectory() as tmpdir:
root = Path(tmpdir)
db_storage = root / "xwechat_files" / "wxid_scope_user" / "db_storage"
db_storage.mkdir(parents=True, exist_ok=True)
(db_storage / "MSG0.db").write_bytes(encrypted_db)
prev_data_dir = os.environ.get("WECHAT_TOOL_DATA_DIR")
try:
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
result = decrypt_wechat_databases(str(db_storage), bad_key_hex)
finally:
if prev_data_dir is None:
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
else:
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data_dir
self.assertEqual(result["status"], "error")
self.assertIn("当前数据库密钥不正确", result["message"])
self.assertIn("账号/当前设备", result["message"])
self.assertIn("另一台设备复制", result["message"])
if __name__ == "__main__":
unittest.main()