mirror of
https://github.com/LifeArchiveProject/WeChatDataAnalysis.git
synced 2026-06-18 15:54:08 +08:00
1064c5a248
Co-authored-by: Codex <noreply@openai.com>
2093 lines
64 KiB
JavaScript
2093 lines
64 KiB
JavaScript
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import {
|
||
dateToUnixSeconds,
|
||
formatMessageFullTime,
|
||
highlightKeyword
|
||
} from '~/lib/chat/formatters'
|
||
|
||
export const createEmptySearchContext = () => ({
|
||
active: false,
|
||
kind: 'search',
|
||
label: '',
|
||
username: '',
|
||
anchorId: '',
|
||
anchorIndex: -1,
|
||
hasMoreBefore: false,
|
||
hasMoreAfter: false,
|
||
loadingBefore: false,
|
||
loadingAfter: false,
|
||
savedMessages: null,
|
||
savedMeta: null
|
||
})
|
||
|
||
export const useChatSearch = ({
|
||
api,
|
||
heatColor,
|
||
contacts,
|
||
selectedAccount,
|
||
selectedContact,
|
||
privacyMode,
|
||
allMessages,
|
||
messagesMeta,
|
||
messages,
|
||
messageContainerRef,
|
||
messagePageSize,
|
||
hasMoreMessages,
|
||
isLoadingMessages,
|
||
normalizeMessage,
|
||
updateJumpToBottomState,
|
||
scrollToMessageId,
|
||
flashMessage,
|
||
highlightMessageId,
|
||
searchContext,
|
||
selectContact,
|
||
loadMoreMessages
|
||
}) => {
|
||
const isDesktopRenderer = () => {
|
||
if (!process.client || typeof window === 'undefined') return false
|
||
return !!window.wechatDesktop?.__brand
|
||
}
|
||
|
||
const logSearchPhase = (phase, details = {}) => {
|
||
const payload = {
|
||
account: String(selectedAccount.value || '').trim(),
|
||
selectedUsername: String(selectedContact.value?.username || '').trim(),
|
||
contextUsername: String(searchContext.value?.username || '').trim(),
|
||
...details
|
||
}
|
||
|
||
if (isDesktopRenderer()) {
|
||
try {
|
||
window.wechatDesktop?.logDebug?.('chat-search', phase, payload)
|
||
} catch {}
|
||
}
|
||
|
||
console.info(`[chat-search] ${phase}`, payload)
|
||
}
|
||
|
||
const describeEventTarget = (target) => {
|
||
if (!target || typeof target !== 'object') return ''
|
||
const nodeName = String(target.nodeName || '').trim().toLowerCase()
|
||
let cls = ''
|
||
try {
|
||
cls = typeof target.className === 'string'
|
||
? target.className
|
||
: String(target.className?.baseVal || '')
|
||
} catch {
|
||
cls = ''
|
||
}
|
||
cls = String(cls || '').trim().replace(/\s+/g, '.')
|
||
if (!nodeName) return cls.slice(0, 120)
|
||
if (!cls) return nodeName
|
||
return `${nodeName}.${cls}`.slice(0, 120)
|
||
}
|
||
|
||
const summarizeHitForLog = (hit, idx) => ({
|
||
index: Number(idx ?? -1),
|
||
hitId: String(hit?.id || '').trim(),
|
||
hitUsername: String(hit?.username || '').trim(),
|
||
conversationName: String(hit?.conversationName || '').trim(),
|
||
senderUsername: String(hit?.senderUsername || '').trim(),
|
||
renderType: String(hit?.renderType || '').trim(),
|
||
createTime: Number(hit?.createTime || 0),
|
||
isSent: !!hit?.isSent
|
||
})
|
||
|
||
const buildRunSearchLogDetails = ({ source = 'unknown', reset = false } = {}) => {
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
return {
|
||
source: String(source || 'unknown'),
|
||
reset: !!reset,
|
||
scope: String(messageSearchScope.value || 'conversation'),
|
||
queryLength: q.length,
|
||
selectedContactUsername: String(selectedContact.value?.username || '').trim(),
|
||
sender: String(messageSearchSender.value || '').trim(),
|
||
sessionType: String(messageSearchSessionType.value || '').trim(),
|
||
rangeDays: String(messageSearchRangeDays.value || '').trim(),
|
||
startDate: String(messageSearchStartDate.value || '').trim(),
|
||
endDate: String(messageSearchEndDate.value || '').trim(),
|
||
offset: Number(messageSearchOffset.value || 0),
|
||
resultCount: Number(messageSearchResults.value?.length || 0)
|
||
}
|
||
}
|
||
|
||
const messageSearchOpen = ref(false)
|
||
const messageSearchQuery = ref('')
|
||
const messageSearchScope = ref('global') // conversation | global
|
||
const messageSearchRangeDays = ref('') // empty means no time filter
|
||
const messageSearchSessionType = ref('') // empty means all (global only): group | single
|
||
const messageSearchSender = ref('') // 发送者筛选
|
||
const messageSearchSenderOptions = ref([])
|
||
const messageSearchSenderLoading = ref(false)
|
||
const messageSearchSenderError = ref('')
|
||
const messageSearchSenderOptionsKey = ref('')
|
||
const messageSearchSenderDropdownOpen = ref(false)
|
||
const messageSearchSenderDropdownRef = ref(null)
|
||
const messageSearchSenderDropdownInputRef = ref(null)
|
||
const messageSearchSenderDropdownQuery = ref('')
|
||
const messageSearchStartDate = ref('') // 自定义开始日期
|
||
const messageSearchEndDate = ref('') // 自定义结束日期
|
||
const messageSearchResults = ref([])
|
||
const messageSearchLoading = ref(false)
|
||
const messageSearchError = ref('')
|
||
const messageSearchBackendStatus = ref('')
|
||
const messageSearchIndexInfo = ref(null)
|
||
const messageSearchHasMore = ref(false)
|
||
const messageSearchOffset = ref(0)
|
||
const messageSearchLimit = 50
|
||
const messageSearchTotal = ref(0)
|
||
const messageSearchSelectedIndex = ref(-1)
|
||
const messageSearchInputRef = ref(null)
|
||
let messageSearchDebounceTimer = null
|
||
let messageSearchIndexPollTimer = null
|
||
|
||
// 搜索UI增强
|
||
const searchInputFocused = ref(false)
|
||
const showAdvancedFilters = ref(false)
|
||
const searchHistory = ref([])
|
||
const SEARCH_HISTORY_KEY = 'wechat_search_history'
|
||
const MAX_SEARCH_HISTORY = 10
|
||
|
||
// 加载搜索历史
|
||
const loadSearchHistory = () => {
|
||
if (!process.client) return
|
||
try {
|
||
const saved = localStorage.getItem(SEARCH_HISTORY_KEY)
|
||
if (saved) {
|
||
searchHistory.value = JSON.parse(saved) || []
|
||
}
|
||
} catch (e) {
|
||
searchHistory.value = []
|
||
}
|
||
}
|
||
|
||
// 保存搜索历史
|
||
const saveSearchHistory = (query) => {
|
||
if (!process.client) return
|
||
if (!query || !query.trim()) return
|
||
const q = query.trim()
|
||
try {
|
||
let history = [...searchHistory.value]
|
||
// 移除重复项
|
||
history = history.filter(item => item !== q)
|
||
// 添加到开头
|
||
history.unshift(q)
|
||
// 限制数量
|
||
if (history.length > MAX_SEARCH_HISTORY) {
|
||
history = history.slice(0, MAX_SEARCH_HISTORY)
|
||
}
|
||
searchHistory.value = history
|
||
localStorage.setItem(SEARCH_HISTORY_KEY, JSON.stringify(history))
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// 清空搜索历史
|
||
const clearSearchHistory = () => {
|
||
if (!process.client) return
|
||
searchHistory.value = []
|
||
try {
|
||
localStorage.removeItem(SEARCH_HISTORY_KEY)
|
||
} catch (e) {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// 应用搜索历史
|
||
const applySearchHistory = async (query) => {
|
||
messageSearchQuery.value = query
|
||
await runMessageSearch({ reset: true, source: 'history-apply' })
|
||
}
|
||
|
||
const messageSearchIndexExists = computed(() => !!messageSearchIndexInfo.value?.exists)
|
||
const messageSearchIndexReady = computed(() => !!messageSearchIndexInfo.value?.ready)
|
||
const messageSearchIndexBuildStatus = computed(() => String(messageSearchIndexInfo.value?.build?.status || ''))
|
||
const messageSearchIndexBuildIndexed = computed(() => Number(messageSearchIndexInfo.value?.build?.indexedMessages || 0))
|
||
const messageSearchIndexMetaCount = computed(() => {
|
||
const meta = messageSearchIndexInfo.value?.meta || {}
|
||
const v = meta.message_count ?? meta.messageCount ?? meta.message_count ?? 0
|
||
return Number(v || 0)
|
||
})
|
||
|
||
const messageSearchIndexProgressText = computed(() => {
|
||
if (messageSearchIndexBuildStatus.value !== 'building') return ''
|
||
const n = Number(messageSearchIndexBuildIndexed.value || 0)
|
||
return n > 0 ? `已索引 ${n.toLocaleString()} 条` : '准备中...'
|
||
})
|
||
|
||
const messageSearchIndexText = computed(() => {
|
||
if (!messageSearchIndexInfo.value) return ''
|
||
if (!messageSearchIndexExists.value) return '索引未建立'
|
||
if (messageSearchIndexBuildStatus.value === 'error') return '索引异常'
|
||
if (!messageSearchIndexReady.value) return '索引未完成,需重建'
|
||
const n = Number(messageSearchIndexMetaCount.value || 0)
|
||
return n > 0 ? `索引已就绪(${n.toLocaleString()} 条)` : '索引已就绪'
|
||
})
|
||
|
||
const messageSearchIndexActionText = computed(() => {
|
||
if (messageSearchIndexBuildStatus.value === 'building') return '建立中'
|
||
return messageSearchIndexExists.value ? '重建索引' : '建立索引'
|
||
})
|
||
|
||
const messageSearchIndexActionDisabled = computed(() => {
|
||
return messageSearchIndexBuildStatus.value === 'building' || messageSearchLoading.value
|
||
})
|
||
|
||
const formatCount = (n) => {
|
||
const v = Number(n || 0)
|
||
if (!Number.isFinite(v) || v <= 0) return ''
|
||
try {
|
||
return v.toLocaleString()
|
||
} catch {
|
||
return String(v)
|
||
}
|
||
}
|
||
|
||
const messageSearchSenderDisabled = computed(() => {
|
||
if (!selectedAccount.value) return true
|
||
const scope = String(messageSearchScope.value || 'conversation')
|
||
if (scope === 'conversation') {
|
||
return !selectedContact.value?.username
|
||
}
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
if (q.length >= 2) return false
|
||
return !String(messageSearchSender.value || '').trim()
|
||
})
|
||
|
||
const messageSearchSelectedSenderInfo = computed(() => {
|
||
const u = String(messageSearchSender.value || '').trim()
|
||
if (!u) return null
|
||
const list = Array.isArray(messageSearchSenderOptions.value) ? messageSearchSenderOptions.value : []
|
||
const found = list.find((s) => String(s?.username || '').trim() === u)
|
||
if (found) return found
|
||
return { username: u, displayName: u, avatar: null, count: null }
|
||
})
|
||
|
||
const messageSearchSelectedSenderInitial = computed(() => {
|
||
const info = messageSearchSelectedSenderInfo.value
|
||
if (!info) return '人'
|
||
const n = String(info.displayName || info.username || '').trim()
|
||
return n ? n.charAt(0) : '人'
|
||
})
|
||
|
||
const messageSearchSenderLabel = computed(() => {
|
||
const cur = String(messageSearchSender.value || '').trim()
|
||
if (!cur) {
|
||
if (String(messageSearchScope.value || '') === 'global' && String(messageSearchQuery.value || '').trim().length < 2) {
|
||
return '发送者'
|
||
}
|
||
return '不限发送者'
|
||
}
|
||
const info = messageSearchSelectedSenderInfo.value
|
||
return String(info?.displayName || info?.username || cur)
|
||
})
|
||
|
||
const filteredMessageSearchSenderOptions = computed(() => {
|
||
const list = Array.isArray(messageSearchSenderOptions.value) ? messageSearchSenderOptions.value : []
|
||
const q = String(messageSearchSenderDropdownQuery.value || '').trim().toLowerCase()
|
||
if (!q) return list
|
||
return list.filter((s) => {
|
||
const u = String(s?.username || '').toLowerCase()
|
||
const n = String(s?.displayName || '').toLowerCase()
|
||
return u.includes(q) || n.includes(q)
|
||
})
|
||
})
|
||
|
||
const closeMessageSearchSenderDropdown = () => {
|
||
messageSearchSenderDropdownOpen.value = false
|
||
messageSearchSenderDropdownQuery.value = ''
|
||
}
|
||
|
||
const getMessageSearchSenderFacetKey = () => {
|
||
const acc = String(selectedAccount.value || '').trim()
|
||
if (!acc) return ''
|
||
const scope = String(messageSearchScope.value || 'conversation')
|
||
const conv = scope === 'conversation' ? String(selectedContact.value?.username || '') : ''
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
const range = String(messageSearchRangeDays.value || '')
|
||
const sd = String(messageSearchStartDate.value || '')
|
||
const ed = String(messageSearchEndDate.value || '')
|
||
const st = scope === 'global' ? String(messageSearchSessionType.value || '').trim() : ''
|
||
return [acc, scope, conv, q, range, sd, ed, st].join('|')
|
||
}
|
||
|
||
const ensureMessageSearchSendersLoaded = async () => {
|
||
const key = getMessageSearchSenderFacetKey()
|
||
if (!key) return
|
||
if (messageSearchSenderOptionsKey.value === key && !messageSearchSenderLoading.value) return
|
||
const list = await fetchMessageSearchSenders()
|
||
messageSearchSenderOptionsKey.value = key
|
||
return list
|
||
}
|
||
|
||
const toggleMessageSearchSenderDropdown = async () => {
|
||
if (messageSearchSenderDisabled.value) return
|
||
if (messageSearchSenderDropdownOpen.value) {
|
||
closeMessageSearchSenderDropdown()
|
||
return
|
||
}
|
||
messageSearchSenderDropdownOpen.value = true
|
||
await ensureMessageSearchSendersLoaded()
|
||
await nextTick()
|
||
try {
|
||
messageSearchSenderDropdownInputRef.value?.focus?.()
|
||
} catch {}
|
||
}
|
||
|
||
const selectMessageSearchSender = (username) => {
|
||
messageSearchSender.value = String(username || '')
|
||
closeMessageSearchSenderDropdown()
|
||
}
|
||
|
||
const fetchMessageSearchIndexStatus = async () => {
|
||
if (!selectedAccount.value) return null
|
||
logSearchPhase('search-index-status:start', {
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
try {
|
||
const resp = await api.getChatSearchIndexStatus({ account: selectedAccount.value })
|
||
messageSearchIndexInfo.value = resp?.index || null
|
||
logSearchPhase('search-index-status:end', {
|
||
exists: !!messageSearchIndexInfo.value?.exists,
|
||
ready: !!messageSearchIndexInfo.value?.ready,
|
||
buildStatus: String(messageSearchIndexInfo.value?.build?.status || '').trim()
|
||
})
|
||
return messageSearchIndexInfo.value
|
||
} catch (e) {
|
||
logSearchPhase('search-index-status:error', {
|
||
error: String(e?.message || e || '')
|
||
})
|
||
return null
|
||
}
|
||
}
|
||
|
||
const fetchMessageSearchSenders = async () => {
|
||
messageSearchSenderError.value = ''
|
||
if (!selectedAccount.value) {
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
logSearchPhase('search-senders:skip:no-account')
|
||
return []
|
||
}
|
||
|
||
const scope = String(messageSearchScope.value || 'conversation')
|
||
const msgQ = String(messageSearchQuery.value || '').trim()
|
||
|
||
const params = {
|
||
account: selectedAccount.value,
|
||
limit: 200
|
||
}
|
||
|
||
if (scope === 'conversation') {
|
||
if (!selectedContact.value?.username) {
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
logSearchPhase('search-senders:skip:no-selected-contact', {
|
||
scope
|
||
})
|
||
return []
|
||
}
|
||
params.username = selectedContact.value.username
|
||
} else {
|
||
if (msgQ.length < 2) {
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
logSearchPhase('search-senders:skip:query-too-short', {
|
||
scope,
|
||
queryLength: msgQ.length
|
||
})
|
||
return []
|
||
}
|
||
}
|
||
|
||
if (msgQ) {
|
||
params.message_q = msgQ
|
||
}
|
||
|
||
params.render_types = 'text'
|
||
|
||
const range = String(messageSearchRangeDays.value || '')
|
||
if (range === 'custom') {
|
||
const start = dateToUnixSeconds(messageSearchStartDate.value, false)
|
||
const end = dateToUnixSeconds(messageSearchEndDate.value, true)
|
||
if (start != null) params.start_time = start
|
||
if (end != null) params.end_time = end
|
||
if (start != null && end != null && start > end) {
|
||
messageSearchSenderError.value = '时间范围不合法:开始日期不能晚于结束日期'
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
return []
|
||
}
|
||
} else {
|
||
const days = Number(range || 0)
|
||
if (days > 0 && Number.isFinite(days)) {
|
||
const end = Math.floor(Date.now() / 1000)
|
||
const start = Math.max(0, end - Math.floor(days * 24 * 3600))
|
||
params.start_time = start
|
||
params.end_time = end
|
||
}
|
||
}
|
||
|
||
if (scope === 'global') {
|
||
const st = String(messageSearchSessionType.value || '').trim()
|
||
if (st) params.session_type = st
|
||
}
|
||
|
||
messageSearchSenderLoading.value = true
|
||
logSearchPhase('search-senders:start', {
|
||
scope,
|
||
queryLength: msgQ.length,
|
||
username: String(params.username || '').trim()
|
||
})
|
||
try {
|
||
const resp = await api.listChatSearchSenders(params)
|
||
const status = String(resp?.status || 'success')
|
||
if (status !== 'success') {
|
||
logSearchPhase('search-senders:non-success', {
|
||
scope,
|
||
status,
|
||
queryLength: msgQ.length,
|
||
message: String(resp?.message || '')
|
||
})
|
||
if (status !== 'index_building') {
|
||
messageSearchSenderError.value = String(resp?.message || '加载发送者失败')
|
||
}
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
return []
|
||
}
|
||
const list = Array.isArray(resp?.senders) ? resp.senders : []
|
||
messageSearchSenderOptions.value = list
|
||
messageSearchSenderOptionsKey.value = getMessageSearchSenderFacetKey()
|
||
const cur = String(messageSearchSender.value || '').trim()
|
||
if (cur && !list.some((s) => String(s?.username || '').trim() === cur)) {
|
||
messageSearchSender.value = ''
|
||
}
|
||
logSearchPhase('search-senders:end', {
|
||
scope,
|
||
queryLength: msgQ.length,
|
||
username: String(params.username || '').trim(),
|
||
optionCount: list.length
|
||
})
|
||
return list
|
||
} catch (e) {
|
||
messageSearchSenderError.value = e?.message || '加载发送者失败'
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
logSearchPhase('search-senders:error', {
|
||
scope,
|
||
queryLength: msgQ.length,
|
||
error: String(e?.message || e || '')
|
||
})
|
||
return []
|
||
} finally {
|
||
messageSearchSenderLoading.value = false
|
||
}
|
||
}
|
||
|
||
const stopMessageSearchIndexPolling = () => {
|
||
if (messageSearchIndexPollTimer) clearInterval(messageSearchIndexPollTimer)
|
||
messageSearchIndexPollTimer = null
|
||
}
|
||
|
||
const ensureMessageSearchIndexPolling = () => {
|
||
if (messageSearchIndexPollTimer) return
|
||
messageSearchIndexPollTimer = setInterval(async () => {
|
||
if (!messageSearchOpen.value) {
|
||
stopMessageSearchIndexPolling()
|
||
return
|
||
}
|
||
|
||
const info = await fetchMessageSearchIndexStatus()
|
||
const exists = !!info?.exists
|
||
const ready = !!info?.ready
|
||
const bs = String(info?.build?.status || '')
|
||
const done = exists && ready && bs !== 'building'
|
||
if (done) {
|
||
stopMessageSearchIndexPolling()
|
||
if (String(messageSearchScope.value || '') === 'conversation') {
|
||
await fetchMessageSearchSenders()
|
||
}
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'index-poll-ready' })
|
||
}
|
||
}
|
||
}, 1200)
|
||
}
|
||
|
||
const onMessageSearchIndexAction = async () => {
|
||
if (!selectedAccount.value) return
|
||
const rebuild = messageSearchIndexExists.value
|
||
try {
|
||
const resp = await api.buildChatSearchIndex({ account: selectedAccount.value, rebuild })
|
||
messageSearchIndexInfo.value = resp?.index || null
|
||
messageSearchBackendStatus.value = 'index_building'
|
||
ensureMessageSearchIndexPolling()
|
||
} catch (e) {
|
||
messageSearchError.value = e?.message || '建立索引失败'
|
||
}
|
||
}
|
||
const getMessageSearchHitAvatarUrl = (hit) => {
|
||
if (!hit) return ''
|
||
const scope = String(messageSearchScope.value || '')
|
||
const url =
|
||
scope === 'global'
|
||
? (hit.conversationAvatar || hit.senderAvatar || '')
|
||
: (hit.senderAvatar || hit.conversationAvatar || '')
|
||
return String(url || '').trim()
|
||
}
|
||
|
||
const getMessageSearchHitAvatarAlt = (hit) => {
|
||
if (!hit) return '头像'
|
||
const scope = String(messageSearchScope.value || '')
|
||
if (scope === 'global') {
|
||
const name = String(hit.conversationName || hit.username || '').trim()
|
||
return name ? `${name} 头像` : '头像'
|
||
}
|
||
let name = String(hit.senderDisplayName || '').trim()
|
||
if (!name) {
|
||
name = hit.isSent ? '我' : String(hit.senderUsername || '').trim()
|
||
}
|
||
return name ? `${name} 头像` : '头像'
|
||
}
|
||
|
||
const getMessageSearchHitAvatarInitial = (hit) => {
|
||
if (!hit) return '?'
|
||
const scope = String(messageSearchScope.value || '')
|
||
let text = ''
|
||
if (scope === 'global') {
|
||
text = String(hit.conversationName || hit.username || '').trim()
|
||
} else {
|
||
text = String(hit.senderDisplayName || '').trim()
|
||
if (!text) {
|
||
text = hit.isSent ? '我' : String(hit.senderUsername || '').trim()
|
||
}
|
||
}
|
||
return (text.charAt(0) || '?').toString()
|
||
}
|
||
|
||
const buildTransientSearchTargetContact = ({ username, displayName = '', avatar = '', isGroup = null } = {}) => {
|
||
const u = String(username || '').trim()
|
||
if (!u) return null
|
||
const name = String(displayName || u).trim() || u
|
||
return {
|
||
id: u,
|
||
username: u,
|
||
name,
|
||
avatar: String(avatar || '').trim() || null,
|
||
avatarColor: '#4B5563',
|
||
lastMessage: '',
|
||
lastMessageTime: '',
|
||
unreadCount: 0,
|
||
isGroup: typeof isGroup === 'boolean' ? isGroup : u.endsWith('@chatroom'),
|
||
isTop: false
|
||
}
|
||
}
|
||
|
||
const resolveSearchTargetContact = ({ username, displayName = '', avatar = '', isGroup = null } = {}) => {
|
||
const u = String(username || '').trim()
|
||
if (!u) return null
|
||
const existing = contacts.value.find((c) => String(c?.username || '').trim() === u)
|
||
if (existing) return existing
|
||
if (String(selectedContact.value?.username || '').trim() === u) return selectedContact.value
|
||
return buildTransientSearchTargetContact({ username: u, displayName, avatar, isGroup })
|
||
}
|
||
const searchContextBannerText = computed(() => {
|
||
if (!searchContext.value?.active) return ''
|
||
const kind = String(searchContext.value.kind || 'search')
|
||
if (kind === 'date') {
|
||
const label = String(searchContext.value.label || '').trim()
|
||
return label ? `已定位到 ${label}(上下文模式)` : '已定位到指定日期(上下文模式)'
|
||
}
|
||
if (kind === 'first') {
|
||
return '已定位到会话顶部(上下文模式)'
|
||
}
|
||
return '已定位到搜索结果(上下文模式)'
|
||
})
|
||
|
||
// 回到最新按钮
|
||
const showJumpToBottom = ref(false)
|
||
|
||
// 时间侧边栏(按日期定位)
|
||
const timeSidebarOpen = ref(false)
|
||
const timeSidebarYear = ref(null)
|
||
const timeSidebarMonth = ref(null) // 1-12
|
||
const timeSidebarCounts = ref({}) // { 'YYYY-MM-DD': count }
|
||
const timeSidebarMax = ref(0)
|
||
const timeSidebarTotal = ref(0)
|
||
const timeSidebarLoading = ref(false)
|
||
const timeSidebarError = ref('')
|
||
const timeSidebarSelectedDate = ref('') // YYYY-MM-DD (current/selected day)
|
||
// Simple in-memory cache per (account|username|YYYY-MM)
|
||
const timeSidebarCache = ref({})
|
||
const timeSidebarWeekdays = ['一', '二', '三', '四', '五', '六', '日']
|
||
|
||
const timeSidebarMonthLabel = computed(() => {
|
||
const y = Number(timeSidebarYear.value || 0)
|
||
const m = Number(timeSidebarMonth.value || 0)
|
||
if (!y || !m) return ''
|
||
return `${y}年${m}月`
|
||
})
|
||
|
||
const timeSidebarYearOptions = computed(() => {
|
||
// WeChat history normally starts after 2011, but keep a broader range for safety.
|
||
const nowY = new Date().getFullYear()
|
||
const minY = 2000
|
||
const maxY = Math.max(nowY, Number(timeSidebarYear.value || 0) || nowY)
|
||
const years = []
|
||
for (let y = maxY; y >= minY; y--) years.push(y)
|
||
return years
|
||
})
|
||
|
||
const timeSidebarActiveDays = computed(() => {
|
||
const counts = timeSidebarCounts.value || {}
|
||
const keys = Object.keys(counts)
|
||
return keys.length
|
||
})
|
||
|
||
const _pad2 = (n) => String(n).padStart(2, '0')
|
||
|
||
const _dateStrFromEpochSeconds = (ts) => {
|
||
const t = Number(ts || 0)
|
||
if (!t) return ''
|
||
try {
|
||
const d = new Date(t * 1000)
|
||
return `${d.getFullYear()}-${_pad2(d.getMonth() + 1)}-${_pad2(d.getDate())}`
|
||
} catch {
|
||
return ''
|
||
}
|
||
}
|
||
|
||
// Calendar heatmap color: reuse Wrapped heat palette, but bucket to Wrapped-like legend levels
|
||
// so ">=1 message" is always visibly tinted (instead of being almost white when max is huge).
|
||
const _calendarHeatColor = (count, maxV) => {
|
||
const v = Math.max(0, Number(count || 0))
|
||
const m = Math.max(0, Number(maxV || 0))
|
||
if (!(v > 0)) return ''
|
||
if (!(m > 0)) return heatColor(1, 1)
|
||
const levels = 6
|
||
const ratio = Math.max(0, Math.min(1, v / m))
|
||
const level = Math.min(levels, Math.max(1, Math.ceil(ratio * levels)))
|
||
const valueForLevel = Math.max(1, Math.round(level * (m / levels)))
|
||
return heatColor(valueForLevel, m)
|
||
}
|
||
|
||
const timeSidebarCalendarCells = computed(() => {
|
||
const y = Number(timeSidebarYear.value || 0)
|
||
const m = Number(timeSidebarMonth.value || 0) // 1-12
|
||
if (!y || !m) return []
|
||
|
||
const daysInMonth = new Date(y, m, 0).getDate()
|
||
const firstDow = new Date(y, m - 1, 1).getDay() // 0=Sun..6=Sat
|
||
const offset = (firstDow + 6) % 7 // Monday=0
|
||
|
||
const maxV = Math.max(0, Number(timeSidebarMax.value || 0))
|
||
const counts = timeSidebarCounts.value || {}
|
||
const selected = String(timeSidebarSelectedDate.value || '').trim()
|
||
|
||
const out = []
|
||
for (let i = 0; i < 42; i++) {
|
||
const dayNum = i - offset + 1
|
||
const inMonth = dayNum >= 1 && dayNum <= daysInMonth
|
||
if (!inMonth) {
|
||
out.push({
|
||
key: `e:${y}-${m}:${i}`,
|
||
day: '',
|
||
dateStr: '',
|
||
count: 0,
|
||
disabled: true,
|
||
className: 'calendar-day-outside',
|
||
style: null,
|
||
title: ''
|
||
})
|
||
continue
|
||
}
|
||
|
||
const dateStr = `${y}-${_pad2(m)}-${_pad2(dayNum)}`
|
||
const count = Math.max(0, Number(counts[dateStr] || 0))
|
||
const disabled = count <= 0
|
||
|
||
const style = !disabled
|
||
? { backgroundColor: _calendarHeatColor(count, Math.max(maxV, count)) }
|
||
: null
|
||
|
||
const className = [
|
||
disabled ? 'calendar-day-empty' : '',
|
||
(selected && dateStr === selected) ? 'calendar-day-selected' : ''
|
||
].filter(Boolean).join(' ')
|
||
|
||
out.push({
|
||
key: dateStr,
|
||
day: String(dayNum),
|
||
dateStr,
|
||
count,
|
||
disabled,
|
||
// NOTE: heatmap bg color is applied via inline style (reusing Wrapped heatmap palette).
|
||
// Dynamic class names like `calendar-day-l${level}` may be purged by Tailwind and lead to no bg color.
|
||
className,
|
||
style,
|
||
title: `${dateStr}:${count} 条`
|
||
})
|
||
}
|
||
return out
|
||
})
|
||
const closeMessageSearch = (reason = 'manual') => {
|
||
logSearchPhase('message-search:close', {
|
||
reason: String(reason || 'manual'),
|
||
queryLength: String(messageSearchQuery.value || '').trim().length,
|
||
resultCount: Number(messageSearchResults.value?.length || 0),
|
||
selectedIndex: Number(messageSearchSelectedIndex.value ?? -1)
|
||
})
|
||
messageSearchOpen.value = false
|
||
closeMessageSearchSenderDropdown()
|
||
messageSearchError.value = ''
|
||
messageSearchLoading.value = false
|
||
messageSearchBackendStatus.value = ''
|
||
stopMessageSearchIndexPolling()
|
||
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
|
||
messageSearchDebounceTimer = null
|
||
}
|
||
|
||
let timeSidebarReqId = 0
|
||
|
||
const closeTimeSidebar = () => {
|
||
timeSidebarOpen.value = false
|
||
timeSidebarError.value = ''
|
||
}
|
||
|
||
const _timeSidebarCacheKey = ({ account, username, year, month }) => {
|
||
const acc = String(account || '').trim()
|
||
const u = String(username || '').trim()
|
||
const y = Number(year || 0)
|
||
const m = Number(month || 0)
|
||
return `${acc}|${u}|${y}-${_pad2(m)}`
|
||
}
|
||
|
||
const _applyTimeSidebarMonthData = (data) => {
|
||
const counts = (data && typeof data.counts === 'object' && !Array.isArray(data.counts)) ? data.counts : {}
|
||
timeSidebarCounts.value = counts
|
||
timeSidebarMax.value = Math.max(0, Number(data?.max || 0))
|
||
timeSidebarTotal.value = Math.max(0, Number(data?.total || 0))
|
||
}
|
||
|
||
const loadTimeSidebarMonth = async ({ year, month, force } = {}) => {
|
||
if (!selectedAccount.value) return
|
||
if (!selectedContact.value?.username) return
|
||
|
||
const y = Number(year || timeSidebarYear.value || 0)
|
||
const m = Number(month || timeSidebarMonth.value || 0)
|
||
if (!y || !m) return
|
||
|
||
timeSidebarYear.value = y
|
||
timeSidebarMonth.value = m
|
||
|
||
const key = _timeSidebarCacheKey({
|
||
account: selectedAccount.value,
|
||
username: selectedContact.value.username,
|
||
year: y,
|
||
month: m
|
||
})
|
||
|
||
if (!force) {
|
||
const cached = timeSidebarCache.value[key]
|
||
if (cached) {
|
||
timeSidebarError.value = ''
|
||
_applyTimeSidebarMonthData(cached)
|
||
return
|
||
}
|
||
}
|
||
|
||
const reqId = ++timeSidebarReqId
|
||
timeSidebarLoading.value = true
|
||
timeSidebarError.value = ''
|
||
|
||
try {
|
||
const resp = await api.getChatMessageDailyCounts({
|
||
account: selectedAccount.value,
|
||
username: selectedContact.value.username,
|
||
year: y,
|
||
month: m
|
||
})
|
||
if (reqId !== timeSidebarReqId) return
|
||
if (String(resp?.status || '') !== 'success') {
|
||
throw new Error(String(resp?.message || '加载日历失败'))
|
||
}
|
||
|
||
const data = {
|
||
counts: resp?.counts || {},
|
||
max: Number(resp?.max || 0),
|
||
total: Number(resp?.total || 0)
|
||
}
|
||
|
||
_applyTimeSidebarMonthData(data)
|
||
timeSidebarCache.value = { ...timeSidebarCache.value, [key]: data }
|
||
} catch (e) {
|
||
if (reqId !== timeSidebarReqId) return
|
||
timeSidebarError.value = e?.message || '加载日历失败'
|
||
_applyTimeSidebarMonthData({ counts: {}, max: 0, total: 0 })
|
||
} finally {
|
||
if (reqId === timeSidebarReqId) {
|
||
timeSidebarLoading.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const _pickTimeSidebarInitialYearMonth = () => {
|
||
const list = messages.value || []
|
||
const last = Array.isArray(list) && list.length ? list[list.length - 1] : null
|
||
const ts = Number(last?.createTime || 0)
|
||
const d = ts ? new Date(ts * 1000) : new Date()
|
||
return { year: d.getFullYear(), month: d.getMonth() + 1 }
|
||
}
|
||
|
||
const _applyTimeSidebarSelectedDate = async (dateStr, { syncMonth } = {}) => {
|
||
const ds = String(dateStr || '').trim()
|
||
if (!ds) return
|
||
if (timeSidebarSelectedDate.value !== ds) {
|
||
timeSidebarSelectedDate.value = ds
|
||
}
|
||
if (!syncMonth || !timeSidebarOpen.value) return
|
||
|
||
const parts = ds.split('-')
|
||
const y = Number(parts?.[0] || 0)
|
||
const m = Number(parts?.[1] || 0)
|
||
if (!y || !m) return
|
||
|
||
if (Number(timeSidebarYear.value || 0) !== y || Number(timeSidebarMonth.value || 0) !== m) {
|
||
timeSidebarYear.value = y
|
||
timeSidebarMonth.value = m
|
||
// Fire and forget; request id guard + cache inside loadTimeSidebarMonth will handle racing.
|
||
await loadTimeSidebarMonth({ year: y, month: m, force: false })
|
||
}
|
||
}
|
||
|
||
const toggleTimeSidebar = async () => {
|
||
timeSidebarOpen.value = !timeSidebarOpen.value
|
||
if (!timeSidebarOpen.value) return
|
||
closeMessageSearch()
|
||
|
||
const { year, month } = _pickTimeSidebarInitialYearMonth()
|
||
timeSidebarYear.value = year
|
||
timeSidebarMonth.value = month
|
||
|
||
// Default selected day: current viewport's latest loaded message day (usually "latest").
|
||
const list = messages.value || []
|
||
const last = Array.isArray(list) && list.length ? list[list.length - 1] : null
|
||
const ds = _dateStrFromEpochSeconds(Number(last?.createTime || 0))
|
||
if (ds) await _applyTimeSidebarSelectedDate(ds, { syncMonth: false })
|
||
|
||
await loadTimeSidebarMonth({ year, month, force: false })
|
||
}
|
||
|
||
const prevTimeSidebarMonth = async () => {
|
||
const y0 = Number(timeSidebarYear.value || 0)
|
||
const m0 = Number(timeSidebarMonth.value || 0)
|
||
if (!y0 || !m0) return
|
||
const y = m0 === 1 ? (y0 - 1) : y0
|
||
const m = m0 === 1 ? 12 : (m0 - 1)
|
||
await loadTimeSidebarMonth({ year: y, month: m, force: false })
|
||
}
|
||
|
||
const nextTimeSidebarMonth = async () => {
|
||
const y0 = Number(timeSidebarYear.value || 0)
|
||
const m0 = Number(timeSidebarMonth.value || 0)
|
||
if (!y0 || !m0) return
|
||
const y = m0 === 12 ? (y0 + 1) : y0
|
||
const m = m0 === 12 ? 1 : (m0 + 1)
|
||
await loadTimeSidebarMonth({ year: y, month: m, force: false })
|
||
}
|
||
|
||
const onTimeSidebarYearMonthChange = async () => {
|
||
if (!timeSidebarOpen.value) return
|
||
const y = Number(timeSidebarYear.value || 0)
|
||
const m = Number(timeSidebarMonth.value || 0)
|
||
if (!y || !m) return
|
||
await loadTimeSidebarMonth({ year: y, month: m, force: false })
|
||
}
|
||
|
||
const ensureMessageSearchScopeValid = () => {
|
||
if (messageSearchScope.value === 'conversation' && !selectedContact.value) {
|
||
messageSearchScope.value = 'global'
|
||
}
|
||
}
|
||
|
||
const toggleMessageSearch = async () => {
|
||
const nextOpen = !messageSearchOpen.value
|
||
logSearchPhase('message-search:toggle', {
|
||
nextOpen,
|
||
queryLength: String(messageSearchQuery.value || '').trim().length,
|
||
resultCount: Number(messageSearchResults.value?.length || 0)
|
||
})
|
||
messageSearchOpen.value = nextOpen
|
||
ensureMessageSearchScopeValid()
|
||
if (!messageSearchOpen.value) {
|
||
closeMessageSearch('toggle-close')
|
||
return
|
||
}
|
||
closeTimeSidebar()
|
||
await nextTick()
|
||
try {
|
||
messageSearchInputRef.value?.focus?.()
|
||
} catch {}
|
||
logSearchPhase('message-search:open:ready', {
|
||
scope: String(messageSearchScope.value || 'conversation'),
|
||
selectedContactUsername: String(selectedContact.value?.username || '').trim()
|
||
})
|
||
await fetchMessageSearchIndexStatus()
|
||
await fetchMessageSearchSenders()
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'toggle-open-with-query' })
|
||
}
|
||
}
|
||
|
||
let messageSearchReqId = 0
|
||
|
||
const runMessageSearch = async ({ reset, source = 'unknown' } = {}) => {
|
||
if (!selectedAccount.value) {
|
||
logSearchPhase('runMessageSearch:skip:no-account', buildRunSearchLogDetails({ source, reset }))
|
||
return
|
||
}
|
||
ensureMessageSearchScopeValid()
|
||
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
logSearchPhase('runMessageSearch:start', buildRunSearchLogDetails({ source, reset }))
|
||
if (!q) {
|
||
logSearchPhase('runMessageSearch:skip:empty-query', buildRunSearchLogDetails({ source, reset }))
|
||
messageSearchResults.value = []
|
||
messageSearchHasMore.value = false
|
||
messageSearchError.value = ''
|
||
messageSearchSelectedIndex.value = -1
|
||
messageSearchBackendStatus.value = ''
|
||
messageSearchTotal.value = 0
|
||
stopMessageSearchIndexPolling()
|
||
return
|
||
}
|
||
|
||
if (reset) {
|
||
messageSearchOffset.value = 0
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
}
|
||
|
||
const reqId = ++messageSearchReqId
|
||
messageSearchLoading.value = true
|
||
messageSearchError.value = ''
|
||
messageSearchBackendStatus.value = ''
|
||
logSearchPhase('runMessageSearch:request:start', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId
|
||
})
|
||
|
||
const scope = String(messageSearchScope.value || 'conversation')
|
||
|
||
const params = {
|
||
account: selectedAccount.value,
|
||
q,
|
||
limit: messageSearchLimit,
|
||
offset: messageSearchOffset.value
|
||
}
|
||
|
||
params.render_types = 'text'
|
||
|
||
const range = String(messageSearchRangeDays.value || '')
|
||
if (range === 'custom') {
|
||
const start = dateToUnixSeconds(messageSearchStartDate.value, false)
|
||
const end = dateToUnixSeconds(messageSearchEndDate.value, true)
|
||
if (start != null) params.start_time = start
|
||
if (end != null) params.end_time = end
|
||
if (start != null && end != null && start > end) {
|
||
messageSearchLoading.value = false
|
||
messageSearchError.value = '时间范围不合法:开始日期不能晚于结束日期'
|
||
return
|
||
}
|
||
} else {
|
||
const days = Number(range || 0)
|
||
if (days > 0 && Number.isFinite(days)) {
|
||
const end = Math.floor(Date.now() / 1000)
|
||
const start = Math.max(0, end - Math.floor(days * 24 * 3600))
|
||
params.start_time = start
|
||
params.end_time = end
|
||
}
|
||
}
|
||
|
||
if (scope === 'global') {
|
||
const st = String(messageSearchSessionType.value || '').trim()
|
||
if (st) params.session_type = st
|
||
}
|
||
|
||
if (String(messageSearchSender.value || '').trim()) {
|
||
params.sender = String(messageSearchSender.value || '').trim()
|
||
}
|
||
|
||
if (scope === 'conversation') {
|
||
if (!selectedContact.value?.username) {
|
||
logSearchPhase('runMessageSearch:skip:no-selected-contact', buildRunSearchLogDetails({ source, reset }))
|
||
messageSearchLoading.value = false
|
||
messageSearchError.value = '请选择一个会话再搜索'
|
||
return
|
||
}
|
||
params.username = selectedContact.value.username
|
||
}
|
||
|
||
try {
|
||
const resp = await api.searchChatMessages(params)
|
||
if (reqId !== messageSearchReqId) {
|
||
logSearchPhase('runMessageSearch:response:stale', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
activeReqId: Number(messageSearchReqId || 0),
|
||
status: String(resp?.status || '').trim()
|
||
})
|
||
return
|
||
}
|
||
|
||
if (resp?.index) {
|
||
messageSearchIndexInfo.value = resp.index
|
||
}
|
||
|
||
const status = String(resp?.status || 'success')
|
||
messageSearchBackendStatus.value = status
|
||
|
||
if (status === 'index_building') {
|
||
logSearchPhase('runMessageSearch:response:index-building', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
status
|
||
})
|
||
if (reset) {
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
}
|
||
messageSearchHasMore.value = false
|
||
messageSearchTotal.value = 0
|
||
ensureMessageSearchIndexPolling()
|
||
return
|
||
}
|
||
|
||
if (status === 'index_error') {
|
||
logSearchPhase('runMessageSearch:response:index-error', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
status,
|
||
message: String(resp?.message || '')
|
||
})
|
||
if (reset) {
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
}
|
||
messageSearchHasMore.value = false
|
||
messageSearchTotal.value = 0
|
||
messageSearchError.value = String(resp?.message || '索引错误')
|
||
stopMessageSearchIndexPolling()
|
||
return
|
||
}
|
||
|
||
if (status !== 'success') {
|
||
logSearchPhase('runMessageSearch:response:non-success', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
status,
|
||
message: String(resp?.message || '')
|
||
})
|
||
if (reset) {
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
}
|
||
messageSearchHasMore.value = false
|
||
messageSearchTotal.value = 0
|
||
messageSearchError.value = String(resp?.message || '搜索失败')
|
||
stopMessageSearchIndexPolling()
|
||
return
|
||
}
|
||
|
||
const hits = Array.isArray(resp?.hits) ? resp.hits : []
|
||
if (reset) {
|
||
messageSearchResults.value = hits
|
||
} else {
|
||
messageSearchResults.value = [...messageSearchResults.value, ...hits]
|
||
}
|
||
messageSearchHasMore.value = !!resp?.hasMore
|
||
messageSearchTotal.value = Number(resp?.total ?? resp?.totalInScan ?? 0)
|
||
stopMessageSearchIndexPolling()
|
||
|
||
if (messageSearchSelectedIndex.value < 0 && messageSearchResults.value.length) {
|
||
messageSearchSelectedIndex.value = 0
|
||
}
|
||
|
||
logSearchPhase('runMessageSearch:response:success', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
status,
|
||
hitsCount: hits.length,
|
||
total: Number(resp?.total ?? resp?.totalInScan ?? 0),
|
||
hasMore: !!resp?.hasMore,
|
||
backendScope: String(resp?.scope || '').trim(),
|
||
backendUsername: String(resp?.username || '').trim()
|
||
})
|
||
|
||
// 保存搜索历史(仅在有结果时保存)
|
||
if (!privacyMode.value && reset && hits.length > 0) {
|
||
saveSearchHistory(q)
|
||
}
|
||
} catch (e) {
|
||
if (reqId !== messageSearchReqId) return
|
||
messageSearchError.value = e?.message || '搜索失败'
|
||
logSearchPhase('runMessageSearch:error', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
error: String(e?.message || e || '')
|
||
})
|
||
} finally {
|
||
if (reqId === messageSearchReqId) {
|
||
messageSearchLoading.value = false
|
||
logSearchPhase('runMessageSearch:finalize', {
|
||
...buildRunSearchLogDetails({ source, reset }),
|
||
reqId,
|
||
loading: !!messageSearchLoading.value,
|
||
error: String(messageSearchError.value || '')
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadMoreSearchResults = async () => {
|
||
if (!messageSearchHasMore.value) return
|
||
if (messageSearchLoading.value) return
|
||
messageSearchOffset.value = Number(messageSearchOffset.value || 0) + messageSearchLimit
|
||
await runMessageSearch({ reset: false, source: 'load-more' })
|
||
}
|
||
|
||
const exitSearchContext = async () => {
|
||
if (!searchContext.value?.active) return
|
||
const u = String(searchContext.value.username || '').trim()
|
||
const saved = searchContext.value.savedMessages
|
||
const savedMeta = searchContext.value.savedMeta
|
||
|
||
if (u && saved) {
|
||
allMessages.value = { ...allMessages.value, [u]: saved }
|
||
}
|
||
if (u && savedMeta) {
|
||
messagesMeta.value = { ...messagesMeta.value, [u]: savedMeta }
|
||
}
|
||
|
||
searchContext.value = {
|
||
active: false,
|
||
kind: 'search',
|
||
label: '',
|
||
username: '',
|
||
anchorId: '',
|
||
anchorIndex: -1,
|
||
hasMoreBefore: false,
|
||
hasMoreAfter: false,
|
||
loadingBefore: false,
|
||
loadingAfter: false,
|
||
savedMessages: null,
|
||
savedMeta: null
|
||
}
|
||
highlightMessageId.value = ''
|
||
await nextTick()
|
||
updateJumpToBottomState()
|
||
}
|
||
|
||
const locateSearchHit = async (hit) => {
|
||
if (!process.client) return
|
||
if (!selectedAccount.value) {
|
||
logSearchPhase('locateSearchHit:skip:no-account', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
hitUsername: String(hit?.username || '').trim()
|
||
})
|
||
return
|
||
}
|
||
if (!hit?.id) {
|
||
logSearchPhase('locateSearchHit:skip:missing-hit-id', {
|
||
hitKeys: Object.keys(hit || {})
|
||
})
|
||
return
|
||
}
|
||
|
||
const targetUsername = String(hit?.username || selectedContact.value?.username || '').trim()
|
||
if (!targetUsername) {
|
||
logSearchPhase('locateSearchHit:skip:missing-target-username', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
hitUsername: String(hit?.username || '').trim(),
|
||
selectedUsernameFallback: String(selectedContact.value?.username || '').trim()
|
||
})
|
||
return
|
||
}
|
||
|
||
logSearchPhase('locateSearchHit:start', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
hitUsername: String(hit?.username || '').trim(),
|
||
targetUsername,
|
||
conversationName: String(hit?.conversationName || '').trim()
|
||
})
|
||
|
||
const targetContact = resolveSearchTargetContact({
|
||
username: targetUsername,
|
||
displayName: String(hit?.conversationName || hit?.username || targetUsername).trim(),
|
||
avatar: String(hit?.conversationAvatar || hit?.senderAvatar || '').trim(),
|
||
isGroup: targetUsername.endsWith('@chatroom')
|
||
})
|
||
logSearchPhase('locateSearchHit:target-resolved', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
contactResolved: !!targetContact,
|
||
contactSource: targetContact
|
||
? (contacts.value.find((c) => String(c?.username || '').trim() === targetUsername) ? 'contacts' : 'transient')
|
||
: 'none'
|
||
})
|
||
if (targetContact && selectedContact.value?.username !== targetUsername) {
|
||
logSearchPhase('locateSearchHit:selectContact:start', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername
|
||
})
|
||
await selectContact(targetContact, { skipLoadMessages: true })
|
||
logSearchPhase('locateSearchHit:selectContact:done', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername
|
||
})
|
||
}
|
||
|
||
if (searchContext.value?.active && searchContext.value.username !== targetUsername) {
|
||
await exitSearchContext()
|
||
logSearchPhase('locateSearchHit:exitSearchContext:done', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername
|
||
})
|
||
}
|
||
|
||
if (!searchContext.value?.active) {
|
||
searchContext.value = {
|
||
active: true,
|
||
kind: 'search',
|
||
label: '',
|
||
username: targetUsername,
|
||
anchorId: String(hit.id),
|
||
anchorIndex: -1,
|
||
hasMoreBefore: true,
|
||
hasMoreAfter: true,
|
||
loadingBefore: false,
|
||
loadingAfter: false,
|
||
savedMessages: allMessages.value[targetUsername] || [],
|
||
savedMeta: messagesMeta.value[targetUsername] || null
|
||
}
|
||
} else {
|
||
searchContext.value.kind = 'search'
|
||
searchContext.value.label = ''
|
||
searchContext.value.anchorId = String(hit.id)
|
||
searchContext.value.hasMoreBefore = true
|
||
searchContext.value.hasMoreAfter = true
|
||
searchContext.value.loadingBefore = false
|
||
searchContext.value.loadingAfter = false
|
||
}
|
||
logSearchPhase('locateSearchHit:search-context:ready', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
contextActive: !!searchContext.value?.active,
|
||
savedMessagesCount: Number(searchContext.value?.savedMessages?.length || 0)
|
||
})
|
||
|
||
try {
|
||
logSearchPhase('locateSearchHit:messagesAround:start', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
before: 35,
|
||
after: 35
|
||
})
|
||
const resp = await api.getChatMessagesAround({
|
||
account: selectedAccount.value,
|
||
username: targetUsername,
|
||
anchor_id: String(hit.id),
|
||
before: 35,
|
||
after: 35
|
||
})
|
||
|
||
const raw = resp?.messages || []
|
||
const mapped = raw.map(normalizeMessage)
|
||
allMessages.value = { ...allMessages.value, [targetUsername]: mapped }
|
||
messagesMeta.value = { ...messagesMeta.value, [targetUsername]: { total: mapped.length, hasMore: false } }
|
||
logSearchPhase('locateSearchHit:messagesAround:end', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
messageCount: mapped.length,
|
||
anchorId: String(resp?.anchorId || hit?.id || '').trim(),
|
||
anchorIndex: Number(resp?.anchorIndex ?? -1)
|
||
})
|
||
|
||
searchContext.value.anchorId = String(resp?.anchorId || hit.id)
|
||
searchContext.value.anchorIndex = Number(resp?.anchorIndex ?? -1)
|
||
|
||
logSearchPhase('locateSearchHit:scroll:start', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
anchorId: String(searchContext.value.anchorId || '').trim(),
|
||
messageCount: mapped.length
|
||
})
|
||
const ok = await scrollToMessageId(searchContext.value.anchorId)
|
||
logSearchPhase('locateSearchHit:scroll:end', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
anchorId: String(searchContext.value.anchorId || '').trim(),
|
||
scrollFound: !!ok
|
||
})
|
||
if (ok) flashMessage(searchContext.value.anchorId)
|
||
} catch (e) {
|
||
logSearchPhase('locateSearchHit:error', {
|
||
hitId: String(hit?.id || '').trim(),
|
||
targetUsername,
|
||
error: String(e?.message || e || '')
|
||
})
|
||
window.alert(e?.message || '定位失败')
|
||
}
|
||
}
|
||
|
||
const locateByAnchorId = async ({ targetUsername, anchorId, kind, label } = {}) => {
|
||
if (!process.client) return
|
||
if (!selectedAccount.value) return
|
||
const u = String(targetUsername || selectedContact.value?.username || '').trim()
|
||
const anchor = String(anchorId || '').trim()
|
||
if (!u || !anchor) return
|
||
|
||
const targetContact = resolveSearchTargetContact({
|
||
username: u,
|
||
displayName: String(selectedContact.value?.name || u).trim(),
|
||
avatar: String(selectedContact.value?.avatar || '').trim(),
|
||
isGroup: u.endsWith('@chatroom')
|
||
})
|
||
if (targetContact && selectedContact.value?.username !== u) {
|
||
await selectContact(targetContact, { skipLoadMessages: true })
|
||
}
|
||
|
||
if (searchContext.value?.active && searchContext.value.username !== u) {
|
||
await exitSearchContext()
|
||
}
|
||
|
||
const kindNorm = String(kind || 'search').trim() || 'search'
|
||
const labelNorm = String(label || '').trim()
|
||
const hasMoreBeforeInit = kindNorm === 'first' ? false : true
|
||
|
||
if (!searchContext.value?.active) {
|
||
searchContext.value = {
|
||
active: true,
|
||
kind: kindNorm,
|
||
label: labelNorm,
|
||
username: u,
|
||
anchorId: anchor,
|
||
anchorIndex: -1,
|
||
hasMoreBefore: hasMoreBeforeInit,
|
||
hasMoreAfter: true,
|
||
loadingBefore: false,
|
||
loadingAfter: false,
|
||
savedMessages: allMessages.value[u] || [],
|
||
savedMeta: messagesMeta.value[u] || null
|
||
}
|
||
} else {
|
||
searchContext.value.kind = kindNorm
|
||
searchContext.value.label = labelNorm
|
||
searchContext.value.anchorId = anchor
|
||
searchContext.value.username = u
|
||
searchContext.value.hasMoreBefore = hasMoreBeforeInit
|
||
searchContext.value.hasMoreAfter = true
|
||
searchContext.value.loadingBefore = false
|
||
searchContext.value.loadingAfter = false
|
||
}
|
||
|
||
try {
|
||
const resp = await api.getChatMessagesAround({
|
||
account: selectedAccount.value,
|
||
username: u,
|
||
anchor_id: anchor,
|
||
before: 35,
|
||
after: 35
|
||
})
|
||
|
||
const raw = resp?.messages || []
|
||
const mapped = raw.map(normalizeMessage)
|
||
allMessages.value = { ...allMessages.value, [u]: mapped }
|
||
messagesMeta.value = { ...messagesMeta.value, [u]: { total: mapped.length, hasMore: false } }
|
||
|
||
searchContext.value.anchorId = String(resp?.anchorId || anchor)
|
||
searchContext.value.anchorIndex = Number(resp?.anchorIndex ?? -1)
|
||
|
||
const ok = await scrollToMessageId(searchContext.value.anchorId)
|
||
if (ok) flashMessage(searchContext.value.anchorId)
|
||
} catch (e) {
|
||
window.alert(e?.message || '定位失败')
|
||
}
|
||
}
|
||
|
||
const locateByDate = async (dateStr) => {
|
||
if (!process.client) return
|
||
if (!selectedAccount.value) return
|
||
if (!selectedContact.value?.username) return
|
||
|
||
const ds = String(dateStr || '').trim()
|
||
if (!ds) return
|
||
await _applyTimeSidebarSelectedDate(ds, { syncMonth: true })
|
||
|
||
try {
|
||
const resp = await api.getChatMessageAnchor({
|
||
account: selectedAccount.value,
|
||
username: selectedContact.value.username,
|
||
kind: 'day',
|
||
date: ds
|
||
})
|
||
const status = String(resp?.status || '')
|
||
const anchorId = String(resp?.anchorId || '').trim()
|
||
if (status !== 'success' || !anchorId) {
|
||
window.alert('当日暂无聊天记录')
|
||
return
|
||
}
|
||
await locateByAnchorId({ targetUsername: selectedContact.value.username, anchorId, kind: 'date', label: ds })
|
||
} catch (e) {
|
||
window.alert(e?.message || '定位失败')
|
||
}
|
||
}
|
||
|
||
const jumpToConversationFirst = async () => {
|
||
if (!process.client) return
|
||
if (!selectedAccount.value) return
|
||
if (!selectedContact.value?.username) return
|
||
|
||
try {
|
||
const resp = await api.getChatMessageAnchor({
|
||
account: selectedAccount.value,
|
||
username: selectedContact.value.username,
|
||
kind: 'first'
|
||
})
|
||
const status = String(resp?.status || '')
|
||
const anchorId = String(resp?.anchorId || '').trim()
|
||
if (status !== 'success' || !anchorId) {
|
||
window.alert('暂无聊天记录')
|
||
return
|
||
}
|
||
const ds = _dateStrFromEpochSeconds(Number(resp?.createTime || 0))
|
||
if (ds) await _applyTimeSidebarSelectedDate(ds, { syncMonth: true })
|
||
await locateByAnchorId({ targetUsername: selectedContact.value.username, anchorId, kind: 'first', label: '' })
|
||
} catch (e) {
|
||
window.alert(e?.message || '定位失败')
|
||
}
|
||
}
|
||
|
||
const onTimeSidebarDayClick = async (cell) => {
|
||
if (!cell || cell.disabled) return
|
||
const ds = String(cell.dateStr || '').trim()
|
||
if (!ds) return
|
||
await locateByDate(ds)
|
||
}
|
||
|
||
const _mergeContextMessages = (username, nextList) => {
|
||
const u = String(username || '').trim()
|
||
if (!u) return
|
||
const list = Array.isArray(nextList) ? nextList : []
|
||
allMessages.value = { ...allMessages.value, [u]: list }
|
||
// Keep meta aligned; context mode doesn't rely on hasMore from meta.
|
||
const prevMeta = messagesMeta.value[u] || null
|
||
messagesMeta.value = {
|
||
...messagesMeta.value,
|
||
[u]: {
|
||
total: Math.max(Number(prevMeta?.total || 0), list.length),
|
||
hasMore: false
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadMoreSearchContextAfter = async () => {
|
||
if (!process.client) return
|
||
if (!selectedAccount.value) return
|
||
if (!searchContext.value?.active) return
|
||
if (searchContext.value.loadingAfter) return
|
||
if (!searchContext.value.hasMoreAfter) return
|
||
|
||
const u = String(searchContext.value.username || selectedContact.value?.username || '').trim()
|
||
if (!u) return
|
||
const existing = allMessages.value[u] || []
|
||
const last = Array.isArray(existing) && existing.length ? existing[existing.length - 1] : null
|
||
const anchorId = String(last?.id || '').trim()
|
||
if (!anchorId) {
|
||
searchContext.value.hasMoreAfter = false
|
||
return
|
||
}
|
||
|
||
const ctxUsername = u
|
||
searchContext.value.loadingAfter = true
|
||
try {
|
||
const resp = await api.getChatMessagesAround({
|
||
account: selectedAccount.value,
|
||
username: ctxUsername,
|
||
anchor_id: anchorId,
|
||
before: 0,
|
||
after: messagePageSize
|
||
})
|
||
|
||
if (!searchContext.value?.active || String(searchContext.value.username || '').trim() !== ctxUsername) return
|
||
|
||
const raw = resp?.messages || []
|
||
const mapped = raw.map(normalizeMessage)
|
||
|
||
const existingIds = new Set(existing.map((m) => String(m?.id || '')))
|
||
const appended = []
|
||
for (const m of mapped) {
|
||
const id = String(m?.id || '').trim()
|
||
if (!id) continue
|
||
if (existingIds.has(id)) continue
|
||
existingIds.add(id)
|
||
appended.push(m)
|
||
}
|
||
|
||
if (!appended.length) {
|
||
searchContext.value.hasMoreAfter = false
|
||
return
|
||
}
|
||
|
||
_mergeContextMessages(ctxUsername, [...existing, ...appended])
|
||
} catch (e) {
|
||
window.alert(e?.message || '加载更多消息失败')
|
||
} finally {
|
||
if (searchContext.value?.active && String(searchContext.value.username || '').trim() === ctxUsername) {
|
||
searchContext.value.loadingAfter = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const loadMoreSearchContextBefore = async () => {
|
||
if (!process.client) return
|
||
if (!selectedAccount.value) return
|
||
if (!searchContext.value?.active) return
|
||
if (searchContext.value.loadingBefore) return
|
||
if (!searchContext.value.hasMoreBefore) return
|
||
|
||
const u = String(searchContext.value.username || selectedContact.value?.username || '').trim()
|
||
if (!u) return
|
||
const existing = allMessages.value[u] || []
|
||
const first = Array.isArray(existing) && existing.length ? existing[0] : null
|
||
const anchorId = String(first?.id || '').trim()
|
||
if (!anchorId) {
|
||
searchContext.value.hasMoreBefore = false
|
||
return
|
||
}
|
||
|
||
const c = messageContainerRef.value
|
||
const beforeScrollHeight = c ? c.scrollHeight : 0
|
||
const beforeScrollTop = c ? c.scrollTop : 0
|
||
|
||
const ctxUsername = u
|
||
searchContext.value.loadingBefore = true
|
||
try {
|
||
const resp = await api.getChatMessagesAround({
|
||
account: selectedAccount.value,
|
||
username: ctxUsername,
|
||
anchor_id: anchorId,
|
||
before: messagePageSize,
|
||
after: 0
|
||
})
|
||
|
||
if (!searchContext.value?.active || String(searchContext.value.username || '').trim() !== ctxUsername) return
|
||
|
||
const raw = resp?.messages || []
|
||
const mapped = raw.map(normalizeMessage)
|
||
|
||
const existingIds = new Set(existing.map((m) => String(m?.id || '')))
|
||
const prepended = []
|
||
for (const m of mapped) {
|
||
const id = String(m?.id || '').trim()
|
||
if (!id) continue
|
||
if (existingIds.has(id)) continue
|
||
existingIds.add(id)
|
||
prepended.push(m)
|
||
}
|
||
|
||
if (!prepended.length) {
|
||
searchContext.value.hasMoreBefore = false
|
||
return
|
||
}
|
||
|
||
_mergeContextMessages(ctxUsername, [...prepended, ...existing])
|
||
|
||
await nextTick()
|
||
const c2 = messageContainerRef.value
|
||
if (c2) {
|
||
const afterScrollHeight = c2.scrollHeight
|
||
c2.scrollTop = beforeScrollTop + (afterScrollHeight - beforeScrollHeight)
|
||
}
|
||
} catch (e) {
|
||
window.alert(e?.message || '加载更多消息失败')
|
||
} finally {
|
||
if (searchContext.value?.active && String(searchContext.value.username || '').trim() === ctxUsername) {
|
||
searchContext.value.loadingBefore = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const onSearchHitPointerDown = (hit, idx, event) => {
|
||
logSearchPhase('search-result:pointerdown', {
|
||
...summarizeHitForLog(hit, idx),
|
||
button: Number(event?.button ?? -1),
|
||
detail: Number(event?.detail ?? 0),
|
||
target: describeEventTarget(event?.target),
|
||
currentTarget: describeEventTarget(event?.currentTarget)
|
||
})
|
||
}
|
||
|
||
const onSearchHitClickCapture = (hit, idx, event) => {
|
||
logSearchPhase('search-result:click-capture', {
|
||
...summarizeHitForLog(hit, idx),
|
||
button: Number(event?.button ?? -1),
|
||
detail: Number(event?.detail ?? 0),
|
||
target: describeEventTarget(event?.target),
|
||
currentTarget: describeEventTarget(event?.currentTarget)
|
||
})
|
||
}
|
||
|
||
const onSearchHitClick = async (hit, idx) => {
|
||
messageSearchSelectedIndex.value = Number(idx || 0)
|
||
logSearchPhase('onSearchHitClick', {
|
||
...summarizeHitForLog(hit, idx),
|
||
selectedIndex: Number(messageSearchSelectedIndex.value || 0)
|
||
})
|
||
await locateSearchHit(hit)
|
||
logSearchPhase('onSearchHitClick:done', {
|
||
...summarizeHitForLog(hit, idx),
|
||
selectedIndex: Number(messageSearchSelectedIndex.value || 0)
|
||
})
|
||
}
|
||
|
||
const onSearchNext = async () => {
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
if (!q) return
|
||
|
||
if (!messageSearchResults.value.length && !messageSearchLoading.value) {
|
||
await runMessageSearch({ reset: true, source: 'search-next-bootstrap' })
|
||
}
|
||
if (!messageSearchResults.value.length) return
|
||
|
||
const cur = Number(messageSearchSelectedIndex.value || 0)
|
||
const next = (cur + 1) % messageSearchResults.value.length
|
||
messageSearchSelectedIndex.value = next
|
||
await locateSearchHit(messageSearchResults.value[next])
|
||
}
|
||
|
||
const onSearchPrev = async () => {
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
if (!q) return
|
||
|
||
if (!messageSearchResults.value.length && !messageSearchLoading.value) {
|
||
await runMessageSearch({ reset: true, source: 'search-prev-bootstrap' })
|
||
}
|
||
if (!messageSearchResults.value.length) return
|
||
|
||
const cur = Number(messageSearchSelectedIndex.value || 0)
|
||
const prev = (cur - 1 + messageSearchResults.value.length) % messageSearchResults.value.length
|
||
messageSearchSelectedIndex.value = prev
|
||
await locateSearchHit(messageSearchResults.value[prev])
|
||
}
|
||
const openMessageSearch = async () => {
|
||
closeTimeSidebar()
|
||
messageSearchOpen.value = true
|
||
ensureMessageSearchScopeValid()
|
||
logSearchPhase('message-search:open:start', {
|
||
scope: String(messageSearchScope.value || 'conversation'),
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
await nextTick()
|
||
try {
|
||
messageSearchInputRef.value?.focus?.()
|
||
} catch {}
|
||
await fetchMessageSearchIndexStatus()
|
||
logSearchPhase('message-search:open:end', {
|
||
scope: String(messageSearchScope.value || 'conversation'),
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
}
|
||
watch(messageSearchScope, async (next, prev) => {
|
||
logSearchPhase('message-search-scope:change', {
|
||
previous: String(prev || '').trim(),
|
||
next: String(next || '').trim(),
|
||
open: !!messageSearchOpen.value,
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
ensureMessageSearchScopeValid()
|
||
closeMessageSearchSenderDropdown()
|
||
messageSearchSender.value = ''
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
await fetchMessageSearchSenders()
|
||
messageSearchOffset.value = 0
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'scope-change' })
|
||
}
|
||
})
|
||
|
||
watch(messageSearchRangeDays, async (next, prev) => {
|
||
logSearchPhase('message-search-range:change', {
|
||
previous: String(prev || '').trim(),
|
||
next: String(next || '').trim(),
|
||
open: !!messageSearchOpen.value,
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
closeMessageSearchSenderDropdown()
|
||
messageSearchOffset.value = 0
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'range-change' })
|
||
}
|
||
})
|
||
|
||
watch(messageSearchSessionType, async (next, prev) => {
|
||
logSearchPhase('message-search-session-type:change', {
|
||
previous: String(prev || '').trim(),
|
||
next: String(next || '').trim(),
|
||
open: !!messageSearchOpen.value,
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
if (String(messageSearchScope.value || '') !== 'global') return
|
||
closeMessageSearchSenderDropdown()
|
||
messageSearchSender.value = ''
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
await fetchMessageSearchSenders()
|
||
messageSearchOffset.value = 0
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'session-type-change' })
|
||
}
|
||
})
|
||
|
||
watch([messageSearchStartDate, messageSearchEndDate], async ([nextStart, nextEnd], [prevStart, prevEnd]) => {
|
||
logSearchPhase('message-search-custom-range:change', {
|
||
previousStart: String(prevStart || '').trim(),
|
||
previousEnd: String(prevEnd || '').trim(),
|
||
nextStart: String(nextStart || '').trim(),
|
||
nextEnd: String(nextEnd || '').trim(),
|
||
open: !!messageSearchOpen.value
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
if (String(messageSearchRangeDays.value || '') !== 'custom') return
|
||
closeMessageSearchSenderDropdown()
|
||
messageSearchOffset.value = 0
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'custom-range-change' })
|
||
}
|
||
})
|
||
|
||
watch(messageSearchSender, async (next, prev) => {
|
||
logSearchPhase('message-search-sender:change', {
|
||
previous: String(prev || '').trim(),
|
||
next: String(next || '').trim(),
|
||
open: !!messageSearchOpen.value,
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
messageSearchOffset.value = 0
|
||
messageSearchResults.value = []
|
||
messageSearchSelectedIndex.value = -1
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'sender-change' })
|
||
}
|
||
})
|
||
|
||
watch(messageSearchQuery, (next, prev) => {
|
||
logSearchPhase('message-search-query:change', {
|
||
previousLength: String(prev || '').trim().length,
|
||
nextLength: String(next || '').trim().length,
|
||
open: !!messageSearchOpen.value
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
|
||
messageSearchDebounceTimer = null
|
||
const q = String(messageSearchQuery.value || '').trim()
|
||
if (q.length < 2) {
|
||
logSearchPhase('message-search-query:debounce:skip-short', {
|
||
queryLength: q.length
|
||
})
|
||
return
|
||
}
|
||
logSearchPhase('message-search-query:debounce:scheduled', {
|
||
queryLength: q.length,
|
||
delayMs: 280
|
||
})
|
||
messageSearchDebounceTimer = setTimeout(() => {
|
||
logSearchPhase('message-search-query:debounce:fire', {
|
||
queryLength: String(messageSearchQuery.value || '').trim().length
|
||
})
|
||
runMessageSearch({ reset: true, source: 'query-debounce' })
|
||
}, 280)
|
||
})
|
||
|
||
watch(
|
||
() => selectedContact.value?.username,
|
||
async () => {
|
||
logSearchPhase('message-search-selected-contact:change', {
|
||
open: !!messageSearchOpen.value,
|
||
scope: String(messageSearchScope.value || '').trim(),
|
||
selectedContactUsername: String(selectedContact.value?.username || '').trim()
|
||
})
|
||
if (!messageSearchOpen.value) return
|
||
if (String(messageSearchScope.value || '') !== 'conversation') return
|
||
closeMessageSearchSenderDropdown()
|
||
messageSearchSender.value = ''
|
||
messageSearchSenderOptions.value = []
|
||
messageSearchSenderOptionsKey.value = ''
|
||
await fetchMessageSearchSenders()
|
||
if (String(messageSearchQuery.value || '').trim()) {
|
||
await runMessageSearch({ reset: true, source: 'selected-contact-change' })
|
||
}
|
||
}
|
||
)
|
||
|
||
watch(messageSearchOpen, (next, prev) => {
|
||
logSearchPhase('message-search-open:change', {
|
||
previous: !!prev,
|
||
next: !!next,
|
||
queryLength: String(messageSearchQuery.value || '').trim().length,
|
||
resultCount: Number(messageSearchResults.value?.length || 0)
|
||
})
|
||
})
|
||
|
||
watch(messageSearchResults, (next, prev) => {
|
||
const nextList = Array.isArray(next) ? next : []
|
||
const prevList = Array.isArray(prev) ? prev : []
|
||
logSearchPhase('message-search-results:change', {
|
||
previousCount: prevList.length,
|
||
nextCount: nextList.length,
|
||
firstHitId: String(nextList[0]?.id || '').trim(),
|
||
selectedIndex: Number(messageSearchSelectedIndex.value ?? -1)
|
||
})
|
||
})
|
||
|
||
const autoLoadReady = ref(true)
|
||
|
||
let timeSidebarScrollSyncRaf = null
|
||
const syncTimeSidebarSelectedDateFromScroll = () => {
|
||
if (!process.client) return
|
||
if (!timeSidebarOpen.value) return
|
||
if (!selectedContact.value) return
|
||
|
||
const c = messageContainerRef.value
|
||
if (!c) return
|
||
|
||
if (timeSidebarScrollSyncRaf) return
|
||
timeSidebarScrollSyncRaf = requestAnimationFrame(() => {
|
||
timeSidebarScrollSyncRaf = null
|
||
try {
|
||
const containerRect = c.getBoundingClientRect()
|
||
const targetY = containerRect.top + 24
|
||
const els = c.querySelectorAll?.('[data-msg-id][data-create-time]') || []
|
||
if (!els || !els.length) return
|
||
|
||
let chosen = null
|
||
for (const el of els) {
|
||
const r = el.getBoundingClientRect?.()
|
||
if (!r) continue
|
||
if (r.bottom >= targetY) {
|
||
chosen = el
|
||
break
|
||
}
|
||
}
|
||
if (!chosen) chosen = els[els.length - 1]
|
||
const ts = Number(chosen?.getAttribute?.('data-create-time') || 0)
|
||
const ds = _dateStrFromEpochSeconds(ts)
|
||
if (!ds) return
|
||
// Don't await inside rAF; keep scroll handler snappy.
|
||
_applyTimeSidebarSelectedDate(ds, { syncMonth: true })
|
||
} catch {}
|
||
})
|
||
}
|
||
|
||
const contextAutoLoadTopReady = ref(true)
|
||
const contextAutoLoadBottomReady = ref(true)
|
||
|
||
const onMessageScrollInContextMode = async () => {
|
||
const c = messageContainerRef.value
|
||
if (!c) return
|
||
if (!searchContext.value?.active) return
|
||
|
||
const distBottom = c.scrollHeight - c.scrollTop - c.clientHeight
|
||
|
||
// Reset "ready" gates when user scrolls away from edges.
|
||
if (c.scrollTop > 160) contextAutoLoadTopReady.value = true
|
||
if (distBottom > 160) contextAutoLoadBottomReady.value = true
|
||
|
||
if (c.scrollTop <= 60 && contextAutoLoadTopReady.value && searchContext.value.hasMoreBefore && !searchContext.value.loadingBefore) {
|
||
contextAutoLoadTopReady.value = false
|
||
await loadMoreSearchContextBefore()
|
||
return
|
||
}
|
||
|
||
if (distBottom <= 80 && contextAutoLoadBottomReady.value && searchContext.value.hasMoreAfter && !searchContext.value.loadingAfter) {
|
||
contextAutoLoadBottomReady.value = false
|
||
await loadMoreSearchContextAfter()
|
||
}
|
||
}
|
||
|
||
const onMessageScroll = async () => {
|
||
const c = messageContainerRef.value
|
||
if (!c) return
|
||
updateJumpToBottomState()
|
||
if (!selectedContact.value) return
|
||
|
||
// Keep the time sidebar selection in sync with the current viewport.
|
||
syncTimeSidebarSelectedDateFromScroll()
|
||
|
||
if (searchContext.value?.active) {
|
||
await onMessageScrollInContextMode()
|
||
return
|
||
}
|
||
|
||
if (c.scrollTop > 120) {
|
||
autoLoadReady.value = true
|
||
return
|
||
}
|
||
|
||
if (c.scrollTop <= 60 && autoLoadReady.value && hasMoreMessages.value && !isLoadingMessages.value) {
|
||
autoLoadReady.value = false
|
||
await loadMoreMessages()
|
||
}
|
||
}
|
||
|
||
const resetSearchState = () => {
|
||
closeMessageSearch()
|
||
closeTimeSidebar()
|
||
timeSidebarYear.value = null
|
||
timeSidebarMonth.value = null
|
||
_applyTimeSidebarMonthData({ counts: {}, max: 0, total: 0 })
|
||
timeSidebarError.value = ''
|
||
timeSidebarSelectedDate.value = ''
|
||
messageSearchResults.value = []
|
||
messageSearchOffset.value = 0
|
||
messageSearchHasMore.value = false
|
||
messageSearchBackendStatus.value = ''
|
||
messageSearchTotal.value = 0
|
||
messageSearchIndexInfo.value = null
|
||
messageSearchSelectedIndex.value = -1
|
||
searchContext.value = createEmptySearchContext()
|
||
highlightMessageId.value = ''
|
||
}
|
||
|
||
onMounted(() => {
|
||
loadSearchHistory()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (messageSearchDebounceTimer) clearTimeout(messageSearchDebounceTimer)
|
||
messageSearchDebounceTimer = null
|
||
stopMessageSearchIndexPolling()
|
||
if (timeSidebarScrollSyncRaf) {
|
||
cancelAnimationFrame(timeSidebarScrollSyncRaf)
|
||
timeSidebarScrollSyncRaf = null
|
||
}
|
||
})
|
||
|
||
return {
|
||
messageSearchOpen,
|
||
messageSearchQuery,
|
||
messageSearchScope,
|
||
messageSearchRangeDays,
|
||
messageSearchSessionType,
|
||
messageSearchSender,
|
||
messageSearchSenderOptions,
|
||
messageSearchSenderLoading,
|
||
messageSearchSenderError,
|
||
messageSearchSenderOptionsKey,
|
||
messageSearchSenderDropdownOpen,
|
||
messageSearchSenderDropdownRef,
|
||
messageSearchSenderDropdownInputRef,
|
||
messageSearchSenderDropdownQuery,
|
||
messageSearchStartDate,
|
||
messageSearchEndDate,
|
||
messageSearchResults,
|
||
messageSearchLoading,
|
||
messageSearchError,
|
||
messageSearchBackendStatus,
|
||
messageSearchIndexInfo,
|
||
messageSearchHasMore,
|
||
messageSearchOffset,
|
||
messageSearchTotal,
|
||
messageSearchSelectedIndex,
|
||
messageSearchInputRef,
|
||
searchInputFocused,
|
||
showAdvancedFilters,
|
||
searchHistory,
|
||
messageSearchIndexExists,
|
||
messageSearchIndexReady,
|
||
messageSearchIndexBuildStatus,
|
||
messageSearchIndexBuildIndexed,
|
||
messageSearchIndexMetaCount,
|
||
messageSearchIndexProgressText,
|
||
messageSearchIndexText,
|
||
messageSearchIndexActionText,
|
||
messageSearchIndexActionDisabled,
|
||
messageSearchSenderDisabled,
|
||
messageSearchSelectedSenderInfo,
|
||
messageSearchSelectedSenderInitial,
|
||
messageSearchSenderLabel,
|
||
filteredMessageSearchSenderOptions,
|
||
searchContextBannerText,
|
||
timeSidebarOpen,
|
||
timeSidebarYear,
|
||
timeSidebarMonth,
|
||
timeSidebarCounts,
|
||
timeSidebarMax,
|
||
timeSidebarTotal,
|
||
timeSidebarLoading,
|
||
timeSidebarError,
|
||
timeSidebarSelectedDate,
|
||
timeSidebarWeekdays,
|
||
timeSidebarMonthLabel,
|
||
timeSidebarYearOptions,
|
||
timeSidebarActiveDays,
|
||
timeSidebarCalendarCells,
|
||
getMessageSearchHitAvatarUrl,
|
||
getMessageSearchHitAvatarAlt,
|
||
getMessageSearchHitAvatarInitial,
|
||
closeMessageSearchSenderDropdown,
|
||
ensureMessageSearchSendersLoaded,
|
||
toggleMessageSearchSenderDropdown,
|
||
selectMessageSearchSender,
|
||
fetchMessageSearchIndexStatus,
|
||
fetchMessageSearchSenders,
|
||
onMessageSearchIndexAction,
|
||
closeMessageSearch,
|
||
closeTimeSidebar,
|
||
loadTimeSidebarMonth,
|
||
toggleTimeSidebar,
|
||
prevTimeSidebarMonth,
|
||
nextTimeSidebarMonth,
|
||
onTimeSidebarYearMonthChange,
|
||
toggleMessageSearch,
|
||
openMessageSearch,
|
||
runMessageSearch,
|
||
loadMoreSearchResults,
|
||
exitSearchContext,
|
||
locateSearchHit,
|
||
locateByAnchorId,
|
||
locateByDate,
|
||
jumpToConversationFirst,
|
||
onTimeSidebarDayClick,
|
||
loadMoreSearchContextAfter,
|
||
loadMoreSearchContextBefore,
|
||
onSearchHitPointerDown,
|
||
onSearchHitClickCapture,
|
||
onSearchHitClick,
|
||
onSearchNext,
|
||
onSearchPrev,
|
||
syncTimeSidebarSelectedDateFromScroll,
|
||
onMessageScrollInContextMode,
|
||
onMessageScroll,
|
||
clearSearchHistory,
|
||
applySearchHistory,
|
||
ensureMessageSearchScopeValid,
|
||
resetSearchState
|
||
}
|
||
}
|