feat: 优化聊天档案搜索体验

- 高亮展示搜索结果中的命中词,覆盖普通文本与结构化消息内容
- 为全部及分类搜索结果提供聊天定位,定位后清空搜索并回到完整消息上下文
- 修复移动端搜索框聚焦自动放大,并补充桌面与移动端测试覆盖
This commit is contained in:
majun.jason
2026-08-05 15:24:36 +08:00
parent 933a87ebbb
commit 17cc99de37
3 changed files with 110 additions and 3 deletions
+57 -2
View File
@@ -306,6 +306,10 @@ body {
opacity: 1;
transform: translate(0, -50%);
}
@media (hover: none) {
.locate-all { opacity: 1; pointer-events: auto; }
.locate-label { display: none; }
}
.message.located .bubble { animation: locate-message 1.5s ease-out; }
@keyframes locate-message {
0%, 32% { outline: 3px solid #36a477; outline-offset: 3px; }
@@ -376,6 +380,14 @@ body {
.sent .bubble { background: var(--mine); border-color: #c7e6d4; border-radius: 18px 10px 18px 18px; }
.sender { color: var(--muted); font-size: 12px; margin-bottom: 5px; }
.content { line-height: 1.7; word-break: break-word; white-space: pre-wrap; }
.search-highlight {
border-radius: 3px;
padding: 0 1px;
background: #ffe58f;
color: inherit;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.audio-wrap { width: 260px; max-width: 100%; min-width: 0; }
.audio { display: block; width: 100%; max-width: 100%; height: 38px; }
.media-status {
@@ -596,7 +608,13 @@ body {
box-shadow: none;
}
.controls { grid-column: 2; min-width: 0; justify-content: flex-start; }
.controls input[type=search] { width: 100%; min-width: 0; height: 36px; padding: 0 10px; }
.controls input[type=search] {
width: 100%;
min-width: 0;
height: 36px;
padding: 0 10px;
font-size: 16px;
}
.filters {
grid-column: 1 / -1;
display: flex;
@@ -1025,7 +1043,7 @@ const renderExportScript = (name: string): string => `
const source = conversations.length > 1 && activeConversation === 'all'
? '<span class="conversation-source">' + esc(message.exportConversationName || '聊天') + '</span>'
: ''
const locateAction = activeKind === 'all'
const locateAction = activeKind === 'all' && !query.value.trim()
? ''
: '<button class="locate-all" type="button" data-locate-index="' + archiveIndex +
'" aria-label="定位到聊天位置"><span class="locate-icon" aria-hidden="true">⌖</span>' +
@@ -1181,6 +1199,40 @@ const renderExportScript = (name: string): string => `
const listBounds = list.getBoundingClientRect()
return list.scrollTop + targetBounds.top - listBounds.top - offset
}
const highlightSearchMatches = () => {
const term = query.value.trim().toLowerCase()
if (!term) return
list.querySelectorAll('.message').forEach((message) => {
const walker = document.createTreeWalker(message, window.NodeFilter.SHOW_TEXT)
const matches = []
while (walker.nextNode()) {
const node = walker.currentNode
const parent = node.parentElement
if (
node.nodeValue && parent && !parent.closest('.locate-all, .search-highlight') &&
node.nodeValue.toLowerCase().includes(term)
) matches.push(node)
}
matches.forEach((node) => {
const text = node.nodeValue
const normalized = text.toLowerCase()
const fragment = document.createDocumentFragment()
let start = 0
let index = normalized.indexOf(term, start)
while (index >= 0) {
fragment.append(text.slice(start, index))
const mark = document.createElement('mark')
mark.className = 'search-highlight'
mark.textContent = text.slice(index, index + term.length)
fragment.append(mark)
start = index + term.length
index = normalized.indexOf(term, start)
}
fragment.append(text.slice(start))
node.replaceWith(fragment)
})
})
}
const renderWindow = (anchorIndex, anchorOffset) => {
const visible = filtered.slice(windowStart, windowEnd)
const before = windowStart > 0 ? '<div class="lazy-hint">向上滚动加载更早消息</div>' : ''
@@ -1188,6 +1240,7 @@ const renderExportScript = (name: string): string => `
list.innerHTML = visible.length
? before + visible.map((message, index) => renderMessage(message, windowStart + index)).join('') + after
: '<div class="empty">没有符合条件的消息<br><small>可以更换筛选条件或关键词</small></div>'
highlightSearchMatches()
if (Number.isInteger(anchorIndex)) {
const anchor = list.querySelector('.message[data-index="' + anchorIndex + '"]')
if (anchor) setScrollTop(scrollTopForTarget(anchor, anchorOffset))
@@ -1260,6 +1313,8 @@ const renderExportScript = (name: string): string => `
const targetMessage = filtered[sourceIndex]
if (!targetMessage) return
rememberTabPosition()
query.value = ''
query.blur()
setActiveKind('all')
filtered = matchingMessages()
renderTimeline()
+34 -1
View File
@@ -198,7 +198,8 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
conversationWidth: conversations.width,
searchTop: search.top,
searchBottom: search.bottom,
searchWidth: search.width
searchWidth: search.width,
searchFontSize: getComputedStyle(document.querySelector('#query')!).fontSize
}
})
expect(
@@ -208,11 +209,27 @@ test('EXPORT-ARCHIVE-01 merged v2 archive is usable offline on desktop and mobil
Math.abs(compactControlBounds.conversationBottom - compactControlBounds.searchBottom)
).toBeLessThanOrEqual(1)
expect(compactControlBounds.searchWidth).toBeGreaterThan(compactControlBounds.conversationWidth)
expect(compactControlBounds.searchFontSize).toBe('16px')
await conversationSelect.selectOption('beta')
await expect(page.locator('.message')).toHaveCount(1)
await conversationSelect.selectOption('all')
await expect(page.locator('.message')).toHaveCount(4)
await expect(searchInput).toBeVisible()
await searchInput.fill('个人聊天消息')
await expect(page.locator('.search-highlight')).toHaveText('个人聊天消息')
const mobileSearchResult = page.locator('.message')
const mobileLocateButton = mobileSearchResult.getByRole('button', {
name: '定位到聊天位置'
})
await mobileSearchResult.hover()
await expect(mobileLocateButton).toHaveCSS('opacity', '1')
await page.screenshot({
path: testInfo.outputPath('search-highlight-390.png'),
animations: 'disabled'
})
await mobileLocateButton.click()
await expect(searchInput).toHaveValue('')
await expect(page.locator('.message.located')).toContainText('个人聊天消息')
const mobileFilterButtons = page.locator('.filter-button:visible')
await expect(mobileFilterButtons).toHaveCount(7)
const filterButtonTops = await mobileFilterButtons.evaluateAll((buttons) =>
@@ -653,6 +670,22 @@ test('EXPORT-ARCHIVE-03 renders shares and locations, and groups payments under
await page.getByText('展开 1 条消息').click()
await expect(page.getByText('项目结论已经确认')).toBeVisible()
const searchInput = page.getByLabel('搜索消息')
await searchInput.fill('真正的公众号标题')
await expect(page.locator('.message')).toHaveCount(1)
await expect(page.locator('.search-highlight')).toHaveText('真正的公众号标题')
const searchResult = page.locator('.message')
await searchResult.hover()
await page.screenshot({
path: testInfo.outputPath('search-highlight-1440.png'),
animations: 'disabled'
})
await searchResult.getByRole('button', { name: '定位到聊天位置' }).click()
await expect(searchInput).toHaveValue('')
await expect(page.getByRole('button', { name: '全部', exact: true })).toHaveClass(/active/)
await expect(page.locator('.search-highlight')).toHaveCount(0)
await expect(page.locator('.message.located')).toContainText('真正的公众号标题')
await page.getByRole('button', { name: '分享', exact: true }).click()
await expect(page.locator('.message')).toHaveCount(5)
await expect(page.locator('[data-rich-kind="forwardBundle"]')).toHaveCount(1)
+19
View File
@@ -34,6 +34,7 @@ describe('export media', () => {
expect(html).toContain('setExpandedTimelineYear')
expect(html).toContain('data-kind="media"')
expect(html).toContain('placeholder="搜索发送者或消息内容…"')
expect(html).toContain('font-size: 16px;')
expect(html).toContain('filtered.slice(windowStart, windowEnd)')
expect(html).toContain('windowStart = Math.max(0, windowEnd - PAGE_SIZE)')
expect(html).toContain('scheduleWindowSlide')
@@ -95,6 +96,8 @@ describe('export media', () => {
search.value = 'needle'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(5)
expect(dom.window.document.querySelectorAll('.timeline-month').length).toBeGreaterThan(1)
expect(
dom.window.document.querySelectorAll('.timeline-year[aria-expanded="true"]')
@@ -237,6 +240,7 @@ describe('export media', () => {
const search = dom.window.document.querySelector('#query') as HTMLInputElement
search.value = '目标文字'
search.dispatchEvent(new dom.window.Event('input'))
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('目标文字')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
@@ -245,6 +249,21 @@ describe('export media', () => {
'0'
)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain('目标文字')
search.value = '稍后消息-137'
search.dispatchEvent(new dom.window.Event('input'))
expect(
dom.window.document.querySelector('[data-kind="all"]')?.classList.contains('active')
).toBe(true)
expect(dom.window.document.querySelectorAll('.message')).toHaveLength(1)
expect(dom.window.document.querySelector('.search-highlight')?.textContent).toBe('稍后消息-137')
;(dom.window.document.querySelector('.locate-all') as HTMLElement).click()
expect(search.value).toBe('')
expect(dom.window.document.querySelectorAll('.search-highlight')).toHaveLength(0)
expect(dom.window.document.querySelectorAll('.locate-all')).toHaveLength(0)
expect(dom.window.document.querySelector('.message.located')?.textContent).toContain(
'稍后消息-137'
)
dom.window.close()
})