Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 2 additions & 2 deletions electron/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
179 changes: 125 additions & 54 deletions electron/services/snsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>()

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 }> {
Expand Down
21 changes: 21 additions & 0 deletions src/components/Sns/SnsFilterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -38,6 +40,8 @@ interface SnsFilterPanelProps {
export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
searchKeyword,
setSearchKeyword,
searchMode,
setSearchMode,
totalFriendsLabel,
contacts,
contactSearch,
Expand Down Expand Up @@ -163,6 +167,23 @@ export const SnsFilterPanel: React.FC<SnsFilterPanelProps> = ({
</button>
)}
</div>
<div className="search-mode-row">
<button
type="button"
className={`search-mode-btn${searchMode === 'all' ? ' active' : ''}`}
onClick={() => setSearchMode('all')}
>全部</button>
<button
type="button"
className={`search-mode-btn${searchMode === 'content' ? ' active' : ''}`}
onClick={() => setSearchMode('content')}
>正文</button>
<button
type="button"
className={`search-mode-btn${searchMode === 'comment' ? ' active' : ''}`}
onClick={() => setSearchMode('comment')}
>评论</button>
</div>
</div>
{/* Contact Widget */}
<div className="filter-widget contact-widget">
Expand Down
32 changes: 32 additions & 0 deletions src/pages/SnsPage.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 18 additions & 8 deletions src/pages/SnsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<Date | undefined>(undefined)

// Contacts state
Expand Down Expand Up @@ -240,6 +241,7 @@ export default function SnsPage() {
const overviewStatsRef = useRef<SnsOverviewStats>(overviewStats)
const overviewStatsStatusRef = useRef<OverviewStatsStatus>(overviewStatsStatus)
const searchKeywordRef = useRef(searchKeyword)
const searchModeRef = useRef<'all' | 'content' | 'comment'>(searchMode)
const jumpTargetDateRef = useRef<Date | undefined>(jumpTargetDate)
const selectedContactUsernamesRef = useRef<string[]>(selectedContactUsernames)
const cacheScopeKeyRef = useRef('')
Expand Down Expand Up @@ -280,6 +282,9 @@ export default function SnsPage() {
useEffect(() => {
searchKeywordRef.current = searchKeyword
}, [searchKeyword])
useEffect(() => {
searchModeRef.current = searchMode
}, [searchMode])
useEffect(() => {
jumpTargetDateRef.current = jumpTargetDate
}, [jumpTargetDate])
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -1092,9 +1099,10 @@ export default function SnsPage() {
limit,
0,
selectedUsernames,
currentSearchKeyword,
effectiveKeyword,
startTs, // default undefined
endTs
endTs,
currentSearchMode
)

if (result.success && result.timeline) {
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand All @@ -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('||'),
Expand Down Expand Up @@ -2031,6 +2039,8 @@ export default function SnsPage() {
<SnsFilterPanel
searchKeyword={searchKeyword}
setSearchKeyword={setSearchKeyword}
searchMode={searchMode}
setSearchMode={setSearchMode}
totalFriendsLabel={
overviewStatsStatus === 'loading'
? '统计中'
Expand Down