实现了服务号的推送以及未读

This commit is contained in:
xuncha
2026-04-12 08:03:12 +08:00
parent f89ad6ec15
commit f2f78bb4e2
10 changed files with 857 additions and 46 deletions

View File

@@ -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;

View File

@@ -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 => (
<div
key={item.username}
onClick={() => 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' : ''}`}
>
<img
@@ -88,6 +111,9 @@ export const BizAccountList: React.FC<{
className="biz-avatar"
alt=""
/>
{(item.unread_count || 0) > 0 && (
<span className="biz-unread-badge">{(item.unread_count || 0) > 99 ? '99+' : item.unread_count}</span>
)}
<div className="biz-info">
<div className="biz-info-top">
<span className="biz-name">{item.name || item.username}</span>

View File

@@ -1058,6 +1058,13 @@ const SessionItem = React.memo(function SessionItem({
</div>
<div className="session-bottom">
<span className="session-summary">{session.summary || '查看公众号历史消息'}</span>
<div className="session-badges">
{session.unreadCount > 0 && (
<span className="unread-badge">
{session.unreadCount > 99 ? '99+' : session.unreadCount}
</span>
)}
</div>
</div>
</div>
</div>
@@ -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<ChatSession | null>((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
}

View File

@@ -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;

View File

@@ -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<configService.MessagePushFilterMode>('all')
const [messagePushFilterList, setMessagePushFilterList] = useState<string[]>([])
const [messagePushFilterDropdownOpen, setMessagePushFilterDropdownOpen] = useState(false)
const [messagePushFilterSearchKeyword, setMessagePushFilterSearchKeyword] = useState('')
const [messagePushTypeFilter, setMessagePushTypeFilter] = useState<'all' | configService.MessagePushSessionType>('all')
const [messagePushContactOptions, setMessagePushContactOptions] = useState<ContactInfo[]>([])
const [antiRevokeSearchKeyword, setAntiRevokeSearchKeyword] = useState('')
const [antiRevokeSelectedIds, setAntiRevokeSelectedIds] = useState<Set<string>>(new Set())
const [antiRevokeStatusMap, setAntiRevokeStatusMap] = useState<Record<string, { installed?: boolean; loading?: boolean; error?: string }>>({})
@@ -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<string, {
username: string
displayName: string
avatarUrl?: string
type: configService.MessagePushSessionType
}>()
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 = {}) {
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint"></span>
<div className="custom-select">
<div
className={`custom-select-trigger ${messagePushFilterDropdownOpen ? 'open' : ''}`}
onClick={() => setMessagePushFilterDropdownOpen(!messagePushFilterDropdownOpen)}
>
<span className="custom-select-value">
{messagePushFilterMode === 'all' ? '推送所有会话' :
messagePushFilterMode === 'whitelist' ? '仅推送白名单' : '屏蔽黑名单'}
</span>
<ChevronDown size={14} className={`custom-select-arrow ${messagePushFilterDropdownOpen ? 'rotate' : ''}`} />
</div>
<div className={`custom-select-dropdown ${messagePushFilterDropdownOpen ? 'open' : ''}`}>
{[
{ value: 'all', label: '推送所有会话' },
{ value: 'whitelist', label: '仅推送白名单' },
{ value: 'blacklist', label: '屏蔽黑名单' }
].map(option => (
<div
key={option.value}
className={`custom-select-option ${messagePushFilterMode === option.value ? 'selected' : ''}`}
onClick={() => { void handleSetMessagePushFilterMode(option.value as configService.MessagePushFilterMode) }}
>
{option.label}
{messagePushFilterMode === option.value && <Check size={14} />}
</div>
))}
</div>
</div>
</div>
{messagePushFilterMode !== 'all' && (
<div className="form-group">
<label>{messagePushFilterMode === 'whitelist' ? '主动推送白名单' : '主动推送黑名单'}</label>
<span className="form-hint">
{messagePushFilterMode === 'whitelist'
? '点击左侧会话添加到白名单,只有白名单会话会推送'
: '点击左侧会话添加到黑名单,黑名单会话不会推送'}
</span>
<div className="push-filter-type-tabs">
{[
{ value: 'all', label: '全部' },
{ value: 'private', label: '私聊' },
{ value: 'group', label: '群聊' },
{ value: 'official', label: '订阅号/服务号' },
{ value: 'other', label: '其他/非好友' }
].map(option => (
<button
key={option.value}
type="button"
className={`push-filter-type-tab ${messagePushTypeFilter === option.value ? 'active' : ''}`}
onClick={() => setMessagePushTypeFilter(option.value as 'all' | configService.MessagePushSessionType)}
>
{option.label}
</button>
))}
</div>
<div className="notification-filter-container">
<div className="filter-panel">
<div className="filter-panel-header">
<span></span>
{messagePushAvailableSessions.length > 0 && (
<button
type="button"
className="filter-panel-action"
onClick={() => { void handleAddAllMessagePushFilterSessions() }}
>
</button>
)}
<div className="filter-search-box">
<Search size={14} />
<input
type="text"
placeholder="搜索会话..."
value={messagePushFilterSearchKeyword}
onChange={(e) => setMessagePushFilterSearchKeyword(e.target.value)}
/>
</div>
</div>
<div className="filter-panel-list">
{messagePushAvailableSessions.length > 0 ? (
messagePushAvailableSessions.map(session => (
<div
key={session.username}
className="filter-panel-item"
onClick={() => { void handleAddMessagePushFilterSession(session.username) }}
>
<Avatar
src={session.avatarUrl}
name={session.displayName || session.username}
size={28}
/>
<span className="filter-item-name">{session.displayName || session.username}</span>
<span className="filter-item-type">{getMessagePushTypeLabel(session.type)}</span>
<span className="filter-item-action">+</span>
</div>
))
) : (
<div className="filter-panel-empty">
{messagePushFilterSearchKeyword ? '没有匹配的会话' : '暂无可添加的会话'}
</div>
)}
</div>
</div>
<div className="filter-panel">
<div className="filter-panel-header">
<span>{messagePushFilterMode === 'whitelist' ? '白名单' : '黑名单'}</span>
{messagePushFilterList.length > 0 && (
<span className="filter-panel-count">{messagePushFilterList.length}</span>
)}
</div>
<div className="filter-panel-list">
{messagePushFilterList.length > 0 ? (
messagePushFilterList.map(username => {
const session = getMessagePushOptionInfo(username)
return (
<div
key={username}
className="filter-panel-item selected"
onClick={() => { void handleRemoveMessagePushFilterSession(username) }}
>
<Avatar
src={session.avatarUrl}
name={session.displayName || username}
size={28}
/>
<span className="filter-item-name">{session.displayName || username}</span>
<span className="filter-item-type">{getMessagePushTypeLabel(session.type)}</span>
<span className="filter-item-action">×</span>
</div>
)
})
) : (
<div className="filter-panel-empty"></div>
)}
</div>
</div>
</div>
</div>
)}
<div className="form-group">
<label></label>
<span className="form-hint"> SSE `HTTP API 服务`</span>
@@ -3384,7 +3655,7 @@ function SettingsPage({ onClose }: SettingsPageProps = {}) {
</div>
<p className="api-desc"> SSE `messageKey` </p>
<div className="api-params">
{['event', 'sessionId', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
{['event', 'sessionId', 'sessionType', 'messageKey', 'avatarUrl', 'sourceName', 'groupName?', 'content'].map((param) => (
<span key={param} className="param">
<code>{param}</code>
</span>