Merge pull request #34 from alfredt999/main

修復1月更新後聊天預覽無法顯示
This commit is contained in:
2977094657
2026-03-14 21:32:11 +08:00
committed by GitHub
22 changed files with 268 additions and 14112 deletions
+5 -3
View File
@@ -289,9 +289,10 @@
</template>
<script setup>
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/utils/desktop-settings'
import { readApiBaseOverride, writeApiBaseOverride } from '~/utils/api-settings'
import { reportServerErrorFromError } from '~/utils/server-error-logging'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting, writeLocalBoolSetting } from '~/lib/desktop-settings'
import { readApiBaseOverride, writeApiBaseOverride } from '~/lib/api-settings'
import { invalidateApiBaseCache } from '~/composables/useApiBase'
import { reportServerErrorFromError } from '~/lib/server-error-logging'
const props = defineProps({
open: {
@@ -624,6 +625,7 @@ const applyDesktopBackendPort = async () => {
const host = String(window.location?.hostname || '').trim() || '127.0.0.1'
const nextOrigin = `${protocol}//${host}:${n}`
writeApiBaseOverride(`${nextOrigin}/api`)
invalidateApiBaseCache()
const waitForHealth = async (healthUrl, timeoutMs = 30_000) => {
const startedAt = Date.now()
@@ -260,7 +260,7 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import Stack from '~/components/wrapped/shared/VueBitsStack.vue'
import WechatEmojiTable, { parseTextWithEmoji } from '~/utils/wechat-emojis'
import WechatEmojiTable, { parseTextWithEmoji } from '~/lib/wechat-emojis'
const props = defineProps({
card: { type: Object, required: true },
@@ -99,7 +99,7 @@ import { computed, inject, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { gsap } from 'gsap'
import KeywordWordCloud from '~/components/wrapped/visualizations/KeywordWordCloud.vue'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { usePrivacyStore } from '~/stores/privacy'
const props = defineProps({
@@ -107,7 +107,7 @@
</template>
<script setup>
import { heatColor } from '~/utils/wrapped/heatmap'
import { heatColor } from '~/lib/wrapped/heatmap'
const props = defineProps({
year: { type: Number, default: new Date().getFullYear() },
@@ -100,7 +100,7 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { usePrivacyStore } from '~/stores/privacy'
const props = defineProps({
@@ -59,7 +59,7 @@
</template>
<script setup>
import { heatColor, maxInMatrix, formatHourRange } from '~/utils/wrapped/heatmap'
import { heatColor, maxInMatrix, formatHourRange } from '~/lib/wrapped/heatmap'
const props = defineProps({
weekdayLabels: { type: Array, default: () => ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] },
+1 -1
View File
@@ -1,4 +1,4 @@
import { reportServerError } from '~/utils/server-error-logging'
import { reportServerError } from '~/lib/server-error-logging'
// API请求组合式函数
export const useApi = () => {
+29 -3
View File
@@ -1,7 +1,22 @@
import { normalizeApiBase, readApiBaseOverride } from '~/utils/api-settings'
import { normalizeApiBase, readApiBaseOverride } from '~/lib/api-settings'
// Client-side cache so that useApiBase() can be called safely outside
// the Nuxt composable context (e.g. inside async callbacks / onMounted chains).
let _clientCache = ''
export const useApiBase = () => {
const config = useRuntimeConfig()
if (process.client && _clientCache) return _clientCache
// useRuntimeConfig() requires the Nuxt app context, which is only
// guaranteed during synchronous setup. On the client we cache the
// result so later (context-less) calls still work.
let config
try {
config = useRuntimeConfig()
} catch {
// Context unavailable fall back to cached value or default.
return _clientCache || '/api'
}
// Default to same-origin `/api` so Nuxt devProxy / backend-mounted UI both work.
// Override priority:
@@ -10,5 +25,16 @@ export const useApiBase = () => {
// 3) `/api`
const override = process.client ? readApiBaseOverride() : ''
const runtime = String(config?.public?.apiBase || '').trim()
return normalizeApiBase(override || runtime || '/api')
const result = normalizeApiBase(override || runtime || '/api')
if (process.client) _clientCache = result
return result
}
/**
* Call this when the user changes the API base override in settings
* so the cached value is refreshed.
*/
export const invalidateApiBaseCache = () => {
_clientCache = ''
}
-14042
View File
File diff suppressed because it is too large Load Diff
+160 -46
View File
@@ -206,11 +206,11 @@
</div>
<select
v-if="availableAccounts.length > 1"
v-model="selectedAccount"
@change="onAccountChange"
class="account-select"
>
<option v-if="!availableAccounts.length" disabled value="">{{ chatAccounts.loading ? '加载中...' : (chatAccounts.error || '无数据库') }}</option>
<option v-for="acc in availableAccounts" :key="acc" :value="acc">{{ acc }}</option>
</select>
</div>
@@ -249,7 +249,7 @@
<div class="relative flex-shrink-0" :class="{ 'privacy-blur': privacyMode }">
<div class="w-[calc(45px/var(--dpr))] h-[calc(45px/var(--dpr))] rounded-md overflow-hidden bg-gray-300">
<div v-if="contact.avatar" class="w-full h-full">
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover" referrerpolicy="no-referrer" @error="onAvatarError($event, contact)">
<img :src="contact.avatar" :alt="contact.name" class="w-full h-full object-cover" loading="lazy" referrerpolicy="no-referrer" @error="onAvatarError($event, contact)">
</div>
<div v-else class="w-full h-full flex items-center justify-center text-white text-xs font-bold"
:style="{ backgroundColor: contact.avatarColor || '#4B5563' }">
@@ -2493,10 +2493,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 { reportServerErrorFromResponse } from '~/utils/server-error-logging'
import { heatColor } from '~/utils/wrapped/heatmap'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { DESKTOP_SETTING_AUTO_REALTIME_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
import { reportServerErrorFromResponse } from '~/lib/server-error-logging'
import { heatColor } from '~/lib/wrapped/heatmap'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { useChatRealtimeStore } from '~/stores/chatRealtime'
import { usePrivacyStore } from '~/stores/privacy'
@@ -2538,6 +2538,12 @@ useHead({
const route = useRoute()
// Capture the API helper once in the synchronous setup scope.
// In Nuxt 4, useApi() useApiBase() useRuntimeConfig() requires the Nuxt
// app context which can be lost inside deferred async functions (onMounted,
// event handlers). By capturing it here we guarantee it always works.
const _api = useApi()
const routeUsername = computed(() => {
const raw = route.params.username
return (Array.isArray(raw) ? raw[0] : raw) || ''
@@ -2704,6 +2710,97 @@ const contactsError = ref('')
const chatAccounts = useChatAccountsStore()
const { selectedAccount, accounts: availableAccounts } = storeToRefs(chatAccounts)
// Pre-fetch accounts during SSR so the dropdown data is embedded in the HTML payload.
// This also serves as a robust fallback when the Nuxt composable context is lost
// inside deferred async functions (e.g. onMounted ensureLoaded).
const _apiBase = useApiBase()
const { data: _prefetchedAccounts } = await useAsyncData('chat-accounts', () => {
// During SSR, relative URLs bypass the devProxy, so hit the backend directly.
if (process.server) {
const port = process.env.WECHAT_TOOL_PORT || '10392'
return $fetch('/api/chat/accounts', { baseURL: `http://127.0.0.1:${port}` })
}
return $fetch('/chat/accounts', { baseURL: _apiBase })
}, { watch: false })
if (_prefetchedAccounts.value?.accounts?.length && !chatAccounts.loaded) {
const resp = _prefetchedAccounts.value
chatAccounts.accounts = resp.accounts
const preferred = chatAccounts.selectedAccount
const fallback = resp.default_account || resp.accounts[0] || ''
chatAccounts.selectedAccount = (preferred && resp.accounts.includes(preferred)) ? preferred : fallback
chatAccounts.loaded = true
}
// Pre-fetch sessions during SSR so the contacts list renders immediately.
const _ssrSelectedAccount = chatAccounts.selectedAccount || ''
const { data: _prefetchedSessions } = await useAsyncData(
'chat-sessions-' + _ssrSelectedAccount,
() => {
if (!_ssrSelectedAccount) return Promise.resolve(null)
const params = new URLSearchParams({
account: _ssrSelectedAccount,
limit: '400',
include_hidden: 'false',
include_official: 'false',
})
if (process.server) {
const port = process.env.WECHAT_TOOL_PORT || '10392'
return $fetch(`/api/chat/sessions?${params}`, { baseURL: `http://127.0.0.1:${port}` })
}
return $fetch(`/chat/sessions?${params}`, { baseURL: _apiBase })
},
{ watch: false },
)
// Populate contacts from SSR-prefetched sessions so the list renders immediately.
// Deliberately omit avatar URLs during SSR to prevent the browser from flooding
// the single-threaded backend with hundreds of avatar requests on first paint.
// Avatars will be populated lazily on the client after hydration.
if (_prefetchedSessions.value?.sessions?.length) {
const _normPreview = (v) => {
const t = String(v || '').trim()
if (/^\[location\]/i.test(t)) return t.replace(/^\[location\]/i, '[位置]')
if (/:\s*\[location\]$/i.test(t)) return t.replace(/\[location\]$/i, '[位置]')
return t
}
const _ssrAvatars = new Map()
contacts.value = _prefetchedSessions.value.sessions.map((s) => {
if (s.avatar) _ssrAvatars.set(s.username || s.id, s.avatar)
return {
id: s.id,
name: s.name || s.username || s.id,
avatar: null, // deferred see _applySsrAvatars below
lastMessage: _normPreview(s.lastMessage || ''),
lastMessageTime: s.lastMessageTime || '',
unreadCount: s.unreadCount || 0,
isGroup: !!s.isGroup,
isTop: !!s.isTop,
username: s.username,
}
})
// After hydration, drip-feed avatar URLs in small batches so the browser
// doesn't fire hundreds of requests at once and starve the backend.
if (process.client && _ssrAvatars.size) {
const _applySsrAvatars = () => {
const entries = Array.from(_ssrAvatars.entries())
const BATCH = 6
let i = 0
const next = () => {
const batch = entries.slice(i, i + BATCH)
if (!batch.length) return
for (const [key, url] of batch) {
const c = contacts.value.find((ct) => (ct.username || ct.id) === key)
if (c) c.avatar = url
}
i += BATCH
if (i < entries.length) setTimeout(next, 150)
}
next()
}
// Delay until after hydration and initial message load have a chance to run.
setTimeout(_applySsrAvatars, 500)
}
}
// Realtime is a global switch (SidebarRail) and only affects the selected account.
const realtimeStore = useChatRealtimeStore()
const {
@@ -2975,7 +3072,7 @@ const selectMessageSearchSender = (username) => {
const fetchMessageSearchIndexStatus = async () => {
if (!selectedAccount.value) return null
const api = useApi()
const api = _api
try {
const resp = await api.getChatSearchIndexStatus({ account: selectedAccount.value })
messageSearchIndexInfo.value = resp?.index || null
@@ -3049,7 +3146,7 @@ const fetchMessageSearchSenders = async () => {
if (st) params.session_type = st
}
const api = useApi()
const api = _api
messageSearchSenderLoading.value = true
try {
const resp = await api.listChatSearchSenders(params)
@@ -3112,7 +3209,7 @@ const ensureMessageSearchIndexPolling = () => {
const onMessageSearchIndexAction = async () => {
if (!selectedAccount.value) return
const api = useApi()
const api = _api
const rebuild = messageSearchIndexExists.value
try {
const resp = await api.buildChatSearchIndex({ account: selectedAccount.value, rebuild })
@@ -3652,7 +3749,7 @@ const stopExportPolling = () => {
const startExportHttpPolling = (exportId) => {
if (!exportId) return
const api = useApi()
const api = _api
exportPollTimer = setInterval(async () => {
try {
const resp = await api.getChatExport(exportId)
@@ -3673,7 +3770,7 @@ const startExportPolling = (exportId) => {
if (!exportId) return
if (process.client && typeof window !== 'undefined' && typeof EventSource !== 'undefined') {
const apiBase = useApiBase()
const apiBase = _apiBase
const url = `${apiBase}/chat/exports/${encodeURIComponent(String(exportId))}/events`
try {
exportEventSource = new EventSource(url)
@@ -3743,7 +3840,7 @@ const fetchContactProfile = async (options = {}) => {
contactProfileLoading.value = true
contactProfileError.value = ''
try {
const api = useApi()
const api = _api
const resp = await api.listChatContacts({
account,
include_friends: true,
@@ -3932,7 +4029,7 @@ watch(
)
const getExportDownloadUrl = (exportId) => {
const apiBase = useApiBase()
const apiBase = _apiBase
return `${apiBase}/chat/exports/${encodeURIComponent(String(exportId || ''))}/download`
}
@@ -4006,7 +4103,7 @@ const startChatExport = async () => {
isExportCreating.value = true
exportAutoSavedFor.value = ''
try {
const api = useApi()
const api = _api
const resp = await api.createChatExport({
account: selectedAccount.value,
scope,
@@ -4041,7 +4138,7 @@ const cancelCurrentExport = async () => {
if (!exportId) return
try {
const api = useApi()
const api = _api
await api.cancelChatExport(exportId)
const resp = await api.getChatExport(exportId)
exportJob.value = resp?.job || exportJob.value
@@ -4316,7 +4413,7 @@ const loadContextMenuEditStatus = async (params) => {
}
try {
const api = useApi()
const api = _api
const resp = await api.getChatEditStatus({ account, username, message_id: messageId })
const cur = String(contextMenu.value?.message?.id || '').trim()
if (contextMenu.value.visible && cur === messageId) {
@@ -4401,7 +4498,7 @@ const openMessageEditModal = async ({ message, mode }) => {
}
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
const row = resp?.row || null
const rawContent = row?.message_content
@@ -4426,7 +4523,7 @@ const saveMessageEditModal = async () => {
messageEditModal.value = { ...messageEditModal.value, saving: true, error: '' }
try {
const api = useApi()
const api = _api
const resp = await api.editChatMessage({
account,
session_id: sessionId,
@@ -4512,7 +4609,7 @@ const openMessageFieldsModal = async (message) => {
}
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessageRaw({ account, username: sessionId, message_id: messageId })
const row = resp?.row || null
const seed = {}
@@ -4559,7 +4656,7 @@ const saveMessageFieldsModal = async () => {
messageFieldsModal.value = { ...messageFieldsModal.value, saving: true, error: '' }
try {
const api = useApi()
const api = _api
await api.editChatMessage({
account,
session_id: sessionId,
@@ -4607,7 +4704,7 @@ const onResetEditedMessageClick = async () => {
if (!ok) return
try {
const api = useApi()
const api = _api
await api.resetChatEditedMessage({ account, session_id: sessionId, message_id: messageId })
closeContextMenu()
await refreshSelectedMessages()
@@ -4631,7 +4728,7 @@ const onRepairMessageSenderAsMeClick = async () => {
if (!ok) return
try {
const api = useApi()
const api = _api
await api.repairChatMessageSender({ account, session_id: sessionId, message_id: messageId, mode: 'me' })
closeContextMenu()
await refreshSelectedMessages()
@@ -4657,7 +4754,7 @@ const onFlipWechatMessageDirectionClick = async () => {
if (!ok) return
try {
const api = useApi()
const api = _api
await api.flipChatMessageDirection({ account, session_id: sessionId, message_id: messageId })
closeContextMenu()
await refreshSelectedMessages()
@@ -4743,7 +4840,7 @@ const onCopyMessageJsonClick = async () => {
const onOpenFolderClick = async () => {
if (contextMenu.value.disabled) return
const api = useApi()
const api = _api
const m = contextMenu.value.message
const kind = contextMenu.value.kind
@@ -4889,7 +4986,7 @@ const loadTimeSidebarMonth = async ({ year, month, force } = {}) => {
}
const reqId = ++timeSidebarReqId
const api = useApi()
const api = _api
timeSidebarLoading.value = true
timeSidebarError.value = ''
@@ -5044,7 +5141,7 @@ const runMessageSearch = async ({ reset } = {}) => {
}
const reqId = ++messageSearchReqId
const api = useApi()
const api = _api
messageSearchLoading.value = true
messageSearchError.value = ''
messageSearchBackendStatus.value = ''
@@ -5255,7 +5352,7 @@ const locateSearchHit = async (hit) => {
}
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessagesAround({
account: selectedAccount.value,
username: targetUsername,
@@ -5326,7 +5423,7 @@ const locateByAnchorId = async ({ targetUsername, anchorId, kind, label } = {})
}
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessagesAround({
account: selectedAccount.value,
username: u,
@@ -5360,7 +5457,7 @@ const locateByDate = async (dateStr) => {
await _applyTimeSidebarSelectedDate(ds, { syncMonth: true })
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessageAnchor({
account: selectedAccount.value,
username: selectedContact.value.username,
@@ -5385,7 +5482,7 @@ const jumpToConversationFirst = async () => {
if (!selectedContact.value?.username) return
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessageAnchor({
account: selectedAccount.value,
username: selectedContact.value.username,
@@ -5448,7 +5545,7 @@ const loadMoreSearchContextAfter = async () => {
const ctxUsername = u
searchContext.value.loadingAfter = true
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessagesAround({
account: selectedAccount.value,
username: ctxUsername,
@@ -5511,7 +5608,7 @@ const loadMoreSearchContextBefore = async () => {
const ctxUsername = u
searchContext.value.loadingBefore = true
try {
const api = useApi()
const api = _api
const resp = await api.getChatMessagesAround({
account: selectedAccount.value,
username: ctxUsername,
@@ -5785,7 +5882,7 @@ const getFileIcon = (fileName) => {
// -
const onFileClick = async (message) => {
if (!message?.fileMd5) return
const api = useApi()
const api = _api
try {
if (!selectedAccount.value) return
@@ -5920,14 +6017,20 @@ const selectContact = async (contact, options = {}) => {
selectedContact.value = contact
const username = nextUsername
if (!username) return
// Fire loadMessages before navigateTo so the fetch starts immediately.
// navigateTo can cause Nuxt to re-run useAsyncData (Suspense), which races
// with or cancels the message fetch when it happens afterward.
if (!options.skipLoadMessages) {
loadMessages({ username, reset: true })
}
if (options.syncRoute !== false && username) {
const current = routeUsername.value || ''
if (current !== username) {
await navigateTo(buildChatPath(username), { replace: options.replaceRoute !== false })
}
}
if (options.skipLoadMessages) return
loadMessages({ username, reset: true })
}
const applyRouteSelection = async () => {
@@ -5962,6 +6065,14 @@ onMounted(() => {
})
const loadContacts = async () => {
// If contacts were already pre-fetched during SSR, skip the initial client-side fetch.
// Only apply route selection (e.g. deep-link to a specific contact).
if (contacts.value.length && !isLoadingContacts.value) {
await applyRouteSelection()
await tryEnableRealtimeAuto()
return
}
isLoadingContacts.value = true
contactsError.value = ''
@@ -5987,7 +6098,7 @@ const loadContacts = async () => {
}
const loadSessionsForSelectedAccount = async () => {
const api = useApi()
const api = _api
if (!selectedAccount.value) {
contacts.value = []
@@ -6074,7 +6185,7 @@ const refreshSessionsForSelectedAccount = async ({ sourceOverride } = {}) => {
if (!selectedAccount.value) return
if (isLoadingContacts.value) return
const api = useApi()
const api = _api
const prevSelected = selectedContact.value?.username || ''
const desiredSource = (sourceOverride != null)
@@ -6159,7 +6270,7 @@ const normalizeMessage = (msg) => {
const sender = isSent ? '我' : (msg.senderDisplayName || msg.senderUsername || selectedContact.value?.name || '')
const fallbackAvatar = (!isSent && !selectedContact.value?.isGroup) ? (selectedContact.value?.avatar || null) : null
const apiBase = useApiBase()
const apiBase = _apiBase
const normalizeMaybeUrl = (u) => (typeof u === 'string' ? u.trim() : '')
const isUsableMediaUrl = (u) => {
const v = normalizeMaybeUrl(u)
@@ -6425,7 +6536,7 @@ const onEmojiDownloadClick = async (message) => {
message._emojiDownloading = true
try {
const api = useApi()
const api = _api
await api.downloadChatEmoji({
account: selectedAccount.value,
md5: message.emojiMd5,
@@ -6826,7 +6937,7 @@ const formatChatHistoryVideoDuration = (value) => {
}
const normalizeChatHistoryRecordItem = (rec) => {
const apiBase = useApiBase()
const apiBase = _apiBase
const account = encodeURIComponent(selectedAccount.value || '')
const username = encodeURIComponent(selectedContact.value?.username || '')
@@ -7206,7 +7317,7 @@ const openNestedChatHistory = (rec) => {
;(async () => {
try {
const api = useApi()
const api = _api
const resp = await api.resolveNestedChatHistory({
account: selectedAccount.value,
server_id: sid,
@@ -7260,7 +7371,7 @@ const resolveChatHistoryLinkRecord = async (rec) => {
if (rec._linkResolving) return null
rec._linkResolving = true
try {
const api = useApi()
const api = _api
const resp = await api.resolveAppMsg({
account: selectedAccount.value,
server_id: sid,
@@ -7270,7 +7381,7 @@ const resolveChatHistoryLinkRecord = async (rec) => {
const content = String(resp.content || '').trim()
const url = String(resp.url || '').trim()
const from = String(resp.from || '').trim()
const apiBase = useApiBase()
const apiBase = _apiBase
const normalizePreviewUrl = (u) => {
const raw = String(u || '').trim()
if (!raw) return ''
@@ -7520,7 +7631,7 @@ const loadMessages = async ({ username, reset }) => {
if (!username) return
if (!selectedAccount.value) return
const api = useApi()
const api = _api
messagesError.value = ''
isLoadingMessages.value = true
activeMessagesFor.value = username
@@ -7627,7 +7738,7 @@ const refreshRealtimeIncremental = async () => {
const container = messageContainerRef.value
const atBottom = !!container && (container.scrollHeight - container.scrollTop - container.clientHeight) < 80
const api = useApi()
const api = _api
const params = {
account: selectedAccount.value,
username,
@@ -7733,10 +7844,13 @@ watch(messageTypeFilter, async (next, prev) => {
watch(
routeUsername,
async () => {
if (!process.client) return
if (isLoadingContacts.value) return
// Skip if contacts haven't loaded yet the initial selection is
// handled by onMounted loadContacts applyRouteSelection.
if (!contacts.value.length) return
await applyRouteSelection()
},
{ immediate: true }
)
watch(messageSearchScope, async () => {
+1 -1
View File
@@ -68,7 +68,7 @@
<script setup>
import { onMounted } from 'vue'
import { useApi } from '~/composables/useApi'
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { DESKTOP_SETTING_DEFAULT_TO_CHAT_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
onMounted(async () => {
if (!process.client || typeof window === 'undefined') return
+3 -3
View File
@@ -705,9 +705,9 @@
import { storeToRefs } from 'pinia'
import { useChatAccountsStore } from '~/stores/chatAccounts'
import { usePrivacyStore } from '~/stores/privacy'
import { parseTextWithEmoji } from '~/utils/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/utils/desktop-settings'
import { reportServerErrorFromError } from '~/utils/server-error-logging'
import { parseTextWithEmoji } from '~/lib/wechat-emojis'
import { SNS_SETTING_USE_CACHE_KEY, readLocalBoolSetting } from '~/lib/desktop-settings'
import { reportServerErrorFromError } from '~/lib/server-error-logging'
useHead({ title: '朋友圈 - 微信数据分析助手' })
+6 -2
View File
@@ -9,6 +9,11 @@ export const useChatAccountsStore = defineStore('chatAccounts', () => {
const error = ref('')
const loaded = ref(false)
// Capture apiBase during synchronous store setup when Nuxt context is available.
// useApiBase() calls useRuntimeConfig() which requires the Nuxt app context;
// that context can be lost inside deferred async functions (e.g. onMounted callbacks).
const _apiBase = useApiBase()
let loadPromise = null
const readSelectedAccount = () => {
@@ -64,8 +69,7 @@ export const useChatAccountsStore = defineStore('chatAccounts', () => {
}
try {
const api = useApi()
const resp = await api.listChatAccounts()
const resp = await $fetch('/chat/accounts', { baseURL: _apiBase })
const nextAccounts = Array.isArray(resp?.accounts) ? resp.accounts : []
accounts.value = nextAccounts
+1 -1
View File
@@ -1,6 +1,6 @@
import { defineStore } from 'pinia'
import { readPrivacyMode, writePrivacyMode } from '~/utils/privacy-mode'
import { readPrivacyMode, writePrivacyMode } from '~/lib/privacy-mode'
export const usePrivacyStore = defineStore('privacy', () => {
const privacyMode = ref(false)
+57 -5
View File
@@ -787,10 +787,14 @@ class WCDBRealtimeConnection:
class WCDBRealtimeManager:
_FAILED_TTL = 60.0 # seconds before retrying a failed connection
def __init__(self) -> None:
self._mu = threading.Lock()
self._conns: dict[str, WCDBRealtimeConnection] = {}
self._connecting: dict[str, threading.Event] = {}
# Negative cache: accounts that failed to connect recently (avoids repeated timeouts).
self._failed: dict[str, float] = {} # account -> monotonic timestamp of failure
def get_status(self, account_dir: Path) -> dict[str, Any]:
account = str(account_dir.name)
@@ -830,9 +834,19 @@ class WCDBRealtimeManager:
conn = self._conns.get(str(account))
return bool(conn and conn.handle > 0)
def ensure_connected(self, account_dir: Path, *, key_hex: Optional[str] = None) -> WCDBRealtimeConnection:
def ensure_connected(
self, account_dir: Path, *, key_hex: Optional[str] = None, timeout: float = 5.0
) -> WCDBRealtimeConnection:
account = str(account_dir.name)
# Fast-reject if this account failed recently to avoid repeated timeouts.
with self._mu:
failed_at = self._failed.get(account)
if failed_at is not None and (time.monotonic() - failed_at) < self._FAILED_TTL:
raise WCDBRealtimeError("WCDB connection recently failed; retry after 60s.")
deadline = time.monotonic() + timeout
while True:
with self._mu:
existing = self._conns.get(account)
@@ -846,22 +860,59 @@ class WCDBRealtimeManager:
break
# Another thread is connecting; wait a bit and retry.
waiter.wait(timeout=10.0)
remaining = deadline - time.monotonic()
if remaining <= 0:
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
waiter.wait(timeout=min(remaining, 10.0))
if time.monotonic() >= deadline:
raise WCDBRealtimeError("Timed out waiting for WCDB connection.")
key = str(key_hex or "").strip()
if not key:
key_item = get_account_keys_from_store(account)
key = str((key_item or {}).get("db_key") or "").strip()
if len(key) != 64:
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
try:
if len(key) != 64:
with self._mu:
self._failed[account] = time.monotonic()
raise WCDBRealtimeError("Missing db key for this account (call /api/keys or decrypt first).")
db_storage_dir = _resolve_account_db_storage_dir(account_dir)
if db_storage_dir is None:
raise WCDBRealtimeError("Cannot resolve db_storage directory for this account.")
session_db_path = _resolve_session_db_path(db_storage_dir)
handle = open_account(session_db_path, key)
# Run open_account in a daemon thread with a timeout to avoid
# blocking indefinitely when the native library hangs (locked DB).
_handle_box: list[int] = []
_open_err: list[Exception] = []
def _do_open() -> None:
try:
_handle_box.append(open_account(session_db_path, key))
except Exception as exc:
_open_err.append(exc)
remaining = max(0.1, deadline - time.monotonic())
open_thread = threading.Thread(target=_do_open, daemon=True)
open_thread.start()
open_thread.join(timeout=remaining)
if open_thread.is_alive():
with self._mu:
self._failed[account] = time.monotonic()
raise WCDBRealtimeError(
f"open_account timed out after {timeout:.0f}s for {session_db_path}"
)
if _open_err:
with self._mu:
self._failed[account] = time.monotonic()
raise _open_err[0]
if not _handle_box:
raise WCDBRealtimeError("open_account returned no handle.")
handle = _handle_box[0]
# Some WCDB APIs (e.g. exec_query on non-session DBs) may require this context.
try:
set_my_wxid(handle, account)
@@ -893,6 +944,7 @@ class WCDBRealtimeManager:
return
with self._mu:
conn = self._conns.pop(a, None)
self._failed.pop(a, None) # clear negative cache on explicit disconnect
if conn is None:
return
try: