mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-02-19 14:20:51 +08:00
refactor(chat-ui): 抽离侧边栏并统一账号/实时/隐私状态
新增 SidebarRail 组件并统一主导航入口 引入 chatAccounts/chatRealtime/privacy 三个 Pinia store 复用全局状态 聊天/联系人/朋友圈页面去重侧栏逻辑,app 根布局统一承载标题栏与内容区
This commit is contained in:
@@ -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>
|
||||
|
||||
240
frontend/components/SidebarRail.vue
Normal file
240
frontend/components/SidebarRail.vue
Normal 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>
|
||||
|
||||
@@ -31,8 +31,7 @@ export default defineNuxtConfig({
|
||||
{ rel: 'icon', type: 'image/png', href: '/logo.png' },
|
||||
{ rel: 'stylesheet', href: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css' }
|
||||
]
|
||||
},
|
||||
pageTransition: { name: 'page', mode: 'out-in' }
|
||||
}
|
||||
},
|
||||
|
||||
// 模块配置
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="h-screen flex overflow-hidden" style="background-color: #EDEDED">
|
||||
<!-- 左侧边栏 -->
|
||||
<div class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<!-- Left sidebar rail is provided by `frontend/app.vue` -->
|
||||
<div v-if="false" class="border-r border-gray-200 flex flex-col" style="background-color: #e8e7e7; width: 60px; min-width: 60px; max-width: 60px">
|
||||
<div class="flex-1 flex flex-col justify-start pt-0 gap-0">
|
||||
<!-- 头像(类似微信侧边栏) -->
|
||||
<div class="w-full h-[60px] flex items-center justify-center">
|
||||
@@ -118,8 +118,7 @@
|
||||
@click="toggleRealtimeFromSidebar"
|
||||
>
|
||||
<div
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors"
|
||||
:class="realtimeEnabled ? 'bg-[#DFF5E7]' : 'bg-transparent group-hover:bg-[#E1E1E1]'"
|
||||
class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]"
|
||||
>
|
||||
<svg class="w-[var(--sidebar-rail-icon)] h-[var(--sidebar-rail-icon)]" :class="realtimeEnabled ? 'text-[#07b75b]' : 'text-[#5d5d5d]'" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M13 2L4 14h7l-1 8 9-12h-7z" />
|
||||
@@ -130,7 +129,7 @@
|
||||
<!-- 隐私模式按钮 -->
|
||||
<div
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="privacyMode = !privacyMode"
|
||||
@click="togglePrivacyMode"
|
||||
:title="privacyMode ? '关闭隐私模式' : '开启隐私模式'"
|
||||
>
|
||||
<div
|
||||
@@ -144,11 +143,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 设置按钮(仅桌面端) -->
|
||||
<!-- 设置按钮 -->
|
||||
<div
|
||||
v-if="isDesktopEnv"
|
||||
class="w-full h-[var(--sidebar-rail-step)] flex items-center justify-center cursor-pointer group"
|
||||
@click="openDesktopSettings"
|
||||
@click="goSettings"
|
||||
title="设置"
|
||||
>
|
||||
<div class="w-[var(--sidebar-rail-btn)] h-[var(--sidebar-rail-btn)] rounded-md flex items-center justify-center transition-colors bg-transparent group-hover:bg-[#E1E1E1]">
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
107
frontend/stores/chatAccounts.js
Normal file
107
frontend/stores/chatAccounts.js
Normal 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,
|
||||
}
|
||||
})
|
||||
226
frontend/stores/chatRealtime.js
Normal file
226
frontend/stores/chatRealtime.js
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
31
frontend/stores/privacy.js
Normal file
31
frontend/stores/privacy.js
Normal 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,
|
||||
}
|
||||
})
|
||||
|
||||
20
frontend/utils/privacy-mode.js
Normal file
20
frontend/utils/privacy-mode.js
Normal 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 {}
|
||||
}
|
||||
Reference in New Issue
Block a user