feat: 新增聊天消息搜索功能

- 会话内搜索:header 加搜索按钮,展开搜索栏,结果列表显示在消息区上方,点击跳转到对应时间
- 全局消息搜索:会话列表搜索框新增消息模式切换按钮,搜索结果展示在会话列表下方,点击跳转到对应会话
- preload 暴露 chat.searchMessages IPC
This commit is contained in:
hicccc77
2026-03-15 19:35:36 +08:00
parent 7024b86d00
commit 053e2cdc64
3 changed files with 270 additions and 5 deletions

View File

@@ -218,6 +218,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
getContacts: () => ipcRenderer.invoke('chat:getContacts'), getContacts: () => ipcRenderer.invoke('chat:getContacts'),
getMessage: (sessionId: string, localId: number) => getMessage: (sessionId: string, localId: number) =>
ipcRenderer.invoke('chat:getMessage', sessionId, localId), ipcRenderer.invoke('chat:getMessage', sessionId, localId),
searchMessages: (keyword: string, sessionId?: string, limit?: number, offset?: number, beginTimestamp?: number, endTimestamp?: number) =>
ipcRenderer.invoke('chat:searchMessages', keyword, sessionId, limit, offset, beginTimestamp, endTimestamp),
onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => { onWcdbChange: (callback: (event: any, data: { type: string; json: string }) => void) => {
ipcRenderer.on('wcdb-change', callback) ipcRenderer.on('wcdb-change', callback)
return () => ipcRenderer.removeListener('wcdb-change', callback) return () => ipcRenderer.removeListener('wcdb-change', callback)

View File

@@ -4630,3 +4630,126 @@
} }
} }
} }
// 会话内搜索栏
.in-session-search-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
.in-session-search-icon {
color: var(--text-tertiary);
flex-shrink: 0;
}
.in-session-search-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 13px;
color: var(--text-primary);
min-width: 0;
&::placeholder { color: var(--text-tertiary); }
}
.in-session-result-count {
font-size: 12px;
color: var(--text-tertiary);
flex-shrink: 0;
}
}
.in-session-results {
max-height: 220px;
overflow-y: auto;
border-bottom: 1px solid var(--border-color);
background: var(--bg-primary);
flex-shrink: 0;
.in-session-result-item {
display: flex;
flex-direction: column;
padding: 7px 12px;
cursor: pointer;
gap: 2px;
border-bottom: 1px solid var(--border-color);
&:hover { background: var(--bg-secondary); }
.result-sender {
font-size: 11px;
color: var(--text-tertiary);
}
.result-content {
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.result-time {
font-size: 11px;
color: var(--text-tertiary);
align-self: flex-end;
}
}
}
// 全局消息搜索结果面板
.global-msg-search-results {
max-height: 320px;
overflow-y: auto;
background: var(--bg-primary);
border-top: 1px solid var(--border-color);
.global-msg-searching,
.global-msg-empty {
display: flex;
align-items: center;
gap: 6px;
padding: 12px;
font-size: 13px;
color: var(--text-tertiary);
}
.global-msg-result-item {
padding: 8px 12px;
cursor: pointer;
border-bottom: 1px solid var(--border-color);
&:hover { background: var(--bg-secondary); }
.global-msg-result-session {
font-size: 11px;
color: var(--text-accent, #07c160);
margin-bottom: 2px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.global-msg-result-content {
font-size: 13px;
color: var(--text-primary);
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.global-msg-result-time {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
}
}
}
.msg-search-toggle-btn.active {
color: var(--accent-color, #07c160);
}
.in-session-search-btn.active {
color: var(--accent-color, #07c160);
}

View File

@@ -564,6 +564,17 @@ function ChatPage(props: ChatPageProps) {
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 }) const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 })
const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false) const [cancelDeleteRequested, setCancelDeleteRequested] = useState(false)
// 会话内搜索
const [showInSessionSearch, setShowInSessionSearch] = useState(false)
const [inSessionQuery, setInSessionQuery] = useState('')
const [inSessionResults, setInSessionResults] = useState<any[]>([])
const [inSessionSearching, setInSessionSearching] = useState(false)
const inSessionSearchRef = useRef<HTMLInputElement>(null)
// 全局消息搜索
const [showGlobalMsgSearch, setShowGlobalMsgSearch] = useState(false)
const [globalMsgQuery, setGlobalMsgQuery] = useState('')
const [globalMsgResults, setGlobalMsgResults] = useState<any[]>([])
const [globalMsgSearching, setGlobalMsgSearching] = useState(false)
// 自定义删除确认对话框 // 自定义删除确认对话框
const [deleteConfirm, setDeleteConfirm] = useState<{ const [deleteConfirm, setDeleteConfirm] = useState<{
@@ -2608,6 +2619,56 @@ function ChatPage(props: ChatPageProps) {
setSearchKeyword('') setSearchKeyword('')
} }
// 会话内搜索
const handleInSessionSearch = useCallback(async (keyword: string) => {
setInSessionQuery(keyword)
if (!keyword.trim() || !currentSessionId) {
setInSessionResults([])
return
}
setInSessionSearching(true)
try {
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), currentSessionId, 50, 0)
setInSessionResults(res?.messages || [])
} catch {
setInSessionResults([])
} finally {
setInSessionSearching(false)
}
}, [currentSessionId])
const handleToggleInSessionSearch = useCallback(() => {
setShowInSessionSearch(v => {
if (v) { setInSessionQuery(''); setInSessionResults([]) }
else setTimeout(() => inSessionSearchRef.current?.focus(), 50)
return !v
})
}, [])
// 全局消息搜索
const handleGlobalMsgSearch = useCallback(async (keyword: string) => {
setGlobalMsgQuery(keyword)
if (!keyword.trim()) {
setGlobalMsgResults([])
return
}
setGlobalMsgSearching(true)
try {
const res = await window.electronAPI.chat.searchMessages(keyword.trim(), undefined, 50, 0)
setGlobalMsgResults(res?.messages || [])
} catch {
setGlobalMsgResults([])
} finally {
setGlobalMsgSearching(false)
}
}, [])
const handleCloseGlobalMsgSearch = useCallback(() => {
setShowGlobalMsgSearch(false)
setGlobalMsgQuery('')
setGlobalMsgResults([])
}, [])
// 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行)
const scrollTimeoutRef = useRef<number | null>(null) const scrollTimeoutRef = useRef<number | null>(null)
const handleScroll = useCallback(() => { const handleScroll = useCallback(() => {
@@ -3895,20 +3956,57 @@ function ChatPage(props: ChatPageProps) {
<input <input
ref={searchInputRef} ref={searchInputRef}
type="text" type="text"
placeholder="搜索" placeholder={showGlobalMsgSearch ? '搜索消息内容...' : '搜索'}
value={searchKeyword} value={showGlobalMsgSearch ? globalMsgQuery : searchKeyword}
onChange={(e) => handleSearch(e.target.value)} onChange={(e) => showGlobalMsgSearch ? handleGlobalMsgSearch(e.target.value) : handleSearch(e.target.value)}
/> />
{searchKeyword && ( {(showGlobalMsgSearch ? globalMsgQuery : searchKeyword) && (
<button className="close-search" onClick={handleCloseSearch}> <button className="close-search" onClick={showGlobalMsgSearch ? handleCloseGlobalMsgSearch : handleCloseSearch}>
<X size={12} /> <X size={12} />
</button> </button>
)} )}
</div> </div>
<button
className={`icon-btn msg-search-toggle-btn ${showGlobalMsgSearch ? 'active' : ''}`}
onClick={() => {
if (showGlobalMsgSearch) handleCloseGlobalMsgSearch()
else { setShowGlobalMsgSearch(true); setTimeout(() => searchInputRef.current?.focus(), 50) }
}}
title={showGlobalMsgSearch ? '退出消息搜索' : '搜索消息内容'}
>
<MessageSquare size={15} />
</button>
<button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}> <button className="icon-btn refresh-btn" onClick={handleRefresh} disabled={isLoadingSessions || isRefreshingSessions}>
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} /> <RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
</button> </button>
</div> </div>
{/* 全局消息搜索结果面板 */}
{showGlobalMsgSearch && (
<div className="global-msg-search-results">
{globalMsgSearching && (
<div className="global-msg-searching"><Loader2 size={14} className="spin" /> ...</div>
)}
{!globalMsgSearching && globalMsgQuery && globalMsgResults.length === 0 && (
<div className="global-msg-empty"></div>
)}
{globalMsgResults.map((msg, i) => (
<div key={i} className="global-msg-result-item" onClick={() => {
const sid = msg._session_id || msg.username
if (sid) {
const target = sessions.find(s => s.username === sid)
if (target) {
handleSelectSession(target)
handleCloseGlobalMsgSearch()
}
}
}}>
<div className="global-msg-result-session">{msg._session_id || msg.username || '未知会话'}</div>
<div className="global-msg-result-content">{(msg.content || msg.strContent || msg.message_content || '').slice(0, 60)}</div>
<div className="global-msg-result-time">{(msg.createTime || msg.create_time) ? new Date((msg.createTime || msg.create_time) * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</div>
</div>
))}
</div>
)}
</div> </div>
{/* 折叠群 header */} {/* 折叠群 header */}
<div className="session-header-panel folded-header"> <div className="session-header-panel folded-header">
@@ -4150,6 +4248,14 @@ function ChatPage(props: ChatPageProps) {
</div>, </div>,
document.body document.body
)} )}
<button
className={`icon-btn in-session-search-btn ${showInSessionSearch ? 'active' : ''}`}
onClick={handleToggleInSessionSearch}
disabled={!currentSessionId}
title="搜索会话消息"
>
<Search size={18} />
</button>
<button <button
className="icon-btn refresh-messages-btn" className="icon-btn refresh-messages-btn"
onClick={handleRefreshMessages} onClick={handleRefreshMessages}
@@ -4182,6 +4288,40 @@ function ChatPage(props: ChatPageProps) {
onClose={() => setChatSnsTimelineTarget(null)} onClose={() => setChatSnsTimelineTarget(null)}
/> />
{/* 会话内搜索栏 */}
{showInSessionSearch && (
<div className="in-session-search-bar">
<Search size={14} className="in-session-search-icon" />
<input
ref={inSessionSearchRef}
type="text"
placeholder="搜索消息..."
value={inSessionQuery}
onChange={e => handleInSessionSearch(e.target.value)}
className="in-session-search-input"
/>
{inSessionSearching && <Loader2 size={14} className="spin" />}
{inSessionQuery && !inSessionSearching && (
<span className="in-session-result-count">{inSessionResults.length} </span>
)}
<button className="icon-btn" onClick={handleToggleInSessionSearch}><X size={14} /></button>
</div>
)}
{showInSessionSearch && inSessionResults.length > 0 && (
<div className="in-session-results">
{inSessionResults.map((msg, i) => (
<div key={i} className="in-session-result-item" onClick={() => {
// 跳转到消息时间
const ts = msg.createTime || msg.create_time
if (ts) handleJumpDateSelect(new Date(ts * 1000))
}}>
<span className="result-sender">{msg.displayName || msg.talker || msg.username || ''}</span>
<span className="result-content">{(msg.content || msg.strContent || msg.message_content || '').slice(0, 80)}</span>
<span className="result-time">{msg.createTime || msg.create_time ? new Date((msg.createTime || msg.create_time) * 1000).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }) : ''}</span>
</div>
))}
</div>
)}
<div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}> <div className={`message-content-wrapper ${hasInitialMessages ? 'loaded' : 'loading'} ${isSessionSwitching ? 'switching' : ''}`}>
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && ( {standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
<div className="standalone-phase-overlay" role="status" aria-live="polite"> <div className="standalone-phase-overlay" role="status" aria-live="polite">