From 9f9be71a76cd635aabeda2663bd0506246dbfd47 Mon Sep 17 00:00:00 2001 From: nDhnzr6r Date: Mon, 25 May 2026 03:12:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9C=8B=E5=8F=8B=E5=9C=88=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E6=94=AF=E6=8C=81=E8=AF=84=E8=AE=BA=E5=86=85=E5=AE=B9?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E4=B8=89=E7=A7=8D=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搜索框下新增「全部/正文/评论」三种模式切换按钮 - 全部模式:同时搜索正文和评论(含评论者昵称),全库无上限 - 正文模式:DLL 全库搜索正文(原行为) - 评论模式:全库扫描,客户端比对评论内容 - 后端 snsService 实现全库分批扫描 + 双路合并逻辑 Closes #1004 --- electron/main.ts | 4 +- electron/preload.ts | 4 +- electron/services/snsService.ts | 179 ++++++++++++++++++-------- src/components/Sns/SnsFilterPanel.tsx | 21 +++ src/pages/SnsPage.scss | 32 +++++ src/pages/SnsPage.tsx | 26 ++-- 6 files changed, 200 insertions(+), 66 deletions(-) diff --git a/electron/main.ts b/electron/main.ts index cf80daf9..9082e151 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -2695,8 +2695,8 @@ function registerIpcHandlers() { return chatService.exportMyFootprint(beginTimestamp, endTimestamp, format, filePath) }) - ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => { - return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime) + ipcMain.handle('sns:getTimeline', async (_, limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number, searchMode?: string) => { + return snsService.getTimeline(limit, offset, usernames, keyword, startTime, endTime, searchMode) }) ipcMain.handle('sns:getSnsUsernames', async () => { diff --git a/electron/preload.ts b/electron/preload.ts index bb175c04..9e93b134 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -521,8 +521,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 朋友圈 sns: { - getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number) => - ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime), + getTimeline: (limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number, searchMode?: string) => + ipcRenderer.invoke('sns:getTimeline', limit, offset, usernames, keyword, startTime, endTime, searchMode), getSnsUsernames: () => ipcRenderer.invoke('sns:getSnsUsernames'), getUserPostCounts: () => ipcRenderer.invoke('sns:getUserPostCounts'), getExportStatsFast: () => ipcRenderer.invoke('sns:getExportStatsFast'), diff --git a/electron/services/snsService.ts b/electron/services/snsService.ts index 65d4941d..87a8baec 100644 --- a/electron/services/snsService.ts +++ b/electron/services/snsService.ts @@ -1165,65 +1165,136 @@ class SnsService { }) } - async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { - const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) - if (!result.success || !result.timeline || result.timeline.length === 0) return result - - const enrichedTimeline = result.timeline.map((post: any) => { - const contact = this.contactCache.get(post.username) - const isVideoPost = post.type === 15 - const rawXml = post.rawXml || '' - const videoKey = extractVideoKey(rawXml) - const location = this.mergeLocation( - this.normalizeLocation((post as { location?: unknown }).location), - this.parseLocationFromXml(rawXml) - ) - - const fixedMedia = (post.media || []).map((m: any) => ({ - url: fixSnsUrl(m.url, m.token, isVideoPost), - thumb: fixSnsUrl(m.thumb, m.token, false), - md5: m.md5, - token: m.token, - key: isVideoPost ? (videoKey || m.key) : m.key, - encIdx: m.encIdx || m.enc_idx, - livePhoto: m.livePhoto ? { - ...m.livePhoto, - url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), - thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), - token: m.livePhoto.token, - key: videoKey || m.livePhoto.key || m.key, - encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx - } : undefined - })) + // 富集单条帖子数据 + private enrichPost(post: any): any { + const contact = this.contactCache.get(post.username) + const isVideoPost = post.type === 15 + const rawXml = post.rawXml || '' + const videoKey = extractVideoKey(rawXml) + const location = this.mergeLocation( + this.normalizeLocation((post as { location?: unknown }).location), + this.parseLocationFromXml(rawXml) + ) - //数据服务已返回完整评论数据(含 emojis、refNickname) - // 如果数据服务评论缺少表情包信息,回退到从 rawXml 重新解析 - const dllComments: any[] = post.comments || [] - const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) - - let finalComments: any[] - if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { - //数据服务数据完整,直接使用 - finalComments = this.fixCommentRefs(dllComments) - } else if (rawXml) { - // 回退:从 rawXml 重新解析(兼容旧版 DLL) - const xmlComments = parseCommentsFromXml(rawXml) - finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments) - } else { - finalComments = this.fixCommentRefs(dllComments) + const fixedMedia = (post.media || []).map((m: any) => ({ + url: fixSnsUrl(m.url, m.token, isVideoPost), + thumb: fixSnsUrl(m.thumb, m.token, false), + md5: m.md5, + token: m.token, + key: isVideoPost ? (videoKey || m.key) : m.key, + encIdx: m.encIdx || m.enc_idx, + livePhoto: m.livePhoto ? { + ...m.livePhoto, + url: fixSnsUrl(m.livePhoto.url, m.livePhoto.token, true), + thumb: fixSnsUrl(m.livePhoto.thumb, m.livePhoto.token, false), + token: m.livePhoto.token, + key: videoKey || m.livePhoto.key || m.key, + encIdx: m.livePhoto.encIdx || m.livePhoto.enc_idx + } : undefined + })) + + const dllComments: any[] = post.comments || [] + const hasEmojisInDll = dllComments.some((c: any) => c.emojis && c.emojis.length > 0) + + let finalComments: any[] + if (dllComments.length > 0 && (hasEmojisInDll || !rawXml)) { + finalComments = this.fixCommentRefs(dllComments) + } else if (rawXml) { + const xmlComments = parseCommentsFromXml(rawXml) + finalComments = xmlComments.length > 0 ? xmlComments : this.fixCommentRefs(dllComments) + } else { + finalComments = this.fixCommentRefs(dllComments) + } + + return { + ...post, + avatarUrl: contact?.avatarUrl, + nickname: post.nickname || contact?.displayName || post.username, + media: fixedMedia, + comments: finalComments, + location + } + } + + // 检查帖子是否匹配关键词(正文或评论) + private postMatchesKeyword(post: any, kw: string, searchMode: string): boolean { + const contentMatch = (post.contentDesc || '').toLowerCase().includes(kw) + if (searchMode === 'content') return contentMatch + const commentMatch = post.comments && post.comments.some((c: any) => { + return (c.content || '').toLowerCase().includes(kw) || + (c.nickname || '').toLowerCase().includes(kw) + }) + if (searchMode === 'comment') return commentMatch + return contentMatch || commentMatch + } + + async getTimeline(limit: number = 20, offset: number = 0, usernames?: string[], keyword?: string, startTime?: number, endTime?: number, searchMode?: string): Promise<{ success: boolean; timeline?: SnsPost[]; error?: string }> { + // 纯正文模式或无关键词:直接用 DLL 搜索(全库) + const mode = searchMode || 'content' + if (mode === 'content' || !keyword) { + const result = await wcdbService.getSnsTimeline(limit, offset, usernames, keyword, startTime, endTime) + if (!result.success || !result.timeline || result.timeline.length === 0) return result + return { ...result, timeline: result.timeline.map((post: any) => this.enrichPost(post)) } + } + + const kw = keyword.toLowerCase() + const seen = new Set() + + if (mode === 'all') { + // 全部模式:DLL 正文搜索(全库) + 全库扫描补评论 + const [contentResult] = await Promise.all([ + wcdbService.getSnsTimeline(100000, 0, usernames, keyword, startTime, endTime) + ]) + const merged: any[] = [] + if (contentResult.success && contentResult.timeline) { + for (const post of contentResult.timeline) { + if (!seen.has(post.id)) { + seen.add(post.id) + merged.push(post) + } + } } + // 全库扫描找评论匹配的帖子(不限上限,扫全库) + const BATCH_SIZE = 200 + let dllOffset = 0 + while (true) { + const result = await wcdbService.getSnsTimeline(BATCH_SIZE, dllOffset, usernames, '', startTime, endTime) + if (!result.success || !result.timeline || result.timeline.length === 0) break + for (const post of result.timeline) { + if (seen.has(post.id)) continue + if (this.postMatchesKeyword(post, kw, mode)) { + seen.add(post.id) + merged.push(post) + } + } + dllOffset += BATCH_SIZE + if (result.timeline.length < BATCH_SIZE) break + } + const page = merged.slice(offset, offset + limit) + return { success: true, timeline: page.map((post: any) => this.enrichPost(post)) } + } - return { - ...post, - avatarUrl: contact?.avatarUrl, - nickname: post.nickname || contact?.displayName || post.username, - media: fixedMedia, - comments: finalComments, - location + // 评论模式:全库扫描 + 客户端过滤评论 + const BATCH_SIZE = 200 + const matches: any[] = [] + let dllOffset = 0 + + while (true) { + const result = await wcdbService.getSnsTimeline(BATCH_SIZE, dllOffset, usernames, '', startTime, endTime) + if (!result.success || !result.timeline || result.timeline.length === 0) break + + for (const post of result.timeline) { + if (this.postMatchesKeyword(post, kw, mode)) { + matches.push(post) + } } - }) - return { ...result, timeline: enrichedTimeline } + dllOffset += BATCH_SIZE + if (result.timeline.length < BATCH_SIZE) break + } + + const page = matches.slice(offset, offset + limit) + return { success: true, timeline: page.map((post: any) => this.enrichPost(post)) } } async debugResource(url: string): Promise<{ success: boolean; status?: number; headers?: any; error?: string }> { diff --git a/src/components/Sns/SnsFilterPanel.tsx b/src/components/Sns/SnsFilterPanel.tsx index 16c860cd..36e8f06e 100644 --- a/src/components/Sns/SnsFilterPanel.tsx +++ b/src/components/Sns/SnsFilterPanel.tsx @@ -20,6 +20,8 @@ interface ContactsCountProgress { interface SnsFilterPanelProps { searchKeyword: string setSearchKeyword: (val: string) => void + searchMode: 'all' | 'content' | 'comment' + setSearchMode: (mode: 'all' | 'content' | 'comment') => void totalFriendsLabel?: string contacts: Contact[] contactSearch: string @@ -38,6 +40,8 @@ interface SnsFilterPanelProps { export const SnsFilterPanel: React.FC = ({ searchKeyword, setSearchKeyword, + searchMode, + setSearchMode, totalFriendsLabel, contacts, contactSearch, @@ -163,6 +167,23 @@ export const SnsFilterPanel: React.FC = ({ )} +
+ + + +
{/* Contact Widget */}
diff --git a/src/pages/SnsPage.scss b/src/pages/SnsPage.scss index fa66895f..ff13fa7a 100644 --- a/src/pages/SnsPage.scss +++ b/src/pages/SnsPage.scss @@ -1227,6 +1227,38 @@ } } + .search-mode-row { + display: flex; + gap: 4px; + margin-top: 6px; + } + + .search-mode-btn { + flex: 1; + height: 26px; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-tertiary); + font-size: 11px; + cursor: pointer; + transition: all 0.15s ease; + padding: 0 6px; + text-align: center; + + &:hover { + color: var(--text-secondary); + border-color: var(--text-tertiary); + } + + &.active { + background: rgba(var(--primary-rgb), 0.12); + border-color: color-mix(in srgb, var(--primary) 40%, var(--border-color)); + color: var(--primary); + font-weight: 600; + } + } + /* Contact Widget - Refactored */ .contact-widget { display: flex; diff --git a/src/pages/SnsPage.tsx b/src/pages/SnsPage.tsx index e347005b..f9def85b 100644 --- a/src/pages/SnsPage.tsx +++ b/src/pages/SnsPage.tsx @@ -173,6 +173,7 @@ export default function SnsPage() { // Filter states const [searchKeyword, setSearchKeyword] = useState('') + const [searchMode, setSearchMode] = useState<'all' | 'content' | 'comment'>('all') const [jumpTargetDate, setJumpTargetDate] = useState(undefined) // Contacts state @@ -240,6 +241,7 @@ export default function SnsPage() { const overviewStatsRef = useRef(overviewStats) const overviewStatsStatusRef = useRef(overviewStatsStatus) const searchKeywordRef = useRef(searchKeyword) + const searchModeRef = useRef<'all' | 'content' | 'comment'>(searchMode) const jumpTargetDateRef = useRef(jumpTargetDate) const selectedContactUsernamesRef = useRef(selectedContactUsernames) const cacheScopeKeyRef = useRef('') @@ -280,6 +282,9 @@ export default function SnsPage() { useEffect(() => { searchKeywordRef.current = searchKeyword }, [searchKeyword]) + useEffect(() => { + searchModeRef.current = searchMode + }, [searchMode]) useEffect(() => { jumpTargetDateRef.current = jumpTargetDate }, [jumpTargetDate]) @@ -1027,8 +1032,9 @@ export default function SnsPage() { else setLoading(true) try { + const currentSearchMode = searchModeRef.current + const effectiveKeyword = searchKeywordRef.current const limit = 20 - const currentSearchKeyword = searchKeywordRef.current const currentJumpTargetDate = jumpTargetDateRef.current const currentSelectedContactUsernames = selectedContactUsernamesRef.current const selectedUsernames = currentSelectedContactUsernames.length > 0 @@ -1051,9 +1057,10 @@ export default function SnsPage() { limit, 0, selectedUsernames, - currentSearchKeyword, + effectiveKeyword, topTs + 1, - undefined + undefined, + currentSearchMode ); if (result.success && result.timeline && result.timeline.length > 0) { @@ -1092,9 +1099,10 @@ export default function SnsPage() { limit, 0, selectedUsernames, - currentSearchKeyword, + effectiveKeyword, startTs, // default undefined - endTs + endTs, + currentSearchMode ) if (result.success && result.timeline) { @@ -1106,7 +1114,7 @@ export default function SnsPage() { // Check for newer items above topTs const topTs = result.timeline[0]?.createTime || 0; if (topTs > 0) { - const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, currentSearchKeyword, topTs + 1, undefined); + const checkResult = await window.electronAPI.sns.getTimeline(1, 0, selectedUsernames, effectiveKeyword, topTs + 1, undefined, currentSearchMode); setHasNewer(!!(checkResult.success && checkResult.timeline && checkResult.timeline.length > 0)); } else { setHasNewer(false); @@ -1735,7 +1743,7 @@ export default function SnsPage() { setContacts([]) setPosts([]); setHasMore(true); setHasNewer(false); setSelectedContactUsernames([]) - setSearchKeyword(''); setJumpTargetDate(undefined); + setSearchKeyword(''); setSearchMode('all'); setJumpTargetDate(undefined); void hydrateSnsPageCache() loadContacts(); loadOverviewStats(); @@ -1750,7 +1758,7 @@ export default function SnsPage() { loadPosts({ reset: true }) }, 500) return () => clearTimeout(timer) - }, [searchKeyword, jumpTargetDate, loadPosts]) + }, [searchKeyword, searchMode, jumpTargetDate, loadPosts]) const selectedContactUsernamesKey = useMemo( () => selectedContactUsernames.join('||'), @@ -2031,6 +2039,8 @@ export default function SnsPage() {