diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index be86d54..da878bc 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -34,6 +34,8 @@ export interface ChatSession { lastMsgSender?: string lastSenderDisplayName?: string selfWxid?: string + isFolded?: boolean // 是否已折叠进"折叠的群聊" + isMuted?: boolean // 是否开启免打扰 } export interface Message { @@ -413,12 +415,29 @@ class ChatService { lastMsgType, displayName, avatarUrl, - lastMsgSender: row.last_msg_sender, // 数据库返回字段 - lastSenderDisplayName: row.last_sender_display_name, // 数据库返回字段 + lastMsgSender: row.last_msg_sender, + lastSenderDisplayName: row.last_sender_display_name, selfWxid: myWxid }) } + // 批量拉取 extra_buffer 状态(isFolded/isMuted),不阻塞主流程 + const allUsernames = sessions.map(s => s.username) + try { + const statusResult = await wcdbService.getContactStatus(allUsernames) + if (statusResult.success && statusResult.map) { + for (const s of sessions) { + const st = statusResult.map[s.username] + if (st) { + s.isFolded = st.isFolded + s.isMuted = st.isMuted + } + } + } + } catch { + // 状态获取失败不影响会话列表返回 + } + // 不等待联系人信息加载,直接返回基础会话列表 // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } @@ -2846,15 +2865,16 @@ class ChatService { private shouldKeepSession(username: string): boolean { if (!username) return false const lowered = username.toLowerCase() - if (lowered.includes('@placeholder') || lowered.includes('foldgroup')) return false + // placeholder_foldgroup 是折叠群入口,需要保留 + if (lowered.includes('@placeholder') && !lowered.includes('foldgroup')) return false if (username.startsWith('gh_')) return false const excludeList = [ 'weixin', 'qqmail', 'fmessage', 'medianote', 'floatbottle', 'newsapp', 'brandsessionholder', 'brandservicesessionholder', 'notifymessage', 'opencustomerservicemsg', 'notification_messages', - 'userexperience_alarm', 'helper_folders', 'placeholder_foldgroup', - '@helper_folders', '@placeholder_foldgroup' + 'userexperience_alarm', 'helper_folders', + '@helper_folders' ] for (const prefix of excludeList) { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index 90b7fa7..1c47b4c 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -3,6 +3,48 @@ import { appendFileSync, existsSync, mkdirSync, readdirSync, statSync, readFileS // DLL 初始化错误信息,用于帮助用户诊断问题 let lastDllInitError: string | null = null + +/** + * 解析 extra_buffer(protobuf)中的免打扰状态 + * - field 12 (tag 0x60): 值非0 = 免打扰 + * 折叠状态通过 contact.flag & 0x10000000 判断 + */ +function parseExtraBuffer(raw: Buffer | string | null | undefined): { isMuted: boolean } { + if (!raw) return { isMuted: false } + // execQuery 返回的 BLOB 列是十六进制字符串,需要先解码 + const buf: Buffer = typeof raw === 'string' ? Buffer.from(raw, 'hex') : raw + if (buf.length === 0) return { isMuted: false } + let isMuted = false + let i = 0 + const len = buf.length + + const readVarint = (): number => { + let result = 0, shift = 0 + while (i < len) { + const b = buf[i++] + result |= (b & 0x7f) << shift + shift += 7 + if (!(b & 0x80)) break + } + return result + } + + while (i < len) { + const tag = readVarint() + const fieldNum = tag >>> 3 + const wireType = tag & 0x07 + if (wireType === 0) { + const val = readVarint() + if (fieldNum === 12 && val !== 0) isMuted = true + } else if (wireType === 2) { + const sz = readVarint() + i += sz + } else if (wireType === 5) { i += 4 + } else if (wireType === 1) { i += 8 + } else { break } + } + return { isMuted } +} export function getLastDllInitError(): string | null { return lastDllInitError } @@ -41,6 +83,7 @@ export class WcdbCore { private wcdbGetMessageTables: any = null private wcdbGetMessageMeta: any = null private wcdbGetContact: any = null + private wcdbGetContactStatus: any = null private wcdbGetMessageTableStats: any = null private wcdbGetAggregateStats: any = null private wcdbGetAvailableYears: any = null @@ -487,6 +530,13 @@ export class WcdbCore { // wcdb_status wcdb_get_contact(wcdb_handle handle, const char* username, char** out_json) this.wcdbGetContact = this.lib.func('int32 wcdb_get_contact(int64 handle, const char* username, _Out_ void** outJson)') + // wcdb_status wcdb_get_contact_status(wcdb_handle handle, const char* usernames_json, char** out_json) + try { + this.wcdbGetContactStatus = this.lib.func('int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)') + } catch { + this.wcdbGetContactStatus = null + } + // wcdb_status wcdb_get_message_table_stats(wcdb_handle handle, const char* session_id, char** out_json) this.wcdbGetMessageTableStats = this.lib.func('int32 wcdb_get_message_table_stats(int64 handle, const char* sessionId, _Out_ void** outJson)') @@ -1370,6 +1420,36 @@ export class WcdbCore { } } + async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + if (!this.ensureReady()) { + return { success: false, error: 'WCDB 未连接' } + } + try { + // 分批查询,避免 SQL 过长(execQuery 不支持参数绑定,直接拼 SQL) + const BATCH = 200 + const map: Record = {} + for (let i = 0; i < usernames.length; i += BATCH) { + const batch = usernames.slice(i, i + BATCH) + const inList = batch.map(u => `'${u.replace(/'/g, "''")}'`).join(',') + const sql = `SELECT username, flag, extra_buffer FROM contact WHERE username IN (${inList})` + const result = await this.execQuery('contact', null, sql) + if (!result.success || !result.rows) continue + for (const row of result.rows) { + const uname: string = row.username + // 折叠:flag bit 28 (0x10000000) + const flag = parseInt(row.flag ?? '0', 10) + const isFolded = (flag & 0x10000000) !== 0 + // 免打扰:extra_buffer field 12 非0 + const { isMuted } = parseExtraBuffer(row.extra_buffer) + map[uname] = { isFolded, isMuted } + } + } + return { success: true, map } + } catch (e) { + return { success: false, error: String(e) } + } + } + async getAggregateStats(sessionIds: string[], beginTimestamp: number = 0, endTimestamp: number = 0): Promise<{ success: boolean; data?: any; error?: string }> { if (!this.ensureReady()) { return { success: false, error: 'WCDB 未连接' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 50cd354..b8834f6 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -290,6 +290,13 @@ export class WcdbService { return this.callWorker('getContact', { username }) } + /** + * 批量获取联系人 extra_buffer 状态(isFolded/isMuted) + */ + async getContactStatus(usernames: string[]): Promise<{ success: boolean; map?: Record; error?: string }> { + return this.callWorker('getContactStatus', { usernames }) + } + /** * 获取聚合统计数据 */ diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 436b3da..d95f5f6 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -87,6 +87,9 @@ if (parentPort) { case 'getContact': result = await core.getContact(payload.username) break + case 'getContactStatus': + result = await core.getContactStatus(payload.usernames) + break case 'getAggregateStats': result = await core.getAggregateStats(payload.sessionIds, payload.beginTimestamp, payload.endTimestamp) break diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index ed735a0..4dcca7d 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/components/GlobalSessionMonitor.tsx b/src/components/GlobalSessionMonitor.tsx index ab6fefb..561bb45 100644 --- a/src/components/GlobalSessionMonitor.tsx +++ b/src/components/GlobalSessionMonitor.tsx @@ -97,6 +97,10 @@ export function GlobalSessionMonitor() { if (!isCurrentSession && (!oldSession || newSession.lastTimestamp > oldSession.lastTimestamp)) { // 这是新消息事件 + // 免打扰、折叠群、折叠入口不弹通知 + if (newSession.isMuted || newSession.isFolded) continue + if (newSession.username.toLowerCase().includes('placeholder_foldgroup')) continue + // 1. 群聊过滤自己发送的消息 if (newSession.username.includes('@chatroom')) { // 如果是自己发的消息,不弹通知 diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index d8c81b9..ea8c403 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -866,6 +866,73 @@ } } +// Header 双 panel 滑动动画 +.session-header-viewport { + overflow: hidden; + position: relative; + display: flex; + flex-direction: row; + width: 100%; + + .session-header-panel { + flex: 0 0 100%; + width: 100%; + display: flex; + align-items: center; + padding: 16px 16px 12px; + min-height: 56px; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + } + + .main-header { + transform: translateX(0); + justify-content: space-between; + } + + .folded-header { + transform: translateX(0); + } + + &.folded { + .main-header { transform: translateX(-100%); } + .folded-header { transform: translateX(-100%); } + } +} + +.folded-view-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + + .back-btn { + width: 32px; + height: 32px; + border: none; + background: transparent; + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-secondary); + flex-shrink: 0; + + &:hover { + background: var(--bg-hover); + } + } + + .folded-view-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } +} + @keyframes searchExpand { from { opacity: 0; @@ -3863,4 +3930,135 @@ overflow: hidden; } } +} + +// 折叠群视图 header +.folded-view-header { + display: flex; + align-items: center; + gap: 8px; + padding: 0 4px; + width: 100%; + + .back-btn { + flex-shrink: 0; + color: var(--text-secondary); + &:hover { + color: var(--text-primary); + } + } + + .folded-view-title { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + } +} + +// 双 panel 滑动容器 +.session-list-viewport { + flex: 1; + overflow: hidden; + position: relative; + display: flex; + flex-direction: row; + // 两个 panel 并排,宽度各 100%,通过 translateX 切换 + width: 100%; + + .session-list-panel { + flex: 0 0 100%; + width: 100%; + display: flex; + flex-direction: column; + overflow: hidden; + transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + } + + // 默认:main 在视口内,folded 在右侧外 + .main-panel { + transform: translateX(0); + } + .folded-panel { + transform: translateX(0); + } + + // 切换到折叠群视图:两个 panel 同时左移 100% + &.folded { + .main-panel { + transform: translateX(-100%); + } + .folded-panel { + transform: translateX(-100%); + } + } + + .session-list { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + + &::-webkit-scrollbar { + width: 8px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.2); + border-radius: 4px; + &:hover { + background: rgba(0, 0, 0, 0.3); + } + } + } +} + +// 免打扰标识 +.session-item { + &.muted { + .session-name { + color: var(--text-secondary); + } + } + + .session-badges { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + + .mute-icon { + color: var(--text-tertiary, #aaa); + opacity: 0.7; + } + + .unread-badge.muted { + background: var(--text-tertiary, #aaa); + box-shadow: none; + } + } +} + +// 折叠群入口样式 +.session-item.fold-entry { + background: var(--card-inner-bg, rgba(0,0,0,0.03)); + + .fold-entry-avatar { + width: 48px; + height: 48px; + border-radius: 8px; + background: var(--primary-color, #07c160); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: #fff; + } + + .session-name { + font-weight: 500; + } } \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index b5ec3df..e79506e 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2 } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, ChevronLeft, Info, Calendar, Database, Hash, Play, Pause, Image as ImageIcon, Link, Mic, CheckCircle, Copy, Check, CheckSquare, Download, BarChart3, Edit2, Trash2, BellOff, Users, FolderClosed } from 'lucide-react' import { useNavigate } from 'react-router-dom' import { createPortal } from 'react-dom' import { useChatStore } from '../stores/chatStore' @@ -178,15 +178,38 @@ const SessionItem = React.memo(function SessionItem({ onSelect: (session: ChatSession) => void formatTime: (timestamp: number) => string }) { - // 缓存格式化的时间 const timeText = useMemo(() => formatTime(session.lastTimestamp || session.sortTimestamp), [formatTime, session.lastTimestamp, session.sortTimestamp] ) + const isFoldEntry = session.username.toLowerCase().includes('placeholder_foldgroup') + + // 折叠入口:专属名称和图标 + if (isFoldEntry) { + return ( +
onSelect(session)} + > +
+ +
+
+
+ 折叠的群聊 +
+
+ {session.summary || ''} +
+
+
+ ) + } + return (
onSelect(session)} >
{session.summary || '暂无消息'} - {session.unreadCount > 0 && ( - - {session.unreadCount > 99 ? '99+' : session.unreadCount} - - )} +
+ {session.isMuted && } + {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
) }, (prevProps, nextProps) => { - // 自定义比较:只在关键属性变化时重渲染 return ( prevProps.session.username === nextProps.session.username && prevProps.session.displayName === nextProps.session.displayName && @@ -221,6 +246,7 @@ const SessionItem = React.memo(function SessionItem({ prevProps.session.unreadCount === nextProps.session.unreadCount && prevProps.session.lastTimestamp === nextProps.session.lastTimestamp && prevProps.session.sortTimestamp === nextProps.session.sortTimestamp && + prevProps.session.isMuted === nextProps.session.isMuted && prevProps.isActive === nextProps.isActive ) }) @@ -288,6 +314,7 @@ function ChatPage(_props: ChatPageProps) { const [copiedField, setCopiedField] = useState(null) const [highlightedMessageKeys, setHighlightedMessageKeys] = useState([]) const [isRefreshingSessions, setIsRefreshingSessions] = useState(false) + const [foldedView, setFoldedView] = useState(false) // 是否在"折叠的群聊"视图 const [hasInitialMessages, setHasInitialMessages] = useState(false) const [noMessageTable, setNoMessageTable] = useState(false) const [fallbackDisplayName, setFallbackDisplayName] = useState(null) @@ -995,6 +1022,11 @@ function ChatPage(_props: ChatPageProps) { // 选择会话 const handleSelectSession = (session: ChatSession) => { + // 点击折叠群入口,切换到折叠群视图 + if (session.username.toLowerCase().includes('placeholder_foldgroup')) { + setFoldedView(true) + return + } if (session.username === currentSessionId) return setCurrentSession(session.username) setCurrentOffset(0) @@ -1011,27 +1043,11 @@ function ChatPage(_props: ChatPageProps) { // 搜索过滤 const handleSearch = (keyword: string) => { setSearchKeyword(keyword) - if (!Array.isArray(sessions)) { - setFilteredSessions([]) - return - } - if (!keyword.trim()) { - setFilteredSessions(sessions) - return - } - const lower = keyword.toLowerCase() - const filtered = sessions.filter(s => - s.displayName?.toLowerCase().includes(lower) || - s.username.toLowerCase().includes(lower) || - s.summary.toLowerCase().includes(lower) - ) - setFilteredSessions(filtered) } // 关闭搜索框 const handleCloseSearch = () => { setSearchKeyword('') - setFilteredSessions(Array.isArray(sessions) ? sessions : []) } // 滚动加载更多 + 显示/隐藏回到底部按钮(优化:节流,避免频繁执行) @@ -1303,23 +1319,40 @@ function ChatPage(_props: ChatPageProps) { searchKeywordRef.current = searchKeyword }, [searchKeyword]) + // 普通视图:隐藏 isFolded 的群,保留 placeholder_foldgroup 入口 useEffect(() => { if (!Array.isArray(sessions)) { setFilteredSessions([]) return } + const visible = sessions.filter(s => { + if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false + return true + }) if (!searchKeyword.trim()) { - setFilteredSessions(sessions) + setFilteredSessions(visible) return } const lower = searchKeyword.toLowerCase() - const filtered = sessions.filter(s => + setFilteredSessions(visible.filter(s => + s.displayName?.toLowerCase().includes(lower) || + s.username.toLowerCase().includes(lower) || + s.summary.toLowerCase().includes(lower) + )) + }, [sessions, searchKeyword, setFilteredSessions]) + + // 折叠群列表(独立计算,供折叠 panel 使用) + const foldedSessions = useMemo(() => { + if (!Array.isArray(sessions)) return [] + const folded = sessions.filter(s => s.isFolded) + if (!searchKeyword.trim() || !foldedView) return folded + const lower = searchKeyword.toLowerCase() + return folded.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) ) - setFilteredSessions(filtered) - }, [sessions, searchKeyword, setFilteredSessions]) + }, [sessions, searchKeyword, foldedView]) // 格式化会话时间(相对时间)- 使用 useMemo 缓存,避免每次渲染都计算 @@ -1984,26 +2017,41 @@ function ChatPage(_props: ChatPageProps) { ref={sidebarRef} style={{ width: sidebarWidth, minWidth: sidebarWidth, maxWidth: sidebarWidth }} > -
-
-
- - handleSearch(e.target.value)} - /> - {searchKeyword && ( - - )} +
+ {/* 普通 header */} +
+
+
+ + handleSearch(e.target.value)} + /> + {searchKeyword && ( + + )} +
+ +
+
+ {/* 折叠群 header */} +
+
+ + + + 折叠的群聊 +
-
@@ -2018,7 +2066,6 @@ function ChatPage(_props: ChatPageProps) { {/* ... (previous content) ... */} {isLoadingSessions ? (
- {/* ... (skeleton items) ... */} {[1, 2, 3, 4, 5].map(i => (
@@ -2029,36 +2076,65 @@ function ChatPage(_props: ChatPageProps) {
))}
- ) : Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( -
{ - isScrollingRef.current = true - if (sessionScrollTimeoutRef.current) { - clearTimeout(sessionScrollTimeoutRef.current) - } - sessionScrollTimeoutRef.current = window.setTimeout(() => { - isScrollingRef.current = false - sessionScrollTimeoutRef.current = null - }, 200) - }} - > - {filteredSessions.map(session => ( - - ))} -
) : ( -
- -

暂无会话

-

请先在数据管理页面解密数据库

+
+ {/* 普通会话列表 */} +
+ {Array.isArray(filteredSessions) && filteredSessions.length > 0 ? ( +
{ + isScrollingRef.current = true + if (sessionScrollTimeoutRef.current) { + clearTimeout(sessionScrollTimeoutRef.current) + } + sessionScrollTimeoutRef.current = window.setTimeout(() => { + isScrollingRef.current = false + sessionScrollTimeoutRef.current = null + }, 200) + }} + > + {filteredSessions.map(session => ( + + ))} +
+ ) : ( +
+ +

暂无会话

+

请先在数据管理页面解密数据库

+
+ )} +
+ + {/* 折叠群列表 */} +
+ {foldedSessions.length > 0 ? ( +
+ {foldedSessions.map(session => ( + + ))} +
+ ) : ( +
+ +

没有折叠的群聊

+
+ )} +
)} diff --git a/src/types/models.ts b/src/types/models.ts index b03e088..e8daa1b 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -12,6 +12,8 @@ export interface ChatSession { lastMsgSender?: string lastSenderDisplayName?: string selfWxid?: string // Helper field to avoid extra API calls + isFolded?: boolean // 是否已折叠进"折叠的群聊" + isMuted?: boolean // 是否开启免打扰 } // 联系人