diff --git a/electron/services/bizService.ts b/electron/services/bizService.ts index f7c0eed..a5bb984 100644 --- a/electron/services/bizService.ts +++ b/electron/services/bizService.ts @@ -13,6 +13,7 @@ export interface BizAccount { type: number last_time: number formatted_last_time: string + unread_count?: number } export interface BizMessage { @@ -104,19 +105,24 @@ export class BizService { if (!root || !accountWxid) return [] const bizLatestTime: Record = {} + const bizUnreadCount: Record = {} try { - const sessionsRes = await wcdbService.getSessions() + const sessionsRes = await chatService.getSessions() if (sessionsRes.success && sessionsRes.sessions) { for (const session of sessionsRes.sessions) { const uname = session.username || session.strUsrName || session.userName || session.id // 适配日志中发现的字段,注意转为整型数字 - const timeStr = session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' + const timeStr = session.lastTimestamp || session.sortTimestamp || session.last_timestamp || session.sort_timestamp || session.nTime || session.timestamp || '0' const time = parseInt(timeStr.toString(), 10) if (usernames.includes(uname) && time > 0) { bizLatestTime[uname] = time } + if (usernames.includes(uname)) { + const unread = Number(session.unreadCount ?? session.unread_count ?? 0) + bizUnreadCount[uname] = Number.isFinite(unread) ? Math.max(0, Math.floor(unread)) : 0 + } } } } catch (e) { @@ -152,7 +158,8 @@ export class BizService { avatar: info?.avatarUrl || '', type: 0, last_time: lastTime, - formatted_last_time: formatBizTime(lastTime) + formatted_last_time: formatBizTime(lastTime), + unread_count: bizUnreadCount[uname] || 0 } }) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index 90f2555..9c06ed2 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -232,6 +232,16 @@ interface SessionDetailExtra { type SessionDetail = SessionDetailFast & SessionDetailExtra +interface SyntheticUnreadState { + readTimestamp: number + scannedTimestamp: number + latestTimestamp: number + unreadCount: number + summaryTimestamp?: number + summary?: string + lastMsgType?: number +} + interface MyFootprintSummary { private_inbound_people: number private_replied_people: number @@ -378,6 +388,7 @@ class ChatService { private readonly messageDbCountSnapshotCacheTtlMs = 8000 private sessionMessageCountCache = new Map() private sessionMessageCountHintCache = new Map() + private syntheticUnreadState = new Map() private sessionMessageCountBatchCache: { dbSignature: string sessionIdsKey: string @@ -865,6 +876,10 @@ class ChatService { } } + await this.addMissingOfficialSessions(sessions, myWxid) + await this.applySyntheticUnreadCounts(sessions) + sessions.sort((a, b) => Number(b.sortTimestamp || b.lastTimestamp || 0) - Number(a.sortTimestamp || a.lastTimestamp || 0)) + // 不等待联系人信息加载,直接返回基础会话列表 // 前端可以异步调用 enrichSessionsWithContacts 来补充信息 return { success: true, sessions } @@ -874,6 +889,242 @@ class ChatService { } } + private async addMissingOfficialSessions(sessions: ChatSession[], myWxid?: string): Promise { + const existing = new Set(sessions.map((session) => String(session.username || '').trim()).filter(Boolean)) + try { + const contactResult = await wcdbService.getContactsCompact() + if (!contactResult.success || !Array.isArray(contactResult.contacts)) return + + for (const row of contactResult.contacts as Record[]) { + const username = String(row.username || '').trim() + if (!username.startsWith('gh_') || existing.has(username)) continue + + sessions.push({ + username, + type: 0, + unreadCount: 0, + summary: '查看公众号历史消息', + sortTimestamp: 0, + lastTimestamp: 0, + lastMsgType: 0, + displayName: row.remark || row.nick_name || row.alias || username, + avatarUrl: undefined, + selfWxid: myWxid + }) + existing.add(username) + } + } catch (error) { + console.warn('[ChatService] 补充公众号会话失败:', error) + } + } + + private shouldUseSyntheticUnread(sessionId: string): boolean { + const normalized = String(sessionId || '').trim() + return normalized.startsWith('gh_') + } + + private async getSessionMessageStatsSnapshot(sessionId: string): Promise<{ total: number; latestTimestamp: number }> { + const tableStatsResult = await wcdbService.getMessageTableStats(sessionId) + if (!tableStatsResult.success || !Array.isArray(tableStatsResult.tables)) { + return { total: 0, latestTimestamp: 0 } + } + + let total = 0 + let latestTimestamp = 0 + for (const row of tableStatsResult.tables as Record[]) { + const count = Number(row.count ?? row.message_count ?? row.messageCount ?? 0) + if (Number.isFinite(count) && count > 0) { + total += Math.floor(count) + } + + const latest = Number( + row.last_timestamp ?? + row.lastTimestamp ?? + row.last_time ?? + row.lastTime ?? + row.max_create_time ?? + row.maxCreateTime ?? + 0 + ) + if (Number.isFinite(latest) && latest > latestTimestamp) { + latestTimestamp = Math.floor(latest) + } + } + + return { total, latestTimestamp } + } + + private async applySyntheticUnreadCounts(sessions: ChatSession[]): Promise { + const candidates = sessions.filter((session) => this.shouldUseSyntheticUnread(session.username)) + if (candidates.length === 0) return + + for (const session of candidates) { + try { + const snapshot = await this.getSessionMessageStatsSnapshot(session.username) + const latestTimestamp = Math.max( + Number(session.lastTimestamp || 0), + Number(session.sortTimestamp || 0), + snapshot.latestTimestamp + ) + if (latestTimestamp > 0) { + session.lastTimestamp = latestTimestamp + session.sortTimestamp = Math.max(Number(session.sortTimestamp || 0), latestTimestamp) + } + if (snapshot.total > 0) { + session.messageCountHint = Math.max(Number(session.messageCountHint || 0), snapshot.total) + this.sessionMessageCountHintCache.set(session.username, session.messageCountHint) + } + + let state = this.syntheticUnreadState.get(session.username) + if (!state) { + const initialUnread = await this.getInitialSyntheticUnreadState(session.username, latestTimestamp) + state = { + readTimestamp: latestTimestamp, + scannedTimestamp: latestTimestamp, + latestTimestamp, + unreadCount: initialUnread.count + } + if (initialUnread.latestMessage) { + state.summary = this.getSessionSummaryFromMessage(initialUnread.latestMessage) + state.summaryTimestamp = Number(initialUnread.latestMessage.createTime || latestTimestamp) + state.lastMsgType = Number(initialUnread.latestMessage.localType || 0) + } + this.syntheticUnreadState.set(session.username, state) + } + + let latestMessageForSummary: Message | undefined + if (latestTimestamp > state.scannedTimestamp) { + const newMessagesResult = await this.getNewMessages( + session.username, + Math.max(0, state.scannedTimestamp), + 1000 + ) + if (newMessagesResult.success && Array.isArray(newMessagesResult.messages)) { + let nextUnread = state.unreadCount + let nextScannedTimestamp = state.scannedTimestamp + for (const message of newMessagesResult.messages) { + const createTime = Number(message.createTime || 0) + if (!Number.isFinite(createTime) || createTime <= state.scannedTimestamp) continue + if (message.isSend === 1) continue + nextUnread += 1 + latestMessageForSummary = message + if (createTime > nextScannedTimestamp) { + nextScannedTimestamp = Math.floor(createTime) + } + } + state.unreadCount = nextUnread + state.scannedTimestamp = Math.max(nextScannedTimestamp, latestTimestamp) + } else { + state.scannedTimestamp = latestTimestamp + } + } + + state.latestTimestamp = Math.max(state.latestTimestamp, latestTimestamp) + if (latestMessageForSummary) { + const summary = this.getSessionSummaryFromMessage(latestMessageForSummary) + if (summary) { + state.summary = summary + state.summaryTimestamp = Number(latestMessageForSummary.createTime || latestTimestamp) + state.lastMsgType = Number(latestMessageForSummary.localType || 0) + } + } + if (state.summary) { + session.summary = state.summary + session.lastMsgType = Number(state.lastMsgType || session.lastMsgType || 0) + } + session.unreadCount = Math.max(Number(session.unreadCount || 0), state.unreadCount) + } catch (error) { + console.warn(`[ChatService] 合成公众号未读失败: ${session.username}`, error) + } + } + } + + private getSessionSummaryFromMessage(message: Message): string { + const cleanOfficialPrefix = (value: string): string => value.replace(/^\s*\[视频号\]\s*/u, '').trim() + let summary = '' + switch (Number(message.localType || 0)) { + case 1: + summary = message.parsedContent || message.rawContent || '' + break + case 3: + summary = '[图片]' + break + case 34: + summary = '[语音]' + break + case 43: + summary = '[视频]' + break + case 47: + summary = '[表情]' + break + case 42: + summary = message.cardNickname || '[名片]' + break + case 48: + summary = '[位置]' + break + case 49: + summary = message.linkTitle || message.fileName || message.parsedContent || '[消息]' + break + default: + summary = message.parsedContent || message.rawContent || this.getMessageTypeLabel(Number(message.localType || 0)) + break + } + return cleanOfficialPrefix(this.cleanString(summary)) + } + + private async getInitialSyntheticUnreadState(sessionId: string, latestTimestamp: number): Promise<{ + count: number + latestMessage?: Message + }> { + const normalizedLatest = Number(latestTimestamp || 0) + if (!Number.isFinite(normalizedLatest) || normalizedLatest <= 0) return { count: 0 } + + const nowSeconds = Math.floor(Date.now() / 1000) + if (Math.abs(nowSeconds - normalizedLatest) > 10 * 60) { + return { count: 0 } + } + + const result = await this.getNewMessages(sessionId, Math.max(0, Math.floor(normalizedLatest) - 1), 20) + if (!result.success || !Array.isArray(result.messages)) return { count: 0 } + const unreadMessages = result.messages.filter((message) => { + const createTime = Number(message.createTime || 0) + return Number.isFinite(createTime) && + createTime >= normalizedLatest && + message.isSend !== 1 + }) + return { + count: unreadMessages.length, + latestMessage: unreadMessages[unreadMessages.length - 1] + } + } + + private markSyntheticUnreadRead(sessionId: string, messages: Message[] = []): void { + const normalized = String(sessionId || '').trim() + if (!this.shouldUseSyntheticUnread(normalized)) return + + let latestTimestamp = 0 + const state = this.syntheticUnreadState.get(normalized) + if (state) latestTimestamp = Math.max(latestTimestamp, state.latestTimestamp, state.scannedTimestamp) + for (const message of messages) { + const createTime = Number(message.createTime || 0) + if (Number.isFinite(createTime) && createTime > latestTimestamp) { + latestTimestamp = Math.floor(createTime) + } + } + + this.syntheticUnreadState.set(normalized, { + readTimestamp: latestTimestamp, + scannedTimestamp: latestTimestamp, + latestTimestamp, + unreadCount: 0, + summary: state?.summary, + summaryTimestamp: state?.summaryTimestamp, + lastMsgType: state?.lastMsgType + }) + } + async getSessionStatuses(usernames: string[]): Promise<{ success: boolean map?: Record @@ -1814,6 +2065,9 @@ class ChatService { releaseMessageCursorMutex?.() this.messageCacheService.set(sessionId, filtered) + if (offset === 0 && startTime === 0 && endTime === 0) { + this.markSyntheticUnreadRead(sessionId, filtered) + } console.log( `[ChatService] getMessages session=${sessionId} rawRowsConsumed=${rawRowsConsumed} visibleMessagesReturned=${filtered.length} filteredOut=${collected.filteredOut || 0} nextOffset=${state.fetched} hasMore=${hasMore}` ) diff --git a/electron/services/config.ts b/electron/services/config.ts index fb05832..250c93d 100644 --- a/electron/services/config.ts +++ b/electron/services/config.ts @@ -61,6 +61,8 @@ interface ConfigSchema { notificationFilterMode: 'all' | 'whitelist' | 'blacklist' notificationFilterList: string[] messagePushEnabled: boolean + messagePushFilterMode: 'all' | 'whitelist' | 'blacklist' + messagePushFilterList: string[] httpApiEnabled: boolean httpApiPort: number httpApiHost: string @@ -177,6 +179,8 @@ export class ConfigService { httpApiPort: 5031, httpApiHost: '127.0.0.1', messagePushEnabled: false, + messagePushFilterMode: 'all', + messagePushFilterList: [], windowCloseBehavior: 'ask', quoteLayout: 'quote-top', wordCloudExcludeWords: [], diff --git a/electron/services/messagePushService.ts b/electron/services/messagePushService.ts index 95c180c..ca7e057 100644 --- a/electron/services/messagePushService.ts +++ b/electron/services/messagePushService.ts @@ -11,6 +11,7 @@ interface SessionBaseline { interface MessagePushPayload { event: 'message.new' sessionId: string + sessionType: 'private' | 'group' | 'official' | 'other' messageKey: string avatarUrl?: string sourceName: string @@ -20,6 +21,8 @@ interface MessagePushPayload { const PUSH_CONFIG_KEYS = new Set([ 'messagePushEnabled', + 'messagePushFilterMode', + 'messagePushFilterList', 'dbPath', 'decryptKey', 'myWxid' @@ -38,6 +41,7 @@ class MessagePushService { private rerunRequested = false private started = false private baselineReady = false + private messageTableScanRequested = false constructor() { this.configService = ConfigService.getInstance() @@ -60,12 +64,15 @@ class MessagePushService { payload = null } - const tableName = String(payload?.table || '').trim().toLowerCase() - if (tableName && tableName !== 'session') { + const tableName = String(payload?.table || '').trim() + if (this.isSessionTableChange(tableName)) { + this.scheduleSync() return } - this.scheduleSync() + if (!tableName || this.isMessageTableChange(tableName)) { + this.scheduleSync({ scanMessageBackedSessions: true }) + } } async handleConfigChanged(key: string): Promise { @@ -91,6 +98,7 @@ class MessagePushService { this.recentMessageKeys.clear() this.groupNicknameCache.clear() this.baselineReady = false + this.messageTableScanRequested = false if (this.debounceTimer) { clearTimeout(this.debounceTimer) this.debounceTimer = null @@ -121,7 +129,11 @@ class MessagePushService { this.baselineReady = true } - private scheduleSync(): void { + private scheduleSync(options: { scanMessageBackedSessions?: boolean } = {}): void { + if (options.scanMessageBackedSessions) { + this.messageTableScanRequested = true + } + if (this.debounceTimer) { clearTimeout(this.debounceTimer) } @@ -141,6 +153,8 @@ class MessagePushService { this.processing = true try { if (!this.isPushEnabled()) return + const scanMessageBackedSessions = this.messageTableScanRequested + this.messageTableScanRequested = false const connectResult = await chatService.connect() if (!connectResult.success) { @@ -163,27 +177,47 @@ class MessagePushService { const previousBaseline = new Map(this.sessionBaseline) this.setBaseline(sessions) - const candidates = sessions.filter((session) => this.shouldInspectSession(previousBaseline.get(session.username), session)) + const candidates = sessions.filter((session) => { + const previous = previousBaseline.get(session.username) + if (this.shouldInspectSession(previous, session)) { + return true + } + return scanMessageBackedSessions && this.shouldScanMessageBackedSession(previous, session) + }) for (const session of candidates) { - await this.pushSessionMessages(session, previousBaseline.get(session.username)) + await this.pushSessionMessages( + session, + previousBaseline.get(session.username) || this.sessionBaseline.get(session.username) + ) } } finally { this.processing = false if (this.rerunRequested) { this.rerunRequested = false - this.scheduleSync() + this.scheduleSync({ scanMessageBackedSessions: this.messageTableScanRequested }) } } } private setBaseline(sessions: ChatSession[]): void { + const previousBaseline = new Map(this.sessionBaseline) + const nextBaseline = new Map() + const nowSeconds = Math.floor(Date.now() / 1000) this.sessionBaseline.clear() for (const session of sessions) { - this.sessionBaseline.set(session.username, { - lastTimestamp: Number(session.lastTimestamp || 0), + const username = String(session.username || '').trim() + if (!username) continue + const previous = previousBaseline.get(username) + const sessionTimestamp = Number(session.lastTimestamp || 0) + const initialTimestamp = sessionTimestamp > 0 ? sessionTimestamp : nowSeconds + nextBaseline.set(username, { + lastTimestamp: Math.max(sessionTimestamp, Number(previous?.lastTimestamp || 0), previous ? 0 : initialTimestamp), unreadCount: Number(session.unreadCount || 0) }) } + for (const [username, baseline] of nextBaseline.entries()) { + this.sessionBaseline.set(username, baseline) + } } private shouldInspectSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { @@ -204,16 +238,30 @@ class MessagePushService { return unreadCount > 0 && lastTimestamp > 0 } - if (lastTimestamp <= previous.lastTimestamp) { + return lastTimestamp > previous.lastTimestamp || unreadCount > previous.unreadCount + } + + private shouldScanMessageBackedSession(previous: SessionBaseline | undefined, session: ChatSession): boolean { + const sessionId = String(session.username || '').trim() + if (!sessionId || sessionId.toLowerCase().includes('placeholder_foldgroup')) { return false } - // unread 未增长时,大概率是自己发送、其他设备已读或状态同步,不作为主动推送 - return unreadCount > previous.unreadCount + const summary = String(session.summary || '').trim() + if (Number(session.lastMsgType || 0) === 10002 || summary.includes('撤回了一条消息')) { + return false + } + + const sessionType = this.getSessionType(sessionId, session) + if (sessionType === 'private') { + return false + } + + return Boolean(previous) || Number(session.lastTimestamp || 0) > 0 } private async pushSessionMessages(session: ChatSession, previous: SessionBaseline | undefined): Promise { - const since = Math.max(0, Number(previous?.lastTimestamp || 0) - 1) + const since = Math.max(0, Number(previous?.lastTimestamp || 0)) const newMessagesResult = await chatService.getNewMessages(session.username, since, 1000) if (!newMessagesResult.success || !newMessagesResult.messages || newMessagesResult.messages.length === 0) { return @@ -224,7 +272,7 @@ class MessagePushService { if (!messageKey) continue if (message.isSend === 1) continue - if (previous && Number(message.createTime || 0) < Number(previous.lastTimestamp || 0)) { + if (previous && Number(message.createTime || 0) <= Number(previous.lastTimestamp || 0)) { continue } @@ -234,9 +282,11 @@ class MessagePushService { const payload = await this.buildPayload(session, message) if (!payload) continue + if (!this.shouldPushPayload(payload)) continue httpService.broadcastMessagePush(payload) this.rememberMessageKey(messageKey) + this.bumpSessionBaseline(session.username, message) } } @@ -246,6 +296,7 @@ class MessagePushService { if (!sessionId || !messageKey) return null const isGroup = sessionId.endsWith('@chatroom') + const sessionType = this.getSessionType(sessionId, session) const content = this.getMessageDisplayContent(message) if (isGroup) { @@ -255,6 +306,7 @@ class MessagePushService { return { event: 'message.new', sessionId, + sessionType, messageKey, avatarUrl: session.avatarUrl || groupInfo?.avatarUrl, groupName, @@ -267,6 +319,7 @@ class MessagePushService { return { event: 'message.new', sessionId, + sessionType, messageKey, avatarUrl: session.avatarUrl || contactInfo?.avatarUrl, sourceName: session.displayName || contactInfo?.displayName || sessionId, @@ -274,10 +327,84 @@ class MessagePushService { } } + private getSessionType(sessionId: string, session: ChatSession): MessagePushPayload['sessionType'] { + if (sessionId.endsWith('@chatroom')) { + return 'group' + } + if (sessionId.startsWith('gh_') || session.type === 'official') { + return 'official' + } + if (session.type === 'friend') { + return 'private' + } + return 'other' + } + + private shouldPushPayload(payload: MessagePushPayload): boolean { + const sessionId = String(payload.sessionId || '').trim() + const filterMode = this.getMessagePushFilterMode() + if (filterMode === 'all') { + return true + } + + const filterList = this.getMessagePushFilterList() + const listed = filterList.has(sessionId) + if (filterMode === 'whitelist') { + return listed + } + return !listed + } + + private getMessagePushFilterMode(): 'all' | 'whitelist' | 'blacklist' { + const value = this.configService.get('messagePushFilterMode') + if (value === 'whitelist' || value === 'blacklist') return value + return 'all' + } + + private getMessagePushFilterList(): Set { + const value = this.configService.get('messagePushFilterList') + if (!Array.isArray(value)) return new Set() + return new Set(value.map((item) => String(item || '').trim()).filter(Boolean)) + } + + private isSessionTableChange(tableName: string): boolean { + return String(tableName || '').trim().toLowerCase() === 'session' + } + + private isMessageTableChange(tableName: string): boolean { + const normalized = String(tableName || '').trim().toLowerCase() + if (!normalized) return false + return normalized === 'message' || + normalized === 'msg' || + normalized.startsWith('message_') || + normalized.startsWith('msg_') || + normalized.includes('message') + } + + private bumpSessionBaseline(sessionId: string, message: Message): void { + const key = String(sessionId || '').trim() + if (!key) return + + const createTime = Number(message.createTime || 0) + if (!Number.isFinite(createTime) || createTime <= 0) return + + const current = this.sessionBaseline.get(key) || { lastTimestamp: 0, unreadCount: 0 } + if (createTime > current.lastTimestamp) { + this.sessionBaseline.set(key, { + ...current, + lastTimestamp: createTime + }) + } + } + private getMessageDisplayContent(message: Message): string | null { + const cleanOfficialPrefix = (value: string | null): string | null => { + if (!value) return value + return value.replace(/^\s*\[视频号\]\s*/u, '').trim() || value + } switch (Number(message.localType || 0)) { case 1: - return message.rawContent || null + return cleanOfficialPrefix(message.rawContent || null) case 3: return '[图片]' case 34: @@ -287,13 +414,13 @@ class MessagePushService { case 47: return '[表情]' case 42: - return message.cardNickname || '[名片]' + return cleanOfficialPrefix(message.cardNickname || '[名片]') case 48: return '[位置]' case 49: - return message.linkTitle || message.fileName || '[消息]' + return cleanOfficialPrefix(message.linkTitle || message.fileName || '[消息]') default: - return message.parsedContent || message.rawContent || null + return cleanOfficialPrefix(message.parsedContent || message.rawContent || null) } } diff --git a/src/pages/BizPage.scss b/src/pages/BizPage.scss index a2faddb..5ff28c6 100644 --- a/src/pages/BizPage.scss +++ b/src/pages/BizPage.scss @@ -11,6 +11,7 @@ } .biz-account-item { + position: relative; display: flex; align-items: center; gap: 12px; @@ -46,6 +47,24 @@ background-color: var(--bg-tertiary); } + .biz-unread-badge { + position: absolute; + top: 8px; + left: 52px; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background: #ff4d4f; + color: #fff; + font-size: 11px; + font-weight: 600; + line-height: 18px; + text-align: center; + border: 2px solid var(--bg-secondary); + box-sizing: border-box; + } + .biz-info { flex: 1; min-width: 0; diff --git a/src/pages/BizPage.tsx b/src/pages/BizPage.tsx index 6831d54..be7b547 100644 --- a/src/pages/BizPage.tsx +++ b/src/pages/BizPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; import { useThemeStore } from '../stores/themeStore'; import { Newspaper, MessageSquareOff } from 'lucide-react'; import './BizPage.scss'; @@ -10,6 +10,7 @@ export interface BizAccount { type: string; last_time: number; formatted_last_time: string; + unread_count?: number; } export const BizAccountList: React.FC<{ @@ -36,25 +37,42 @@ export const BizAccountList: React.FC<{ initWxid().then(_r => { }); }, []); - useEffect(() => { - const fetch = async () => { - if (!myWxid) { - return; - } + const fetchAccounts = useCallback(async () => { + if (!myWxid) { + return; + } - setLoading(true); - try { - const res = await window.electronAPI.biz.listAccounts(myWxid) - setAccounts(res || []); - } catch (err) { - console.error('获取服务号列表失败:', err); - } finally { - setLoading(false); - } - }; - fetch().then(_r => { } ); + setLoading(true); + try { + const res = await window.electronAPI.biz.listAccounts(myWxid) + setAccounts(res || []); + } catch (err) { + console.error('获取服务号列表失败:', err); + } finally { + setLoading(false); + } }, [myWxid]); + useEffect(() => { + fetchAccounts().then(_r => { }); + }, [fetchAccounts]); + + useEffect(() => { + if (!window.electronAPI.chat.onWcdbChange) return; + const removeListener = window.electronAPI.chat.onWcdbChange((_event: any, data: { json?: string }) => { + try { + const payload = JSON.parse(data.json || '{}'); + const tableName = String(payload.table || '').toLowerCase(); + if (!tableName || tableName === 'session' || tableName.includes('message') || tableName.startsWith('msg_')) { + fetchAccounts().then(_r => { }); + } + } catch { + fetchAccounts().then(_r => { }); + } + }); + return () => removeListener(); + }, [fetchAccounts]); + const filtered = useMemo(() => { let result = accounts; @@ -80,7 +98,12 @@ export const BizAccountList: React.FC<{ {filtered.map(item => (
onSelect(item)} + onClick={() => { + setAccounts(prev => prev.map(account => + account.username === item.username ? { ...account, unread_count: 0 } : account + )); + onSelect({ ...item, unread_count: 0 }); + }} className={`biz-account-item ${selectedUsername === item.username ? 'active' : ''} ${item.username === 'gh_3dfda90e39d6' ? 'pay-account' : ''}`} > + {(item.unread_count || 0) > 0 && ( + {(item.unread_count || 0) > 99 ? '99+' : item.unread_count} + )}
{item.name || item.username} diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index ec05e62..7c94600 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1058,6 +1058,13 @@ const SessionItem = React.memo(function SessionItem({
{session.summary || '查看公众号历史消息'} +
+ {session.unreadCount > 0 && ( + + {session.unreadCount > 99 ? '99+' : session.unreadCount} + + )} +
@@ -5049,24 +5056,37 @@ function ChatPage(props: ChatPageProps) { return [] } + const officialSessions = sessions.filter(s => s.username.startsWith('gh_')) + // 检查是否有折叠的群聊 const foldedGroups = sessions.filter(s => s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) const hasFoldedGroups = foldedGroups.length > 0 let visible = sessions.filter(s => { if (s.isFolded && !s.username.toLowerCase().includes('placeholder_foldgroup')) return false + if (s.username.startsWith('gh_')) return false return true }) + const latestOfficial = officialSessions.reduce((latest, current) => { + if (!latest) return current + const latestTime = latest.sortTimestamp || latest.lastTimestamp + const currentTime = current.sortTimestamp || current.lastTimestamp + return currentTime > latestTime ? current : latest + }, null) + const officialUnreadCount = officialSessions.reduce((sum, s) => sum + (s.unreadCount || 0), 0) + const bizEntry: ChatSession = { username: OFFICIAL_ACCOUNTS_VIRTUAL_ID, displayName: '公众号', - summary: '查看公众号历史消息', + summary: latestOfficial + ? `${latestOfficial.displayName || latestOfficial.username}: ${latestOfficial.summary || '查看公众号历史消息'}` + : '查看公众号历史消息', type: 0, sortTimestamp: 9999999999, // 放到最前面? 目前还没有严格的对时间进行排序, 后面可以改一下 - lastTimestamp: 0, - lastMsgType: 0, - unreadCount: 0, + lastTimestamp: latestOfficial?.lastTimestamp || latestOfficial?.sortTimestamp || 0, + lastMsgType: latestOfficial?.lastMsgType || 0, + unreadCount: officialUnreadCount, isMuted: false, isFolded: false } diff --git a/src/pages/SettingsPage.scss b/src/pages/SettingsPage.scss index 3761efc..ac35a22 100644 --- a/src/pages/SettingsPage.scss +++ b/src/pages/SettingsPage.scss @@ -2349,6 +2349,24 @@ border-radius: 10px; } +.filter-panel-action { + flex-shrink: 0; + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-tertiary); + color: var(--text-secondary); + padding: 4px 8px; + font-size: 12px; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 42%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-tertiary)); + } +} + .filter-panel-list { flex: 1; min-height: 200px; @@ -2412,6 +2430,16 @@ white-space: nowrap; } + .filter-item-type { + flex-shrink: 0; + padding: 2px 6px; + border-radius: 6px; + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + } + .filter-item-action { font-size: 18px; font-weight: 500; @@ -2421,6 +2449,36 @@ } } +.push-filter-type-tabs { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; + margin-bottom: 10px; +} + +.push-filter-type-tab { + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + padding: 6px 10px; + font-size: 13px; + cursor: pointer; + transition: all 0.16s ease; + + &:hover { + color: var(--text-primary); + border-color: color-mix(in srgb, var(--primary) 38%, var(--border-color)); + } + + &.active { + color: var(--primary); + border-color: color-mix(in srgb, var(--primary) 54%, var(--border-color)); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); + } +} + .filter-panel-empty { display: flex; align-items: center; diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index 3dfacf8..6b05c7a 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -6,6 +6,7 @@ import { useThemeStore, themes } from '../stores/themeStore' import { useAnalyticsStore } from '../stores/analyticsStore' import { dialog } from '../services/ipc' import * as configService from '../services/config' +import type { ContactInfo } from '../types/models' import { Eye, EyeOff, FolderSearch, FolderOpen, Search, Copy, RotateCcw, Trash2, Plug, Check, Sun, Moon, Monitor, @@ -225,6 +226,12 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const [isTogglingApi, setIsTogglingApi] = useState(false) const [showApiWarning, setShowApiWarning] = useState(false) const [messagePushEnabled, setMessagePushEnabled] = useState(false) + const [messagePushFilterMode, setMessagePushFilterMode] = useState('all') + const [messagePushFilterList, setMessagePushFilterList] = useState([]) + const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false) + const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('') + const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<'all' | configService.MessagePushSessionType>('all') + const [messagePushContactOptions, setMessagePushContactOptions] = useState([]) const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('') const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState>(new Set()) const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState>({}) @@ -356,15 +363,16 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setFilterModeDropdownOpen(false) setPositionDropdownOpen(false) setCloseBehaviorDropdownOpen(false) + setMessagePushFilterDropdownOpen(false) } } - if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen) { + if (filterModeDropdownOpen || positionDropdownOpen || closeBehaviorDropdownOpen || messagePushFilterDropdownOpen) { document.addEventListener('click', handleClickOutside) } return () => { document.removeEventListener('click', handleClickOutside) } - }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, positionDropdownOpen]) + }, [closeBehaviorDropdownOpen, filterModeDropdownOpen, messagePushFilterDropdownOpen, positionDropdownOpen]) const loadConfig = async () => { @@ -387,6 +395,9 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { const savedNotificationFilterMode = await configService.getNotificationFilterMode() const savedNotificationFilterList = await configService.getNotificationFilterList() const savedMessagePushEnabled = await configService.getMessagePushEnabled() + const savedMessagePushFilterMode = await configService.getMessagePushFilterMode() + const savedMessagePushFilterList = await configService.getMessagePushFilterList() + const contactsResult = await window.electronAPI.chat.getContacts({ lite: true }) const savedLaunchAtStartupStatus = await window.electronAPI.app.getLaunchAtStartupStatus() const savedWindowCloseBehavior = await configService.getWindowCloseBehavior() const savedQuoteLayout = await configService.getQuoteLayout() @@ -437,6 +448,11 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { setNotificationFilterMode(savedNotificationFilterMode) setNotificationFilterList(savedNotificationFilterList) setMessagePushEnabled(savedMessagePushEnabled) + setMessagePushFilterMode(savedMessagePushFilterMode) + setMessagePushFilterList(savedMessagePushFilterList) + if (contactsResult.success && Array.isArray(contactsResult.contacts)) { + setMessagePushContactOptions(contactsResult.contacts as ContactInfo[]) + } setLaunchAtStartup(savedLaunchAtStartupStatus.enabled) setLaunchAtStartupSupported(savedLaunchAtStartupStatus.supported) setLaunchAtStartupReason(savedLaunchAtStartupStatus.reason || '') @@ -2517,6 +2533,116 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { showMessage(enabled ? '已开启主动推送' : '已关闭主动推送', true) } + const getMessagePushSessionType = (session: { username: string; type?: ContactInfo['type'] | number }): configService.MessagePushSessionType => { + const username = String(session.username || '').trim() + if (username.endsWith('@chatroom')) return 'group' + if (username.startsWith('gh_') || session.type === 'official') return 'official' + if (username.toLowerCase().includes('placeholder_foldgroup')) return 'other' + if (session.type === 'former_friend' || session.type === 'other') return 'other' + return 'private' + } + + const getMessagePushTypeLabel = (type: configService.MessagePushSessionType) => { + switch (type) { + case 'private': return '私聊' + case 'group': return '群聊' + case 'official': return '订阅号/服务号' + default: return '其他/非好友' + } + } + + const handleSetMessagePushFilterMode = async (mode: configService.MessagePushFilterMode) => { + setMessagePushFilterMode(mode) + setMessagePushFilterDropdownOpen(false) + await configService.setMessagePushFilterMode(mode) + showMessage( + mode === 'all' ? '主动推送已设为接收所有会话' : + mode === 'whitelist' ? '主动推送已设为仅推送白名单' : '主动推送已设为屏蔽黑名单', + true + ) + } + + const handleAddMessagePushFilterSession = async (username: string) => { + if (messagePushFilterList.includes(username)) return + const next = [...messagePushFilterList, username] + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage('已添加到主动推送过滤列表', true) + } + + const handleRemoveMessagePushFilterSession = async (username: string) => { + const next = messagePushFilterList.filter(item => item !== username) + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage('已从主动推送过滤列表移除', true) + } + + const handleAddAllMessagePushFilterSessions = async () => { + const usernames = messagePushAvailableSessions.map(session => session.username) + if (usernames.length === 0) return + const next = Array.from(new Set([...messagePushFilterList, ...usernames])) + setMessagePushFilterList(next) + await configService.setMessagePushFilterList(next) + showMessage(`已添加 ${usernames.length} 个会话`, true) + } + + const messagePushOptionMap = new Map() + + for (const session of chatSessions) { + if (session.username.toLowerCase().includes('placeholder_foldgroup')) continue + messagePushOptionMap.set(session.username, { + username: session.username, + displayName: session.displayName || session.username, + avatarUrl: session.avatarUrl, + type: getMessagePushSessionType(session) + }) + } + + for (const contact of messagePushContactOptions) { + if (!contact.username) continue + if (contact.type !== 'friend' && contact.type !== 'group' && contact.type !== 'official' && contact.type !== 'former_friend') continue + const existing = messagePushOptionMap.get(contact.username) + messagePushOptionMap.set(contact.username, { + username: contact.username, + displayName: existing?.displayName || contact.displayName || contact.remark || contact.nickname || contact.username, + avatarUrl: existing?.avatarUrl || contact.avatarUrl, + type: getMessagePushSessionType(contact) + }) + } + + const messagePushOptions = Array.from(messagePushOptionMap.values()) + .sort((a, b) => { + const aSession = chatSessions.find(session => session.username === a.username) + const bSession = chatSessions.find(session => session.username === b.username) + return Number(bSession?.sortTimestamp || bSession?.lastTimestamp || 0) - + Number(aSession?.sortTimestamp || aSession?.lastTimestamp || 0) + }) + + const messagePushAvailableSessions = messagePushOptions.filter(session => { + if (messagePushFilterList.includes(session.username)) return false + if (messagePushTypeFilter !== 'all' && session.type !== messagePushTypeFilter) return false + if (messagePushFilterSearchKeyword.trim()) { + const keyword = messagePushFilterSearchKeyword.trim().toLowerCase() + return String(session.displayName || '').toLowerCase().includes(keyword) || + session.username.toLowerCase().includes(keyword) + } + return true + }) + + const getMessagePushOptionInfo = (username: string) => { + return messagePushOptionMap.get(username) || { + username, + displayName: username, + avatarUrl: undefined, + type: 'other' as configService.MessagePushSessionType + } + } + const handleTestInsightConnection = async () => { setIsTestingInsight(true) setInsightTestResult(null) @@ -3350,6 +3476,151 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) { +
+ + 选择只推送特定会话,或屏蔽特定会话 +
+
setMessagePushFilterDropdownOpen(!messagePushFilterDropdownOpen)} + > + + {messagePushFilterMode === 'all' ? '推送所有会话' : + messagePushFilterMode === 'whitelist' ? '仅推送白名单' : '屏蔽黑名单'} + + +
+
+ {[ + { value: 'all', label: '推送所有会话' }, + { value: 'whitelist', label: '仅推送白名单' }, + { value: 'blacklist', label: '屏蔽黑名单' } + ].map(option => ( +
{ void handleSetMessagePushFilterMode(option.value as configService.MessagePushFilterMode) }} + > + {option.label} + {messagePushFilterMode === option.value && } +
+ ))} +
+
+
+ + {messagePushFilterMode !== 'all' && ( +
+ + + {messagePushFilterMode === 'whitelist' + ? '点击左侧会话添加到白名单,只有白名单会话会推送' + : '点击左侧会话添加到黑名单,黑名单会话不会推送'} + +
+ {[ + { value: 'all', label: '全部' }, + { value: 'private', label: '私聊' }, + { value: 'group', label: '群聊' }, + { value: 'official', label: '订阅号/服务号' }, + { value: 'other', label: '其他/非好友' } + ].map(option => ( + + ))} +
+
+
+
+ 可选会话 + {messagePushAvailableSessions.length > 0 && ( + + )} +
+ + setMessagePushFilterSearchKeyword(e.target.value)} + /> +
+
+
+ {messagePushAvailableSessions.length > 0 ? ( + messagePushAvailableSessions.map(session => ( +
{ void handleAddMessagePushFilterSession(session.username) }} + > + + {session.displayName || session.username} + {getMessagePushTypeLabel(session.type)} + + +
+ )) + ) : ( +
+ {messagePushFilterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'} +
+ )} +
+
+ +
+
+ {messagePushFilterMode === 'whitelist' ? '白名单' : '黑名单'} + {messagePushFilterList.length > 0 && ( + {messagePushFilterList.length} + )} +
+
+ {messagePushFilterList.length > 0 ? ( + messagePushFilterList.map(username => { + const session = getMessagePushOptionInfo(username) + return ( +
{ void handleRemoveMessagePushFilterSession(username) }} + > + + {session.displayName || username} + {getMessagePushTypeLabel(session.type)} + × +
+ ) + }) + ) : ( +
尚未添加任何会话
+ )} +
+
+
+
+ )} +
外部软件连接这个 SSE 地址即可接收新消息推送;需要先开启上方 `HTTP API 服务` @@ -3384,7 +3655,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {

通过 SSE 长连接接收消息事件,建议接收端按 `messageKey` 去重。

- {['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( + {['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => ( {param} diff --git a/src/services/config.ts b/src/services/config.ts index ce6bb1e..afbbee4 100644 --- a/src/services/config.ts +++ b/src/services/config.ts @@ -72,6 +72,8 @@ export const CONFIG_KEYS = { HTTP_API_PORT: 'httpApiPort', HTTP_API_HOST: 'httpApiHost', MESSAGE_PUSH_ENABLED: 'messagePushEnabled', + MESSAGE_PUSH_FILTER_MODE: 'messagePushFilterMode', + MESSAGE_PUSH_FILTER_LIST: 'messagePushFilterList', WINDOW_CLOSE_BEHAVIOR: 'windowCloseBehavior', QUOTE_LAYOUT: 'quoteLayout', @@ -1505,6 +1507,29 @@ export async function setMessagePushEnabled(enabled: boolean): Promise { await config.set(CONFIG_KEYS.MESSAGE_PUSH_ENABLED, enabled) } +export type MessagePushFilterMode = 'all' | 'whitelist' | 'blacklist' +export type MessagePushSessionType = 'private' | 'group' | 'official' | 'other' + +export async function getMessagePushFilterMode(): Promise { + const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE) + if (value === 'whitelist' || value === 'blacklist') return value + return 'all' +} + +export async function setMessagePushFilterMode(mode: MessagePushFilterMode): Promise { + await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_MODE, mode) +} + +export async function getMessagePushFilterList(): Promise { + const value = await config.get(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST) + return Array.isArray(value) ? value.map(item => String(item || '').trim()).filter(Boolean) : [] +} + +export async function setMessagePushFilterList(list: string[]): Promise { + const normalized = Array.from(new Set((list || []).map(item => String(item || '').trim()).filter(Boolean))) + await config.set(CONFIG_KEYS.MESSAGE_PUSH_FILTER_LIST, normalized) +} + export async function getWindowCloseBehavior(): Promise { const value = await config.get(CONFIG_KEYS.WINDOW_CLOSE_BEHAVIOR) if (value === 'tray' || value === 'quit') return value