mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-24 23:06:51 +00:00
feat: 新增聊天消息搜索功能
- 会话内搜索:header 加搜索按钮,展开搜索栏,结果列表显示在消息区上方,点击跳转到对应时间 - 全局消息搜索:会话列表搜索框新增消息模式切换按钮,搜索结果展示在会话列表下方,点击跳转到对应会话 - preload 暴露 chat.searchMessages IPC
This commit is contained in:
@@ -218,6 +218,8 @@ contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getContacts: () => ipcRenderer.invoke('chat:getContacts'),
|
||||
getMessage: (sessionId: string, localId: number) =>
|
||||
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) => {
|
||||
ipcRenderer.on('wcdb-change', callback)
|
||||
return () => ipcRenderer.removeListener('wcdb-change', callback)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -564,6 +564,17 @@ function ChatPage(props: ChatPageProps) {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [deleteProgress, setDeleteProgress] = useState({ current: 0, total: 0 })
|
||||
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<{
|
||||
@@ -2608,6 +2619,56 @@ function ChatPage(props: ChatPageProps) {
|
||||
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 handleScroll = useCallback(() => {
|
||||
@@ -3895,20 +3956,57 @@ function ChatPage(props: ChatPageProps) {
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder="搜索"
|
||||
value={searchKeyword}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder={showGlobalMsgSearch ? '搜索消息内容...' : '搜索'}
|
||||
value={showGlobalMsgSearch ? globalMsgQuery : searchKeyword}
|
||||
onChange={(e) => showGlobalMsgSearch ? handleGlobalMsgSearch(e.target.value) : handleSearch(e.target.value)}
|
||||
/>
|
||||
{searchKeyword && (
|
||||
<button className="close-search" onClick={handleCloseSearch}>
|
||||
{(showGlobalMsgSearch ? globalMsgQuery : searchKeyword) && (
|
||||
<button className="close-search" onClick={showGlobalMsgSearch ? handleCloseGlobalMsgSearch : handleCloseSearch}>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</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}>
|
||||
<RefreshCw size={16} className={(isLoadingSessions || isRefreshingSessions) ? 'spin' : ''} />
|
||||
</button>
|
||||
</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>
|
||||
{/* 折叠群 header */}
|
||||
<div className="session-header-panel folded-header">
|
||||
@@ -4150,6 +4248,14 @@ function ChatPage(props: ChatPageProps) {
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
<button
|
||||
className={`icon-btn in-session-search-btn ${showInSessionSearch ? 'active' : ''}`}
|
||||
onClick={handleToggleInSessionSearch}
|
||||
disabled={!currentSessionId}
|
||||
title="搜索会话消息"
|
||||
>
|
||||
<Search size={18} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn refresh-messages-btn"
|
||||
onClick={handleRefreshMessages}
|
||||
@@ -4182,6 +4288,40 @@ function ChatPage(props: ChatPageProps) {
|
||||
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' : ''}`}>
|
||||
{standaloneSessionWindow && standaloneLoadStage !== 'ready' && (
|
||||
<div className="standalone-phase-overlay" role="status" aria-live="polite">
|
||||
|
||||
Reference in New Issue
Block a user