mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
@@ -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,4 +1,4 @@
|
||||
import { reportServerError } from '~/utils/server-error-logging'
|
||||
import { reportServerError } from '~/lib/server-error-logging'
|
||||
|
||||
// API请求组合式函数
|
||||
export const useApi = () => {
|
||||
|
||||
@@ -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 = ''
|
||||
}
|
||||
|
||||
Generated
-14042
File diff suppressed because it is too large
Load Diff
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: '朋友圈 - 微信数据分析助手' })
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user