mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
5 Commits
+25
-20
@@ -1,13 +1,22 @@
|
||||
<template>
|
||||
<div :class="rootClass">
|
||||
<DesktopTitleBar v-if="isDesktop && !isChatRoute" />
|
||||
<div :class="contentClass">
|
||||
<NuxtPage />
|
||||
<SidebarRail v-if="showSidebar" />
|
||||
<div class="flex-1 flex flex-col min-h-0">
|
||||
<!-- Desktop titlebar lives above the page content (right column) -->
|
||||
<DesktopTitleBar />
|
||||
<div :class="contentClass">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
// 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
|
||||
// server HTML (no patch) and the layout/CSS fixes won't apply reliably.
|
||||
@@ -23,25 +32,32 @@ onMounted(() => {
|
||||
isDesktop.value = !!window?.wechatDesktop
|
||||
updateDprVar()
|
||||
window.addEventListener('resize', updateDprVar)
|
||||
|
||||
// Init global UI state.
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const privacy = usePrivacyStore()
|
||||
void chatAccounts.ensureLoaded()
|
||||
privacy.init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', updateDprVar)
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat') || route.path?.startsWith('/sns') || route.path?.startsWith('/contacts'))
|
||||
|
||||
const rootClass = computed(() => {
|
||||
const base = 'bg-gradient-to-br from-green-50 via-emerald-50 to-green-100'
|
||||
return isDesktop.value
|
||||
? `wechat-desktop h-screen flex flex-col overflow-hidden ${base}`
|
||||
: `min-h-screen ${base}`
|
||||
? `wechat-desktop h-screen flex overflow-hidden ${base}`
|
||||
: `h-screen flex overflow-hidden ${base}`
|
||||
})
|
||||
|
||||
const contentClass = computed(() =>
|
||||
isDesktop.value ? 'wechat-desktop-content flex-1 overflow-auto min-h-0' : ''
|
||||
isDesktop.value
|
||||
? 'wechat-desktop-content flex-1 overflow-auto min-h-0'
|
||||
: 'flex-1 overflow-auto min-h-0'
|
||||
)
|
||||
|
||||
const showSidebar = computed(() => !String(route.path || '').startsWith('/wrapped'))
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -70,15 +86,4 @@ const contentClass = computed(() =>
|
||||
.wechat-desktop .wechat-desktop-content > .min-h-screen {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 页面过渡动画 - 渐显渐隐效果 */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div
|
||||
class="border-r border-gray-200 flex flex-col"
|
||||
:style="{ backgroundColor: '#e8e7e7', width: '60px', minWidth: '60px', maxWidth: '60px' }"
|
||||
>
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- Avatar -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: '#4B5563' }"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat -->
|
||||
<div
|
||||
class="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]'">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moments -->
|
||||
<div
|
||||
class="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]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
|
||||
<line x1="9.69" y1="8" x2="21.17" y2="8" />
|
||||
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
|
||||
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
|
||||
<line x1="14.31" y1="16" x2="2.83" y2="16" />
|
||||
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contacts -->
|
||||
<div
|
||||
class="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]'">
|
||||
<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" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wrapped -->
|
||||
<div
|
||||
class="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]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="5" width="16" height="15" rx="2" />
|
||||
<path d="M8 3v4" />
|
||||
<path d="M16 3v4" />
|
||||
<path d="M4 9h16" />
|
||||
<path d="M8.5 15l2-2 1.5 1.5 3-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Realtime -->
|
||||
<div
|
||||
class="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]">
|
||||
<svg
|
||||
class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]"
|
||||
:class="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.7"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 2L4 14h7l-1 8 9-12h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy -->
|
||||
<div
|
||||
class="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">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings -->
|
||||
<div
|
||||
class="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="isSettingsRoute ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
||||
/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount } = storeToRefs(chatAccounts)
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const { enabled: realtimeEnabled, available: realtimeAvailable, checking: realtimeChecking, statusError: realtimeStatusError, toggling: realtimeToggling } = storeToRefs(realtimeStore)
|
||||
|
||||
onMounted(async () => {
|
||||
await chatAccounts.ensureLoaded()
|
||||
})
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
const isSettingsRoute = computed(() => route.path?.startsWith('/settings'))
|
||||
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goContacts = async () => {
|
||||
await navigateTo('/contacts')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
const goSettings = async () => {
|
||||
await navigateTo('/settings')
|
||||
}
|
||||
|
||||
const realtimeBusy = computed(() => !!realtimeChecking.value || !!realtimeToggling.value)
|
||||
|
||||
const realtimeTitle = computed(() => {
|
||||
if (realtimeEnabled.value) return '关闭实时更新(全局)'
|
||||
if (realtimeAvailable.value) return '开启实时更新(全局)'
|
||||
return realtimeStatusError.value || '实时模式不可用'
|
||||
})
|
||||
|
||||
const toggleRealtime = async () => {
|
||||
if (realtimeBusy.value) return
|
||||
await realtimeStore.toggle({ silent: false })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,8 +31,7 @@ export default defineNuxtConfig({
|
||||
{ rel: 'icon', type: 'image/png', href: '/logo.png' },
|
||||
{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' }
|
||||
]
|
||||
},
|
||||
pageTransition: { name: 'page', mode: 'out-in' }
|
||||
}
|
||||
},
|
||||
|
||||
// 模块配置
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<!-- Left sidebar rail is provided by `frontend/app.vue` -->
|
||||
<div v-if="false" class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- 头像(类似微信侧边栏) -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
@@ -118,8 +118,7 @@
|
||||
@click="toggleRealtimeFromSidebar"
|
||||
>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors"
|
||||
:class="realtimeEnabled ? 'bg-[#DFF5E7]' : 'bg-transparent group-hover:bg-[#E1E1E1]'"
|
||||
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="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M13 2L4 14h7l-1 8 9-12h-7z" />
|
||||
@@ -130,7 +129,7 @@
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
@click="togglePrivacyMode"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<div
|
||||
@@ -144,11 +143,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置按钮(仅桌面端) -->
|
||||
<!-- 设置按钮 -->
|
||||
<div
|
||||
v-if="isDesktopEnv"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="openDesktopSettings"
|
||||
@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]">
|
||||
@@ -242,14 +240,20 @@
|
||||
@click="selectContact(contact)">
|
||||
<div class="flex items-center space-x-3 w-full">
|
||||
<!-- 联系人头像 -->
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300 flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div v-if="contact.avatar" class="w-full h-full">
|
||||
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover" referrerpolicy="no-referrer" @error="onAvatarError($event, contact)">
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: contact.avatarColor || '#4B5563' }">
|
||||
{{ contact.name.charAt(0) }}
|
||||
<div class="relative flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
|
||||
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300">
|
||||
<div v-if="contact.avatar" class="w-full h-full">
|
||||
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover" referrerpolicy="no-referrer" @error="onAvatarError($event, contact)">
|
||||
</div>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
:style="{ backgroundColor: contact.avatarColor || '#4B5563' }">
|
||||
{{ contact.name.charAt(0) }}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
v-if="contact.unreadCount > 0"
|
||||
class="absolute z-10 -top-[calc(4px/var(--dpr))] -right-[calc(4px/var(--dpr))] w-[calc(10px/var(--dpr))] h-[calc(10px/var(--dpr))] bg-[#ed4d4d] rounded-full"
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<!-- 联系人信息 -->
|
||||
@@ -257,13 +261,12 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-900 truncate" :class="{ 'privacy-blur': privacyMode }">{{ contact.name }}</h3>
|
||||
<div class="flex items-center flex-shrink-0 ml-2">
|
||||
<span v-if="contact.unreadCount > 0" class="text-[10px] text-white bg-red-500 rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center mr-2">
|
||||
{{ contact.unreadCount > 99 ? '99+' : contact.unreadCount }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500">{{ contact.lastMessageTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">{{ contact.lastMessage }}</p>
|
||||
<p class="text-xs text-gray-500 truncate mt-0.5 leading-tight" :class="{ 'privacy-blur': privacyMode }">
|
||||
{{ contact.unreadCount > 0 ? `[${contact.unreadCount > 99 ? '99+' : contact.unreadCount}条] ` : '' }}{{ contact.lastMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -276,8 +279,6 @@
|
||||
|
||||
<!-- 右侧聊天区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<!-- 桌面端将自绘标题栏放到右侧区域,避免遮挡左侧栏(更接近原生微信布局) -->
|
||||
<DesktopTitleBar />
|
||||
<div class="flex-1 flex min-h-0">
|
||||
<!-- 聊天主区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
@@ -616,15 +617,15 @@
|
||||
<img v-else :src="seg.emojiSrc" :alt="seg.content" class="inline-block w-[1.25em] h-[1.25em] align-text-bottom mx-px">
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="message.quoteTitle || message.quoteContent"
|
||||
class="mt-[5px] px-2 text-xs text-neutral-600 rounded max-w-[404px] max-h-[61px] flex items-center bg-[#e1e1e1]">
|
||||
<div class="py-2 min-w-0 w-full">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 min-w-0 hover:opacity-80"
|
||||
<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]">
|
||||
<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>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 min-w-0 hover:opacity-80"
|
||||
:disabled="!message.quoteVoiceUrl"
|
||||
:class="!message.quoteVoiceUrl ? 'opacity-60 cursor-not-allowed' : ''"
|
||||
@click.stop="message.quoteVoiceUrl && playQuoteVoice(message)"
|
||||
@@ -647,13 +648,35 @@
|
||||
:ref="el => setVoiceRef(getQuoteVoiceId(message), el)"
|
||||
:src="message.quoteVoiceUrl"
|
||||
preload="none"
|
||||
class="hidden"
|
||||
></audio>
|
||||
</div>
|
||||
<div v-else class="line-clamp-2">{{ message.quoteTitle ? (message.quoteTitle + ': ') : '' }}{{ message.quoteContent }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
class="hidden"
|
||||
></audio>
|
||||
</div>
|
||||
<div v-else class="line-clamp-2">
|
||||
<span v-if="message.quoteTitle">{{ message.quoteTitle }}:</span>
|
||||
<span
|
||||
v-if="message.quoteContent && !(isQuotedImage(message) && message.quoteTitle && message.quoteImageUrl && !message._quoteImageError)"
|
||||
:class="message.quoteTitle ? 'ml-1' : ''"
|
||||
>
|
||||
{{ message.quoteContent }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="isQuotedImage(message) && message.quoteImageUrl && !message._quoteImageError"
|
||||
class="ml-2 my-2 flex-shrink-0 w-[45px] h-[45px] rounded overflow-hidden cursor-pointer"
|
||||
@click.stop="openImagePreview(message.quoteImageUrl)"
|
||||
>
|
||||
<img
|
||||
:src="message.quoteImageUrl"
|
||||
alt="引用图片"
|
||||
class="w-full h-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@error="onQuoteImageError(message)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 合并转发聊天记录(Chat History) -->
|
||||
<div
|
||||
v-else-if="message.renderType === 'chatHistory'"
|
||||
@@ -1701,108 +1724,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 桌面端设置弹窗 -->
|
||||
<div
|
||||
v-if="desktopSettingsOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
||||
@click.self="closeDesktopSettings"
|
||||
>
|
||||
<div class="w-full max-w-md bg-white rounded-lg shadow-lg">
|
||||
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<div class="text-base font-medium text-gray-900">设置</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-500 hover:text-gray-700"
|
||||
@click="closeDesktopSettings"
|
||||
title="关闭"
|
||||
>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">开机自启动</div>
|
||||
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoLaunch"
|
||||
:disabled="desktopAutoLaunchLoading"
|
||||
@change="onDesktopAutoLaunchToggle"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
|
||||
<div class="text-xs text-gray-500">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="text-sm px-2 py-1 rounded-md border border-gray-200"
|
||||
:disabled="desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
|
||||
<div class="text-xs text-gray-500">进入聊天页后自动打开“实时开关”(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoRealtime"
|
||||
@change="onDesktopAutoRealtimeToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
|
||||
<div class="text-xs text-gray-500">有已解密账号时,打开应用默认跳转到 /chat(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopDefaultToChatWhenData"
|
||||
@change="onDesktopDefaultToChatToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 border-t border-gray-200 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
|
||||
@click="closeDesktopSettings"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineComponent, h, toRaw } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
|
||||
definePageMeta({
|
||||
key: 'chat'
|
||||
@@ -1810,6 +1737,10 @@ definePageMeta({
|
||||
|
||||
import { useApi } from '~/composables/useApi'
|
||||
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { useChatRealtimeStore } from '~/stores/chatRealtime'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
|
||||
import zipIconUrl from '~/assets/images/wechat/zip.png'
|
||||
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
|
||||
@@ -1846,9 +1777,6 @@ useHead({
|
||||
})
|
||||
|
||||
const route = useRoute()
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
|
||||
const routeUsername = computed(() => {
|
||||
const raw = route.params.username
|
||||
@@ -1869,25 +1797,9 @@ const contactProfileData = ref(null)
|
||||
let contactProfileHoverHideTimer = null
|
||||
|
||||
// 隐私模式
|
||||
const privacyMode = ref(false)
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
|
||||
} catch {}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => privacyMode.value,
|
||||
(v) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
)
|
||||
const privacyStore = usePrivacyStore()
|
||||
privacyStore.init()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px,支持拖动调整并持久化
|
||||
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
|
||||
@@ -2014,141 +1926,12 @@ onMounted(() => {
|
||||
|
||||
// 桌面端设置(仅 Electron 环境可见)
|
||||
const isDesktopEnv = ref(false)
|
||||
const desktopSettingsOpen = ref(false)
|
||||
|
||||
const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
|
||||
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
|
||||
const desktopAutoRealtime = ref(false)
|
||||
const desktopDefaultToChatWhenData = ref(false)
|
||||
|
||||
const desktopAutoLaunch = ref(false)
|
||||
const desktopAutoLaunchLoading = ref(false)
|
||||
const desktopAutoLaunchError = ref('')
|
||||
|
||||
const desktopCloseBehavior = ref('tray') // tray | exit
|
||||
const desktopCloseBehaviorLoading = ref(false)
|
||||
const desktopCloseBehaviorError = ref('')
|
||||
|
||||
const readLocalBool = (key) => {
|
||||
if (!process.client) return false
|
||||
try {
|
||||
return localStorage.getItem(key) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const writeLocalBool = (key, value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(key, value ? 'true' : 'false')
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 尽量早读本地设置,避免首次加载联系人时拿不到 autoRealtime 选项
|
||||
if (process.client) {
|
||||
desktopAutoRealtime.value = readLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY)
|
||||
desktopDefaultToChatWhenData.value = readLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY)
|
||||
}
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopAutoLaunch = async (enabled) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
|
||||
await refreshDesktopAutoLaunch()
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopCloseBehavior = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getCloseBehavior) return
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.getCloseBehavior()
|
||||
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopCloseBehavior = async (behavior) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setCloseBehavior) return
|
||||
const desired = (String(behavior || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.setCloseBehavior(desired)
|
||||
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
|
||||
await refreshDesktopCloseBehavior()
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openDesktopSettings = async () => {
|
||||
desktopSettingsOpen.value = true
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
}
|
||||
|
||||
const closeDesktopSettings = () => {
|
||||
desktopSettingsOpen.value = false
|
||||
}
|
||||
|
||||
const onDesktopAutoLaunchToggle = async (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
await setDesktopAutoLaunch(checked)
|
||||
}
|
||||
|
||||
const onDesktopCloseBehaviorChange = async (ev) => {
|
||||
const v = String(ev?.target?.value || '').trim()
|
||||
await setDesktopCloseBehavior(v)
|
||||
}
|
||||
|
||||
const onDesktopAutoRealtimeToggle = async (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopAutoRealtime.value = checked
|
||||
writeLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
|
||||
if (checked) {
|
||||
// 开启后尝试立即启用实时模式(不可用则静默忽略)
|
||||
try {
|
||||
await tryEnableRealtimeAuto()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopDefaultToChatToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopDefaultToChatWhenData.value = checked
|
||||
writeLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
|
||||
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
|
||||
}
|
||||
|
||||
// 联系人数据
|
||||
@@ -2158,45 +1941,22 @@ const searchQuery = ref('')
|
||||
|
||||
const isLoadingContacts = ref(false)
|
||||
const contactsError = ref('')
|
||||
const selectedAccount = ref(null)
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
|
||||
|
||||
const availableAccounts = ref([])
|
||||
// Realtime is a global switch (SidebarRail) and only affects the selected account.
|
||||
const realtimeStore = useChatRealtimeStore()
|
||||
const {
|
||||
enabled: realtimeEnabled,
|
||||
toggleSeq: realtimeToggleSeq,
|
||||
lastToggleAction: realtimeLastToggleAction,
|
||||
changeSeq: realtimeChangeSeq,
|
||||
} = storeToRefs(realtimeStore)
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goContacts = async () => {
|
||||
await navigateTo('/contacts')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
// 实时更新(WCDB DLL + db_storage watcher)
|
||||
const realtimeEnabled = ref(false)
|
||||
const realtimeAvailable = ref(false)
|
||||
const realtimeChecking = ref(false)
|
||||
const realtimeStatusInfo = ref(null)
|
||||
const realtimeStatusError = ref('')
|
||||
let realtimeEventSource = null
|
||||
let realtimeRefreshFuture = null
|
||||
let realtimeRefreshQueued = false
|
||||
let realtimeSessionsRefreshFuture = null
|
||||
let realtimeSessionsRefreshQueued = false
|
||||
let realtimeFullSyncFuture = null
|
||||
let realtimeFullSyncQueued = false
|
||||
let realtimeFullSyncPriority = ''
|
||||
let realtimeChangeDebounceTimer = null
|
||||
|
||||
const allMessages = ref({})
|
||||
|
||||
@@ -3167,11 +2927,6 @@ const onContactCardMouseEnter = () => {
|
||||
clearContactProfileHoverHideTimer()
|
||||
}
|
||||
|
||||
const toggleRealtimeFromSidebar = async () => {
|
||||
if (realtimeChecking.value) return
|
||||
await toggleRealtime()
|
||||
}
|
||||
|
||||
watch(exportModalOpen, (open) => {
|
||||
if (!process.client) return
|
||||
if (!open) {
|
||||
@@ -3464,6 +3219,19 @@ const isQuotedVoice = (message) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const isQuotedImage = (message) => {
|
||||
const t = String(message?.quoteType || '').trim()
|
||||
if (t === '3') return true
|
||||
if (String(message?.quoteContent || '').trim() === '[图片]' && String(message?.quoteServerId || '').trim()) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const onQuoteImageError = (message) => {
|
||||
try {
|
||||
if (message) message._quoteImageError = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const playQuoteVoice = (message) => {
|
||||
playVoice({ id: getQuoteVoiceId(message) })
|
||||
}
|
||||
@@ -4303,23 +4071,18 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
const loadContacts = async () => {
|
||||
const api = useApi()
|
||||
isLoadingContacts.value = true
|
||||
contactsError.value = ''
|
||||
|
||||
try {
|
||||
const accountsResp = await api.listChatAccounts()
|
||||
const accounts = accountsResp?.accounts || []
|
||||
availableAccounts.value = accounts
|
||||
selectedAccount.value = selectedAccount.value || accountsResp?.default_account || accounts[0] || null
|
||||
await chatAccounts.ensureLoaded()
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
contacts.value = []
|
||||
selectedContact.value = null
|
||||
contactsError.value = accountsResp?.message || '未检测到已解密账号,请先解密数据库。'
|
||||
contactsError.value = chatAccounts.error || '未检测到已解密账号,请先解密数据库。'
|
||||
return
|
||||
}
|
||||
|
||||
await loadSessionsForSelectedAccount()
|
||||
} catch (e) {
|
||||
contacts.value = []
|
||||
@@ -4474,49 +4237,6 @@ const queueRealtimeSessionsRefresh = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const runRealtimeFullSync = async (priorityUsername) => {
|
||||
if (!realtimeEnabled.value) return null
|
||||
if (!process.client || typeof window === 'undefined') return null
|
||||
if (!selectedAccount.value) return null
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
return await api.syncChatRealtimeAll({
|
||||
account: selectedAccount.value,
|
||||
max_scan: 200,
|
||||
priority_username: String(priorityUsername || '').trim(),
|
||||
priority_max_scan: 600,
|
||||
include_hidden: true,
|
||||
include_official: true
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const queueRealtimeFullSync = (priorityUsername) => {
|
||||
const u = String(priorityUsername || '').trim()
|
||||
if (u) realtimeFullSyncPriority = u
|
||||
|
||||
if (realtimeFullSyncFuture) {
|
||||
realtimeFullSyncQueued = true
|
||||
return realtimeFullSyncFuture
|
||||
}
|
||||
|
||||
const priority = realtimeFullSyncPriority
|
||||
realtimeFullSyncPriority = ''
|
||||
|
||||
realtimeFullSyncFuture = runRealtimeFullSync(priority).finally(() => {
|
||||
realtimeFullSyncFuture = null
|
||||
if (realtimeFullSyncQueued) {
|
||||
realtimeFullSyncQueued = false
|
||||
queueRealtimeFullSync(realtimeFullSyncPriority)
|
||||
}
|
||||
})
|
||||
|
||||
return realtimeFullSyncFuture
|
||||
}
|
||||
|
||||
const onAccountChange = async () => {
|
||||
try {
|
||||
isLoadingContacts.value = true
|
||||
@@ -4665,6 +4385,23 @@ const normalizeMessage = (msg) => {
|
||||
}
|
||||
}
|
||||
|
||||
const quoteServerIdStr = String(msg.quoteServerId || '').trim()
|
||||
const quoteTypeStr = String(msg.quoteType || '').trim()
|
||||
const quoteVoiceUrl = quoteServerIdStr
|
||||
? `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(quoteServerIdStr)}`
|
||||
: ''
|
||||
const quoteImageUrl = (() => {
|
||||
if (!quoteServerIdStr) return ''
|
||||
if (quoteTypeStr !== '3' && String(msg.quoteContent || '').trim() !== '[图片]') return ''
|
||||
const convUsername = String(selectedContact.value?.username || '').trim()
|
||||
const parts = [
|
||||
`account=${encodeURIComponent(selectedAccount.value || '')}`,
|
||||
`server_id=${encodeURIComponent(quoteServerIdStr)}`,
|
||||
convUsername ? `username=${encodeURIComponent(convUsername)}` : ''
|
||||
].filter(Boolean)
|
||||
return parts.length ? `${mediaBase}/api/chat/media/image?${parts.join('&')}` : ''
|
||||
})()
|
||||
|
||||
return {
|
||||
id: msg.id,
|
||||
serverId: msg.serverId || 0,
|
||||
@@ -4701,12 +4438,12 @@ const normalizeMessage = (msg) => {
|
||||
quoteTitle: msg.quoteTitle || '',
|
||||
quoteContent,
|
||||
quoteUsername: msg.quoteUsername || '',
|
||||
quoteServerId: String(msg.quoteServerId || '').trim(),
|
||||
quoteType: String(msg.quoteType || '').trim(),
|
||||
quoteServerId: quoteServerIdStr,
|
||||
quoteType: quoteTypeStr,
|
||||
quoteVoiceLength: msg.quoteVoiceLength || '',
|
||||
quoteVoiceUrl: String(msg.quoteServerId || '').trim()
|
||||
? `${mediaBase}/api/chat/media/voice?account=${encodeURIComponent(selectedAccount.value || '')}&server_id=${encodeURIComponent(String(msg.quoteServerId || '').trim())}`
|
||||
: '',
|
||||
quoteVoiceUrl,
|
||||
quoteImageUrl: quoteImageUrl || '',
|
||||
_quoteImageError: false,
|
||||
amount: msg.amount || '',
|
||||
coverUrl: msg.coverUrl || '',
|
||||
fileSize: msg.fileSize || '',
|
||||
@@ -5231,7 +4968,6 @@ onUnmounted(() => {
|
||||
highlightMessageTimer = null
|
||||
stopMessageSearchIndexPolling()
|
||||
stopExportPolling()
|
||||
stopRealtimeStream()
|
||||
})
|
||||
|
||||
const dedupeMessagesById = (list) => {
|
||||
@@ -5348,46 +5084,6 @@ const refreshSelectedMessages = async () => {
|
||||
await loadMessages({ username: selectedContact.value.username, reset: true })
|
||||
}
|
||||
|
||||
const fetchRealtimeStatus = async () => {
|
||||
if (!process.client) return
|
||||
if (!selectedAccount.value) {
|
||||
realtimeAvailable.value = false
|
||||
realtimeStatusInfo.value = null
|
||||
realtimeStatusError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
realtimeChecking.value = true
|
||||
try {
|
||||
const resp = await api.getChatRealtimeStatus({ account: selectedAccount.value })
|
||||
realtimeAvailable.value = !!resp?.available
|
||||
realtimeStatusInfo.value = resp?.realtime || null
|
||||
realtimeStatusError.value = ''
|
||||
} catch (e) {
|
||||
realtimeAvailable.value = false
|
||||
realtimeStatusInfo.value = null
|
||||
realtimeStatusError.value = e?.message || '实时状态获取失败'
|
||||
} finally {
|
||||
realtimeChecking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopRealtimeStream = () => {
|
||||
if (realtimeEventSource) {
|
||||
try {
|
||||
realtimeEventSource.close()
|
||||
} catch {}
|
||||
realtimeEventSource = null
|
||||
}
|
||||
if (realtimeChangeDebounceTimer) {
|
||||
try {
|
||||
clearTimeout(realtimeChangeDebounceTimer)
|
||||
} catch {}
|
||||
realtimeChangeDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshRealtimeIncremental = async () => {
|
||||
if (!realtimeEnabled.value) return
|
||||
if (!selectedAccount.value) return
|
||||
@@ -5459,115 +5155,46 @@ const queueRealtimeRefresh = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const queueRealtimeChange = () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!realtimeEnabled.value) return
|
||||
if (realtimeChangeDebounceTimer) return
|
||||
|
||||
// Debounce noisy db_storage change events to avoid hammering the backend.
|
||||
realtimeChangeDebounceTimer = setTimeout(() => {
|
||||
realtimeChangeDebounceTimer = null
|
||||
queueRealtimeRefresh()
|
||||
queueRealtimeSessionsRefresh()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const startRealtimeStream = () => {
|
||||
stopRealtimeStream()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!realtimeEnabled.value) return
|
||||
if (!selectedAccount.value) return
|
||||
if (typeof EventSource === 'undefined') return
|
||||
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(String(selectedAccount.value))}`
|
||||
try {
|
||||
realtimeEventSource = new EventSource(url)
|
||||
} catch (e) {
|
||||
realtimeEventSource = null
|
||||
return
|
||||
}
|
||||
|
||||
realtimeEventSource.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data || '{}'))
|
||||
if (String(data?.type || '') === 'change') {
|
||||
queueRealtimeChange()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
realtimeEventSource.onerror = () => {
|
||||
stopRealtimeStream()
|
||||
}
|
||||
}
|
||||
|
||||
const toggleRealtime = async (opts = {}) => {
|
||||
const silent = !!opts?.silent
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!selectedAccount.value) return
|
||||
|
||||
if (!realtimeEnabled.value) {
|
||||
await fetchRealtimeStatus()
|
||||
if (!realtimeAvailable.value) {
|
||||
if (!silent) {
|
||||
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
|
||||
}
|
||||
return false
|
||||
}
|
||||
realtimeEnabled.value = true
|
||||
startRealtimeStream()
|
||||
queueRealtimeSessionsRefresh()
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first,
|
||||
// otherwise the UI will fall back to an outdated decrypted snapshot.
|
||||
realtimeEnabled.value = false
|
||||
stopRealtimeStream()
|
||||
try {
|
||||
const api = useApi()
|
||||
const u = String(selectedContact.value?.username || '').trim()
|
||||
// Sync all sessions once before falling back to the decrypted snapshot.
|
||||
// This keeps the sidebar session list consistent (e.g. new friends) after a refresh.
|
||||
await api.syncChatRealtimeAll({
|
||||
account: selectedAccount.value,
|
||||
max_scan: 200,
|
||||
priority_username: u,
|
||||
priority_max_scan: 5000,
|
||||
include_hidden: true,
|
||||
include_official: true
|
||||
})
|
||||
} catch {}
|
||||
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const tryEnableRealtimeAuto = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!isDesktopEnv.value) return
|
||||
if (!desktopAutoRealtime.value) return
|
||||
if (realtimeEnabled.value) return
|
||||
if (!selectedAccount.value) return
|
||||
|
||||
try {
|
||||
await toggleRealtime({ silent: true })
|
||||
await realtimeStore.enable({ silent: true })
|
||||
} catch {}
|
||||
}
|
||||
|
||||
watch(selectedAccount, async () => {
|
||||
await fetchRealtimeStatus()
|
||||
if (realtimeEnabled.value) {
|
||||
startRealtimeStream()
|
||||
watch(realtimeChangeSeq, () => {
|
||||
queueRealtimeRefresh()
|
||||
queueRealtimeSessionsRefresh()
|
||||
})
|
||||
|
||||
watch(realtimeToggleSeq, async () => {
|
||||
const action = String(realtimeLastToggleAction.value || '')
|
||||
if (action === 'enabled') {
|
||||
await refreshSessionsForSelectedAccount({ sourceOverride: 'realtime' })
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (action === 'disabled') {
|
||||
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
|
||||
if (selectedContact.value?.username) {
|
||||
await refreshSelectedMessages()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => selectedContact.value?.username,
|
||||
(u) => {
|
||||
realtimeStore.setPriorityUsername(u || '')
|
||||
}
|
||||
)
|
||||
|
||||
watch(messageTypeFilter, async (next, prev) => {
|
||||
if (String(next || '') === String(prev || '')) return
|
||||
if (!selectedContact.value?.username) return
|
||||
|
||||
+10
-134
@@ -1,80 +1,6 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold" style="background-color: #4B5563">我</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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]'">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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]'">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
|
||||
<line x1="9.69" y1="8" x2="21.17" y2="8" />
|
||||
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
|
||||
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
|
||||
<line x1="14.31" y1="16" x2="2.83" y2="16" />
|
||||
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" 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]">
|
||||
<div class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)] text-[#07b75b]">
|
||||
<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" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="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]'">
|
||||
<svg class="w-full h-full" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 16v-5" />
|
||||
<path d="M12 16v-8" />
|
||||
<path d="M16 16v-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group" @click="privacyMode = !privacyMode" :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">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<DesktopTitleBar />
|
||||
<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">
|
||||
@@ -198,34 +124,20 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
useHead({ title: '联系人 - 微信数据分析助手' })
|
||||
|
||||
const route = useRoute()
|
||||
const api = useApi()
|
||||
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { accounts: availableAccounts, selectedAccount } = storeToRefs(chatAccounts)
|
||||
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
const privacyMode = ref(false)
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
|
||||
} catch {}
|
||||
})
|
||||
watch(() => privacyMode.value, (v) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
|
||||
} catch {}
|
||||
})
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
|
||||
|
||||
const availableAccounts = ref([])
|
||||
const selectedAccount = ref(null)
|
||||
const searchKeyword = ref('')
|
||||
|
||||
const contactTypes = reactive({
|
||||
@@ -258,12 +170,6 @@ const exporting = ref(false)
|
||||
const exportMsg = ref('')
|
||||
const exportOk = ref(false)
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const typeLabel = (type) => {
|
||||
if (type === 'friend') return '好友'
|
||||
if (type === 'group') return '群聊'
|
||||
@@ -278,18 +184,6 @@ const typeBadgeClass = (type) => {
|
||||
return 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
const isDesktopExportRuntime = () => {
|
||||
return !!(process.client && window?.wechatDesktop?.chooseDirectory)
|
||||
}
|
||||
@@ -430,15 +324,7 @@ const exportContactsInWeb = async () => {
|
||||
}
|
||||
|
||||
const loadAccounts = async () => {
|
||||
try {
|
||||
const resp = await api.listChatAccounts()
|
||||
const accounts = resp?.accounts || []
|
||||
availableAccounts.value = accounts
|
||||
selectedAccount.value = selectedAccount.value || resp?.default_account || accounts[0] || null
|
||||
} catch (e) {
|
||||
availableAccounts.value = []
|
||||
selectedAccount.value = null
|
||||
}
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
}
|
||||
|
||||
const loadContacts = async () => {
|
||||
@@ -555,18 +441,8 @@ const startExport = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
privacyStore.init()
|
||||
await loadAccounts()
|
||||
await loadContacts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.privacy-blur {
|
||||
filter: blur(9px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.privacy-blur:hover {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -68,17 +68,12 @@
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useApi } from '~/composables/useApi'
|
||||
|
||||
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
|
||||
onMounted(async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop) return
|
||||
|
||||
let enabled = false
|
||||
try {
|
||||
enabled = localStorage.getItem(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY) === 'true'
|
||||
} catch {}
|
||||
const enabled = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
|
||||
if (!enabled) return
|
||||
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
<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="flex-1 min-h-0 overflow-auto p-6">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-gray-200 bg-[#F7F7F7]">
|
||||
<div class="text-lg font-semibold text-gray-900">设置</div>
|
||||
<div class="text-sm text-gray-500 mt-1">桌面端相关行为与启动偏好</div>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-5">
|
||||
<div v-if="!isDesktopEnv" class="rounded-md border border-amber-200 bg-amber-50 text-amber-900 px-3 py-2 text-xs leading-5">
|
||||
当前为浏览器环境:“桌面行为”分组仅桌面端可用;“启动偏好”分组可正常使用。
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">桌面行为</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">开机自启动</div>
|
||||
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoLaunch"
|
||||
:disabled="!isDesktopEnv || desktopAutoLaunchLoading"
|
||||
@change="onDesktopAutoLaunchToggle"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopAutoLaunchError }}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
|
||||
<div class="text-xs text-gray-500">点击关闭按钮时:默认最小化到托盘</div>
|
||||
</div>
|
||||
<select
|
||||
class="text-sm px-2 py-1 rounded-md border border-gray-200 bg-white"
|
||||
:disabled="!isDesktopEnv || desktopCloseBehaviorLoading"
|
||||
:value="desktopCloseBehavior"
|
||||
@change="onDesktopCloseBehaviorChange"
|
||||
>
|
||||
<option value="tray">最小化到托盘</option>
|
||||
<option value="exit">直接退出</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
|
||||
{{ desktopCloseBehaviorError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-gray-200">
|
||||
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
|
||||
<div class="text-sm font-medium text-gray-900">启动偏好</div>
|
||||
</div>
|
||||
<div class="px-4 py-3 space-y-4">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
|
||||
<div class="text-xs text-gray-500">进入聊天页后自动打开“实时开关”(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopAutoRealtime"
|
||||
@change="onDesktopAutoRealtimeToggle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
|
||||
<div class="text-xs text-gray-500">有已解密账号时,打开应用默认跳转到 /chat(默认关闭)</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4"
|
||||
:checked="desktopDefaultToChatWhenData"
|
||||
@change="onDesktopDefaultToChatToggle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
|
||||
|
||||
useHead({ title: '设置 - 微信数据分析助手' })
|
||||
|
||||
const isDesktopEnv = ref(false)
|
||||
|
||||
const desktopAutoRealtime = ref(false)
|
||||
const desktopDefaultToChatWhenData = ref(false)
|
||||
|
||||
const desktopAutoLaunch = ref(false)
|
||||
const desktopAutoLaunchLoading = ref(false)
|
||||
const desktopAutoLaunchError = ref('')
|
||||
|
||||
const desktopCloseBehavior = ref('tray')
|
||||
const desktopCloseBehaviorLoading = ref(false)
|
||||
const desktopCloseBehaviorError = ref('')
|
||||
|
||||
const refreshDesktopAutoLaunch = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopAutoLaunch = async (enabled) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setAutoLaunch) return
|
||||
desktopAutoLaunchLoading.value = true
|
||||
desktopAutoLaunchError.value = ''
|
||||
try {
|
||||
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
|
||||
} catch (e) {
|
||||
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
|
||||
await refreshDesktopAutoLaunch()
|
||||
} finally {
|
||||
desktopAutoLaunchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshDesktopCloseBehavior = async () => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.getCloseBehavior) return
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.getCloseBehavior()
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setDesktopCloseBehavior = async (behavior) => {
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!window.wechatDesktop?.setCloseBehavior) return
|
||||
const desired = String(behavior || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
desktopCloseBehaviorLoading.value = true
|
||||
desktopCloseBehaviorError.value = ''
|
||||
try {
|
||||
const v = await window.wechatDesktop.setCloseBehavior(desired)
|
||||
desktopCloseBehavior.value = String(v || '').toLowerCase() === 'exit' ? 'exit' : 'tray'
|
||||
} catch (e) {
|
||||
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
|
||||
await refreshDesktopCloseBehavior()
|
||||
} finally {
|
||||
desktopCloseBehaviorLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDesktopAutoLaunchToggle = async (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
await setDesktopAutoLaunch(checked)
|
||||
}
|
||||
|
||||
const onDesktopCloseBehaviorChange = async (ev) => {
|
||||
const v = String(ev?.target?.value || '').trim()
|
||||
await setDesktopCloseBehavior(v)
|
||||
}
|
||||
|
||||
const onDesktopAutoRealtimeToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopAutoRealtime.value = checked
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
|
||||
}
|
||||
|
||||
const onDesktopDefaultToChatToggle = (ev) => {
|
||||
const checked = !!ev?.target?.checked
|
||||
desktopDefaultToChatWhenData.value = checked
|
||||
writeLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (process.client && typeof window !== 'undefined') {
|
||||
isDesktopEnv.value = !!window.wechatDesktop
|
||||
}
|
||||
|
||||
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
|
||||
desktopDefaultToChatWhenData.value = readLocalBoolSetting(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, false)
|
||||
|
||||
if (isDesktopEnv.value) {
|
||||
await refreshDesktopAutoLaunch()
|
||||
await refreshDesktopCloseBehavior()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
+14
-212
@@ -1,146 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- 头像(类似微信侧边栏) -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
<div class="w-[40px] h-[40px] rounded-md overflow-hidden bg-gray-300 flex-shrink-0">
|
||||
<img v-if="selfAvatarUrl" :src="selfAvatarUrl" alt="avatar" class="w-full h-full object-cover" />
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
|
||||
style="background-color: #4B5563"
|
||||
>
|
||||
我
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 聊天图标 -->
|
||||
<div
|
||||
class="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]'">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 朋友圈图标(Aperture 风格) -->
|
||||
<div
|
||||
class="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]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="14.31" y1="8" x2="20.05" y2="17.94" />
|
||||
<line x1="9.69" y1="8" x2="21.17" y2="8" />
|
||||
<line x1="7.38" y1="12" x2="13.12" y2="2.06" />
|
||||
<line x1="9.69" y1="16" x2="3.95" y2="6.06" />
|
||||
<line x1="14.31" y1="16" x2="2.83" y2="16" />
|
||||
<line x1="16.62" y1="12" x2="10.88" y2="21.94" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 联系人图标 -->
|
||||
<div
|
||||
class="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]'">
|
||||
<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" />
|
||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
|
||||
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 年度总结图标 -->
|
||||
<div
|
||||
class="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]'">
|
||||
<svg
|
||||
class="w-full h-full"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<rect x="4" y="4" width="16" height="16" rx="2" />
|
||||
<path d="M8 16v-5" />
|
||||
<path d="M12 16v-8" />
|
||||
<path d="M16 16v-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
: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">
|
||||
<path v-if="privacyMode" stroke-linecap="round" stroke-linejoin="round" d="M3.98 8.223A10.477 10.477 0 001.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.45 10.45 0 0112 4.5c4.756 0 8.773 3.162 10.065 7.498a10.523 10.523 0 01-4.293 5.774M6.228 6.228L3 3m3.228 3.228l3.65 3.65m7.894 7.894L21 21m-3.228-3.228l-3.65-3.65m0 0a3 3 0 10-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" d="M2.036 12.322a1.012 1.012 0 010-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178z" />
|
||||
<circle v-if="!privacyMode" cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧朋友圈区域 -->
|
||||
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
|
||||
<!-- 桌面端标题栏放在内容区(与聊天页一致) -->
|
||||
<DesktopTitleBar />
|
||||
|
||||
<div class="flex-1 overflow-auto min-h-0">
|
||||
<div class="max-w-2xl mx-auto px-4 py-4">
|
||||
<div v-if="error" class="text-sm text-red-500 whitespace-pre-wrap py-2">{{ error }}</div>
|
||||
@@ -425,39 +286,19 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
import { usePrivacyStore } from '~/stores/privacy'
|
||||
|
||||
useHead({ title: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const isChatRoute = computed(() => route.path?.startsWith('/chat'))
|
||||
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
|
||||
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
|
||||
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
|
||||
|
||||
// 隐私模式(聊天/朋友圈共用本地开关)
|
||||
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
const privacyMode = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
|
||||
} catch {}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => privacyMode.value,
|
||||
(v) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
)
|
||||
|
||||
const api = useApi()
|
||||
const selectedAccount = ref(null)
|
||||
const availableAccounts = ref([])
|
||||
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
|
||||
|
||||
const privacyStore = usePrivacyStore()
|
||||
const { privacyMode } = storeToRefs(privacyStore)
|
||||
|
||||
const posts = ref([])
|
||||
const hasMore = ref(true)
|
||||
@@ -665,12 +506,6 @@ const onCopyPostJsonClick = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const selfAvatarUrl = computed(() => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
if (!acc) return ''
|
||||
return `${mediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
|
||||
})
|
||||
|
||||
const postAvatarUrl = (username) => {
|
||||
const acc = String(selectedAccount.value || '').trim()
|
||||
const u = String(username || '').trim()
|
||||
@@ -1026,15 +861,9 @@ const formatRelativeTime = (tsSeconds) => {
|
||||
|
||||
const loadAccounts = async () => {
|
||||
error.value = ''
|
||||
try {
|
||||
const resp = await api.listChatAccounts()
|
||||
const accounts = resp?.accounts || []
|
||||
availableAccounts.value = accounts
|
||||
selectedAccount.value = selectedAccount.value || resp?.default_account || accounts[0] || null
|
||||
} catch (e) {
|
||||
error.value = e?.message || '加载账号失败'
|
||||
availableAccounts.value = []
|
||||
selectedAccount.value = null
|
||||
await chatAccounts.ensureLoaded({ force: true })
|
||||
if (!selectedAccount.value) {
|
||||
error.value = chatAccounts.error || '未检测到已解密账号,请先解密数据库。'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,22 +893,6 @@ const loadPosts = async ({ reset }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const goChat = async () => {
|
||||
await navigateTo('/chat')
|
||||
}
|
||||
|
||||
const goSns = async () => {
|
||||
await navigateTo('/sns')
|
||||
}
|
||||
|
||||
const goContacts = async () => {
|
||||
await navigateTo('/contacts')
|
||||
}
|
||||
|
||||
const goWrapped = async () => {
|
||||
await navigateTo('/wrapped')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => selectedAccount.value,
|
||||
async (v, oldV) => {
|
||||
@@ -1104,6 +917,7 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
privacyStore.init()
|
||||
await loadAccounts()
|
||||
loadSnsMediaOverrides()
|
||||
loadSnsSettings()
|
||||
@@ -1134,15 +948,3 @@ onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onGlobalKeyDown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 隐私模式模糊效果 */
|
||||
.privacy-blur {
|
||||
filter: blur(9px);
|
||||
transition: filter 0.2s ease;
|
||||
}
|
||||
|
||||
.privacy-blur:hover {
|
||||
filter: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const SELECTED_ACCOUNT_KEY = 'ui.selected_account'
|
||||
|
||||
export const useChatAccountsStore = defineStore('chatAccounts', () => {
|
||||
const accounts = ref([])
|
||||
const selectedAccount = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const loaded = ref(false)
|
||||
|
||||
let loadPromise = null
|
||||
|
||||
const readSelectedAccount = () => {
|
||||
if (!process.client) return null
|
||||
try {
|
||||
const raw = localStorage.getItem(SELECTED_ACCOUNT_KEY)
|
||||
const v = String(raw || '').trim()
|
||||
return v || null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const writeSelectedAccount = (value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
const v = String(value || '').trim()
|
||||
if (!v) {
|
||||
localStorage.removeItem(SELECTED_ACCOUNT_KEY)
|
||||
return
|
||||
}
|
||||
localStorage.setItem(SELECTED_ACCOUNT_KEY, v)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const setSelectedAccount = (next) => {
|
||||
selectedAccount.value = next ? String(next) : null
|
||||
writeSelectedAccount(selectedAccount.value)
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
watch(selectedAccount, (next) => {
|
||||
writeSelectedAccount(next)
|
||||
})
|
||||
}
|
||||
|
||||
const ensureLoaded = async ({ force = false } = {}) => {
|
||||
if (!process.client) return
|
||||
if (loaded.value && !force) return
|
||||
|
||||
if (loadPromise && !force) {
|
||||
await loadPromise
|
||||
return
|
||||
}
|
||||
|
||||
loadPromise = (async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
if (!selectedAccount.value) {
|
||||
const cached = readSelectedAccount()
|
||||
if (cached) selectedAccount.value = cached
|
||||
}
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
const resp = await api.listChatAccounts()
|
||||
const nextAccounts = Array.isArray(resp?.accounts) ? resp.accounts : []
|
||||
accounts.value = nextAccounts
|
||||
|
||||
const preferred = String(selectedAccount.value || '').trim()
|
||||
const defaultAccount = String(resp?.default_account || '').trim()
|
||||
const fallback = defaultAccount || nextAccounts[0] || ''
|
||||
const nextSelected = preferred && nextAccounts.includes(preferred) ? preferred : (fallback || null)
|
||||
|
||||
selectedAccount.value = nextSelected
|
||||
writeSelectedAccount(nextSelected)
|
||||
loaded.value = true
|
||||
} catch (e) {
|
||||
accounts.value = []
|
||||
selectedAccount.value = null
|
||||
writeSelectedAccount(null)
|
||||
loaded.value = true
|
||||
error.value = e?.message || '加载账号失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})()
|
||||
|
||||
try {
|
||||
await loadPromise
|
||||
} finally {
|
||||
loadPromise = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
selectedAccount,
|
||||
loading,
|
||||
error,
|
||||
loaded,
|
||||
ensureLoaded,
|
||||
setSelectedAccount,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,226 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useChatAccountsStore } from '~/stores/chatAccounts'
|
||||
|
||||
export const useChatRealtimeStore = defineStore('chatRealtime', () => {
|
||||
const chatAccounts = useChatAccountsStore()
|
||||
|
||||
const enabled = ref(false)
|
||||
const available = ref(false)
|
||||
const checking = ref(false)
|
||||
const statusInfo = ref(null)
|
||||
const statusError = ref('')
|
||||
const toggling = ref(false)
|
||||
const toggleSeq = ref(0)
|
||||
const lastToggleAction = ref('')
|
||||
const changeSeq = ref(0)
|
||||
const priorityUsername = ref('')
|
||||
|
||||
let eventSource = null
|
||||
let changeDebounceTimer = null
|
||||
|
||||
const getAccount = () => String(chatAccounts.selectedAccount || '').trim()
|
||||
|
||||
const setPriorityUsername = (username) => {
|
||||
priorityUsername.value = String(username || '').trim()
|
||||
}
|
||||
|
||||
const ensureReadyAccount = async () => {
|
||||
if (!process.client) return false
|
||||
await chatAccounts.ensureLoaded()
|
||||
return !!getAccount()
|
||||
}
|
||||
|
||||
const fetchStatus = async () => {
|
||||
if (!process.client) return
|
||||
const account = getAccount()
|
||||
if (!account) {
|
||||
available.value = false
|
||||
statusInfo.value = null
|
||||
statusError.value = '未检测到已解密账号,请先解密数据库。'
|
||||
return
|
||||
}
|
||||
|
||||
const api = useApi()
|
||||
checking.value = true
|
||||
statusError.value = ''
|
||||
try {
|
||||
const resp = await api.getChatRealtimeStatus({ account })
|
||||
available.value = !!resp?.available
|
||||
statusInfo.value = resp?.realtime || null
|
||||
statusError.value = ''
|
||||
} catch (e) {
|
||||
available.value = false
|
||||
statusInfo.value = null
|
||||
statusError.value = e?.message || '实时状态获取失败'
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const stopStream = () => {
|
||||
if (eventSource) {
|
||||
try {
|
||||
eventSource.close()
|
||||
} catch {}
|
||||
eventSource = null
|
||||
}
|
||||
if (changeDebounceTimer) {
|
||||
try {
|
||||
clearTimeout(changeDebounceTimer)
|
||||
} catch {}
|
||||
changeDebounceTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const bumpChangeSeqDebounced = () => {
|
||||
if (changeDebounceTimer) return
|
||||
changeDebounceTimer = setTimeout(() => {
|
||||
changeDebounceTimer = null
|
||||
changeSeq.value += 1
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const startStream = () => {
|
||||
stopStream()
|
||||
if (!process.client || typeof window === 'undefined') return
|
||||
if (!enabled.value) return
|
||||
const account = getAccount()
|
||||
if (!account) return
|
||||
if (typeof EventSource === 'undefined') return
|
||||
|
||||
const base = 'http://localhost:8000'
|
||||
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(account)}`
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(url)
|
||||
} catch {
|
||||
eventSource = null
|
||||
return
|
||||
}
|
||||
|
||||
eventSource.onmessage = (ev) => {
|
||||
try {
|
||||
const data = JSON.parse(String(ev.data || '{}'))
|
||||
if (String(data?.type || '') === 'change') {
|
||||
bumpChangeSeqDebounced()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// Keep `enabled` as-is; same behavior as the old in-page implementation.
|
||||
stopStream()
|
||||
}
|
||||
}
|
||||
|
||||
const enable = async ({ silent = false } = {}) => {
|
||||
if (toggling.value) return false
|
||||
toggling.value = true
|
||||
try {
|
||||
const ok = await ensureReadyAccount()
|
||||
if (!ok) {
|
||||
if (!silent && process.client && typeof window !== 'undefined') {
|
||||
window.alert('未检测到已解密账号,请先解密数据库。')
|
||||
}
|
||||
statusError.value = '未检测到已解密账号,请先解密数据库。'
|
||||
return false
|
||||
}
|
||||
|
||||
await fetchStatus()
|
||||
if (!available.value) {
|
||||
if (!silent && process.client && typeof window !== 'undefined') {
|
||||
window.alert(statusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
enabled.value = true
|
||||
startStream()
|
||||
lastToggleAction.value = 'enabled'
|
||||
toggleSeq.value += 1
|
||||
return true
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const disable = async ({ silent = false } = {}) => {
|
||||
if (toggling.value) return false
|
||||
toggling.value = true
|
||||
try {
|
||||
const account = getAccount()
|
||||
enabled.value = false
|
||||
stopStream()
|
||||
|
||||
if (!account) {
|
||||
lastToggleAction.value = 'disabled'
|
||||
toggleSeq.value += 1
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
const api = useApi()
|
||||
await api.syncChatRealtimeAll({
|
||||
account,
|
||||
max_scan: 200,
|
||||
priority_username: priorityUsername.value || '',
|
||||
priority_max_scan: 5000,
|
||||
include_hidden: true,
|
||||
include_official: true,
|
||||
})
|
||||
} catch (e) {
|
||||
if (!silent && process.client && typeof window !== 'undefined') {
|
||||
window.alert(e?.message || '关闭实时模式时同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
lastToggleAction.value = 'disabled'
|
||||
toggleSeq.value += 1
|
||||
return true
|
||||
} finally {
|
||||
toggling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const toggle = async (opts = {}) => {
|
||||
return enabled.value ? await disable(opts) : await enable(opts)
|
||||
}
|
||||
|
||||
if (process.client) {
|
||||
watch(
|
||||
() => chatAccounts.selectedAccount,
|
||||
async () => {
|
||||
setPriorityUsername('')
|
||||
await fetchStatus()
|
||||
if (enabled.value) {
|
||||
startStream()
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
available,
|
||||
checking,
|
||||
statusInfo,
|
||||
statusError,
|
||||
toggling,
|
||||
toggleSeq,
|
||||
lastToggleAction,
|
||||
changeSeq,
|
||||
priorityUsername,
|
||||
|
||||
setPriorityUsername,
|
||||
ensureReadyAccount,
|
||||
fetchStatus,
|
||||
startStream,
|
||||
stopStream,
|
||||
enable,
|
||||
disable,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { readPrivacyMode, writePrivacyMode } from '~/utils/privacy-mode'
|
||||
|
||||
export const usePrivacyStore = defineStore('privacy', () => {
|
||||
const privacyMode = ref(false)
|
||||
const initialized = ref(false)
|
||||
|
||||
const init = () => {
|
||||
if (initialized.value) return
|
||||
initialized.value = true
|
||||
privacyMode.value = readPrivacyMode(false)
|
||||
}
|
||||
|
||||
const set = (enabled) => {
|
||||
privacyMode.value = !!enabled
|
||||
writePrivacyMode(privacyMode.value)
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
set(!privacyMode.value)
|
||||
}
|
||||
|
||||
return {
|
||||
privacyMode,
|
||||
init,
|
||||
set,
|
||||
toggle,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
|
||||
export const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
|
||||
|
||||
export const readLocalBoolSetting = (key, fallback = false) => {
|
||||
if (!process.client) return !!fallback
|
||||
try {
|
||||
const raw = localStorage.getItem(String(key || ''))
|
||||
if (raw == null) return !!fallback
|
||||
return String(raw).toLowerCase() === 'true'
|
||||
} catch {
|
||||
return !!fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const writeLocalBoolSetting = (key, value) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(String(key || ''), value ? 'true' : 'false')
|
||||
} catch {}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export const PRIVACY_MODE_KEY = 'ui.privacy_mode'
|
||||
|
||||
export const readPrivacyMode = (fallback = false) => {
|
||||
if (!process.client) return !!fallback
|
||||
try {
|
||||
const raw = localStorage.getItem(PRIVACY_MODE_KEY)
|
||||
if (raw == null) return !!fallback
|
||||
const normalized = String(raw).trim().toLowerCase()
|
||||
return normalized === '1' || normalized === 'true'
|
||||
} catch {
|
||||
return !!fallback
|
||||
}
|
||||
}
|
||||
|
||||
export const writePrivacyMode = (enabled) => {
|
||||
if (!process.client) return
|
||||
try {
|
||||
localStorage.setItem(PRIVACY_MODE_KEY, enabled ? '1' : '0')
|
||||
} catch {}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ from .chat_helpers import (
|
||||
_load_contact_rows,
|
||||
_lookup_resource_md5,
|
||||
_parse_app_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
_quote_ident,
|
||||
@@ -954,13 +955,7 @@ def _parse_message_for_export(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
import re as _re
|
||||
|
||||
content_text = _re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = _re.sub(r"\\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
|
||||
@@ -645,6 +645,43 @@ def _extract_xml_tag_or_attr(xml_text: str, name: str) -> str:
|
||||
return _extract_xml_attr(xml_text, name)
|
||||
|
||||
|
||||
def _parse_system_message_content(raw_text: str) -> str:
|
||||
text = str(raw_text or "").strip()
|
||||
if not text:
|
||||
return "[系统消息]"
|
||||
|
||||
def _clean_system_text(value: str) -> str:
|
||||
candidate = str(value or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
nested_content = _extract_xml_tag_text(candidate, "content")
|
||||
if nested_content:
|
||||
candidate = nested_content
|
||||
|
||||
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
|
||||
|
||||
if "revokemsg" in text.lower():
|
||||
replace_msg = _extract_xml_tag_text(text, "replacemsg")
|
||||
cleaned_replace_msg = _clean_system_text(replace_msg)
|
||||
if cleaned_replace_msg:
|
||||
return cleaned_replace_msg
|
||||
|
||||
revoke_msg = _extract_xml_tag_text(text, "revokemsg")
|
||||
cleaned_revoke_msg = _clean_system_text(revoke_msg)
|
||||
if cleaned_revoke_msg:
|
||||
return cleaned_revoke_msg
|
||||
|
||||
return "撤回了一条消息"
|
||||
|
||||
content_text = _clean_system_text(text)
|
||||
return content_text or "[系统消息]"
|
||||
|
||||
|
||||
def _extract_refermsg_block(xml_text: str) -> str:
|
||||
if not xml_text:
|
||||
return ""
|
||||
@@ -1053,11 +1090,7 @@ def _build_latest_message_preview(
|
||||
|
||||
content_text = ""
|
||||
if local_type == 10000:
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 244813135921:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
qt = str(parsed.get("quoteTitle") or "").strip()
|
||||
@@ -1093,7 +1126,7 @@ def _build_latest_message_preview(
|
||||
elif local_type == 43 or local_type == 62:
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
content_text = "[表情]"
|
||||
content_text = "[动画表情]"
|
||||
else:
|
||||
if raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
|
||||
content_text = raw_text
|
||||
@@ -1107,6 +1140,101 @@ def _build_latest_message_preview(
|
||||
return content_text
|
||||
|
||||
|
||||
def _extract_group_preview_sender_username(preview_text: str) -> str:
|
||||
text = str(preview_text or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
match = re.match(r"^([^:\s]{1,128}):\s*.+$", text)
|
||||
if not match:
|
||||
return ""
|
||||
|
||||
sender = str(match.group(1) or "").strip()
|
||||
if not sender:
|
||||
return ""
|
||||
|
||||
if sender.startswith("wxid_") or sender.endswith("@chatroom") or ("@" in sender):
|
||||
return sender
|
||||
if re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{1,127}", sender):
|
||||
return sender
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_session_preview_text(
|
||||
preview_text: str,
|
||||
*,
|
||||
is_group: bool,
|
||||
sender_display_names: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
text = re.sub(r"\s+", " ", str(preview_text or "").strip()).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = text.replace("[表情]", "[动画表情]")
|
||||
if (not is_group) or text.startswith("[草稿]"):
|
||||
return text
|
||||
|
||||
match = re.match(r"^([^:\s]{1,128}):\s*(.+)$", text)
|
||||
if not match:
|
||||
return text
|
||||
|
||||
sender_username = str(match.group(1) or "").strip()
|
||||
body = str(match.group(2) or "").strip()
|
||||
if (not sender_username) or (not body):
|
||||
return text
|
||||
|
||||
display_name = str((sender_display_names or {}).get(sender_username) or "").strip()
|
||||
if display_name and display_name != sender_username:
|
||||
return f"{display_name}: {body}"
|
||||
return text
|
||||
|
||||
|
||||
def _replace_preview_sender_prefix(preview_text: str, sender_display_name: str) -> str:
|
||||
text = re.sub(r"\s+", " ", str(preview_text or "").strip()).strip()
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
display_name = str(sender_display_name or "").strip()
|
||||
if (not display_name) or text.startswith("[草稿]"):
|
||||
return text
|
||||
|
||||
match = re.match(r"^([^:\n]{1,128}):\s*(.+)$", text)
|
||||
if not match:
|
||||
return text
|
||||
|
||||
body = re.sub(r"\s+", " ", str(match.group(2) or "").strip()).strip()
|
||||
if not body:
|
||||
return text
|
||||
return f"{display_name}: {body}"
|
||||
|
||||
|
||||
def _build_group_sender_display_name_map(
|
||||
contact_db_path: Path,
|
||||
previews: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
group_sender_usernames: set[str] = set()
|
||||
for conv_username, preview_text in previews.items():
|
||||
if not str(conv_username or "").endswith("@chatroom"):
|
||||
continue
|
||||
sender_username = _extract_group_preview_sender_username(preview_text)
|
||||
if sender_username:
|
||||
group_sender_usernames.add(sender_username)
|
||||
|
||||
if not group_sender_usernames:
|
||||
return {}
|
||||
|
||||
display_names: dict[str, str] = {}
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, list(group_sender_usernames))
|
||||
for sender_username in group_sender_usernames:
|
||||
row = sender_contact_rows.get(sender_username)
|
||||
if row is None:
|
||||
continue
|
||||
display_name = _pick_display_name(row, sender_username)
|
||||
if display_name and display_name != sender_username:
|
||||
display_names[sender_username] = display_name
|
||||
return display_names
|
||||
|
||||
|
||||
def _load_latest_message_previews(account_dir: Path, usernames: list[str]) -> dict[str, str]:
|
||||
if not usernames:
|
||||
return {}
|
||||
@@ -1338,6 +1466,208 @@ def _load_contact_rows(contact_db_path: Path, usernames: list[str]) -> dict[str,
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_group_nickname_map_from_contact_db(
|
||||
contact_db_path: Path,
|
||||
chatroom_id: str,
|
||||
sender_usernames: list[str],
|
||||
) -> dict[str, str]:
|
||||
"""Best-effort mapping for group member nickname (aka group card) from contact.db.
|
||||
|
||||
WeChat stores per-chatroom member nicknames in `contact.db.chat_room.ext_buffer` as a protobuf-like blob.
|
||||
This helper parses that blob and returns { sender_username -> group_nickname } for the requested senders.
|
||||
|
||||
Notes:
|
||||
- Best-effort: never raises; returns {} on any failure.
|
||||
- Only resolves usernames included in `sender_usernames` to keep parsing cheap.
|
||||
"""
|
||||
|
||||
chatroom = str(chatroom_id or "").strip()
|
||||
if not chatroom.endswith("@chatroom"):
|
||||
return {}
|
||||
|
||||
targets = list(dict.fromkeys([str(x or "").strip() for x in sender_usernames if str(x or "").strip()]))
|
||||
if not targets:
|
||||
return {}
|
||||
target_set = set(targets)
|
||||
|
||||
def decode_varint(raw: bytes, offset: int) -> tuple[Optional[int], int]:
|
||||
value = 0
|
||||
shift = 0
|
||||
pos = int(offset)
|
||||
n = len(raw)
|
||||
while pos < n:
|
||||
byte = raw[pos]
|
||||
pos += 1
|
||||
value |= (byte & 0x7F) << shift
|
||||
if (byte & 0x80) == 0:
|
||||
return value, pos
|
||||
shift += 7
|
||||
if shift > 63:
|
||||
return None, n
|
||||
return None, n
|
||||
|
||||
def iter_fields(raw: bytes):
|
||||
idx = 0
|
||||
n = len(raw)
|
||||
while idx < n:
|
||||
tag, idx_next = decode_varint(raw, idx)
|
||||
if tag is None or idx_next <= idx:
|
||||
break
|
||||
idx = idx_next
|
||||
field_no = int(tag) >> 3
|
||||
wire_type = int(tag) & 0x7
|
||||
|
||||
if wire_type == 0:
|
||||
_, idx_next = decode_varint(raw, idx)
|
||||
if idx_next <= idx:
|
||||
break
|
||||
idx = idx_next
|
||||
continue
|
||||
|
||||
if wire_type == 2:
|
||||
size, idx_next = decode_varint(raw, idx)
|
||||
if size is None or idx_next <= idx:
|
||||
break
|
||||
idx = idx_next
|
||||
end = idx + int(size)
|
||||
if end > n:
|
||||
break
|
||||
chunk = raw[idx:end]
|
||||
idx = end
|
||||
yield field_no, wire_type, chunk
|
||||
continue
|
||||
|
||||
if wire_type == 1:
|
||||
idx += 8
|
||||
continue
|
||||
if wire_type == 5:
|
||||
idx += 4
|
||||
continue
|
||||
break
|
||||
|
||||
def is_strong_username_hint(s: str) -> bool:
|
||||
v = str(s or "").strip()
|
||||
return v.startswith("wxid_") or v.endswith("@chatroom") or v.startswith("gh_") or ("@" in v)
|
||||
|
||||
def looks_like_username(s: str) -> bool:
|
||||
v = str(s or "").strip()
|
||||
if not v:
|
||||
return False
|
||||
if is_strong_username_hint(v):
|
||||
return True
|
||||
# Common alias-style WeChat IDs are ASCII-ish and do not contain whitespace.
|
||||
if len(v) < 6 or len(v) > 32:
|
||||
return False
|
||||
if re.search(r"\s", v):
|
||||
return False
|
||||
if not re.match(r"^[A-Za-z][A-Za-z0-9_-]+$", v):
|
||||
return False
|
||||
if v.isdigit():
|
||||
return False
|
||||
return True
|
||||
|
||||
def pick_display(strings: list[tuple[int, str]], target: str) -> str:
|
||||
best_score = -1
|
||||
best = ""
|
||||
for i, (fno, value) in enumerate(strings):
|
||||
v = str(value or "").strip()
|
||||
if (not v) or v == target:
|
||||
continue
|
||||
if is_strong_username_hint(v):
|
||||
continue
|
||||
if "\n" in v or "\r" in v:
|
||||
continue
|
||||
if len(v) > 64:
|
||||
continue
|
||||
|
||||
score = 0
|
||||
if int(fno) == 2:
|
||||
score += 100
|
||||
if not looks_like_username(v):
|
||||
score += 20
|
||||
score += max(0, 32 - len(v))
|
||||
# Stable tie-breaker: prefer earlier appearance.
|
||||
score = score * 1000 - i
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = v
|
||||
return best
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT ext_buffer FROM chat_room WHERE username = ? LIMIT 1",
|
||||
(chatroom,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return {}
|
||||
|
||||
ext = row[0]
|
||||
if ext is None:
|
||||
return {}
|
||||
if isinstance(ext, memoryview):
|
||||
ext_buf = ext.tobytes()
|
||||
elif isinstance(ext, (bytes, bytearray)):
|
||||
ext_buf = bytes(ext)
|
||||
else:
|
||||
return {}
|
||||
if not ext_buf:
|
||||
return {}
|
||||
|
||||
out: dict[str, str] = {}
|
||||
for _, wire_type, chunk in iter_fields(ext_buf):
|
||||
if wire_type != 2 or (not chunk):
|
||||
continue
|
||||
|
||||
# Parse submessage and collect UTF-8 strings.
|
||||
strings: list[tuple[int, str]] = []
|
||||
try:
|
||||
for sfno, swire, sval in iter_fields(chunk):
|
||||
if swire != 2:
|
||||
continue
|
||||
if not sval:
|
||||
continue
|
||||
if len(sval) > 256:
|
||||
continue
|
||||
try:
|
||||
txt = bytes(sval).decode("utf-8", errors="strict")
|
||||
except Exception:
|
||||
continue
|
||||
txt = txt.strip()
|
||||
if not txt:
|
||||
continue
|
||||
strings.append((int(sfno), txt))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if not strings:
|
||||
continue
|
||||
|
||||
present = [v for _, v in strings if v in target_set and v not in out]
|
||||
if not present:
|
||||
continue
|
||||
|
||||
for target in present:
|
||||
disp = pick_display(strings, target)
|
||||
if disp:
|
||||
out[target] = disp
|
||||
if len(out) >= len(target_set):
|
||||
break
|
||||
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _load_usernames_by_display_names(contact_db_path: Path, names: list[str]) -> dict[str, str]:
|
||||
"""Best-effort mapping from display name -> username using contact.db.
|
||||
|
||||
@@ -1515,11 +1845,7 @@ def _row_to_search_hit(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
|
||||
@@ -23,17 +23,17 @@ logger = get_logger(__name__)
|
||||
|
||||
|
||||
# 运行时输出目录(桌面端可通过 WECHAT_TOOL_DATA_DIR 指向可写目录)
|
||||
_OUTPUT_DATABASES_DIR = get_output_databases_dir()
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _list_decrypted_accounts() -> list[str]:
|
||||
"""列出已解密输出的账号目录名(仅保留包含 session.db + contact.db 的账号)"""
|
||||
if not _OUTPUT_DATABASES_DIR.exists():
|
||||
output_db_dir = get_output_databases_dir()
|
||||
if not output_db_dir.exists():
|
||||
return []
|
||||
|
||||
accounts: list[str] = []
|
||||
for p in _OUTPUT_DATABASES_DIR.iterdir():
|
||||
for p in output_db_dir.iterdir():
|
||||
if not p.is_dir():
|
||||
continue
|
||||
if (p / "session.db").exists() and (p / "contact.db").exists():
|
||||
@@ -45,6 +45,7 @@ def _list_decrypted_accounts() -> list[str]:
|
||||
|
||||
def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
"""解析账号目录,并进行路径安全校验(防止路径穿越)"""
|
||||
output_db_dir = get_output_databases_dir()
|
||||
accounts = _list_decrypted_accounts()
|
||||
if not accounts:
|
||||
raise HTTPException(
|
||||
@@ -53,8 +54,8 @@ def _resolve_account_dir(account: Optional[str]) -> Path:
|
||||
)
|
||||
|
||||
selected = account or accounts[0]
|
||||
base = _OUTPUT_DATABASES_DIR.resolve()
|
||||
candidate = (_OUTPUT_DATABASES_DIR / selected).resolve()
|
||||
base = output_db_dir.resolve()
|
||||
candidate = (output_db_dir / selected).resolve()
|
||||
|
||||
if candidate != base and base not in candidate.parents:
|
||||
raise HTTPException(status_code=400, detail="Invalid account path.")
|
||||
|
||||
@@ -39,11 +39,17 @@ from ..chat_helpers import (
|
||||
_make_snippet,
|
||||
_match_tokens,
|
||||
_load_contact_rows,
|
||||
_load_group_nickname_map_from_contact_db,
|
||||
_load_usernames_by_display_names,
|
||||
_load_latest_message_previews,
|
||||
_build_group_sender_display_name_map,
|
||||
_normalize_session_preview_text,
|
||||
_extract_group_preview_sender_username,
|
||||
_replace_preview_sender_prefix,
|
||||
_lookup_resource_md5,
|
||||
_normalize_xml_url,
|
||||
_parse_app_message,
|
||||
_parse_system_message_content,
|
||||
_parse_pat_message,
|
||||
_pick_display_name,
|
||||
_query_head_image_usernames,
|
||||
@@ -69,6 +75,8 @@ from ..wcdb_realtime import (
|
||||
WCDB_REALTIME,
|
||||
get_avatar_urls as _wcdb_get_avatar_urls,
|
||||
get_display_names as _wcdb_get_display_names,
|
||||
get_group_members as _wcdb_get_group_members,
|
||||
get_group_nicknames as _wcdb_get_group_nicknames,
|
||||
get_messages as _wcdb_get_messages,
|
||||
get_sessions as _wcdb_get_sessions,
|
||||
)
|
||||
@@ -97,6 +105,142 @@ def _avatar_url_unified(
|
||||
return _build_avatar_url(str(account_dir.name or ""), u)
|
||||
|
||||
|
||||
def _load_group_nickname_map_from_wcdb(
|
||||
*,
|
||||
account_dir: Path,
|
||||
chatroom_id: str,
|
||||
sender_usernames: list[str],
|
||||
rt_conn=None,
|
||||
) -> dict[str, str]:
|
||||
chatroom = str(chatroom_id or "").strip()
|
||||
if not chatroom.endswith("@chatroom"):
|
||||
return {}
|
||||
|
||||
targets = list(dict.fromkeys([str(x or "").strip() for x in sender_usernames if str(x or "").strip()]))
|
||||
if not targets:
|
||||
return {}
|
||||
|
||||
try:
|
||||
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
target_set = set(targets)
|
||||
out: dict[str, str] = {}
|
||||
|
||||
try:
|
||||
with wcdb_conn.lock:
|
||||
nickname_map = _wcdb_get_group_nicknames(wcdb_conn.handle, chatroom)
|
||||
for username, nickname in (nickname_map or {}).items():
|
||||
su = str(username or "").strip()
|
||||
nn = str(nickname or "").strip()
|
||||
if su and nn and su in target_set:
|
||||
out[su] = nn
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
unresolved = [u for u in targets if u not in out]
|
||||
if not unresolved:
|
||||
return out
|
||||
|
||||
try:
|
||||
with wcdb_conn.lock:
|
||||
members = _wcdb_get_group_members(wcdb_conn.handle, chatroom)
|
||||
except Exception:
|
||||
return out
|
||||
|
||||
if not members:
|
||||
return out
|
||||
|
||||
unresolved_set = set(unresolved)
|
||||
for member in members:
|
||||
try:
|
||||
username = str(member.get("username") or "").strip()
|
||||
except Exception:
|
||||
username = ""
|
||||
if (not username) or (username not in unresolved_set):
|
||||
continue
|
||||
|
||||
nickname = ""
|
||||
for key in ("nickname", "displayName", "remark", "originalName"):
|
||||
try:
|
||||
candidate = str(member.get(key) or "").strip()
|
||||
except Exception:
|
||||
candidate = ""
|
||||
if candidate:
|
||||
nickname = candidate
|
||||
break
|
||||
if nickname:
|
||||
out[username] = nickname
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _load_group_nickname_map(
|
||||
*,
|
||||
account_dir: Path,
|
||||
contact_db_path: Path,
|
||||
chatroom_id: str,
|
||||
sender_usernames: list[str],
|
||||
rt_conn=None,
|
||||
) -> dict[str, str]:
|
||||
"""Resolve group member nickname (group card) via WCDB and contact.db ext_buffer (best-effort)."""
|
||||
|
||||
contact_map: dict[str, str] = {}
|
||||
try:
|
||||
contact_map = _load_group_nickname_map_from_contact_db(
|
||||
contact_db_path,
|
||||
chatroom_id,
|
||||
sender_usernames,
|
||||
)
|
||||
except Exception:
|
||||
contact_map = {}
|
||||
|
||||
wcdb_map: dict[str, str] = {}
|
||||
try:
|
||||
wcdb_map = _load_group_nickname_map_from_wcdb(
|
||||
account_dir=account_dir,
|
||||
chatroom_id=chatroom_id,
|
||||
sender_usernames=sender_usernames,
|
||||
rt_conn=rt_conn,
|
||||
)
|
||||
except Exception:
|
||||
wcdb_map = {}
|
||||
|
||||
if not contact_map and not wcdb_map:
|
||||
return {}
|
||||
|
||||
# Merge: WCDB wins (newer DLLs may provide higher-quality group nicknames).
|
||||
merged: dict[str, str] = {}
|
||||
merged.update(contact_map)
|
||||
merged.update(wcdb_map)
|
||||
return merged
|
||||
|
||||
|
||||
def _resolve_sender_display_name(
|
||||
*,
|
||||
sender_username: str,
|
||||
sender_contact_rows: dict[str, sqlite3.Row],
|
||||
wcdb_display_names: dict[str, str],
|
||||
group_nicknames: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
su = str(sender_username or "").strip()
|
||||
if not su:
|
||||
return ""
|
||||
|
||||
gn = str((group_nicknames or {}).get(su) or "").strip()
|
||||
if gn:
|
||||
return gn
|
||||
|
||||
row = sender_contact_rows.get(su)
|
||||
display_name = _pick_display_name(row, su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
return display_name
|
||||
|
||||
|
||||
def _realtime_sync_lock(account: str, username: str) -> threading.Lock:
|
||||
key = (str(account or "").strip(), str(username or "").strip())
|
||||
with _REALTIME_SYNC_MU:
|
||||
@@ -557,8 +701,11 @@ def _upsert_session_table_rows(conn: sqlite3.Connection, rows: list[dict[str, An
|
||||
"draft",
|
||||
"last_timestamp",
|
||||
"sort_timestamp",
|
||||
"last_msg_locald_id",
|
||||
"last_msg_type",
|
||||
"last_msg_sub_type",
|
||||
"last_msg_sender",
|
||||
"last_sender_display_name",
|
||||
]
|
||||
update_cols = [c for c in desired_cols if c in cols]
|
||||
if not update_cols:
|
||||
@@ -583,7 +730,15 @@ def _upsert_session_table_rows(conn: sqlite3.Connection, rows: list[dict[str, An
|
||||
continue
|
||||
values: list[Any] = []
|
||||
for c in update_cols:
|
||||
if c in {"unread_count", "is_hidden", "last_timestamp", "sort_timestamp", "last_msg_type", "last_msg_sub_type"}:
|
||||
if c in {
|
||||
"unread_count",
|
||||
"is_hidden",
|
||||
"last_timestamp",
|
||||
"sort_timestamp",
|
||||
"last_msg_locald_id",
|
||||
"last_msg_type",
|
||||
"last_msg_sub_type",
|
||||
}:
|
||||
values.append(_int((r or {}).get(c)))
|
||||
else:
|
||||
values.append(_text((r or {}).get(c)))
|
||||
@@ -1510,8 +1665,17 @@ def sync_chat_realtime_messages_all(
|
||||
"sort_timestamp",
|
||||
item.get("sortTimestamp", item.get("last_timestamp", item.get("lastTimestamp", 0))),
|
||||
),
|
||||
"last_msg_locald_id": item.get(
|
||||
"last_msg_locald_id",
|
||||
item.get("lastMsgLocaldId", item.get("lastMsgLocalId", 0)),
|
||||
),
|
||||
"last_msg_type": item.get("last_msg_type", item.get("lastMsgType", 0)),
|
||||
"last_msg_sub_type": item.get("last_msg_sub_type", item.get("lastMsgSubType", 0)),
|
||||
"last_msg_sender": item.get("last_msg_sender", item.get("lastMsgSender", "")),
|
||||
"last_sender_display_name": item.get(
|
||||
"last_sender_display_name",
|
||||
item.get("lastSenderDisplayName", ""),
|
||||
),
|
||||
}
|
||||
# Prefer the row with the newer sort timestamp for the same username.
|
||||
prev = realtime_rows_by_user.get(uname)
|
||||
@@ -2137,11 +2301,7 @@ def _append_full_messages_from_rows(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -2598,6 +2758,13 @@ def _postprocess_full_messages(
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
group_nicknames = _load_group_nickname_map(
|
||||
account_dir=account_dir,
|
||||
contact_db_path=contact_db_path,
|
||||
chatroom_id=username,
|
||||
sender_usernames=uniq_senders,
|
||||
)
|
||||
|
||||
for m in merged:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
|
||||
@@ -2613,13 +2780,12 @@ def _postprocess_full_messages(
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
row = sender_contact_rows.get(su)
|
||||
display_name = _pick_display_name(row, su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
m["senderDisplayName"] = display_name
|
||||
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,
|
||||
@@ -2836,6 +3002,17 @@ def list_chat_sessions(
|
||||
"sort_timestamp": item.get("sort_timestamp", item.get("sortTimestamp", item.get("last_timestamp", 0))),
|
||||
"last_msg_type": item.get("last_msg_type", item.get("lastMsgType", 0)),
|
||||
"last_msg_sub_type": item.get("last_msg_sub_type", item.get("lastMsgSubType", 0)),
|
||||
# Keep these fields so group session previews can render "sender: content" without
|
||||
# crashing (realtime rows are dicts, not sqlite Rows).
|
||||
"last_msg_sender": item.get("last_msg_sender", item.get("lastMsgSender", "")),
|
||||
"last_sender_display_name": item.get(
|
||||
"last_sender_display_name",
|
||||
item.get("lastSenderDisplayName", ""),
|
||||
),
|
||||
"last_msg_locald_id": item.get(
|
||||
"last_msg_locald_id",
|
||||
item.get("lastMsgLocaldId", item.get("lastMsgLocalId", 0)),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2923,12 +3100,16 @@ def list_chat_sessions(
|
||||
try:
|
||||
need_display: list[str] = []
|
||||
need_avatar: list[str] = []
|
||||
if source_norm == "realtime":
|
||||
# In realtime mode, always ask WCDB for display names: decrypted contact.db can be stale.
|
||||
need_display = [str(u or "").strip() for u in usernames if str(u or "").strip()]
|
||||
for u in usernames:
|
||||
if not u:
|
||||
continue
|
||||
row = contact_rows.get(u)
|
||||
if _pick_display_name(row, u) == u:
|
||||
need_display.append(u)
|
||||
if source_norm != "realtime":
|
||||
row = contact_rows.get(u)
|
||||
if _pick_display_name(row, u) == u:
|
||||
need_display.append(u)
|
||||
if source_norm == "realtime":
|
||||
# In realtime mode, prefer WCDB-resolved avatar URLs (contact.db can be stale).
|
||||
if u not in local_avatar_usernames:
|
||||
@@ -2983,14 +3164,40 @@ def list_chat_sessions(
|
||||
if v:
|
||||
last_previews[u] = v
|
||||
|
||||
group_sender_display_names: dict[str, str] = _build_group_sender_display_name_map(
|
||||
contact_db_path,
|
||||
last_previews,
|
||||
)
|
||||
unresolved = []
|
||||
for conv_username, preview_text in last_previews.items():
|
||||
if not str(conv_username or "").endswith("@chatroom"):
|
||||
continue
|
||||
sender_username = _extract_group_preview_sender_username(preview_text)
|
||||
if sender_username and sender_username not in group_sender_display_names:
|
||||
unresolved.append(sender_username)
|
||||
unresolved = list(dict.fromkeys(unresolved))
|
||||
if unresolved:
|
||||
try:
|
||||
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
wcdb_names = _wcdb_get_display_names(wcdb_conn.handle, unresolved)
|
||||
for sender_username in unresolved:
|
||||
wcdb_name = str(wcdb_names.get(sender_username) or "").strip()
|
||||
if wcdb_name and wcdb_name != sender_username:
|
||||
group_sender_display_names[sender_username] = wcdb_name
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
sessions: list[dict[str, Any]] = []
|
||||
for r in filtered:
|
||||
username = r["username"]
|
||||
c_row = contact_rows.get(username)
|
||||
|
||||
display_name = _pick_display_name(c_row, username)
|
||||
if display_name == username:
|
||||
wd = str(wcdb_display_names.get(username) or "").strip()
|
||||
wd = str(wcdb_display_names.get(username) or "").strip()
|
||||
if source_norm == "realtime" and wd and wd != username:
|
||||
display_name = wd
|
||||
elif display_name == username:
|
||||
if wd and wd != username:
|
||||
display_name = wd
|
||||
|
||||
@@ -3046,6 +3253,37 @@ def list_chat_sessions(
|
||||
if last_msg_type == 81604378673 or (last_msg_type == 49 and last_msg_sub_type == 19):
|
||||
last_message = "[聊天记录]"
|
||||
|
||||
last_message = _normalize_session_preview_text(
|
||||
last_message,
|
||||
is_group=bool(str(username or "").endswith("@chatroom")),
|
||||
sender_display_names=group_sender_display_names,
|
||||
)
|
||||
if str(username or "").endswith("@chatroom") and str(last_message or "") and not str(last_message).startswith("[草稿]"):
|
||||
# Prefer group card nickname when available. In realtime mode, WCDB session rows can provide
|
||||
# `last_sender_display_name`, but we may still get a summary that doesn't include "sender:".
|
||||
# Also guard against URL schemes like "https://..." being mis-parsed as "https: //...".
|
||||
raw_sender_display = ""
|
||||
try:
|
||||
raw_sender_display = r["last_sender_display_name"]
|
||||
except Exception:
|
||||
try:
|
||||
raw_sender_display = r.get("last_sender_display_name", "")
|
||||
except Exception:
|
||||
raw_sender_display = ""
|
||||
sender_display = _decode_sqlite_text(raw_sender_display).strip()
|
||||
if sender_display:
|
||||
text = re.sub(r"\s+", " ", str(last_message or "").strip()).strip()
|
||||
match = re.match(r"^([^:\n]{1,128}):\s*(.+)$", text)
|
||||
if match:
|
||||
prefix = str(match.group(1) or "").strip()
|
||||
body = re.sub(r"\s+", " ", str(match.group(2) or "").strip()).strip()
|
||||
if prefix.lower() in {"http", "https"} and body.startswith("//"):
|
||||
last_message = f"{sender_display}: {text}"
|
||||
else:
|
||||
last_message = f"{sender_display}: {body}"
|
||||
else:
|
||||
last_message = f"{sender_display}: {text}"
|
||||
|
||||
last_time = _format_session_time(r["sort_timestamp"] or r["last_timestamp"])
|
||||
|
||||
sessions.append(
|
||||
@@ -3248,13 +3486,7 @@ def _collect_chat_messages(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
import re
|
||||
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -3957,13 +4189,7 @@ def list_chat_messages(
|
||||
|
||||
if local_type == 10000:
|
||||
render_type = "system"
|
||||
if "revokemsg" in raw_text:
|
||||
content_text = "撤回了一条消息"
|
||||
else:
|
||||
import re
|
||||
|
||||
content_text = re.sub(r"</?[_a-zA-Z0-9]+[^>]*>", "", raw_text)
|
||||
content_text = re.sub(r"\s+", " ", content_text).strip() or "[系统消息]"
|
||||
content_text = _parse_system_message_content(raw_text)
|
||||
elif local_type == 49:
|
||||
parsed = _parse_app_message(raw_text)
|
||||
render_type = str(parsed.get("renderType") or "text")
|
||||
@@ -4412,6 +4638,13 @@ def list_chat_messages(
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
group_nicknames = _load_group_nickname_map(
|
||||
account_dir=account_dir,
|
||||
contact_db_path=contact_db_path,
|
||||
chatroom_id=username,
|
||||
sender_usernames=uniq_senders,
|
||||
)
|
||||
|
||||
for m in merged:
|
||||
# If appmsg doesn't provide sourcedisplayname, try mapping sourceusername to display name.
|
||||
if (not str(m.get("from") or "").strip()) and str(m.get("fromUsername") or "").strip():
|
||||
@@ -4427,13 +4660,12 @@ def list_chat_messages(
|
||||
su = str(m.get("senderUsername") or "")
|
||||
if not su:
|
||||
continue
|
||||
row = sender_contact_rows.get(su)
|
||||
display_name = _pick_display_name(row, su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
m["senderDisplayName"] = display_name
|
||||
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,
|
||||
@@ -4930,19 +5162,24 @@ async def _search_chat_messages_via_fts(
|
||||
username=username,
|
||||
local_avatar_usernames=local_avatar_usernames,
|
||||
)
|
||||
group_nicknames = _load_group_nickname_map(
|
||||
account_dir=account_dir,
|
||||
contact_db_path=contact_db_path,
|
||||
chatroom_id=username,
|
||||
sender_usernames=[str(x.get("senderUsername") or "") for x in hits],
|
||||
)
|
||||
|
||||
for h in hits:
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
h["conversationName"] = conv_name
|
||||
h["conversationAvatar"] = conv_avatar
|
||||
if su:
|
||||
row = contact_rows.get(su)
|
||||
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == username else su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
h["senderDisplayName"] = display_name
|
||||
h["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=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,
|
||||
@@ -4986,6 +5223,23 @@ async def _search_chat_messages_via_fts(
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
|
||||
group_senders_by_room: dict[str, list[str]] = {}
|
||||
for h in hits:
|
||||
cu = str(h.get("username") or "").strip()
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
if (not cu.endswith("@chatroom")) or (not su):
|
||||
continue
|
||||
group_senders_by_room.setdefault(cu, []).append(su)
|
||||
|
||||
group_nickname_cache: dict[str, dict[str, str]] = {}
|
||||
for cu, senders in group_senders_by_room.items():
|
||||
group_nickname_cache[cu] = _load_group_nickname_map(
|
||||
account_dir=account_dir,
|
||||
contact_db_path=contact_db_path,
|
||||
chatroom_id=cu,
|
||||
sender_usernames=senders,
|
||||
)
|
||||
|
||||
for h in hits:
|
||||
cu = str(h.get("username") or "").strip()
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
@@ -5003,13 +5257,12 @@ async def _search_chat_messages_via_fts(
|
||||
)
|
||||
h["conversationAvatar"] = conv_avatar
|
||||
if su:
|
||||
row = contact_rows.get(su)
|
||||
display_name = _pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
|
||||
if display_name == su:
|
||||
wd = str(wcdb_display_names.get(su) or "").strip()
|
||||
if wd and wd != su:
|
||||
display_name = wd
|
||||
h["senderDisplayName"] = display_name
|
||||
h["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names=wcdb_display_names,
|
||||
group_nicknames=group_nickname_cache.get(cu, {}),
|
||||
)
|
||||
avatar_url = base_url + _avatar_url_unified(
|
||||
account_dir=account_dir,
|
||||
username=su,
|
||||
@@ -5272,13 +5525,23 @@ async def search_chat_messages(
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_usernames)
|
||||
conv_row = contact_rows.get(username)
|
||||
conv_name = _pick_display_name(conv_row, username)
|
||||
group_nicknames = _load_group_nickname_map(
|
||||
account_dir=account_dir,
|
||||
contact_db_path=contact_db_path,
|
||||
chatroom_id=username,
|
||||
sender_usernames=[str(x.get("senderUsername") or "") for x in page],
|
||||
)
|
||||
|
||||
for h in page:
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
h["conversationName"] = conv_name
|
||||
if su:
|
||||
row = contact_rows.get(su)
|
||||
h["senderDisplayName"] = _pick_display_name(row, su) if row is not None else (conv_name if su == username else su)
|
||||
h["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names={},
|
||||
group_nicknames=group_nicknames,
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
@@ -5360,6 +5623,23 @@ async def search_chat_messages(
|
||||
)
|
||||
contact_rows = _load_contact_rows(contact_db_path, uniq_contacts)
|
||||
|
||||
group_senders_by_room: dict[str, list[str]] = {}
|
||||
for h in page:
|
||||
cu = str(h.get("username") or "").strip()
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
if (not cu.endswith("@chatroom")) or (not su):
|
||||
continue
|
||||
group_senders_by_room.setdefault(cu, []).append(su)
|
||||
|
||||
group_nickname_cache: dict[str, dict[str, str]] = {}
|
||||
for cu, senders in group_senders_by_room.items():
|
||||
group_nickname_cache[cu] = _load_group_nickname_map(
|
||||
account_dir=account_dir,
|
||||
contact_db_path=contact_db_path,
|
||||
chatroom_id=cu,
|
||||
sender_usernames=senders,
|
||||
)
|
||||
|
||||
for h in page:
|
||||
cu = str(h.get("username") or "").strip()
|
||||
su = str(h.get("senderUsername") or "").strip()
|
||||
@@ -5367,8 +5647,12 @@ async def search_chat_messages(
|
||||
conv_name = _pick_display_name(crow, cu) if cu else ""
|
||||
h["conversationName"] = conv_name or cu
|
||||
if su:
|
||||
row = contact_rows.get(su)
|
||||
h["senderDisplayName"] = _pick_display_name(row, su) if row is not None else (conv_name if su == cu else su)
|
||||
h["senderDisplayName"] = _resolve_sender_display_name(
|
||||
sender_username=su,
|
||||
sender_contact_rows=contact_rows,
|
||||
wcdb_display_names={},
|
||||
group_nicknames=group_nickname_cache.get(cu, {}),
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
|
||||
@@ -688,6 +688,83 @@ def _lookup_resource_md5_by_server_id(account_dir_str: str, server_id: int, want
|
||||
pass
|
||||
|
||||
|
||||
@lru_cache(maxsize=4096)
|
||||
def _lookup_image_md5_by_server_id_from_messages(account_dir_str: str, server_id: int, username: str) -> str:
|
||||
account_dir_str = str(account_dir_str or "").strip()
|
||||
username = str(username or "").strip()
|
||||
if not account_dir_str or not username:
|
||||
return ""
|
||||
|
||||
try:
|
||||
sid = int(server_id or 0)
|
||||
except Exception:
|
||||
sid = 0
|
||||
if not sid:
|
||||
return ""
|
||||
|
||||
try:
|
||||
chat_hash = hashlib.md5(username.encode()).hexdigest()
|
||||
except Exception:
|
||||
return ""
|
||||
if not chat_hash:
|
||||
return ""
|
||||
|
||||
table_name = f"Msg_{chat_hash}"
|
||||
account_dir = Path(account_dir_str)
|
||||
|
||||
db_paths: list[Path] = []
|
||||
try:
|
||||
for p in account_dir.glob("message_*.db"):
|
||||
try:
|
||||
if p.is_file():
|
||||
db_paths.append(p)
|
||||
except Exception:
|
||||
continue
|
||||
except Exception:
|
||||
db_paths = []
|
||||
|
||||
if not db_paths:
|
||||
return ""
|
||||
db_paths.sort(key=lambda p: p.name)
|
||||
|
||||
for db_path in db_paths:
|
||||
try:
|
||||
conn = sqlite3.connect(str(db_path))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
try:
|
||||
row = conn.execute(
|
||||
f"SELECT local_type, packed_info_data FROM {table_name} "
|
||||
"WHERE server_id = ? ORDER BY create_time DESC LIMIT 1",
|
||||
(sid,),
|
||||
).fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not row:
|
||||
continue
|
||||
|
||||
try:
|
||||
local_type = int(row[0] or 0)
|
||||
except Exception:
|
||||
local_type = 0
|
||||
if local_type != 3:
|
||||
continue
|
||||
|
||||
md5 = _extract_md5_from_packed_info(row[1])
|
||||
md5_norm = str(md5 or "").strip().lower()
|
||||
if _is_valid_md5(md5_norm):
|
||||
return md5_norm
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _is_safe_http_url(url: str) -> bool:
|
||||
u = str(url or "").strip()
|
||||
if not u:
|
||||
@@ -1062,6 +1139,12 @@ async def get_chat_image(
|
||||
resource_md5 = _lookup_resource_md5_by_server_id(str(account_dir), int(server_id), want_local_type=3)
|
||||
if resource_md5:
|
||||
md5 = resource_md5
|
||||
elif username:
|
||||
md5_from_msg = _lookup_image_md5_by_server_id_from_messages(
|
||||
str(account_dir), int(server_id), str(username)
|
||||
)
|
||||
if md5_from_msg:
|
||||
md5 = md5_from_msg
|
||||
|
||||
# md5 模式:优先从解密资源目录读取(更快)
|
||||
if md5:
|
||||
|
||||
@@ -102,6 +102,17 @@ def _load_wcdb_lib() -> ctypes.CDLL:
|
||||
lib.wcdb_get_group_members.argtypes = [ctypes.c_int64, ctypes.c_char_p, ctypes.POINTER(ctypes.c_char_p)]
|
||||
lib.wcdb_get_group_members.restype = ctypes.c_int
|
||||
|
||||
# Optional (newer DLLs): wcdb_get_group_nicknames(handle, chatroom_id, out_json)
|
||||
try:
|
||||
lib.wcdb_get_group_nicknames.argtypes = [
|
||||
ctypes.c_int64,
|
||||
ctypes.c_char_p,
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
]
|
||||
lib.wcdb_get_group_nicknames.restype = ctypes.c_int
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Optional: execute arbitrary SQL on a selected database kind/path.
|
||||
# Signature: wcdb_exec_query(handle, kind, path, sql, out_json)
|
||||
try:
|
||||
@@ -355,6 +366,41 @@ def get_avatar_urls(handle: int, usernames: list[str]) -> dict[str, str]:
|
||||
return {}
|
||||
|
||||
|
||||
def get_group_members(handle: int, chatroom_id: str) -> list[dict[str, Any]]:
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
cid = str(chatroom_id or "").strip()
|
||||
if not cid:
|
||||
return []
|
||||
out_json = _call_out_json(lib.wcdb_get_group_members, ctypes.c_int64(int(handle)), cid.encode("utf-8"))
|
||||
decoded = _safe_load_json(out_json)
|
||||
if isinstance(decoded, list):
|
||||
out: list[dict[str, Any]] = []
|
||||
for x in decoded:
|
||||
if isinstance(x, dict):
|
||||
out.append(x)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def get_group_nicknames(handle: int, chatroom_id: str) -> dict[str, str]:
|
||||
_ensure_initialized()
|
||||
lib = _load_wcdb_lib()
|
||||
fn = getattr(lib, "wcdb_get_group_nicknames", None)
|
||||
if not fn:
|
||||
return {}
|
||||
|
||||
cid = str(chatroom_id or "").strip()
|
||||
if not cid:
|
||||
return {}
|
||||
|
||||
out_json = _call_out_json(fn, ctypes.c_int64(int(handle)), cid.encode("utf-8"))
|
||||
decoded = _safe_load_json(out_json)
|
||||
if isinstance(decoded, dict):
|
||||
return {str(k): str(v) for k, v in decoded.items()}
|
||||
return {}
|
||||
|
||||
|
||||
def exec_query(handle: int, *, kind: str, path: Optional[str], sql: str) -> list[dict[str, Any]]:
|
||||
"""Execute raw SQL on a specific db kind/path via WCDB.
|
||||
|
||||
|
||||
@@ -122,6 +122,16 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
(3, 1003, 49, 3, 2, 1735689603, '<msg><appmsg><type>2000</type><des>收到转账0.01元</des></appmsg></msg>', None),
|
||||
(4, 1004, 1, 4, 2, 1735689604, '普通文本消息', None),
|
||||
(5, 1005, 10000, 5, 2, 1735689605, '系统提示消息', None),
|
||||
(
|
||||
6,
|
||||
1006,
|
||||
10000,
|
||||
6,
|
||||
2,
|
||||
1735689606,
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“测试好友”撤回了一条消息]]></replacemsg></revokemsg></sysmsg>',
|
||||
None,
|
||||
),
|
||||
]
|
||||
conn.executemany(
|
||||
f"INSERT INTO {table_name} (local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content, compress_content) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
@@ -413,6 +423,37 @@ class TestChatExportMessageTypesSemantics(unittest.TestCase):
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
def test_system_revoke_exports_readable_revoker_content(self):
|
||||
with TemporaryDirectory() as td:
|
||||
root = Path(td)
|
||||
account = "wxid_test"
|
||||
username = "wxid_friend"
|
||||
self._prepare_account(root, account=account, username=username)
|
||||
|
||||
prev_data = os.environ.get("WECHAT_TOOL_DATA_DIR")
|
||||
try:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = str(root)
|
||||
svc = self._reload_export_modules()
|
||||
job = self._create_job(
|
||||
svc.CHAT_EXPORT_MANAGER,
|
||||
account=account,
|
||||
username=username,
|
||||
message_types=["system"],
|
||||
include_media=False,
|
||||
)
|
||||
self.assertEqual(job.status, "done", msg=job.error)
|
||||
|
||||
payload, _, _ = self._load_export_payload(job.zip_path)
|
||||
revoke_msg = next((m for m in payload.get("messages", []) if int(m.get("serverId") or 0) == 1006), None)
|
||||
self.assertIsNotNone(revoke_msg)
|
||||
self.assertEqual(str(revoke_msg.get("renderType") or ""), "system")
|
||||
self.assertEqual(str(revoke_msg.get("content") or ""), "“测试好友”撤回了一条消息")
|
||||
finally:
|
||||
if prev_data is None:
|
||||
os.environ.pop("WECHAT_TOOL_DATA_DIR", None)
|
||||
else:
|
||||
os.environ["WECHAT_TOOL_DATA_DIR"] = prev_data
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.routers import chat as chat_router
|
||||
|
||||
|
||||
class _DummyRequest:
|
||||
base_url = "http://testserver/"
|
||||
|
||||
|
||||
class _DummyConn:
|
||||
def __init__(self) -> None:
|
||||
self.handle = 1
|
||||
self.lock = threading.Lock()
|
||||
|
||||
|
||||
def _seed_session_db(session_db_path: Path) -> None:
|
||||
conn = sqlite3.connect(str(session_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable (
|
||||
username TEXT PRIMARY KEY,
|
||||
unread_count INTEGER DEFAULT 0,
|
||||
is_hidden INTEGER DEFAULT 0,
|
||||
summary TEXT DEFAULT '',
|
||||
draft TEXT DEFAULT '',
|
||||
last_timestamp INTEGER DEFAULT 0,
|
||||
sort_timestamp INTEGER DEFAULT 0,
|
||||
last_msg_locald_id INTEGER DEFAULT 0,
|
||||
last_msg_type INTEGER DEFAULT 0,
|
||||
last_msg_sub_type INTEGER DEFAULT 0,
|
||||
last_msg_sender TEXT DEFAULT '',
|
||||
last_sender_display_name TEXT DEFAULT ''
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestChatRealtimeSyncAllUpdatesSenderDisplayName(unittest.TestCase):
|
||||
def test_sync_all_upserts_last_sender_display_name(self):
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
_seed_session_db(account_dir / "session.db")
|
||||
|
||||
conn = _DummyConn()
|
||||
sessions_rows = [
|
||||
{
|
||||
"username": "demo@chatroom",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"summary": "hello",
|
||||
"draft": "",
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片A",
|
||||
"last_msg_locald_id": 777,
|
||||
}
|
||||
]
|
||||
|
||||
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_sessions", return_value=sessions_rows),
|
||||
patch.object(chat_router, "_ensure_decrypted_message_tables", return_value={}),
|
||||
patch.object(chat_router, "_should_keep_session", return_value=True),
|
||||
):
|
||||
resp = chat_router.sync_chat_realtime_messages_all(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
max_scan=20,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
|
||||
db = sqlite3.connect(str(account_dir / "session.db"))
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT last_sender_display_name, last_msg_sender, last_msg_locald_id FROM SessionTable WHERE username = ? LIMIT 1",
|
||||
("demo@chatroom",),
|
||||
).fetchone()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
self.assertIsNotNone(row)
|
||||
self.assertEqual(str(row[0] or ""), "群名片A")
|
||||
self.assertEqual(str(row[1] or ""), "wxid_demo")
|
||||
self.assertEqual(int(row[2] or 0), 777)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import (
|
||||
_build_group_sender_display_name_map,
|
||||
_normalize_session_preview_text,
|
||||
_replace_preview_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
class TestChatSessionPreviewFormatting(unittest.TestCase):
|
||||
def test_normalize_session_preview_emoji_label(self):
|
||||
out = _normalize_session_preview_text("[表情]", is_group=False, sender_display_names={})
|
||||
self.assertEqual(out, "[动画表情]")
|
||||
|
||||
def test_normalize_group_preview_sender_display_name(self):
|
||||
out = _normalize_session_preview_text(
|
||||
"wxid_u3gwceqvne2m22: [表情]",
|
||||
is_group=True,
|
||||
sender_display_names={"wxid_u3gwceqvne2m22": "食神"},
|
||||
)
|
||||
self.assertEqual(out, "食神: [动画表情]")
|
||||
|
||||
def test_build_group_sender_display_name_map_from_contact_db(self):
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact (
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("wxid_u3gwceqvne2m22", "", "食神", "", "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
mapping = _build_group_sender_display_name_map(
|
||||
contact_db_path,
|
||||
{"demo@chatroom": "wxid_u3gwceqvne2m22: [动画表情]"},
|
||||
)
|
||||
self.assertEqual(mapping.get("wxid_u3gwceqvne2m22"), "食神")
|
||||
|
||||
def test_replace_preview_sender_prefix_uses_group_nickname(self):
|
||||
out = _replace_preview_sender_prefix("去码头整点🍟: [动画表情]", "麻辣香锅")
|
||||
self.assertEqual(out, "麻辣香锅: [动画表情]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
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 TestChatSessionsRealtimeSenderPreview(unittest.TestCase):
|
||||
def _run(self, sessions_rows: list[dict]) -> dict:
|
||||
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_sessions", return_value=sessions_rows),
|
||||
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_contact_rows", return_value={}),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_should_keep_session", return_value=True),
|
||||
patch.object(chat_router, "_avatar_url_unified", return_value="/avatar"),
|
||||
):
|
||||
return chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="latest",
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
def test_realtime_sessions_group_summary_prefixed_by_sender_display_name(self):
|
||||
resp = self._run(
|
||||
[
|
||||
{
|
||||
"username": "demo@chatroom",
|
||||
"summary": "hello",
|
||||
"draft": "",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片A",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片A: hello")
|
||||
|
||||
def test_realtime_sessions_group_url_summary_keeps_scheme(self):
|
||||
resp = self._run(
|
||||
[
|
||||
{
|
||||
"username": "url@chatroom",
|
||||
"summary": "https://example.com/x",
|
||||
"draft": "",
|
||||
"unread_count": 0,
|
||||
"is_hidden": 0,
|
||||
"last_timestamp": 123,
|
||||
"sort_timestamp": 123,
|
||||
"last_msg_type": 1,
|
||||
"last_msg_sub_type": 0,
|
||||
"last_msg_sender": "wxid_demo",
|
||||
"last_sender_display_name": "群名片B",
|
||||
}
|
||||
]
|
||||
)
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertEqual(sessions[0].get("lastMessage"), "群名片B: https://example.com/x")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _parse_system_message_content
|
||||
|
||||
|
||||
class TestChatSystemMessageParsing(unittest.TestCase):
|
||||
def test_extract_replacemsg_for_revoke(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA[“张三”撤回了一条消息]]>'
|
||||
"</replacemsg></revokemsg></sysmsg>"
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "“张三”撤回了一条消息")
|
||||
|
||||
def test_extract_nested_content_in_replacemsg(self):
|
||||
raw_text = (
|
||||
'<sysmsg type="revokemsg"><revokemsg><replacemsg><![CDATA['
|
||||
'<content>"黄智欢" 撤回了一条消息</content><revoketime>0</revoketime>'
|
||||
']]></replacemsg></revokemsg></sysmsg>'
|
||||
)
|
||||
self.assertEqual(_parse_system_message_content(raw_text), '"黄智欢" 撤回了一条消息')
|
||||
|
||||
def test_extract_revokemsg_text_when_replacemsg_missing(self):
|
||||
raw_text = "<revokemsg>你撤回了一条消息</revokemsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "你撤回了一条消息")
|
||||
|
||||
def test_revoke_fallback_when_no_readable_text(self):
|
||||
raw_text = '<sysmsg type="revokemsg"></sysmsg>'
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "撤回了一条消息")
|
||||
|
||||
def test_normal_system_message_still_cleaned(self):
|
||||
raw_text = "<sysmsg><template><![CDATA[ 张三 加入了群聊 ]]></template></sysmsg>"
|
||||
self.assertEqual(_parse_system_message_content(raw_text), "张三 加入了群聊")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,114 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
|
||||
from wechat_decrypt_tool.chat_helpers import _load_group_nickname_map_from_contact_db
|
||||
|
||||
|
||||
def _enc_varint(n: int) -> bytes:
|
||||
v = int(n)
|
||||
out = bytearray()
|
||||
while True:
|
||||
b = v & 0x7F
|
||||
v >>= 7
|
||||
if v:
|
||||
out.append(b | 0x80)
|
||||
else:
|
||||
out.append(b)
|
||||
break
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _enc_tag(field_no: int, wire_type: int) -> bytes:
|
||||
return _enc_varint((int(field_no) << 3) | int(wire_type))
|
||||
|
||||
|
||||
def _enc_len(field_no: int, data: bytes) -> bytes:
|
||||
b = bytes(data or b"")
|
||||
return _enc_tag(field_no, 2) + _enc_varint(len(b)) + b
|
||||
|
||||
|
||||
def _member_entry(*, inner: bytes) -> bytes:
|
||||
# contact.db ext_buffer uses repeated length-delimited submessages; the top-level field number is not important
|
||||
# for our best-effort parser, so we use field 1.
|
||||
return _enc_len(1, inner)
|
||||
|
||||
|
||||
class TestGroupNicknameExtBufferParsing(unittest.TestCase):
|
||||
def test_parse_pattern_a_field1_username_field2_display(self):
|
||||
chatroom = "demo@chatroom"
|
||||
username = "wxid_demo_123456"
|
||||
display = "群名片A"
|
||||
|
||||
inner = _enc_len(1, username.encode("utf-8")) + _enc_len(2, display.encode("utf-8"))
|
||||
ext_buffer = _member_entry(inner=inner)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_room(id, username, owner, ext_buffer) VALUES (?, ?, ?, ?)",
|
||||
(1, chatroom, "", ext_buffer),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, chatroom, [username])
|
||||
self.assertEqual(out.get(username), display)
|
||||
|
||||
def test_parse_pattern_b_field4_username_field1_display(self):
|
||||
chatroom = "demo2@chatroom"
|
||||
username = "wxid_demo_abcdef"
|
||||
display = "hjlbingo"
|
||||
|
||||
inner = _enc_len(4, username.encode("utf-8")) + _enc_len(1, display.encode("utf-8"))
|
||||
ext_buffer = _member_entry(inner=inner)
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO chat_room(id, username, owner, ext_buffer) VALUES (?, ?, ?, ?)",
|
||||
(1, chatroom, "", ext_buffer),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, chatroom, [username])
|
||||
self.assertEqual(out.get(username), display)
|
||||
|
||||
def test_non_chatroom_returns_empty(self):
|
||||
with TemporaryDirectory() as td:
|
||||
contact_db_path = Path(td) / "contact.db"
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
try:
|
||||
conn.execute(
|
||||
"CREATE TABLE chat_room(id INTEGER PRIMARY KEY, username TEXT, owner TEXT, ext_buffer BLOB)"
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
out = _load_group_nickname_map_from_contact_db(contact_db_path, "wxid_not_chatroom", ["wxid_xxx"])
|
||||
self.assertEqual(out, {})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user