mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
Compare commits
3 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>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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' }
|
||||
}
|
||||
},
|
||||
|
||||
// 模块配置
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.6 KiB |
@@ -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 {}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
@@ -618,6 +618,39 @@ def _normalize_xml_url(url: str) -> str:
|
||||
return u.replace("&", "&").strip()
|
||||
|
||||
|
||||
def _is_mp_weixin_article_url(url: str) -> bool:
|
||||
u = str(url or "").strip()
|
||||
if not u:
|
||||
return False
|
||||
|
||||
try:
|
||||
host = str(urlparse(u).hostname or "").strip().lower()
|
||||
if host == "mp.weixin.qq.com" or host.endswith(".mp.weixin.qq.com"):
|
||||
return True
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
lu = u.lower()
|
||||
return "mp.weixin.qq.com/" in lu
|
||||
|
||||
|
||||
def _classify_link_share(*, app_type: int, url: str, source_username: str, desc: str) -> tuple[str, str]:
|
||||
src = str(source_username or "").strip().lower()
|
||||
is_official_article = bool(
|
||||
app_type in (5, 68)
|
||||
and (_is_mp_weixin_article_url(url) or src.startswith("gh_"))
|
||||
)
|
||||
|
||||
link_type = "official_article" if is_official_article else "web_link"
|
||||
|
||||
d = str(desc or "").strip()
|
||||
hashtag_count = len(re.findall(r"#[^#\s]+", d))
|
||||
|
||||
# 公众号文章中「封面图 + 底栏标题」卡片特征:摘要以 #话题# 风格为主。
|
||||
link_style = "cover" if (is_official_article and (d.startswith("#") or hashtag_count >= 2)) else "default"
|
||||
return link_type, link_style
|
||||
|
||||
|
||||
def _extract_xml_tag_text(xml_text: str, tag: str) -> str:
|
||||
if not xml_text or not tag:
|
||||
return ""
|
||||
@@ -689,6 +722,65 @@ def _extract_refermsg_block(xml_text: str) -> str:
|
||||
return (m.group(1) or "").strip() if m else ""
|
||||
|
||||
|
||||
def _extract_refermsg_content(refer_block: str) -> str:
|
||||
if not refer_block:
|
||||
return ""
|
||||
|
||||
cdata_match = re.search(
|
||||
r"<content\b[^>]*>\s*<!\[CDATA\[(.*?)\]\]>\s*</content>",
|
||||
refer_block,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if cdata_match:
|
||||
return str(cdata_match.group(1) or "").strip()
|
||||
|
||||
return _extract_xml_tag_text(refer_block, "content")
|
||||
|
||||
|
||||
def _summarize_nested_quote_content(raw_content: str) -> str:
|
||||
candidate = str(raw_content or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
lower = candidate.lower()
|
||||
if "<msg" not in lower and "<appmsg" not in lower:
|
||||
return candidate
|
||||
|
||||
for tag in ("title", "des"):
|
||||
value = _extract_xml_tag_text(candidate, tag)
|
||||
if value:
|
||||
return value
|
||||
|
||||
content_value = _extract_xml_tag_text(candidate, "content")
|
||||
if content_value and (not str(content_value).lstrip().startswith("<")):
|
||||
return content_value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_nested_quote_thumb_url(raw_content: str) -> str:
|
||||
candidate = str(raw_content or "").strip()
|
||||
if not candidate:
|
||||
return ""
|
||||
|
||||
probes = [candidate]
|
||||
|
||||
if candidate.startswith("wxid_"):
|
||||
colon = candidate.find(":")
|
||||
if 0 < colon <= 64:
|
||||
rest = candidate[colon + 1 :].strip()
|
||||
if rest:
|
||||
probes.append(rest)
|
||||
|
||||
for probe in probes:
|
||||
for key in ("thumburl", "cdnthumburl", "cdnthumurl", "coverurl", "cover"):
|
||||
value = _normalize_xml_url(_extract_xml_tag_or_attr(probe, key))
|
||||
if value:
|
||||
return value
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _infer_transfer_status_text(
|
||||
is_sent: bool,
|
||||
paysubtype: str,
|
||||
@@ -702,7 +794,7 @@ def _infer_transfer_status_text(
|
||||
rs = str(receivestatus or "").strip()
|
||||
|
||||
if rs == "1":
|
||||
return "已收款"
|
||||
return "已被接收" if is_sent else "已收款"
|
||||
if rs == "2":
|
||||
return "已退还"
|
||||
if rs == "3":
|
||||
@@ -718,7 +810,7 @@ def _infer_transfer_status_text(
|
||||
if t == "8":
|
||||
return "发起转账"
|
||||
if t == "3":
|
||||
return "已收款" if is_sent else "已被接收"
|
||||
return "已被接收" if is_sent else "已收款"
|
||||
if t == "1":
|
||||
return "转账"
|
||||
|
||||
@@ -770,10 +862,22 @@ def _extract_sender_from_group_xml(xml_text: str) -> str:
|
||||
if not xml_text:
|
||||
return ""
|
||||
|
||||
v = _extract_xml_tag_text(xml_text, "fromusername")
|
||||
probe_text = xml_text
|
||||
try:
|
||||
# Avoid picking nested quoted-message sender from <refermsg>.
|
||||
probe_text = re.sub(
|
||||
r"(<refermsg[^>]*>.*?</refermsg>)",
|
||||
"",
|
||||
xml_text,
|
||||
flags=re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
except Exception:
|
||||
probe_text = xml_text
|
||||
|
||||
v = _extract_xml_tag_text(probe_text, "fromusername")
|
||||
if v:
|
||||
return v
|
||||
v = _extract_xml_attr(xml_text, "fromusername")
|
||||
v = _extract_xml_attr(probe_text, "fromusername")
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
@@ -846,6 +950,12 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
|
||||
if app_type in (5, 68) and url:
|
||||
thumb_url = _normalize_xml_url(_extract_xml_tag_text(text, "thumburl"))
|
||||
link_type, link_style = _classify_link_share(
|
||||
app_type=app_type,
|
||||
url=url,
|
||||
source_username=str(source_username or "").strip(),
|
||||
desc=str(des or "").strip(),
|
||||
)
|
||||
return {
|
||||
"renderType": "link",
|
||||
"content": des or title or "[链接]",
|
||||
@@ -854,6 +964,8 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"thumbUrl": thumb_url or "",
|
||||
"from": str(source_display_name or "").strip(),
|
||||
"fromUsername": str(source_username or "").strip(),
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
}
|
||||
|
||||
if app_type in (6, 74):
|
||||
@@ -907,7 +1019,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
or ""
|
||||
)
|
||||
refer_svrid = _extract_xml_tag_or_attr(refer_block, "svrid")
|
||||
refer_content = _extract_xml_tag_text(refer_block, "content")
|
||||
refer_content = _extract_refermsg_content(refer_block)
|
||||
refer_type = _extract_xml_tag_or_attr(refer_block, "type")
|
||||
|
||||
rt = (reply_text or "").strip()
|
||||
@@ -924,6 +1036,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
refer_content = rest
|
||||
|
||||
t = str(refer_type or "").strip()
|
||||
quote_thumb_url = ""
|
||||
quote_voice_length = ""
|
||||
if t == "3":
|
||||
refer_content = "[图片]"
|
||||
@@ -944,8 +1057,29 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
except Exception:
|
||||
quote_voice_length = ""
|
||||
refer_content = "[语音]"
|
||||
elif t == "49" and refer_content:
|
||||
refer_content = f"[链接] {refer_content}".strip()
|
||||
elif t == "57":
|
||||
summarized = _summarize_nested_quote_content(str(refer_content or ""))
|
||||
if summarized:
|
||||
refer_content = summarized
|
||||
elif str(refer_content or "").lstrip().startswith("<"):
|
||||
refer_content = "[引用消息]"
|
||||
elif t in {"49", "5", "68"}:
|
||||
raw_link_content = str(refer_content or "").strip()
|
||||
summarized = _summarize_nested_quote_content(raw_link_content)
|
||||
link_text = str(summarized or raw_link_content).strip()
|
||||
quote_thumb_url = _extract_nested_quote_thumb_url(raw_link_content)
|
||||
|
||||
if link_text.startswith("wxid_"):
|
||||
colon = link_text.find(":")
|
||||
if 0 < colon <= 64:
|
||||
maybe_rest = link_text[colon + 1 :].strip()
|
||||
if maybe_rest:
|
||||
second_try = _summarize_nested_quote_content(maybe_rest)
|
||||
link_text = str(second_try or maybe_rest).strip()
|
||||
if not quote_thumb_url:
|
||||
quote_thumb_url = _extract_nested_quote_thumb_url(maybe_rest)
|
||||
|
||||
refer_content = f"[链接] {link_text}".strip() if link_text else "[链接]"
|
||||
|
||||
return {
|
||||
"renderType": "quote",
|
||||
@@ -954,6 +1088,7 @@ def _parse_app_message(text: str) -> dict[str, Any]:
|
||||
"quoteTitle": refer_displayname or "",
|
||||
"quoteContent": refer_content or "",
|
||||
"quoteType": t,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"quoteServerId": str(refer_svrid or "").strip(),
|
||||
"quoteVoiceLength": quote_voice_length,
|
||||
}
|
||||
@@ -1818,10 +1953,10 @@ def _row_to_search_hit(
|
||||
if is_group and raw_text and (not raw_text.startswith("<")) and (not raw_text.startswith('"<')):
|
||||
sender_prefix, raw_text = _split_group_sender_prefix(raw_text, sender_username)
|
||||
|
||||
if is_group and sender_prefix:
|
||||
if is_group and sender_prefix and (not sender_username):
|
||||
sender_username = sender_prefix
|
||||
|
||||
if is_group and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
if is_group and (not sender_username) and raw_text and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
xml_sender = _extract_sender_from_group_xml(raw_text)
|
||||
if xml_sender:
|
||||
sender_username = xml_sender
|
||||
@@ -1838,6 +1973,9 @@ def _row_to_search_hit(
|
||||
quote_username = ""
|
||||
quote_title = ""
|
||||
quote_content = ""
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
amount = ""
|
||||
pay_sub_type = ""
|
||||
transfer_status = ""
|
||||
@@ -1854,6 +1992,9 @@ def _row_to_search_hit(
|
||||
url = str(parsed.get("url") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
amount = str(parsed.get("amount") or "")
|
||||
pay_sub_type = str(parsed.get("paySubType") or "")
|
||||
@@ -1878,6 +2019,7 @@ def _row_to_search_hit(
|
||||
content_text = str(parsed.get("content") or "[引用消息]")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
elif local_type == 3:
|
||||
render_type = "image"
|
||||
@@ -1927,6 +2069,9 @@ def _row_to_search_hit(
|
||||
url = str(parsed.get("url") or url)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
pay_sub_type = str(parsed.get("paySubType") or pay_sub_type)
|
||||
quote_username = str(parsed.get("quoteUsername") or quote_username)
|
||||
@@ -1966,9 +2111,12 @@ def _row_to_search_hit(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"quoteUsername": quote_username,
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"amount": amount,
|
||||
"paySubType": pay_sub_type,
|
||||
"transferStatus": transfer_status,
|
||||
|
||||
@@ -92,6 +92,11 @@ _REALTIME_SYNC_LOCKS: dict[tuple[str, str], threading.Lock] = {}
|
||||
_REALTIME_SYNC_ALL_LOCKS: dict[str, threading.Lock] = {}
|
||||
|
||||
|
||||
def _is_hex_md5(value: Any) -> bool:
|
||||
s = str(value or "").strip().lower()
|
||||
return len(s) == 32 and all(c in "0123456789abcdef" for c in s)
|
||||
|
||||
|
||||
def _avatar_url_unified(
|
||||
*,
|
||||
account_dir: Path,
|
||||
@@ -787,6 +792,82 @@ def _load_session_last_message_times(conn: sqlite3.Connection, usernames: list[s
|
||||
return out
|
||||
|
||||
|
||||
def _session_row_get(row: Any, key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
if isinstance(row, sqlite3.Row):
|
||||
return row[key]
|
||||
except Exception:
|
||||
return default
|
||||
try:
|
||||
return row.get(key, default)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _contact_flag_is_top(flag_value: Any) -> bool:
|
||||
try:
|
||||
flag_int = int(flag_value)
|
||||
except Exception:
|
||||
return False
|
||||
if flag_int < 0:
|
||||
flag_int &= (1 << 64) - 1
|
||||
return bool((flag_int >> 11) & 1)
|
||||
|
||||
|
||||
def _load_contact_top_flags(contact_db_path: Path, usernames: list[str]) -> dict[str, bool]:
|
||||
uniq = list(dict.fromkeys([str(u or "").strip() for u in usernames if str(u or "").strip()]))
|
||||
if not uniq:
|
||||
return {}
|
||||
if not contact_db_path.exists():
|
||||
return {}
|
||||
|
||||
out: dict[str, bool] = {}
|
||||
conn = sqlite3.connect(str(contact_db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
def has_flag_column(table: str) -> bool:
|
||||
try:
|
||||
rows = conn.execute(f"PRAGMA table_info({table})").fetchall()
|
||||
except Exception:
|
||||
return False
|
||||
cols: set[str] = set()
|
||||
for r in rows:
|
||||
try:
|
||||
cols.add(str(r["name"] if isinstance(r, sqlite3.Row) else r[1]).strip().lower())
|
||||
except Exception:
|
||||
continue
|
||||
return ("username" in cols) and ("flag" in cols)
|
||||
|
||||
chunk_size = 900
|
||||
for table in ("contact", "stranger"):
|
||||
if not has_flag_column(table):
|
||||
continue
|
||||
|
||||
for i in range(0, len(uniq), chunk_size):
|
||||
chunk = uniq[i : i + chunk_size]
|
||||
placeholders = ",".join(["?"] * len(chunk))
|
||||
try:
|
||||
rows = conn.execute(
|
||||
f"SELECT username, flag FROM {table} WHERE username IN ({placeholders})",
|
||||
chunk,
|
||||
).fetchall()
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
for r in rows:
|
||||
username = str(_session_row_get(r, "username", "") or "").strip()
|
||||
if not username:
|
||||
continue
|
||||
is_top = _contact_flag_is_top(_session_row_get(r, "flag", 0))
|
||||
if is_top:
|
||||
out[username] = True
|
||||
else:
|
||||
out.setdefault(username, False)
|
||||
return out
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/api/chat/realtime/sync", summary="实时消息同步到解密库(按会话增量)")
|
||||
def sync_chat_realtime_messages(
|
||||
request: Request,
|
||||
@@ -2251,7 +2332,7 @@ def _append_full_messages_from_rows(
|
||||
if is_group and sender_prefix and (not sender_username):
|
||||
sender_username = sender_prefix
|
||||
|
||||
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
xml_sender = _extract_sender_from_group_xml(raw_text)
|
||||
if xml_sender:
|
||||
sender_username = xml_sender
|
||||
@@ -2287,6 +2368,9 @@ def _append_full_messages_from_rows(
|
||||
quote_username = ""
|
||||
quote_title = ""
|
||||
quote_content = ""
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -2313,6 +2397,9 @@ def _append_full_messages_from_rows(
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -2356,6 +2443,9 @@ def _append_full_messages_from_rows(
|
||||
content_text = str(parsed.get("content") or "[引用消息]")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -2468,6 +2558,20 @@ def _append_full_messages_from_rows(
|
||||
local_id=local_id,
|
||||
create_time=create_time,
|
||||
)
|
||||
|
||||
# Some WeChat builds store the on-disk thumbnail basename (32-hex) in packed_info_data (protobuf),
|
||||
# while the message XML only carries a long cdnthumburl file_id. Prefer packed_info_data when present.
|
||||
if not _is_hex_md5(video_thumb_md5):
|
||||
try:
|
||||
packed_val = r["packed_info_data"]
|
||||
except Exception:
|
||||
try:
|
||||
packed_val = r.get("packed_info_data") # type: ignore[attr-defined]
|
||||
except Exception:
|
||||
packed_val = None
|
||||
packed_md5 = _extract_md5_from_packed_info(packed_val)
|
||||
if packed_md5:
|
||||
video_thumb_md5 = packed_md5
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
@@ -2527,6 +2631,9 @@ def _append_full_messages_from_rows(
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -2578,6 +2685,8 @@ def _append_full_messages_from_rows(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -2601,6 +2710,7 @@ def _append_full_messages_from_rows(
|
||||
"quoteVoiceLength": str(quote_voice_length).strip(),
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"amount": amount,
|
||||
"coverUrl": cover_url,
|
||||
"fileSize": file_size,
|
||||
@@ -2619,6 +2729,111 @@ def _append_full_messages_from_rows(
|
||||
pass
|
||||
|
||||
|
||||
def _postprocess_transfer_messages(merged: list[dict[str, Any]]) -> None:
|
||||
# 后处理:关联转账消息的最终状态
|
||||
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
|
||||
# paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
|
||||
#
|
||||
# Windows 微信在部分场景会为同一笔转账记录两条消息:
|
||||
# - paysubtype=1/8:发起/待收款(这里回填为“已被接收”)
|
||||
# - paysubtype=3:收款确认(展示为“已收款”)
|
||||
#
|
||||
# 这两条消息的 isSent 并不能稳定表示“付款方/收款方视角”,因此这里以 transferId 关联结果为准:
|
||||
# - 将原始转账消息(1/8)回填为“已被接收”
|
||||
# - 若同一 transferId 同时存在原始消息与 paysubtype=3 消息,则将 paysubtype=3 的那条校正为“已收款”
|
||||
|
||||
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
|
||||
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
|
||||
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
|
||||
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
|
||||
pending_transfer_ids: set[str] = set() # (paysubtype=1/8) 的 transferId,用于识别“收款确认”消息
|
||||
|
||||
for m in merged:
|
||||
if m.get("renderType") != "transfer":
|
||||
continue
|
||||
|
||||
pst = str(m.get("paySubType") or "")
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
amt = str(m.get("amount") or "")
|
||||
ts = int(m.get("createTime") or 0)
|
||||
|
||||
if tid and pst in ("1", "8"):
|
||||
pending_transfer_ids.add(tid)
|
||||
|
||||
if pst in ("4", "9"): # 退还状态
|
||||
if tid:
|
||||
returned_transfer_ids.add(tid)
|
||||
if amt:
|
||||
returned_amounts_with_time.append((amt, ts))
|
||||
elif pst == "3": # 已收款状态
|
||||
if tid:
|
||||
received_transfer_ids.add(tid)
|
||||
if amt:
|
||||
received_amounts_with_time.append((amt, ts))
|
||||
|
||||
backfilled_message_ids: set[str] = set()
|
||||
|
||||
for m in merged:
|
||||
if m.get("renderType") != "transfer":
|
||||
continue
|
||||
|
||||
pst = str(m.get("paySubType") or "")
|
||||
if pst not in ("1", "8"):
|
||||
continue
|
||||
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
amt = str(m.get("amount") or "")
|
||||
ts = int(m.get("createTime") or 0)
|
||||
|
||||
should_mark_returned = False
|
||||
should_mark_received = False
|
||||
|
||||
# 策略1:精确 transferId 匹配
|
||||
if tid:
|
||||
if tid in returned_transfer_ids:
|
||||
should_mark_returned = True
|
||||
elif tid in received_transfer_ids:
|
||||
should_mark_received = True
|
||||
|
||||
# 策略2:回退到金额+时间窗口匹配(24小时内同金额)
|
||||
if not should_mark_returned and not should_mark_received and amt:
|
||||
for ret_amt, ret_ts in returned_amounts_with_time:
|
||||
if ret_amt == amt and abs(ret_ts - ts) <= 86400:
|
||||
should_mark_returned = True
|
||||
break
|
||||
if not should_mark_returned:
|
||||
for rec_amt, rec_ts in received_amounts_with_time:
|
||||
if rec_amt == amt and abs(rec_ts - ts) <= 86400:
|
||||
should_mark_received = True
|
||||
break
|
||||
|
||||
if should_mark_returned:
|
||||
m["paySubType"] = "9"
|
||||
m["transferStatus"] = "已被退还"
|
||||
elif should_mark_received:
|
||||
m["paySubType"] = "3"
|
||||
m["transferStatus"] = "已被接收"
|
||||
mid = str(m.get("id") or "").strip()
|
||||
if mid:
|
||||
backfilled_message_ids.add(mid)
|
||||
|
||||
# 修正收款确认消息:当同一 transferId 同时存在原始转账消息(1/8)与收款消息(3)时,
|
||||
# paysubtype=3 的那条通常是收款确认消息,状态文案应为“已收款”。
|
||||
for m in merged:
|
||||
if m.get("renderType") != "transfer":
|
||||
continue
|
||||
pst = str(m.get("paySubType") or "")
|
||||
if pst != "3":
|
||||
continue
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
if not tid or tid not in pending_transfer_ids:
|
||||
continue
|
||||
mid = str(m.get("id") or "").strip()
|
||||
if mid and mid in backfilled_message_ids:
|
||||
continue
|
||||
m["transferStatus"] = "已收款"
|
||||
|
||||
|
||||
def _postprocess_full_messages(
|
||||
*,
|
||||
merged: list[dict[str, Any]],
|
||||
@@ -2631,75 +2846,7 @@ def _postprocess_full_messages(
|
||||
contact_db_path: Path,
|
||||
head_image_db_path: Path,
|
||||
) -> None:
|
||||
# 后处理:关联转账消息的最终状态
|
||||
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
|
||||
# paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
|
||||
|
||||
# 收集已退还和已收款的转账ID和金额
|
||||
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
|
||||
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
|
||||
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
|
||||
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
|
||||
|
||||
for m in merged:
|
||||
if m.get("renderType") == "transfer":
|
||||
pst = str(m.get("paySubType") or "")
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
amt = str(m.get("amount") or "")
|
||||
ts = int(m.get("createTime") or 0)
|
||||
|
||||
if pst in ("4", "9"): # 退还状态
|
||||
if tid:
|
||||
returned_transfer_ids.add(tid)
|
||||
if amt:
|
||||
returned_amounts_with_time.append((amt, ts))
|
||||
elif pst == "3": # 已收款状态
|
||||
if tid:
|
||||
received_transfer_ids.add(tid)
|
||||
if amt:
|
||||
received_amounts_with_time.append((amt, ts))
|
||||
|
||||
# 更新原始转账消息的状态
|
||||
for m in merged:
|
||||
if m.get("renderType") == "transfer":
|
||||
pst = str(m.get("paySubType") or "")
|
||||
# 只更新未确定状态的原始转账消息(paysubtype=1 或 8)
|
||||
if pst in ("1", "8"):
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
amt = str(m.get("amount") or "")
|
||||
ts = int(m.get("createTime") or 0)
|
||||
|
||||
# 优先检查退还状态(退还优先于收款)
|
||||
should_mark_returned = False
|
||||
should_mark_received = False
|
||||
|
||||
# 策略1:精确 transferId 匹配
|
||||
if tid:
|
||||
if tid in returned_transfer_ids:
|
||||
should_mark_returned = True
|
||||
elif tid in received_transfer_ids:
|
||||
should_mark_received = True
|
||||
|
||||
# 策略2:回退到金额+时间窗口匹配(24小时内同金额)
|
||||
if not should_mark_returned and not should_mark_received and amt:
|
||||
for ret_amt, ret_ts in returned_amounts_with_time:
|
||||
if ret_amt == amt and abs(ret_ts - ts) <= 86400:
|
||||
should_mark_returned = True
|
||||
break
|
||||
if not should_mark_returned:
|
||||
for rec_amt, rec_ts in received_amounts_with_time:
|
||||
if rec_amt == amt and abs(rec_ts - ts) <= 86400:
|
||||
should_mark_received = True
|
||||
break
|
||||
|
||||
if should_mark_returned:
|
||||
m["paySubType"] = "9"
|
||||
m["transferStatus"] = "已被退还"
|
||||
elif should_mark_received:
|
||||
m["paySubType"] = "3"
|
||||
# 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收"
|
||||
is_sent = m.get("isSent", False)
|
||||
m["transferStatus"] = "已收款" if is_sent else "已被接收"
|
||||
_postprocess_transfer_messages(merged)
|
||||
|
||||
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
|
||||
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
|
||||
@@ -3074,20 +3221,45 @@ def list_chat_sessions(
|
||||
finally:
|
||||
sconn.close()
|
||||
|
||||
filtered: list[sqlite3.Row] = []
|
||||
usernames: list[str] = []
|
||||
filtered: list[Any] = []
|
||||
for r in rows:
|
||||
username = r["username"] or ""
|
||||
username = _session_row_get(r, "username", "") or ""
|
||||
if not username:
|
||||
continue
|
||||
if not include_hidden and int(r["is_hidden"] or 0) == 1:
|
||||
if not include_hidden and int((_session_row_get(r, "is_hidden", 0) or 0)) == 1:
|
||||
continue
|
||||
if not _should_keep_session(username, include_official=include_official):
|
||||
continue
|
||||
filtered.append(r)
|
||||
usernames.append(username)
|
||||
if len(filtered) >= int(limit):
|
||||
break
|
||||
|
||||
raw_usernames = [str(_session_row_get(r, "username", "") or "").strip() for r in filtered]
|
||||
top_flags = _load_contact_top_flags(contact_db_path, raw_usernames)
|
||||
|
||||
def _to_int(v: Any) -> int:
|
||||
try:
|
||||
return int(v or 0)
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def _session_sort_key(row: Any) -> tuple[int, int, int]:
|
||||
username = str(_session_row_get(row, "username", "") or "").strip()
|
||||
sort_ts = _to_int(_session_row_get(row, "sort_timestamp", 0))
|
||||
last_ts = _to_int(_session_row_get(row, "last_timestamp", 0))
|
||||
return (
|
||||
1 if bool(top_flags.get(username, False)) else 0,
|
||||
sort_ts,
|
||||
last_ts,
|
||||
)
|
||||
|
||||
filtered.sort(key=_session_sort_key, reverse=True)
|
||||
if len(filtered) > int(limit):
|
||||
filtered = filtered[: int(limit)]
|
||||
|
||||
usernames: list[str] = []
|
||||
for r in filtered:
|
||||
username = str(_session_row_get(r, "username", "") or "").strip()
|
||||
if username:
|
||||
usernames.append(username)
|
||||
|
||||
contact_rows = _load_contact_rows(contact_db_path, usernames)
|
||||
local_avatar_usernames = _query_head_image_usernames(head_image_db_path, usernames)
|
||||
@@ -3121,12 +3293,20 @@ def list_chat_sessions(
|
||||
need_display = list(dict.fromkeys(need_display))
|
||||
need_avatar = list(dict.fromkeys(need_avatar))
|
||||
if need_display or need_avatar:
|
||||
wcdb_conn = rt_conn or WCDB_REALTIME.ensure_connected(account_dir)
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
wcdb_conn = rt_conn
|
||||
if wcdb_conn is None:
|
||||
status = WCDB_REALTIME.get_status(account_dir)
|
||||
can_connect = bool(status.get("dll_present")) and bool(status.get("key_present")) and bool(
|
||||
status.get("session_db_path")
|
||||
)
|
||||
if can_connect:
|
||||
wcdb_conn = WCDB_REALTIME.ensure_connected(account_dir)
|
||||
if wcdb_conn is not None:
|
||||
with wcdb_conn.lock:
|
||||
if need_display:
|
||||
wcdb_display_names = _wcdb_get_display_names(wcdb_conn.handle, need_display)
|
||||
if need_avatar:
|
||||
wcdb_avatar_urls = _wcdb_get_avatar_urls(wcdb_conn.handle, need_avatar)
|
||||
except Exception:
|
||||
wcdb_display_names = {}
|
||||
wcdb_avatar_urls = {}
|
||||
@@ -3296,6 +3476,7 @@ def list_chat_sessions(
|
||||
"lastMessageTime": last_time,
|
||||
"unreadCount": int(r["unread_count"] or 0),
|
||||
"isGroup": bool(username.endswith("@chatroom")),
|
||||
"isTop": bool(top_flags.get(str(username or "").strip(), False)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3439,7 +3620,7 @@ def _collect_chat_messages(
|
||||
if is_group and sender_prefix and (not sender_username):
|
||||
sender_username = sender_prefix
|
||||
|
||||
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
xml_sender = _extract_sender_from_group_xml(raw_text)
|
||||
if xml_sender:
|
||||
sender_username = xml_sender
|
||||
@@ -3472,6 +3653,9 @@ def _collect_chat_messages(
|
||||
quote_username = ""
|
||||
quote_title = ""
|
||||
quote_content = ""
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -3498,6 +3682,9 @@ def _collect_chat_messages(
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -3541,6 +3728,9 @@ def _collect_chat_messages(
|
||||
content_text = str(parsed.get("content") or "[引用消息]")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -3640,6 +3830,11 @@ def _collect_chat_messages(
|
||||
local_id=local_id,
|
||||
create_time=create_time,
|
||||
)
|
||||
|
||||
if not _is_hex_md5(video_thumb_md5):
|
||||
packed_md5 = _extract_md5_from_packed_info(r["packed_info_data"])
|
||||
if packed_md5:
|
||||
video_thumb_md5 = packed_md5
|
||||
content_text = "[视频]"
|
||||
elif local_type == 47:
|
||||
render_type = "emoji"
|
||||
@@ -3701,6 +3896,9 @@ def _collect_chat_messages(
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -3758,6 +3956,8 @@ def _collect_chat_messages(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -3781,6 +3981,7 @@ def _collect_chat_messages(
|
||||
"quoteVoiceLength": str(quote_voice_length).strip(),
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"amount": amount,
|
||||
"coverUrl": cover_url,
|
||||
"fileSize": file_size,
|
||||
@@ -4139,7 +4340,7 @@ def list_chat_messages(
|
||||
if is_group and sender_prefix:
|
||||
sender_username = sender_prefix
|
||||
|
||||
if is_group and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
if is_group and (not sender_username) and (raw_text.startswith("<") or raw_text.startswith('"<')):
|
||||
xml_sender = _extract_sender_from_group_xml(raw_text)
|
||||
if xml_sender:
|
||||
sender_username = xml_sender
|
||||
@@ -4175,6 +4376,9 @@ def list_chat_messages(
|
||||
quote_username = ""
|
||||
quote_title = ""
|
||||
quote_content = ""
|
||||
quote_thumb_url = ""
|
||||
link_type = ""
|
||||
link_style = ""
|
||||
quote_server_id = ""
|
||||
quote_type = ""
|
||||
quote_voice_length = ""
|
||||
@@ -4201,6 +4405,9 @@ def list_chat_messages(
|
||||
record_item = str(parsed.get("recordItem") or "")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -4244,6 +4451,9 @@ def list_chat_messages(
|
||||
content_text = str(parsed.get("content") or "[引用消息]")
|
||||
quote_title = str(parsed.get("quoteTitle") or "")
|
||||
quote_content = str(parsed.get("quoteContent") or "")
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or "")
|
||||
link_type = str(parsed.get("linkType") or "")
|
||||
link_style = str(parsed.get("linkStyle") or "")
|
||||
quote_username = str(parsed.get("quoteUsername") or "")
|
||||
quote_server_id = str(parsed.get("quoteServerId") or "")
|
||||
quote_type = str(parsed.get("quoteType") or "")
|
||||
@@ -4400,6 +4610,9 @@ def list_chat_messages(
|
||||
record_item = str(parsed.get("recordItem") or record_item)
|
||||
quote_title = str(parsed.get("quoteTitle") or quote_title)
|
||||
quote_content = str(parsed.get("quoteContent") or quote_content)
|
||||
quote_thumb_url = str(parsed.get("quoteThumbUrl") or quote_thumb_url)
|
||||
link_type = str(parsed.get("linkType") or link_type)
|
||||
link_style = str(parsed.get("linkStyle") or link_style)
|
||||
amount = str(parsed.get("amount") or amount)
|
||||
cover_url = str(parsed.get("coverUrl") or cover_url)
|
||||
thumb_url = str(parsed.get("thumbUrl") or thumb_url)
|
||||
@@ -4450,6 +4663,8 @@ def list_chat_messages(
|
||||
"content": content_text,
|
||||
"title": title,
|
||||
"url": url,
|
||||
"linkType": link_type,
|
||||
"linkStyle": link_style,
|
||||
"from": from_name,
|
||||
"fromUsername": from_username,
|
||||
"recordItem": record_item,
|
||||
@@ -4473,6 +4688,7 @@ def list_chat_messages(
|
||||
"quoteVoiceLength": str(quote_voice_length).strip(),
|
||||
"quoteTitle": quote_title,
|
||||
"quoteContent": quote_content,
|
||||
"quoteThumbUrl": quote_thumb_url,
|
||||
"amount": amount,
|
||||
"coverUrl": cover_url,
|
||||
"fileSize": file_size,
|
||||
@@ -4509,81 +4725,38 @@ def list_chat_messages(
|
||||
deduped.append(m)
|
||||
merged = deduped
|
||||
|
||||
# 后处理:关联转账消息的最终状态
|
||||
# 策略:优先使用 transferId 精确匹配,回退到金额+时间窗口匹配
|
||||
# paysubtype 含义:1=不明确 3=已收款 4=对方退回给你 8=发起转账 9=被对方退回 10=已过期
|
||||
_postprocess_transfer_messages(merged)
|
||||
|
||||
# 收集已退还和已收款的转账ID和金额
|
||||
returned_transfer_ids: set[str] = set() # 退还状态的 transferId
|
||||
received_transfer_ids: set[str] = set() # 已收款状态的 transferId
|
||||
returned_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于退还回退匹配
|
||||
received_amounts_with_time: list[tuple[str, int]] = [] # (金额, 时间戳) 用于收款回退匹配
|
||||
def sort_key(m: dict[str, Any]) -> tuple[int, int, int]:
|
||||
sseq = int(m.get("sortSeq") or 0)
|
||||
cts = int(m.get("createTime") or 0)
|
||||
lid = int(m.get("localId") or 0)
|
||||
return (cts, sseq, lid)
|
||||
|
||||
for m in merged:
|
||||
if m.get("renderType") == "transfer":
|
||||
pst = str(m.get("paySubType") or "")
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
amt = str(m.get("amount") or "")
|
||||
ts = int(m.get("createTime") or 0)
|
||||
merged.sort(key=sort_key, reverse=True)
|
||||
has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit))))
|
||||
page = merged[int(offset) : int(offset) + int(limit)]
|
||||
if want_asc:
|
||||
page = list(reversed(page))
|
||||
|
||||
if pst in ("4", "9"): # 退还状态
|
||||
if tid:
|
||||
returned_transfer_ids.add(tid)
|
||||
if amt:
|
||||
returned_amounts_with_time.append((amt, ts))
|
||||
elif pst == "3": # 已收款状态
|
||||
if tid:
|
||||
received_transfer_ids.add(tid)
|
||||
if amt:
|
||||
received_amounts_with_time.append((amt, ts))
|
||||
# Hot path optimization: only enrich the page we return.
|
||||
if not page:
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
"username": username,
|
||||
"total": int(offset) + (1 if has_more_global else 0),
|
||||
"hasMore": bool(has_more_global),
|
||||
"messages": [],
|
||||
}
|
||||
|
||||
# 更新原始转账消息的状态
|
||||
for m in merged:
|
||||
if m.get("renderType") == "transfer":
|
||||
pst = str(m.get("paySubType") or "")
|
||||
# 只更新未确定状态的原始转账消息(paysubtype=1 或 8)
|
||||
if pst in ("1", "8"):
|
||||
tid = str(m.get("transferId") or "").strip()
|
||||
amt = str(m.get("amount") or "")
|
||||
ts = int(m.get("createTime") or 0)
|
||||
|
||||
# 优先检查退还状态(退还优先于收款)
|
||||
should_mark_returned = False
|
||||
should_mark_received = False
|
||||
|
||||
# 策略1:精确 transferId 匹配
|
||||
if tid:
|
||||
if tid in returned_transfer_ids:
|
||||
should_mark_returned = True
|
||||
elif tid in received_transfer_ids:
|
||||
should_mark_received = True
|
||||
|
||||
# 策略2:回退到金额+时间窗口匹配(24小时内同金额)
|
||||
if not should_mark_returned and not should_mark_received and amt:
|
||||
for ret_amt, ret_ts in returned_amounts_with_time:
|
||||
if ret_amt == amt and abs(ret_ts - ts) <= 86400:
|
||||
should_mark_returned = True
|
||||
break
|
||||
if not should_mark_returned:
|
||||
for rec_amt, rec_ts in received_amounts_with_time:
|
||||
if rec_amt == amt and abs(rec_ts - ts) <= 86400:
|
||||
should_mark_received = True
|
||||
break
|
||||
|
||||
if should_mark_returned:
|
||||
m["paySubType"] = "9"
|
||||
m["transferStatus"] = "已被退还"
|
||||
elif should_mark_received:
|
||||
m["paySubType"] = "3"
|
||||
# 根据 isSent 判断:发起方显示"已收款",收款方显示"已被接收"
|
||||
is_sent = m.get("isSent", False)
|
||||
m["transferStatus"] = "已收款" if is_sent else "已被接收"
|
||||
messages_window = page
|
||||
|
||||
# Some appmsg payloads provide only `from` (sourcedisplayname) but not `fromUsername` (sourceusername).
|
||||
# Recover `fromUsername` via contact.db so the frontend can render the publisher avatar.
|
||||
missing_from_names = [
|
||||
str(m.get("from") or "").strip()
|
||||
for m in merged
|
||||
for m in messages_window
|
||||
if str(m.get("renderType") or "").strip() == "link"
|
||||
and str(m.get("from") or "").strip()
|
||||
and not str(m.get("fromUsername") or "").strip()
|
||||
@@ -4591,7 +4764,7 @@ def list_chat_messages(
|
||||
if missing_from_names:
|
||||
name_to_username = _load_usernames_by_display_names(contact_db_path, missing_from_names)
|
||||
if name_to_username:
|
||||
for m in merged:
|
||||
for m in messages_window:
|
||||
if str(m.get("fromUsername") or "").strip():
|
||||
continue
|
||||
if str(m.get("renderType") or "").strip() != "link":
|
||||
@@ -4600,10 +4773,33 @@ def list_chat_messages(
|
||||
if fn and fn in name_to_username:
|
||||
m["fromUsername"] = name_to_username[fn]
|
||||
|
||||
from_usernames = [str(m.get("fromUsername") or "").strip() for m in merged]
|
||||
pat_usernames_in_page: set[str] = set()
|
||||
for m in messages_window:
|
||||
if int(m.get("type") or 0) != 266287972401:
|
||||
continue
|
||||
raw = str(m.get("_rawText") or "")
|
||||
if not raw:
|
||||
continue
|
||||
template = _extract_xml_tag_text(raw, "template")
|
||||
if not template:
|
||||
continue
|
||||
pat_usernames_in_page.update({mm.group(1) for mm in re.finditer(r"\$\{([^}]+)\}", template) if mm.group(1)})
|
||||
|
||||
from_usernames = [str(m.get("fromUsername") or "").strip() for m in messages_window]
|
||||
sender_usernames_in_page = [str(m.get("senderUsername") or "").strip() for m in messages_window]
|
||||
quote_usernames_in_page = [str(m.get("quoteUsername") or "").strip() for m in messages_window]
|
||||
uniq_senders = list(
|
||||
dict.fromkeys(
|
||||
[u for u in (sender_usernames + list(pat_usernames) + quote_usernames + from_usernames) if u]
|
||||
[
|
||||
u
|
||||
for u in (
|
||||
sender_usernames_in_page
|
||||
+ list(pat_usernames_in_page)
|
||||
+ quote_usernames_in_page
|
||||
+ from_usernames
|
||||
)
|
||||
if u
|
||||
]
|
||||
)
|
||||
)
|
||||
sender_contact_rows = _load_contact_rows(contact_db_path, uniq_senders)
|
||||
@@ -4645,7 +4841,7 @@ def list_chat_messages(
|
||||
sender_usernames=uniq_senders,
|
||||
)
|
||||
|
||||
for m in merged:
|
||||
for m in messages_window:
|
||||
# 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():
|
||||
fu = str(m.get("fromUsername") or "").strip()
|
||||
@@ -4789,18 +4985,6 @@ def list_chat_messages(
|
||||
if "_rawText" in m:
|
||||
m.pop("_rawText", None)
|
||||
|
||||
def sort_key(m: dict[str, Any]) -> tuple[int, int, int]:
|
||||
sseq = int(m.get("sortSeq") or 0)
|
||||
cts = int(m.get("createTime") or 0)
|
||||
lid = int(m.get("localId") or 0)
|
||||
return (cts, sseq, lid)
|
||||
|
||||
merged.sort(key=sort_key, reverse=True)
|
||||
has_more_global = bool(has_more_any or (len(merged) > (int(offset) + int(limit))))
|
||||
page = merged[int(offset) : int(offset) + int(limit)]
|
||||
if want_asc:
|
||||
page = list(reversed(page))
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"account": account_dir.name,
|
||||
@@ -5762,10 +5946,21 @@ async def get_chat_messages_around(
|
||||
my_rowid = None
|
||||
|
||||
quoted_table = _quote_ident(table_name)
|
||||
has_packed_info_data = False
|
||||
try:
|
||||
cols = conn.execute(f"PRAGMA table_info({quoted_table})").fetchall()
|
||||
has_packed_info_data = any(str(c[1] or "").strip().lower() == "packed_info_data" for c in cols)
|
||||
except Exception:
|
||||
has_packed_info_data = False
|
||||
packed_select = (
|
||||
"m.packed_info_data AS packed_info_data, " if has_packed_info_data else "NULL AS packed_info_data, "
|
||||
)
|
||||
sql_anchor_with_join = (
|
||||
"SELECT "
|
||||
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
|
||||
"m.message_content, m.compress_content, n.user_name AS sender_username "
|
||||
"m.message_content, m.compress_content, "
|
||||
+ packed_select
|
||||
+ "n.user_name AS sender_username "
|
||||
f"FROM {quoted_table} m "
|
||||
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
|
||||
"WHERE m.local_id = ? "
|
||||
@@ -5774,7 +5969,9 @@ async def get_chat_messages_around(
|
||||
sql_anchor_no_join = (
|
||||
"SELECT "
|
||||
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
|
||||
"m.message_content, m.compress_content, '' AS sender_username "
|
||||
"m.message_content, m.compress_content, "
|
||||
+ packed_select
|
||||
+ "'' AS sender_username "
|
||||
f"FROM {quoted_table} m "
|
||||
"WHERE m.local_id = ? "
|
||||
"LIMIT 1"
|
||||
@@ -5811,7 +6008,9 @@ async def get_chat_messages_around(
|
||||
sql_before_with_join = (
|
||||
"SELECT "
|
||||
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
|
||||
"m.message_content, m.compress_content, n.user_name AS sender_username "
|
||||
"m.message_content, m.compress_content, "
|
||||
+ packed_select
|
||||
+ "n.user_name AS sender_username "
|
||||
f"FROM {quoted_table} m "
|
||||
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
|
||||
f"{where_before} "
|
||||
@@ -5821,7 +6020,9 @@ async def get_chat_messages_around(
|
||||
sql_before_no_join = (
|
||||
"SELECT "
|
||||
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
|
||||
"m.message_content, m.compress_content, '' AS sender_username "
|
||||
"m.message_content, m.compress_content, "
|
||||
+ packed_select
|
||||
+ "'' AS sender_username "
|
||||
f"FROM {quoted_table} m "
|
||||
f"{where_before} "
|
||||
"ORDER BY m.create_time DESC, COALESCE(m.sort_seq, 0) DESC, m.local_id DESC "
|
||||
@@ -5831,7 +6032,9 @@ async def get_chat_messages_around(
|
||||
sql_after_with_join = (
|
||||
"SELECT "
|
||||
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
|
||||
"m.message_content, m.compress_content, n.user_name AS sender_username "
|
||||
"m.message_content, m.compress_content, "
|
||||
+ packed_select
|
||||
+ "n.user_name AS sender_username "
|
||||
f"FROM {quoted_table} m "
|
||||
"LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid "
|
||||
f"{where_after} "
|
||||
@@ -5841,7 +6044,9 @@ async def get_chat_messages_around(
|
||||
sql_after_no_join = (
|
||||
"SELECT "
|
||||
"m.local_id, m.server_id, m.local_type, m.sort_seq, m.real_sender_id, m.create_time, "
|
||||
"m.message_content, m.compress_content, '' AS sender_username "
|
||||
"m.message_content, m.compress_content, "
|
||||
+ packed_select
|
||||
+ "'' AS sender_username "
|
||||
f"FROM {quoted_table} m "
|
||||
f"{where_after} "
|
||||
"ORDER BY m.create_time ASC, COALESCE(m.sort_seq, 0) ASC, m.local_id ASC "
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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 TestChatRealtimeVideoThumbMd5FromPackedInfo(unittest.TestCase):
|
||||
def test_video_thumb_md5_filled_from_packed_info(self):
|
||||
packed_md5 = "faff984641f9dd174e01c74f0796c9ae"
|
||||
file_id = "3057020100044b3049020100020445eb9d5102032f54690204749999db0204698c336b0424deadbeef"
|
||||
video_md5 = "22e6612411898b6d43b7e773e504d506"
|
||||
xml = (
|
||||
'<?xml version="1.0"?>\n'
|
||||
"<msg>\n"
|
||||
f' <videomsg fromusername="wxid_sender" md5="{video_md5}" cdnthumburl="{file_id}" cdnvideourl="{file_id}" />\n'
|
||||
"</msg>\n"
|
||||
)
|
||||
|
||||
wcdb_rows = [
|
||||
{
|
||||
"localId": 1,
|
||||
"serverId": 123,
|
||||
"localType": 43,
|
||||
"sortSeq": 1700000000000,
|
||||
"realSenderId": 1,
|
||||
"createTime": 1700000000,
|
||||
"messageContent": xml,
|
||||
"compressContent": None,
|
||||
"packedInfoData": packed_md5.encode("ascii"),
|
||||
"senderUsername": "wxid_sender",
|
||||
}
|
||||
]
|
||||
|
||||
with TemporaryDirectory() as td:
|
||||
account_dir = Path(td) / "acc"
|
||||
account_dir.mkdir(parents=True, exist_ok=True)
|
||||
conn = _DummyConn()
|
||||
|
||||
with (
|
||||
patch.object(chat_router, "_resolve_account_dir", return_value=account_dir),
|
||||
patch.object(chat_router.WCDB_REALTIME, "ensure_connected", return_value=conn),
|
||||
patch.object(chat_router, "_wcdb_get_messages", return_value=wcdb_rows),
|
||||
patch.object(chat_router, "_load_contact_rows", return_value={}),
|
||||
patch.object(chat_router, "_query_head_image_usernames", return_value=set()),
|
||||
patch.object(chat_router, "_wcdb_get_display_names", return_value={}),
|
||||
patch.object(chat_router, "_wcdb_get_avatar_urls", return_value={}),
|
||||
patch.object(chat_router, "_load_usernames_by_display_names", return_value={}),
|
||||
patch.object(chat_router, "_load_group_nickname_map", return_value={}),
|
||||
):
|
||||
resp = chat_router.list_chat_messages(
|
||||
_DummyRequest(),
|
||||
username="demo@chatroom",
|
||||
account="acc",
|
||||
limit=50,
|
||||
offset=0,
|
||||
order="asc",
|
||||
render_types=None,
|
||||
source="realtime",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
messages = resp.get("messages") or []
|
||||
self.assertEqual(len(messages), 1)
|
||||
msg = messages[0]
|
||||
self.assertEqual(msg.get("renderType"), "video")
|
||||
self.assertEqual(msg.get("videoThumbMd5"), packed_md5)
|
||||
thumb_url = str(msg.get("videoThumbUrl") or "")
|
||||
self.assertIn(f"md5={packed_md5}", thumb_url)
|
||||
self.assertNotIn("file_id=", thumb_url)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import sqlite3
|
||||
import sys
|
||||
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/"
|
||||
|
||||
|
||||
def _seed_session_db(path: Path, rows: list[tuple[str, int, int, str]]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE SessionTable(
|
||||
username TEXT PRIMARY KEY,
|
||||
unread_count INTEGER,
|
||||
is_hidden INTEGER,
|
||||
summary TEXT,
|
||||
draft TEXT,
|
||||
last_timestamp INTEGER,
|
||||
sort_timestamp INTEGER,
|
||||
last_msg_type INTEGER,
|
||||
last_msg_sub_type INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username, sort_timestamp, last_timestamp, summary in rows:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO SessionTable(
|
||||
username, unread_count, is_hidden, summary, draft,
|
||||
last_timestamp, sort_timestamp, last_msg_type, last_msg_sub_type
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
username,
|
||||
0,
|
||||
0,
|
||||
summary,
|
||||
"",
|
||||
int(last_timestamp),
|
||||
int(sort_timestamp),
|
||||
1,
|
||||
0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_contact_db_with_flag(path: Path, flags: dict[str, int]) -> None:
|
||||
conn = sqlite3.connect(str(path))
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE contact(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT,
|
||||
flag INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE stranger(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT,
|
||||
flag INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username, flag in flags.items():
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "", "", "", "", int(flag)),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _seed_contact_db_without_flag(path: Path, usernames: list[str]) -> None:
|
||||
conn = sqlite3.connect(str(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(
|
||||
"""
|
||||
CREATE TABLE stranger(
|
||||
username TEXT,
|
||||
remark TEXT,
|
||||
nick_name TEXT,
|
||||
alias TEXT,
|
||||
big_head_url TEXT,
|
||||
small_head_url TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
for username in usernames:
|
||||
conn.execute(
|
||||
"INSERT INTO contact VALUES (?, ?, ?, ?, ?, ?)",
|
||||
(username, "", "", "", "", ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestChatSessionsPinning(unittest.TestCase):
|
||||
def test_pinned_session_is_sorted_first_and_has_is_top(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",
|
||||
[
|
||||
("wxid_new", 200, 200, "new message"),
|
||||
("wxid_top", 100, 100, "top older message"),
|
||||
],
|
||||
)
|
||||
_seed_contact_db_with_flag(
|
||||
account_dir / "contact.db",
|
||||
{
|
||||
"wxid_new": 0,
|
||||
"wxid_top": 1 << 11,
|
||||
},
|
||||
)
|
||||
|
||||
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
|
||||
resp = chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="session",
|
||||
source="",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 2)
|
||||
self.assertEqual(sessions[0].get("username"), "wxid_top")
|
||||
self.assertTrue(bool(sessions[0].get("isTop")))
|
||||
self.assertEqual(sessions[1].get("username"), "wxid_new")
|
||||
self.assertFalse(bool(sessions[1].get("isTop")))
|
||||
|
||||
def test_missing_flag_column_does_not_error_and_defaults_false(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",
|
||||
[
|
||||
("wxid_top", 100, 100, "hello"),
|
||||
],
|
||||
)
|
||||
_seed_contact_db_without_flag(account_dir / "contact.db", ["wxid_top"])
|
||||
|
||||
with patch.object(chat_router, "_resolve_account_dir", return_value=account_dir):
|
||||
resp = chat_router.list_chat_sessions(
|
||||
_DummyRequest(),
|
||||
account="acc",
|
||||
limit=50,
|
||||
include_hidden=True,
|
||||
include_official=True,
|
||||
preview="session",
|
||||
source="",
|
||||
)
|
||||
|
||||
self.assertEqual(resp.get("status"), "success")
|
||||
sessions = resp.get("sessions") or []
|
||||
self.assertEqual(len(sessions), 1)
|
||||
self.assertFalse(bool(sessions[0].get("isTop")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
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 _extract_sender_from_group_xml
|
||||
|
||||
|
||||
class TestGroupXmlSenderExtraction(unittest.TestCase):
|
||||
def test_prefers_outer_fromusername_over_nested_refermsg(self):
|
||||
xml_text = (
|
||||
'<msg><appmsg><type>57</type>'
|
||||
'<refermsg><fromusername>quoted_user@chatroom</fromusername></refermsg>'
|
||||
'</appmsg><fromusername>actual_sender@chatroom</fromusername></msg>'
|
||||
)
|
||||
self.assertEqual(_extract_sender_from_group_xml(xml_text), "actual_sender@chatroom")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,115 @@
|
||||
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_app_message
|
||||
|
||||
|
||||
class TestParseAppMessage(unittest.TestCase):
|
||||
def test_quote_type_57_nested_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>一松一紧</title><des></des><action></action><type>57</type>'
|
||||
'<showtype>0</showtype><soundtype>0</soundtype><mediatagname></mediatagname>'
|
||||
'<messageext></messageext><messageaction></messageaction><content></content>'
|
||||
'<url></url><appattach><totallen>0</totallen><attachid></attachid><fileext></fileext></appattach>'
|
||||
'<extinfo></extinfo><sourceusername></sourceusername><sourcedisplayname></sourcedisplayname>'
|
||||
'<commenturl></commenturl><refermsg>'
|
||||
'<type>57</type><svrid>1173057991425172913</svrid>'
|
||||
'<fromusr>44372432598@chatroom</fromusr><chatusr>44372432598@chatroom</chatusr>'
|
||||
'<displayname><![CDATA[ㅤ磁父]]></displayname>'
|
||||
'<content><![CDATA[<msg><appmsg appid="" sdkver="0"><title>那里紧?哪里张?</title><des></des>'
|
||||
'<action></action><type>57</type><showtype>0</showtype><soundtype>0</soundtype>'
|
||||
'<mediatagname></mediatagname><messageext></messageext><messageaction></messageaction>'
|
||||
'<content></content><url></url><appattach><totallen>0</totallen><attachid></attachid>'
|
||||
'<fileext></fileext></appattach><extinfo></extinfo><sourceusername></sourceusername>'
|
||||
'<sourcedisplayname></sourcedisplayname><commenturl></commenturl></appmsg></msg>]]></content>'
|
||||
'</refermsg></appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("content"), "一松一紧")
|
||||
self.assertEqual(parsed.get("quoteType"), "57")
|
||||
self.assertEqual(parsed.get("quoteContent"), "那里紧?哪里张?")
|
||||
|
||||
def test_quote_type_57_plain_text_refermsg_keeps_text(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>回复</title><type>57</type>'
|
||||
'<refermsg><type>57</type><content><![CDATA[普通文本引用]]></content></refermsg>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("quoteContent"), "普通文本引用")
|
||||
|
||||
def test_quote_type_49_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>这种傻逼公众号怎么还在看</title><type>57</type>'
|
||||
'<refermsg><type>49</type><displayname><![CDATA[水豚喧喧]]></displayname>'
|
||||
'<content><![CDATA[wxid_gryaI8aopjio22: <?xml version="1.0"?><msg><appmsg appid="" sdkver="0">'
|
||||
'<title>为自己的美丽漂亮善良知性发声😊</title><des></des>'
|
||||
'<type>5</type><url>https://mp.weixin.qq.com/s/example</url>'
|
||||
'<thumburl>https://mmbiz.qpic.cn/some-thumb.jpg</thumburl>'
|
||||
'</appmsg></msg>]]></content></refermsg></appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("quoteType"), "49")
|
||||
self.assertEqual(parsed.get("quoteTitle"), "水豚喧喧")
|
||||
self.assertEqual(parsed.get("quoteContent"), "[链接] 为自己的美丽漂亮善良知性发声😊")
|
||||
self.assertEqual(parsed.get("quoteThumbUrl"), "https://mmbiz.qpic.cn/some-thumb.jpg")
|
||||
|
||||
def test_public_account_link_exposes_link_type_and_style(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>为自己的美丽漂亮善良知性发声😊</title>'
|
||||
'<des>#日常穿搭灵感 #白色蕾丝裙穿搭 #知性美女</des>'
|
||||
'<type>5</type>'
|
||||
'<url>http://mp.weixin.qq.com/s?__biz=xx&mid=1</url>'
|
||||
'<thumburl>http://mmbiz.qpic.cn/abc/640?wx_fmt=jpeg</thumburl>'
|
||||
'<sourceusername>gh_0cef8eaa987d</sourceusername>'
|
||||
'<sourcedisplayname>草莓不甜芒果甜</sourcedisplayname>'
|
||||
'</appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "link")
|
||||
self.assertEqual(parsed.get("linkType"), "official_article")
|
||||
self.assertEqual(parsed.get("linkStyle"), "cover")
|
||||
|
||||
def test_quote_type_5_nested_xml_refermsg_uses_inner_title(self):
|
||||
raw_text = (
|
||||
'<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>这个年龄有点大啊</title><type>57</type>'
|
||||
'<refermsg><type>5</type><displayname><![CDATA[水豚噜噜]]></displayname>'
|
||||
'<content><![CDATA[wxid_qrval8aopiio22:\n<?xml version="1.0"?>\n<msg><appmsg appid="" sdkver="0">'
|
||||
'<title>谁说冬天不能穿裙子?</title><des></des><type>5</type>'
|
||||
'<thumburl>https://mmbiz.qpic.cn/some-thumb2.jpg</thumburl>'
|
||||
'<url>https://mp.weixin.qq.com/s/example2</url>'
|
||||
'</appmsg></msg>]]></content></refermsg></appmsg></msg>'
|
||||
)
|
||||
|
||||
parsed = _parse_app_message(raw_text)
|
||||
|
||||
self.assertEqual(parsed.get("renderType"), "quote")
|
||||
self.assertEqual(parsed.get("quoteType"), "5")
|
||||
self.assertEqual(parsed.get("quoteTitle"), "水豚噜噜")
|
||||
self.assertEqual(parsed.get("quoteContent"), "[链接] 谁说冬天不能穿裙子?")
|
||||
self.assertEqual(parsed.get("quoteThumbUrl"), "https://mmbiz.qpic.cn/some-thumb2.jpg")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,68 @@
|
||||
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.routers import chat as chat_router
|
||||
|
||||
|
||||
class TestTransferPostprocess(unittest.TestCase):
|
||||
def test_backfilled_pending_and_received_confirmation_have_expected_titles(self):
|
||||
transfer_id = "1000050001202601152035503031545"
|
||||
merged = [
|
||||
{
|
||||
"id": "message_0:Msg_x:60",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "1",
|
||||
"transferId": transfer_id,
|
||||
"amount": "¥100.00",
|
||||
"createTime": 1768463200,
|
||||
"isSent": False,
|
||||
"transferStatus": "",
|
||||
},
|
||||
{
|
||||
"id": "message_0:Msg_x:65",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "3",
|
||||
"transferId": transfer_id,
|
||||
"amount": "¥100.00",
|
||||
"createTime": 1768463246,
|
||||
"isSent": True,
|
||||
# Pre-inferred value (may be "已被接收") should be corrected by postprocess.
|
||||
"transferStatus": "已被接收",
|
||||
},
|
||||
]
|
||||
|
||||
chat_router._postprocess_transfer_messages(merged)
|
||||
|
||||
self.assertEqual(merged[0].get("paySubType"), "3")
|
||||
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
|
||||
self.assertEqual(merged[1].get("paySubType"), "3")
|
||||
self.assertEqual(merged[1].get("transferStatus"), "已收款")
|
||||
|
||||
def test_received_message_without_pending_is_left_unchanged(self):
|
||||
merged = [
|
||||
{
|
||||
"id": "message_0:Msg_x:65",
|
||||
"renderType": "transfer",
|
||||
"paySubType": "3",
|
||||
"transferId": "t1",
|
||||
"amount": "¥100.00",
|
||||
"createTime": 1,
|
||||
"isSent": True,
|
||||
"transferStatus": "已被接收",
|
||||
}
|
||||
]
|
||||
|
||||
chat_router._postprocess_transfer_messages(merged)
|
||||
|
||||
self.assertEqual(merged[0].get("transferStatus"), "已被接收")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
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 _infer_transfer_status_text
|
||||
|
||||
|
||||
class TestTransferStatusText(unittest.TestCase):
|
||||
def test_paysubtype_3_sent_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=True,
|
||||
paysubtype="3",
|
||||
receivestatus="",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已被接收")
|
||||
|
||||
def test_paysubtype_3_received_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=False,
|
||||
paysubtype="3",
|
||||
receivestatus="",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已收款")
|
||||
|
||||
def test_receivestatus_1_sent_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=True,
|
||||
paysubtype="1",
|
||||
receivestatus="1",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已被接收")
|
||||
|
||||
def test_receivestatus_1_received_side(self):
|
||||
status = _infer_transfer_status_text(
|
||||
is_sent=False,
|
||||
paysubtype="1",
|
||||
receivestatus="1",
|
||||
sendertitle="",
|
||||
receivertitle="",
|
||||
senderdes="",
|
||||
receiverdes="",
|
||||
)
|
||||
self.assertEqual(status, "已收款")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user