refactor(chat-ui): 抽离侧边栏并统一账号/实时/隐私状态

新增 SidebarRail 组件并统一主导航入口

引入 chatAccounts/chatRealtime/privacy 三个 Pinia store 复用全局状态

聊天/联系人/朋友圈页面去重侧栏逻辑,app 根布局统一承载标题栏与内容区
This commit is contained in:
2977094657
2026-02-11 12:14:21 +08:00
parent 7447a904b3
commit 2ce479aefd
10 changed files with 728 additions and 852 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' }
}
},
// 模块配置

View File

@@ -1,7 +1,7 @@
<template>
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
<!-- 左侧边栏 -->
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
<!-- Left sidebar rail is provided by `frontend/app.vue` -->
<div v-if="false" class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
<!-- 头像类似微信侧边栏 -->
<div class="w-full h-[60px] flex items-center justify-center">
@@ -118,8 +118,7 @@
@click="toggleRealtimeFromSidebar"
>
<div
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors"
:class="realtimeEnabled ? 'bg-[#DFF5E7]' : 'bg-transparent group-hover:bg-[#E1E1E1]'"
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
>
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M13 2L4 14h7l-1 8 9-12h-7z" />
@@ -130,7 +129,7 @@
<!-- 隐私模式按钮 -->
<div
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="privacyMode = !privacyMode"
@click="togglePrivacyMode"
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
>
<div
@@ -144,11 +143,10 @@
</div>
</div>
<!-- 设置按钮仅桌面端 -->
<!-- 设置按钮 -->
<div
v-if="isDesktopEnv"
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
@click="openDesktopSettings"
@click="goSettings"
title="设置"
>
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
@@ -281,8 +279,6 @@
<!-- 右侧聊天区域 -->
<div class="flex-1 flex flex-col min-h-0" style="background-color: #EDEDED">
<!-- 桌面端将自绘标题栏放到右侧区域避免遮挡左侧栏更接近原生微信布局 -->
<DesktopTitleBar />
<div class="flex-1 flex min-h-0">
<!-- 聊天主区域 -->
<div class="flex-1 flex flex-col min-h-0 min-w-0">
@@ -1728,108 +1724,12 @@
</div>
</div>
<!-- 桌面端设置弹窗 -->
<div
v-if="desktopSettingsOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
@click.self="closeDesktopSettings"
>
<div class="w-full max-w-md bg-white rounded-lg shadow-lg">
<div class="px-5 py-4 border-b border-gray-200 flex items-center justify-between">
<div class="text-base font-medium text-gray-900">设置</div>
<button
type="button"
class="text-gray-500 hover:text-gray-700"
@click="closeDesktopSettings"
title="关闭"
>
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="px-5 py-4 space-y-4">
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">开机自启动</div>
<div class="text-xs text-gray-500">系统登录后自动启动桌面端</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopAutoLaunch"
:disabled="desktopAutoLaunchLoading"
@change="onDesktopAutoLaunchToggle"
/>
</div>
<div v-if="desktopAutoLaunchError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopAutoLaunchError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">关闭窗口行为</div>
<div class="text-xs text-gray-500">点击关闭按钮时默认最小化到托盘</div>
</div>
<select
class="text-sm px-2 py-1 rounded-md border border-gray-200"
:disabled="desktopCloseBehaviorLoading"
:value="desktopCloseBehavior"
@change="onDesktopCloseBehaviorChange"
>
<option value="tray">最小化到托盘</option>
<option value="exit">直接退出</option>
</select>
</div>
<div v-if="desktopCloseBehaviorError" class="text-xs text-red-600 whitespace-pre-wrap">
{{ desktopCloseBehaviorError }}
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">启动后自动开启实时获取</div>
<div class="text-xs text-gray-500">进入聊天页后自动打开实时开关默认关闭</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopAutoRealtime"
@change="onDesktopAutoRealtimeToggle"
/>
</div>
<div class="flex items-center justify-between gap-4">
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900">有数据时默认进入聊天页</div>
<div class="text-xs text-gray-500">有已解密账号时打开应用默认跳转到 /chat默认关闭</div>
</div>
<input
type="checkbox"
class="h-4 w-4"
:checked="desktopDefaultToChatWhenData"
@change="onDesktopDefaultToChatToggle"
/>
</div>
</div>
<div class="px-5 py-4 border-t border-gray-200 flex items-center justify-end gap-2">
<button
type="button"
class="text-sm px-3 py-2 rounded-md bg-white border border-gray-200 hover:bg-gray-50"
@click="closeDesktopSettings"
>
关闭
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onUnmounted, nextTick, defineComponent, h, toRaw } from 'vue'
import { storeToRefs } from 'pinia'
definePageMeta({
key: 'chat'
@@ -1837,6 +1737,10 @@ definePageMeta({
import { useApi } from '~/composables/useApi'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
import { usePrivacyStore } from '~/stores/privacy'
import wechatPcLogoUrl from '~/assets/images/wechat/WeChat-Icon-Logo.wine.svg'
import zipIconUrl from '~/assets/images/wechat/zip.png'
import pdfIconUrl from '~/assets/images/wechat/pdf.png'
@@ -1873,9 +1777,6 @@ useHead({
})
const route = useRoute()
const isSnsRoute = computed(() => route.path?.startsWith('/sns'))
const isContactsRoute = computed(() => route.path?.startsWith('/contacts'))
const isWrappedRoute = computed(() => route.path?.startsWith('/wrapped'))
const routeUsername = computed(() => {
const raw = route.params.username
@@ -1896,25 +1797,9 @@ const contactProfileData = ref(null)
let contactProfileHoverHideTimer = null
// 隐私模式
const privacyMode = ref(false)
const PRIVACY_MODE_KEY = 'ui.privacy_mode'
onMounted(() => {
if (!process.client) return
try {
privacyMode.value = localStorage.getItem(PRIVACY_MODE_KEY) === '1'
} catch {}
})
watch(
() => privacyMode.value,
(v) => {
if (!process.client) return
try {
localStorage.setItem(PRIVACY_MODE_KEY, v ? '1' : '0')
} catch {}
}
)
const privacyStore = usePrivacyStore()
privacyStore.init()
const { privacyMode } = storeToRefs(privacyStore)
// 会话列表(中间栏)宽度(按物理像素 px 配置):默认 295px支持拖动调整并持久化
const SESSION_LIST_WIDTH_KEY = 'ui.chat.session_list_width_physical'
@@ -2041,141 +1926,12 @@ onMounted(() => {
// 桌面端设置(仅 Electron 环境可见)
const isDesktopEnv = ref(false)
const desktopSettingsOpen = ref(false)
const DESKTOP_SETTING_AUTO_REALTIME_KEY = 'desktop.settings.autoRealtime'
const DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY = 'desktop.settings.defaultToChatWhenData'
const desktopAutoRealtime = ref(false)
const desktopDefaultToChatWhenData = ref(false)
const desktopAutoLaunch = ref(false)
const desktopAutoLaunchLoading = ref(false)
const desktopAutoLaunchError = ref('')
const desktopCloseBehavior = ref('tray') // tray | exit
const desktopCloseBehaviorLoading = ref(false)
const desktopCloseBehaviorError = ref('')
const readLocalBool = (key) => {
if (!process.client) return false
try {
return localStorage.getItem(key) === 'true'
} catch {
return false
}
}
const writeLocalBool = (key, value) => {
if (!process.client) return
try {
localStorage.setItem(key, value ? 'true' : 'false')
} catch {}
}
// 尽量早读本地设置,避免首次加载联系人时拿不到 autoRealtime 选项
if (process.client) {
desktopAutoRealtime.value = readLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY)
desktopDefaultToChatWhenData.value = readLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY)
}
const refreshDesktopAutoLaunch = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.getAutoLaunch())
} catch (e) {
desktopAutoLaunchError.value = e?.message || '读取开机自启动状态失败'
} finally {
desktopAutoLaunchLoading.value = false
}
}
const setDesktopAutoLaunch = async (enabled) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setAutoLaunch) return
desktopAutoLaunchLoading.value = true
desktopAutoLaunchError.value = ''
try {
desktopAutoLaunch.value = !!(await window.wechatDesktop.setAutoLaunch(!!enabled))
} catch (e) {
desktopAutoLaunchError.value = e?.message || '设置开机自启动失败'
await refreshDesktopAutoLaunch()
} finally {
desktopAutoLaunchLoading.value = false
}
}
const refreshDesktopCloseBehavior = async () => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.getCloseBehavior) return
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.getCloseBehavior()
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '读取关闭窗口行为失败'
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const setDesktopCloseBehavior = async (behavior) => {
if (!process.client || typeof window === 'undefined') return
if (!window.wechatDesktop?.setCloseBehavior) return
const desired = (String(behavior || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
desktopCloseBehaviorLoading.value = true
desktopCloseBehaviorError.value = ''
try {
const v = await window.wechatDesktop.setCloseBehavior(desired)
desktopCloseBehavior.value = (String(v || '').toLowerCase() === 'exit') ? 'exit' : 'tray'
} catch (e) {
desktopCloseBehaviorError.value = e?.message || '设置关闭窗口行为失败'
await refreshDesktopCloseBehavior()
} finally {
desktopCloseBehaviorLoading.value = false
}
}
const openDesktopSettings = async () => {
desktopSettingsOpen.value = true
await refreshDesktopAutoLaunch()
await refreshDesktopCloseBehavior()
}
const closeDesktopSettings = () => {
desktopSettingsOpen.value = false
}
const onDesktopAutoLaunchToggle = async (ev) => {
const checked = !!ev?.target?.checked
await setDesktopAutoLaunch(checked)
}
const onDesktopCloseBehaviorChange = async (ev) => {
const v = String(ev?.target?.value || '').trim()
await setDesktopCloseBehavior(v)
}
const onDesktopAutoRealtimeToggle = async (ev) => {
const checked = !!ev?.target?.checked
desktopAutoRealtime.value = checked
writeLocalBool(DESKTOP_SETTING_AUTO_REALTIME_KEY, checked)
if (checked) {
// 开启后尝试立即启用实时模式(不可用则静默忽略)
try {
await tryEnableRealtimeAuto()
} catch {}
}
}
const onDesktopDefaultToChatToggle = (ev) => {
const checked = !!ev?.target?.checked
desktopDefaultToChatWhenData.value = checked
writeLocalBool(DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, checked)
desktopAutoRealtime.value = readLocalBoolSetting(DESKTOP_SETTING_AUTO_REALTIME_KEY, false)
}
// 联系人数据
@@ -2185,45 +1941,22 @@ const searchQuery = ref('')
const isLoadingContacts = ref(false)
const contactsError = ref('')
const selectedAccount = ref(null)
const chatAccounts = useChatAccountsStore()
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
const availableAccounts = ref([])
// Realtime is a global switch (SidebarRail) and only affects the selected account.
const realtimeStore = useChatRealtimeStore()
const {
enabled: realtimeEnabled,
toggleSeq: realtimeToggleSeq,
lastToggleAction: realtimeLastToggleAction,
changeSeq: realtimeChangeSeq,
} = storeToRefs(realtimeStore)
const sidebarMediaBase = process.client ? 'http://localhost:8000' : ''
const selfAvatarUrl = computed(() => {
const acc = String(selectedAccount.value || '').trim()
if (!acc) return ''
return `${sidebarMediaBase}/api/chat/avatar?account=${encodeURIComponent(acc)}&username=${encodeURIComponent(acc)}`
})
const goSns = async () => {
await navigateTo('/sns')
}
const goContacts = async () => {
await navigateTo('/contacts')
}
const goWrapped = async () => {
await navigateTo('/wrapped')
}
// 实时更新WCDB DLL + db_storage watcher
const realtimeEnabled = ref(false)
const realtimeAvailable = ref(false)
const realtimeChecking = ref(false)
const realtimeStatusInfo = ref(null)
const realtimeStatusError = ref('')
let realtimeEventSource = null
let realtimeRefreshFuture = null
let realtimeRefreshQueued = false
let realtimeSessionsRefreshFuture = null
let realtimeSessionsRefreshQueued = false
let realtimeFullSyncFuture = null
let realtimeFullSyncQueued = false
let realtimeFullSyncPriority = ''
let realtimeChangeDebounceTimer = null
const allMessages = ref({})
@@ -3194,11 +2927,6 @@ const onContactCardMouseEnter = () => {
clearContactProfileHoverHideTimer()
}
const toggleRealtimeFromSidebar = async () => {
if (realtimeChecking.value) return
await toggleRealtime()
}
watch(exportModalOpen, (open) => {
if (!process.client) return
if (!open) {
@@ -4343,23 +4071,18 @@ onMounted(() => {
})
const loadContacts = async () => {
const api = useApi()
isLoadingContacts.value = true
contactsError.value = ''
try {
const accountsResp = await api.listChatAccounts()
const accounts = accountsResp?.accounts || []
availableAccounts.value = accounts
selectedAccount.value = selectedAccount.value || accountsResp?.default_account || accounts[0] || null
await chatAccounts.ensureLoaded()
if (!selectedAccount.value) {
contacts.value = []
selectedContact.value = null
contactsError.value = accountsResp?.message || '未检测到已解密账号,请先解密数据库。'
contactsError.value = chatAccounts.error || '未检测到已解密账号,请先解密数据库。'
return
}
await loadSessionsForSelectedAccount()
} catch (e) {
contacts.value = []
@@ -4514,49 +4237,6 @@ const queueRealtimeSessionsRefresh = () => {
})
}
const runRealtimeFullSync = async (priorityUsername) => {
if (!realtimeEnabled.value) return null
if (!process.client || typeof window === 'undefined') return null
if (!selectedAccount.value) return null
try {
const api = useApi()
return await api.syncChatRealtimeAll({
account: selectedAccount.value,
max_scan: 200,
priority_username: String(priorityUsername || '').trim(),
priority_max_scan: 600,
include_hidden: true,
include_official: true
})
} catch {
return null
}
}
const queueRealtimeFullSync = (priorityUsername) => {
const u = String(priorityUsername || '').trim()
if (u) realtimeFullSyncPriority = u
if (realtimeFullSyncFuture) {
realtimeFullSyncQueued = true
return realtimeFullSyncFuture
}
const priority = realtimeFullSyncPriority
realtimeFullSyncPriority = ''
realtimeFullSyncFuture = runRealtimeFullSync(priority).finally(() => {
realtimeFullSyncFuture = null
if (realtimeFullSyncQueued) {
realtimeFullSyncQueued = false
queueRealtimeFullSync(realtimeFullSyncPriority)
}
})
return realtimeFullSyncFuture
}
const onAccountChange = async () => {
try {
isLoadingContacts.value = true
@@ -5288,7 +4968,6 @@ onUnmounted(() => {
highlightMessageTimer = null
stopMessageSearchIndexPolling()
stopExportPolling()
stopRealtimeStream()
})
const dedupeMessagesById = (list) => {
@@ -5405,46 +5084,6 @@ const refreshSelectedMessages = async () => {
await loadMessages({ username: selectedContact.value.username, reset: true })
}
const fetchRealtimeStatus = async () => {
if (!process.client) return
if (!selectedAccount.value) {
realtimeAvailable.value = false
realtimeStatusInfo.value = null
realtimeStatusError.value = ''
return
}
const api = useApi()
realtimeChecking.value = true
try {
const resp = await api.getChatRealtimeStatus({ account: selectedAccount.value })
realtimeAvailable.value = !!resp?.available
realtimeStatusInfo.value = resp?.realtime || null
realtimeStatusError.value = ''
} catch (e) {
realtimeAvailable.value = false
realtimeStatusInfo.value = null
realtimeStatusError.value = e?.message || '实时状态获取失败'
} finally {
realtimeChecking.value = false
}
}
const stopRealtimeStream = () => {
if (realtimeEventSource) {
try {
realtimeEventSource.close()
} catch {}
realtimeEventSource = null
}
if (realtimeChangeDebounceTimer) {
try {
clearTimeout(realtimeChangeDebounceTimer)
} catch {}
realtimeChangeDebounceTimer = null
}
}
const refreshRealtimeIncremental = async () => {
if (!realtimeEnabled.value) return
if (!selectedAccount.value) return
@@ -5516,115 +5155,46 @@ const queueRealtimeRefresh = () => {
})
}
const queueRealtimeChange = () => {
if (!process.client || typeof window === 'undefined') return
if (!realtimeEnabled.value) return
if (realtimeChangeDebounceTimer) return
// Debounce noisy db_storage change events to avoid hammering the backend.
realtimeChangeDebounceTimer = setTimeout(() => {
realtimeChangeDebounceTimer = null
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
}, 500)
}
const startRealtimeStream = () => {
stopRealtimeStream()
if (!process.client || typeof window === 'undefined') return
if (!realtimeEnabled.value) return
if (!selectedAccount.value) return
if (typeof EventSource === 'undefined') return
const base = 'http://localhost:8000'
const url = `${base}/api/chat/realtime/stream?account=${encodeURIComponent(String(selectedAccount.value))}`
try {
realtimeEventSource = new EventSource(url)
} catch (e) {
realtimeEventSource = null
return
}
realtimeEventSource.onmessage = (ev) => {
try {
const data = JSON.parse(String(ev.data || '{}'))
if (String(data?.type || '') === 'change') {
queueRealtimeChange()
}
} catch {}
}
realtimeEventSource.onerror = () => {
stopRealtimeStream()
}
}
const toggleRealtime = async (opts = {}) => {
const silent = !!opts?.silent
if (!process.client || typeof window === 'undefined') return
if (!selectedAccount.value) return
if (!realtimeEnabled.value) {
await fetchRealtimeStatus()
if (!realtimeAvailable.value) {
if (!silent) {
window.alert(realtimeStatusError.value || '实时模式不可用:缺少密钥或 db_storage 路径。')
}
return false
}
realtimeEnabled.value = true
startRealtimeStream()
queueRealtimeSessionsRefresh()
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
return true
}
// Turning off realtime: sync the latest WCDB rows into the decrypted sqlite DB first,
// otherwise the UI will fall back to an outdated decrypted snapshot.
realtimeEnabled.value = false
stopRealtimeStream()
try {
const api = useApi()
const u = String(selectedContact.value?.username || '').trim()
// Sync all sessions once before falling back to the decrypted snapshot.
// This keeps the sidebar session list consistent (e.g. new friends) after a refresh.
await api.syncChatRealtimeAll({
account: selectedAccount.value,
max_scan: 200,
priority_username: u,
priority_max_scan: 5000,
include_hidden: true,
include_official: true
})
} catch {}
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
return true
}
const tryEnableRealtimeAuto = async () => {
if (!process.client || typeof window === 'undefined') return
if (!isDesktopEnv.value) return
if (!desktopAutoRealtime.value) return
if (realtimeEnabled.value) return
if (!selectedAccount.value) return
try {
await toggleRealtime({ silent: true })
await realtimeStore.enable({ silent: true })
} catch {}
}
watch(selectedAccount, async () => {
await fetchRealtimeStatus()
if (realtimeEnabled.value) {
startRealtimeStream()
watch(realtimeChangeSeq, () => {
queueRealtimeRefresh()
queueRealtimeSessionsRefresh()
})
watch(realtimeToggleSeq, async () => {
const action = String(realtimeLastToggleAction.value || '')
if (action === 'enabled') {
await refreshSessionsForSelectedAccount({ sourceOverride: 'realtime' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
return
}
if (action === 'disabled') {
await refreshSessionsForSelectedAccount({ sourceOverride: '' })
if (selectedContact.value?.username) {
await refreshSelectedMessages()
}
}
})
watch(
() => selectedContact.value?.username,
(u) => {
realtimeStore.setPriorityUsername(u || '')
}
)
watch(messageTypeFilter, async (next, prev) => {
if (String(next || '') === String(prev || '')) return
if (!selectedContact.value?.username) return

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
}
})

View File

@@ -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,
}
})

View File

@@ -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,
}
})

View File

@@ -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 {}
}