Files
2026-04-04 21:45:16 +08:00

2093 lines
64 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}