feat: 增加退群监控功能

This commit is contained in:
Wxw-Gu
2026-08-31 21:06:53 +08:00
parent 6cbf9af662
commit e59332b514
22 changed files with 3321 additions and 30 deletions
Binary file not shown.
Binary file not shown.
+34
View File
@@ -111,6 +111,7 @@ import {
} from './services/bootstrap-cache'
import { installSafeConsole } from './safe-log'
import { agentHubService } from './services/agent-hub-service'
import { groupExitMonitorService } from './services/group-exit-monitor-service'
import { personalWechatSendService } from './services/personal-wechat-send-service'
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
import { scheduledReportService } from './services/scheduled-report-service'
@@ -746,6 +747,7 @@ app.whenReady().then(async () => {
chat.getCurrentAccountRoot() === selectedRoot
) {
console.log('[WCDB4] db:init reuse current connection')
void groupExitMonitorService.start(true)
return { success: true, monitoring: true }
}
const nextWechatDb = await WechatDb.create(key, selectedRoot)
@@ -753,6 +755,7 @@ app.whenReady().then(async () => {
await nextWechatDb.closeAsync()
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
}
groupExitMonitorService.stop()
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
if (resolvedRoot) {
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
@@ -774,11 +777,13 @@ app.whenReady().then(async () => {
videoAssetService = new VideoAssetService(wcdb4Client)
const monitoring = await wcdb4Client.startMonitor((type, json) => {
wcdb4Client.invalidateSessionCache()
groupExitMonitorService.notifyDatabaseChanged(json)
recallArchiveMonitor?.handleDatabaseChange(json)
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
void groupExitMonitorService.start(monitoring)
const recentSession = sessions[0]
if (recentSession?.username) {
void wcdb4Client
@@ -1123,6 +1128,24 @@ app.whenReady().then(async () => {
return snapshot
})
ipcMain.handle('group-exit-monitor:getState', () => groupExitMonitorService.getState())
ipcMain.handle(
'group-exit-monitor:setGroups',
(_, roomIds: string[], notificationRoomIds?: string[]) =>
groupExitMonitorService.setMonitoredRoomIds(
Array.isArray(roomIds) ? roomIds : [],
Array.isArray(notificationRoomIds) ? notificationRoomIds : []
)
)
ipcMain.handle('group-exit-monitor:setTemplate', (_, template: unknown) =>
groupExitMonitorService.setNotificationTemplate(template)
)
ipcMain.handle('group-exit-monitor:checkNow', () => groupExitMonitorService.checkNow())
ipcMain.handle('group-exit-monitor:clearEvents', () => groupExitMonitorService.clearEvents())
ipcMain.handle('group-exit-monitor:markRead', (_, readAt?: number) =>
groupExitMonitorService.markRead(readAt)
)
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
ipcMain.handle(
'knowledge:search',
@@ -1734,12 +1757,21 @@ app.whenReady().then(async () => {
})
ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => {
groupExitMonitorService.stop()
const ok = chat.reopenWithRoot(accountRoot)
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
const client = chat.getChatDb()?.getWcdb4Client()
if (client) {
voiceService = new VoiceService(client)
voiceRecognition?.connect(voiceService, client.getAccountRoot())
const monitoring = await client.startMonitor((type, json) => {
client.invalidateSessionCache()
groupExitMonitorService.notifyDatabaseChanged(json)
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
}
})
void groupExitMonitorService.start(monitoring)
}
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
const settings = loadSettings()
@@ -1773,6 +1805,7 @@ app.whenReady().then(async () => {
voiceBatchService?.cancel()
voiceRecognition?.disconnect()
voiceService = null
groupExitMonitorService.stop()
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
return { success: true }
})
@@ -2027,6 +2060,7 @@ app.on('before-quit', (event) => {
void (async () => {
agentHubService.stop()
scheduledReportService.stop()
groupExitMonitorService.stop()
flushBootstrapCacheWritesSync()
const [, nativeCallsDrained] = await Promise.all([
apiServer.stop().catch(() => undefined),
+4 -3
View File
@@ -583,8 +583,8 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = wcdb4Client
.getGroupMembers(roomId)
const rawMembers = wcdb4Client.getGroupMembers(roomId)
const members = (Array.isArray(rawMembers) ? rawMembers : [])
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
@@ -604,7 +604,8 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
if (!roomId || !roomId.endsWith('@chatroom')) return null
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
const rawMembers = await wcdb4Client.getGroupMembersAsync(roomId)
const members = (Array.isArray(rawMembers) ? rawMembers : [])
.filter((member) => member?.m_nsUsrName)
.map((member) => ({
wxid: member.m_nsUsrName,
@@ -0,0 +1,579 @@
import { app, BrowserWindow } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import * as chat from './chat-service'
import { personalWechatCapabilityService } from './personal-wechat-capability-service'
import { personalWechatSendService } from './personal-wechat-send-service'
import {
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
renderGroupExitMonitorNotification,
validateGroupExitNotificationTemplate,
type GroupExitMonitorEvent,
type GroupExitMonitorMember,
type GroupExitMonitorState
} from '../../shared/group-exit-monitor'
type StoredState = {
accountRoot?: string
events?: Partial<GroupExitMonitorEvent>[]
lastReadAt?: number
monitorSelectionConfigured?: boolean
monitoredRoomIds?: string[]
notificationRoomIds?: string[]
notificationTemplate?: unknown
}
type GroupSnapshotRecord = {
contactId: string
roomId: string
groupName: string
members: GroupExitMonitorMember[]
/** 查询失败时保留旧快照。 */
membersValid?: boolean
}
const DB_CHANGE_DEBOUNCE_MS = 350
const GROUP_READ_CONCURRENCY = 8
const DUPLICATE_WINDOW_MS = 2 * 60 * 1000
const MAX_EVENTS = 500
class GroupExitMonitorService {
private active = false
private nativeMonitorActive = false
private snapshots = new Map<string, GroupSnapshotRecord>()
private changeTimer: NodeJS.Timeout | null = null
private checking = false
private checkQueued = false
private initializing = false
private lastCheckedAt: number | undefined
private lastReadAt = 0
private events: GroupExitMonitorEvent[] = []
private monitorSelectionConfigured = true
private monitoredRoomIds = new Set<string>()
private notificationRoomIds = new Set<string>()
private loaded = false
private accountRoot = ''
private eventSequence = 0
private scopeGeneration = 0
private notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
getState(): GroupExitMonitorState {
this.ensureLoaded()
return {
events: [...this.events],
running: this.active && chat.isReady(),
nativeMonitorActive: this.nativeMonitorActive,
monitoredGroupCount: this.snapshots.size,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate,
lastCheckedAt: this.lastCheckedAt,
lastReadAt: this.lastReadAt,
unreadCount: this.events.filter((event) => event.detectedAt > this.lastReadAt).length
}
}
async start(nativeMonitorActive: boolean): Promise<void> {
this.ensureLoaded()
const currentRoot = chat.getCurrentAccountRoot()
if (
currentRoot &&
this.accountRoot &&
path.resolve(currentRoot) !== path.resolve(this.accountRoot)
) {
this.events = []
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
}
if (currentRoot) this.accountRoot = currentRoot
this.active = true
this.nativeMonitorActive = nativeMonitorActive
this.snapshots.clear()
this.checkQueued = false
this.initializing = true
const scopeGeneration = ++this.scopeGeneration
const generation = this.eventSequence + 1
this.eventSequence = generation
try {
const groups = await this.readGroups()
if (
!this.active ||
this.eventSequence !== generation ||
this.scopeGeneration !== scopeGeneration
)
return
if (groups) this.replaceSnapshots(groups)
this.save()
this.broadcast()
} finally {
this.initializing = false
if (this.checkQueued && this.active) {
this.checkQueued = false
void this.check()
}
}
}
stop(): void {
this.active = false
this.nativeMonitorActive = false
this.eventSequence += 1
this.scopeGeneration += 1
this.snapshots.clear()
this.checkQueued = false
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = null
this.broadcast()
}
notifyDatabaseChanged(rawPayload: string): void {
if (!this.active || !isContactEvent(rawPayload)) return
try {
chat.getChatDb()?.getWcdb4Client().invalidateGroupNicknameCache()
} catch {
// 缓存清理失败时继续检查。
}
if (this.changeTimer) clearTimeout(this.changeTimer)
this.changeTimer = setTimeout(() => {
this.changeTimer = null
void this.check()
}, DB_CHANGE_DEBOUNCE_MS)
}
async checkNow(): Promise<GroupExitMonitorState> {
if (!this.active && chat.isReady()) await this.start(false)
await this.check()
return this.getState()
}
async setMonitoredRoomIds(
roomIds: string[],
notificationRoomIds: string[] = []
): Promise<GroupExitMonitorState> {
this.ensureLoaded()
this.eventSequence += 1
this.scopeGeneration += 1
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(roomIds)
const requestedNotifications = normalizeRoomIds(notificationRoomIds)
this.notificationRoomIds = new Set(
Array.from(requestedNotifications).filter((roomId) => this.monitoredRoomIds.has(roomId))
)
// 更换监控范围后重新建基线,避免旧快照误报。
this.snapshots.clear()
this.lastCheckedAt = undefined
this.save()
this.broadcast()
// 建立基线放到后台,保存配置可以立即返回。
if (this.active) void this.check()
return this.getState()
}
setNotificationTemplate(value: unknown): GroupExitMonitorState {
this.ensureLoaded()
const result = validateGroupExitNotificationTemplate(value)
if (!result.valid || !result.template) {
throw new Error(result.error || '退群监测模板无效')
}
this.notificationTemplate = result.template
this.save()
this.broadcast()
return this.getState()
}
clearEvents(): GroupExitMonitorState {
this.ensureLoaded()
this.events = []
this.lastReadAt = Date.now()
this.save()
this.broadcast()
return this.getState()
}
markRead(readAt?: number): GroupExitMonitorState {
this.ensureLoaded()
this.lastReadAt = Math.max(this.lastReadAt, Number(readAt) || Date.now())
this.save()
this.broadcast()
return this.getState()
}
private async check(): Promise<void> {
if (!this.active || !chat.isReady()) return
if (this.initializing) {
this.checkQueued = true
return
}
if (this.checking) {
this.checkQueued = true
return
}
this.checking = true
const scopeGeneration = this.scopeGeneration
try {
const groups = await this.readGroups()
if (!groups || !this.active || scopeGeneration !== this.scopeGeneration) return
const currentRoomIds = new Set(groups.map((group) => group.roomId))
for (const roomId of this.snapshots.keys()) {
if (!currentRoomIds.has(roomId)) this.snapshots.delete(roomId)
}
for (const next of groups) {
if (scopeGeneration !== this.scopeGeneration) return
if (next.membersValid === false) continue
const previous = this.snapshots.get(next.roomId)
if (previous?.membersValid === false) {
this.snapshots.set(next.roomId, next)
continue
}
// 空数组可能是查询失败,先保留旧基线,避免误报。
if (previous && previous.members.length > 0 && next.members.length === 0) continue
this.snapshots.set(next.roomId, next)
if (!previous) continue
// 只有群人数下降才记录退群。
if (next.members.length >= previous.members.length) continue
const removed = findRemovedGroupMembers(previous.members, next.members)
for (const member of removed) {
const event = this.recordExit(next, member, previous.members.length, next.members.length)
if (event && this.notificationRoomIds.has(next.roomId)) {
void this.notifyGroup(next, event)
}
}
}
this.lastCheckedAt = Date.now()
this.save()
this.broadcast()
} finally {
this.checking = false
if (this.checkQueued && this.active) {
this.checkQueued = false
void this.check()
}
}
}
private async readGroups(): Promise<GroupSnapshotRecord[] | null> {
const database = chat.getChatDb()
if (!database) return null
try {
const client = database.getWcdb4Client()
const rawSessions = await client.getSessionsAsync({ hydrateDisplayNames: true })
const sessions = Array.isArray(rawSessions) ? rawSessions : []
const groups = sessions.filter(
(session) =>
session.username?.endsWith('@chatroom') === true &&
(!this.monitorSelectionConfigured || this.monitoredRoomIds.has(session.username))
)
const records: Array<GroupSnapshotRecord | null> = new Array(groups.length).fill(null)
let nextIndex = 0
const workerCount = Math.min(GROUP_READ_CONCURRENCY, groups.length)
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (true) {
const index = nextIndex++
if (index >= groups.length) return
const session = groups[index]
const contactId = client.md5(session.username)
try {
const snapshot = await chat.getGroupSnapshotAsync(contactId)
const groupName = cleanGroupName(session.nickname, session.username)
if (!snapshot || snapshot.roomId !== session.username) {
records[index] = {
contactId,
roomId: session.username,
groupName,
members: [],
membersValid: false
}
continue
}
const rawMembers = (snapshot as { members?: unknown }).members
if (!Array.isArray(rawMembers)) {
console.warn(
`[GroupMonitor] 群成员快照不是数组 roomId=${session.username}; 保留上一份基线`
)
records[index] = {
contactId,
roomId: session.username,
groupName,
members: [],
membersValid: false
}
continue
}
records[index] = {
contactId,
roomId: snapshot.roomId,
groupName: cleanGroupName(session.nickname, snapshot.roomId),
members: rawMembers
.filter((member) => member?.wxid)
.map((member) => ({
wxid: member.wxid,
nickname: member.nickname,
groupNickname: member.groupNickname,
wechatNickname: member.wechatNickname,
remark: member.remark,
avatar: member.avatar
})),
membersValid: true
}
} catch (error) {
console.warn(`[GroupMonitor] 读取群成员失败 roomId=${session.username}:`, error)
records[index] = {
contactId,
roomId: session.username,
groupName: cleanGroupName(session.nickname, session.username),
members: [],
membersValid: false
}
}
}
})
)
return records.filter((record): record is GroupSnapshotRecord => Boolean(record))
} catch (error) {
console.warn('[GroupMonitor] 读取群成员快照失败:', error)
return null
}
}
private replaceSnapshots(groups: GroupSnapshotRecord[]): void {
this.snapshots = new Map(
groups.filter((group) => group.membersValid !== false).map((group) => [group.roomId, group])
)
}
private recordExit(
group: GroupSnapshotRecord,
member: GroupExitMonitorMember,
previousCount: number,
currentCount: number
): GroupExitMonitorEvent | null {
const memberName = groupExitMemberName(member)
const wechatName = String(member.wechatNickname || '').trim()
const groupRemark = String(member.groupNickname || '').trim()
const contactRemark = String(member.remark || '').trim()
const detectedAt = Date.now()
const message = `${memberName}退出了${group.groupName}`
const duplicate = this.events.some(
(event) =>
event.roomId === group.roomId &&
event.memberWxid === member.wxid &&
event.previousCount === previousCount &&
event.currentCount === currentCount &&
Math.abs(event.detectedAt - detectedAt) < DUPLICATE_WINDOW_MS
)
if (duplicate) return null
const event: GroupExitMonitorEvent = {
id: `${group.roomId}:${member.wxid}:${detectedAt}:${this.eventSequence++}`,
contactId: group.contactId,
roomId: group.roomId,
groupName: group.groupName,
memberWxid: member.wxid,
memberName,
wechatName,
groupRemark,
contactRemark,
previousCount,
currentCount,
delta: currentCount - previousCount,
message,
detectedAt
}
this.events = [event, ...this.events].slice(0, MAX_EVENTS)
console.log(
`[GroupMonitor] detected member exit roomId=${group.roomId} member=${member.wxid} ${previousCount}->${currentCount}`
)
return event
}
private async notifyGroup(
group: GroupSnapshotRecord,
event: GroupExitMonitorEvent
): Promise<void> {
try {
const capability = await personalWechatCapabilityService.getPersonalWechatSendCapability()
if (!capability.ready || !capability.capabilities.text) {
console.warn(`[GroupMonitor] 跳过群聊通知 roomId=${group.roomId}: ${capability.message}`)
return
}
// 具体传输方式由发送服务按平台处理。
const result = await personalWechatSendService.send({
type: 'text',
to: group.roomId,
isGroup: true,
text: renderGroupExitMonitorNotification(event, this.notificationTemplate)
})
if (!result.success) {
console.warn(
`[GroupMonitor] 群聊通知发送失败 roomId=${group.roomId}: ${result.error || ''}`
)
}
} catch (error) {
console.warn(
`[GroupMonitor] 群聊通知异常 roomId=${group.roomId}:`,
error instanceof Error ? error.message : String(error)
)
}
}
private filePath(): string {
return path.join(app.getPath('userData'), 'group-exit-monitor.json')
}
private ensureLoaded(): void {
if (this.loaded) return
this.loaded = true
try {
const stored = fs.readJsonSync(this.filePath()) as StoredState
this.events = normalizeEvents(stored.events)
this.lastReadAt = Number(stored.lastReadAt) || 0
this.accountRoot = String(stored.accountRoot || '')
// 没有显式范围时按空范围处理,保留已有选择。
this.monitorSelectionConfigured = true
this.monitoredRoomIds = normalizeRoomIds(stored.monitoredRoomIds || [])
this.notificationRoomIds = new Set(
Array.from(normalizeRoomIds(stored.notificationRoomIds || [])).filter((roomId) =>
this.monitoredRoomIds.has(roomId)
)
)
this.notificationTemplate = normalizeGroupExitNotificationTemplate(
stored.notificationTemplate
)
} catch {
// 首次启动或文件损坏时从空记录开始。
this.events = []
this.lastReadAt = 0
this.monitorSelectionConfigured = true
this.monitoredRoomIds.clear()
this.notificationRoomIds.clear()
this.notificationTemplate = normalizeGroupExitNotificationTemplate(undefined)
}
}
private save(): void {
this.ensureLoaded()
try {
fs.ensureDirSync(path.dirname(this.filePath()))
fs.writeJsonSync(
this.filePath(),
{
accountRoot: this.accountRoot,
events: this.events,
lastReadAt: this.lastReadAt,
monitorSelectionConfigured: this.monitorSelectionConfigured,
monitoredRoomIds: Array.from(this.monitoredRoomIds),
notificationRoomIds: Array.from(this.notificationRoomIds),
notificationTemplate: this.notificationTemplate
},
{ spaces: 2 }
)
} catch (error) {
console.warn('[GroupMonitor] 保存事件失败:', error)
}
}
private broadcast(): void {
const state = this.getState()
for (const window of BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send('group-exit-monitor:state', state)
}
}
}
function normalizeRoomIds(roomIds: string[]): Set<string> {
return new Set(
(Array.isArray(roomIds) ? roomIds : [])
.map((roomId) => String(roomId || '').trim())
.filter((roomId) => roomId.endsWith('@chatroom'))
)
}
function cleanGroupName(value: string | undefined, roomId: string): string {
const name = String(value || '').trim()
return name && name !== roomId && !name.startsWith('wxid_') ? name : roomId
}
function normalizeEvents(
values: Partial<GroupExitMonitorEvent>[] | undefined
): GroupExitMonitorEvent[] {
if (!Array.isArray(values)) return []
const normalized: GroupExitMonitorEvent[] = []
for (const value of values) {
const memberName = String(value.memberName || '').trim()
const groupName = String(value.groupName || '').trim()
const roomId = String(value.roomId || '').trim()
const previousCount = Number(value.previousCount)
const currentCount = Number(value.currentCount)
const detectedAt = Number(value.detectedAt)
// 旧记录没有人数变化,无法确认退群,直接忽略。
if (
!memberName ||
!groupName ||
!roomId ||
!Number.isFinite(previousCount) ||
!Number.isFinite(currentCount) ||
previousCount <= currentCount ||
previousCount <= 0 ||
!Number.isFinite(detectedAt) ||
detectedAt <= 0
) {
continue
}
const memberWxid = String(value.memberWxid || '').trim()
const duplicate = normalized.some(
(event) =>
event.roomId === roomId &&
event.memberWxid === memberWxid &&
event.previousCount === previousCount &&
event.currentCount === currentCount &&
Math.abs(event.detectedAt - detectedAt) < DUPLICATE_WINDOW_MS
)
if (duplicate) continue
normalized.push({
id: String(value.id || `${roomId}:${memberWxid || memberName}:${detectedAt}`),
contactId: String(value.contactId || ''),
roomId,
groupName,
memberWxid,
memberName,
wechatName: String(value.wechatName || '').trim(),
groupRemark: String(value.groupRemark || '').trim(),
contactRemark: String(value.contactRemark || '').trim(),
previousCount,
currentCount,
delta: Number.isFinite(Number(value.delta))
? Number(value.delta)
: currentCount - previousCount,
message: String(value.message || `${memberName}退出了${groupName}`),
detectedAt
})
if (normalized.length >= MAX_EVENTS) break
}
return normalized
}
function isContactEvent(rawPayload: string): boolean {
const payload = String(rawPayload || '').trim()
if (!payload) return false
try {
const parsed = JSON.parse(payload) as { table?: unknown }
return String(parsed.table || '').toLowerCase() === 'contact'
} catch {
return /["']table["']\s*:\s*["']contact["']/i.test(payload)
}
}
export const groupExitMonitorService = new GroupExitMonitorService()
export { GroupExitMonitorService }
+45 -25
View File
@@ -987,6 +987,11 @@ export class Wcdb4Client {
this.sessionDisplayNamesHydrated = false
}
/** 清理群备注缓存,不影响会话缓存。 */
invalidateGroupNicknameCache(): void {
this.groupNicknameCache.clear()
}
getChatTables(): { name: string; db_number: string }[] {
if (this.cachedChatTables) return this.cachedChatTables
const sessions =
@@ -2740,14 +2745,15 @@ export class Wcdb4Client {
])
const content = this.decodeMessageContent(contentRaw, compressRaw)
const contentSender = this.extractSenderFromMessageContent(content)
const sender = this.pickString(row, [
'sender_username',
'senderUsername',
'sender',
'fromUsername',
'from_username',
'WCDB_CT_sender_username'
]) || contentSender
const sender =
this.pickString(row, [
'sender_username',
'senderUsername',
'sender',
'fromUsername',
'from_username',
'WCDB_CT_sender_username'
]) || contentSender
const createTime = this.pickNumber(row, [
'create_time',
'createTime',
@@ -2795,21 +2801,32 @@ export class Wcdb4Client {
'mesDes',
'WCDB_CT_is_send'
])
const parsedIsSend = rawIsSend === undefined || rawIsSend === null
? false
: this.pickBoolean(row, [
'computed_is_send',
'computedIsSend',
'is_send',
'isSend',
'mesDes',
'WCDB_CT_is_send'
])
const isSelfSender = sender ? this.getMyUsernameCandidates().some((candidate) => {
const left = String(candidate || '').trim().toLowerCase()
const right = String(sender || '').trim().toLowerCase()
return Boolean(left && right && (left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`)))
}) : false
const parsedIsSend =
rawIsSend === undefined || rawIsSend === null
? false
: this.pickBoolean(row, [
'computed_is_send',
'computedIsSend',
'is_send',
'isSend',
'mesDes',
'WCDB_CT_is_send'
])
const isSelfSender = sender
? this.getMyUsernameCandidates().some((candidate) => {
const left = String(candidate || '')
.trim()
.toLowerCase()
const right = String(sender || '')
.trim()
.toLowerCase()
return Boolean(
left &&
right &&
(left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`))
)
})
: false
const isSend = isSelfSender || parsedIsSend
return {
@@ -2828,11 +2845,14 @@ export class Wcdb4Client {
/** 解决群里自己的消息被识别成 user */
private extractSenderFromMessageContent(content: string): string {
const firstLine = String(content || '').split(/\r?\n/, 1)[0].trim()
const firstLine = String(content || '')
.split(/\r?\n/, 1)[0]
.trim()
if (!firstLine || firstLine.length > 128 || /[<>]/.test(firstLine)) return ''
const candidate = firstLine.replace(/:$/, '').trim()
if (!candidate || /\s/.test(candidate)) return ''
if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_')) return candidate
if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_'))
return candidate
return this.getMyUsernameCandidates().includes(candidate) ? candidate : ''
}
+11
View File
@@ -79,6 +79,7 @@ import type {
PersonalWechatVoiceEncodingEnvironmentResult
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type {
AppUpdateCheckResult,
AppUpdateInstallResult,
@@ -254,6 +255,16 @@ declare global {
avatar: string
}[]
} | null>
getGroupExitMonitorState: () => Promise<GroupExitMonitorState>
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
) => Promise<GroupExitMonitorState>
setGroupExitMonitorNotificationTemplate: (template: string) => Promise<GroupExitMonitorState>
checkGroupExitMonitorNow: () => Promise<GroupExitMonitorState>
clearGroupExitMonitorEvents: () => Promise<GroupExitMonitorState>
markGroupExitMonitorRead: (readAt?: number) => Promise<GroupExitMonitorState>
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => () => void
search: (keyword: string) => Promise<string | null>
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
+24
View File
@@ -52,6 +52,7 @@ import type {
} from '../shared/personal-wechat-voice-runtime'
import type { AppLogEntry } from '../shared/app-log'
import type { AppUpdateState } from '../shared/app-update'
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
import type { CacheSummary } from '../shared/cache'
import type { ExportRequest, ExportJobProgress } from '../shared/export'
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
@@ -128,6 +129,29 @@ const api = {
options?: { limit?: number }
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
getGroupExitMonitorState: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:getState'),
setGroupExitMonitorGroups: (
roomIds: string[],
notificationRoomIds?: string[]
): Promise<GroupExitMonitorState> =>
notificationRoomIds === undefined
? ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds)
: ipcRenderer.invoke('group-exit-monitor:setGroups', roomIds, notificationRoomIds),
setGroupExitMonitorNotificationTemplate: (template: string): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:setTemplate', template),
checkGroupExitMonitorNow: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:checkNow'),
clearGroupExitMonitorEvents: (): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:clearEvents'),
markGroupExitMonitorRead: (readAt?: number): Promise<GroupExitMonitorState> =>
ipcRenderer.invoke('group-exit-monitor:markRead', readAt),
onGroupExitMonitorState: (callback: (state: GroupExitMonitorState) => void) => {
const listener = (_event: Electron.IpcRendererEvent, state: GroupExitMonitorState): void =>
callback(state)
ipcRenderer.on('group-exit-monitor:state', listener)
return () => ipcRenderer.removeListener('group-exit-monitor:state', listener)
},
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> =>
ipcRenderer.invoke('knowledge:search', request),
+3
View File
@@ -41,6 +41,7 @@ import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform } from './utils/runtime-environment'
import { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -1908,6 +1909,8 @@ function App(): React.ReactElement {
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'exit-monitor':
return <GroupExitMonitorWorkspace dbReady={isDatabaseConnected} contacts={contacts} />
case 'agent-hub':
return <AgentHubWorkspace />
case 'api':
@@ -1,5 +1,6 @@
import React from 'react'
import { AppPage, PRIMARY_NAV_ITEMS } from './navigation'
import type { GroupExitMonitorState } from '../../../../shared/group-exit-monitor'
interface NavIconProps {
page: AppPage
@@ -37,6 +38,14 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M9.5 16.5h4" />
</svg>
)
case 'exit-monitor':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5.5 19V6.5h8V19" />
<path d="M9.5 12h10" />
<path d="m16.5 9 3 3-3 3" />
</svg>
)
case 'export':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -84,6 +93,36 @@ export function PrimaryNavigation({
activePage,
onPageChange
}: PrimaryNavigationProps): React.ReactElement {
const [unreadExitCount, setUnreadExitCount] = React.useState(0)
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.getGroupExitMonitorState !== 'function') return
let disposed = false
const update = (state: GroupExitMonitorState): void => {
if (disposed) return
setUnreadExitCount(activePage === 'exit-monitor' ? 0 : state.unreadCount)
}
void api
.getGroupExitMonitorState()
.then(update)
.catch(() => undefined)
const unsubscribe =
typeof api.onGroupExitMonitorState === 'function' ? api.onGroupExitMonitorState(update) : null
return () => {
disposed = true
unsubscribe?.()
}
}, [activePage])
React.useEffect(() => {
if (activePage !== 'exit-monitor') return
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.markGroupExitMonitorRead !== 'function') return
setUnreadExitCount(0)
void api.markGroupExitMonitorRead().catch(() => undefined)
}, [activePage])
return (
<nav className="primary-navigation" aria-label="一级导航">
{PRIMARY_NAV_ITEMS.map((item) => (
@@ -96,6 +135,9 @@ export function PrimaryNavigation({
>
<span className="primary-nav-mark" aria-hidden>
<NavIcon page={item.id} />
{item.id === 'exit-monitor' && unreadExitCount > 0 && (
<span className="primary-nav-badge">{Math.min(unreadExitCount, 99)}</span>
)}
</span>
<span className="primary-nav-label">{item.label}</span>
</button>
@@ -1,4 +1,12 @@
export type AppPage = 'archive' | 'search' | 'report' | 'agent-hub' | 'export' | 'api' | 'settings'
export type AppPage =
| 'archive'
| 'search'
| 'report'
| 'exit-monitor'
| 'agent-hub'
| 'export'
| 'api'
| 'settings'
export interface NavigationItem {
id: AppPage
@@ -9,6 +17,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' },
{ id: 'search', label: '问问微信' },
{ id: 'report', label: '日报' },
{ id: 'exit-monitor', label: '退群监控' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
@@ -0,0 +1,838 @@
import React from 'react'
import {
Button,
Checkbox,
EmptyState,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger
} from '../../components/ui'
import type { Contact } from '../../../../shared/types'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE,
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
validateGroupExitNotificationTemplate,
type GroupExitMonitorState
} from '../../../../shared/group-exit-monitor'
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
interface GroupExitMonitorWorkspaceProps {
dbReady: boolean
contacts?: Contact[]
}
const EMPTY_STATE: GroupExitMonitorState = {
events: [],
running: false,
nativeMonitorActive: false,
monitoredGroupCount: 0,
monitorSelectionConfigured: true,
monitoredRoomIds: [],
notificationRoomIds: [],
lastReadAt: 0,
unreadCount: 0
}
const formatDetectedAt = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
const dayStart = (timestamp: number): number => {
const date = new Date(timestamp)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
const formatLastChecked = (timestamp?: number): string => {
if (!timestamp) return '尚未检查'
const elapsed = Math.max(0, Date.now() - timestamp)
if (elapsed < 60_000) return '刚刚'
if (elapsed < 60 * 60_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`
return formatDetectedAt(timestamp)
}
const contactDisplayName = (contact: Contact): string =>
contact.m_nsNickName?.trim() || contact.remark?.trim() || contact.m_nsUsrName || '未命名群聊'
const isGroupContact = (contact: Contact): boolean =>
contact.type === 'group' || contact.m_nsUsrName?.trim().endsWith('@chatroom') === true
const contactRoomId = (contact: Contact): string => contact.m_nsUsrName.trim()
const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): string => {
if (!capability) return '发送能力检查中'
if (capability.ready && capability.capabilities.text) return '发送能力已就绪'
if (capability.status === 'unsupported') return '发送能力不可用'
return '发送能力未就绪'
}
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
function SendCapabilityStatus({
capability,
className = ''
}: {
capability: PersonalWechatSendCapability | null
className?: string
}): React.ReactElement {
const label = sendCapabilityLabel(capability)
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`exit-monitor-send-status ${sendCapabilityTone(capability)} ${className}`}
tabIndex={0}
aria-label={label}
>
<span className="exit-monitor-status-dot" aria-hidden="true" />
{label}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{capability?.message || '正在读取微信发送能力'}</TooltipContent>
</Tooltip>
)
}
function GroupExitNotificationTemplate({
template,
saving,
error,
onSave
}: {
template: string
saving: boolean
error: string
onSave: (template: string) => Promise<boolean>
}): React.ReactElement {
const [open, setOpen] = React.useState(false)
const [draft, setDraft] = React.useState(template)
const [validationError, setValidationError] = React.useState('')
React.useEffect(() => {
if (!open) setDraft(template)
}, [open, template])
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
setValidationError('')
if (nextOpen) setDraft(template)
}
const saveDraft = async (): Promise<void> => {
const result = validateGroupExitNotificationTemplate(draft)
if (!result.valid || !result.template) {
setValidationError(result.error || '模板无效')
return
}
setValidationError('')
const saved = await onSave(result.template)
if (saved) setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" aria-label="查看退群监测模板">
模板
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="exit-monitor-template-popover">
<div className="exit-monitor-template-heading">
<div>
<span className="exit-monitor-eyebrow">消息格式</span>
<h2>退群监测模板</h2>
</div>
<span className="exit-monitor-template-status">发送时使用</span>
</div>
<Textarea
id="exit-monitor-notification-template"
aria-label="退群监测模板内容"
value={draft}
rows={10}
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value)}
className="exit-monitor-template-editor"
/>
<div className="exit-monitor-template-meta">
<span>
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{time}`}
</span>
<span>
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
</span>
</div>
<p>退群时会替换占位符;群备注指退群前的群内昵称,没有群备注时显示“未设置”。</p>
{validationError || error ? (
<p className="exit-monitor-template-error">{validationError || error}</p>
) : null}
<div className="exit-monitor-template-actions">
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
{saving ? '保存中...' : '保存模板'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
const name = contactDisplayName(contact)
const initial = name.charAt(0) || '?'
return (
<span className="exit-monitor-group-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" aria-hidden="true" /> : initial}
</span>
)
}
interface ManageGroupsProps {
groups: Contact[]
selectedRoomIds: Set<string>
notificationRoomIds: Set<string>
sendCapability: PersonalWechatSendCapability | null
keyword: string
listFilter: GroupSelectionFilter
saving: boolean
dbReady: boolean
onKeywordChange: (keyword: string) => void
onListFilterChange: (filter: GroupSelectionFilter) => void
onToggle: (roomId: string) => void
onToggleNotification: (roomId: string) => void
onToggleAll: () => void
onSave: () => void
onCancel: () => void
notificationTemplate: string
templateSaving: boolean
templateError: string
onSaveTemplate: (template: string) => Promise<boolean>
}
function ManageGroups({
groups,
selectedRoomIds,
notificationRoomIds,
sendCapability,
keyword,
listFilter,
saving,
dbReady,
onKeywordChange,
onListFilterChange,
onToggle,
onToggleNotification,
onToggleAll,
onSave,
onCancel,
notificationTemplate,
templateSaving,
templateError,
onSaveTemplate
}: ManageGroupsProps): React.ReactElement {
const lowerKeyword = keyword.trim().toLowerCase()
const filteredGroups = React.useMemo(() => {
const keywordGroups = lowerKeyword
? groups.filter((contact) =>
[contactDisplayName(contact), contactRoomId(contact), contact.remark].some((value) =>
value?.toLowerCase().includes(lowerKeyword)
)
)
: groups
return keywordGroups.filter((group) => {
const selected = selectedRoomIds.has(contactRoomId(group))
return listFilter === 'all' || (listFilter === 'selected' ? selected : !selected)
})
}, [groups, listFilter, lowerKeyword, selectedRoomIds])
const allGroupsSelected =
groups.length > 0 && groups.every((group) => selectedRoomIds.has(contactRoomId(group)))
return (
<div className="exit-monitor-manage-page">
<header className="exit-monitor-manage-header">
<div>
<button type="button" className="exit-monitor-back" onClick={onCancel}>
<span aria-hidden="true">‹</span>
返回退群监控
</button>
<span className="exit-monitor-eyebrow">监控范围</span>
<h1>管理群聊</h1>
<p>选择需要持续关注成员变化的群聊,保存后会重新建立成员快照。</p>
</div>
<div className="exit-monitor-manage-header-status">
<GroupExitNotificationTemplate
template={notificationTemplate}
saving={templateSaving}
error={templateError}
onSave={onSaveTemplate}
/>
<SendCapabilityStatus capability={sendCapability} />
<div className="exit-monitor-manage-count" aria-live="polite">
已选择 <strong>{selectedRoomIds.size}</strong> 个群聊
</div>
</div>
</header>
<section
className="exit-monitor-manage-section"
aria-labelledby="exit-monitor-manage-list-title"
>
<div className="exit-monitor-manage-toolbar">
<div>
<span className="exit-monitor-eyebrow">群聊列表</span>
<h2 id="exit-monitor-manage-list-title">选择监控群聊</h2>
</div>
<div className="exit-monitor-manage-tools">
<Select
value={listFilter}
onValueChange={(value) => onListFilterChange(value as GroupSelectionFilter)}
>
<SelectTrigger aria-label="筛选群聊状态" className="exit-monitor-group-filter">
<SelectValue placeholder="筛选状态" />
</SelectTrigger>
<SelectContent className="z-modal">
<SelectItem value="all">全部</SelectItem>
<SelectItem value="selected">已选择</SelectItem>
<SelectItem value="unselected">未选择</SelectItem>
</SelectContent>
</Select>
<label className="sr-only" htmlFor="exit-monitor-group-search">
搜索群聊
</label>
<Input
id="exit-monitor-group-search"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
placeholder="搜索群聊"
className="exit-monitor-group-search"
/>
<Button variant="ghost" size="sm" onClick={onToggleAll} disabled={!groups.length}>
{allGroupsSelected ? '取消全选' : '全选'}
</Button>
</div>
</div>
{!dbReady || !groups.length ? (
<EmptyState
className="exit-monitor-manage-empty"
title={dbReady ? '没有可监控的群聊' : '数据库未连接'}
description={
dbReady
? '当前账号没有读取到群聊,会话列表刷新后可以重新尝试。'
: '连接微信数据库后才能选择监控群聊。'
}
/>
) : filteredGroups.length ? (
<div className="exit-monitor-group-list">
{filteredGroups.map((contact) => {
const roomId = contactRoomId(contact)
const name = contactDisplayName(contact)
const checked = selectedRoomIds.has(roomId)
const notificationChecked = notificationRoomIds.has(roomId)
return (
<div key={roomId} className={`exit-monitor-group-row ${checked ? 'selected' : ''}`}>
<Checkbox
checked={checked}
onCheckedChange={() => onToggle(roomId)}
aria-label={`监控${name}`}
/>
<GroupAvatar contact={contact} />
<span className="exit-monitor-group-row-body">
<strong>{name}</strong>
<span>
{contact.remark && contact.remark !== name ? contact.remark : roomId}
</span>
</span>
<span className="exit-monitor-group-row-status">
{checked ? '监控中' : '未选择'}
</span>
<label className="exit-monitor-group-notification">
<Checkbox
checked={notificationChecked}
disabled={!checked}
onCheckedChange={() => onToggleNotification(roomId)}
aria-label={`是否通知${name}`}
/>
<span>通知群聊</span>
</label>
</div>
)
})}
</div>
) : (
<EmptyState
className="exit-monitor-manage-empty"
title="没有匹配的群聊"
description="换个关键词试试,或清空搜索条件查看全部群聊。"
/>
)}
</section>
<footer className="exit-monitor-manage-footer">
<span>保存后将从新的成员快照开始记录退群动态;通知仅发送到已勾选群聊。</span>
<div>
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={onSave} disabled={!dbReady || saving}>
{saving ? '保存中...' : '保存监控群聊'}
</Button>
</div>
</footer>
</div>
)
}
export function GroupExitMonitorWorkspace({
dbReady,
contacts = []
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
const [checking, setChecking] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState('')
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
null
)
const [templateSaving, setTemplateSaving] = React.useState(false)
const [templateError, setTemplateError] = React.useState('')
const [selectedRoomId, setSelectedRoomId] = React.useState('all')
const [view, setView] = React.useState<'events' | 'manage'>('events')
const [manageKeyword, setManageKeyword] = React.useState('')
const [manageFilter, setManageFilter] = React.useState<GroupSelectionFilter>('all')
const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState<Set<string>>(
() => new Set()
)
const [selectedManageNotificationRoomIds, setSelectedManageNotificationRoomIds] = React.useState<
Set<string>
>(() => new Set())
const groups = React.useMemo(() => {
const seen = new Set<string>()
return contacts.filter(isGroupContact).filter((contact) => {
const roomId = contactRoomId(contact)
if (!roomId || seen.has(roomId)) return false
seen.add(roomId)
return true
})
}, [contacts])
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.getGroupExitMonitorState !== 'function') return
let disposed = false
const update = (next: GroupExitMonitorState): void => {
if (!disposed) setState(next)
}
void api
.getGroupExitMonitorState()
.then(update)
.catch((reason) => {
if (!disposed) setError(reason instanceof Error ? reason.message : String(reason))
})
const unsubscribe =
typeof api.onGroupExitMonitorState === 'function' ? api.onGroupExitMonitorState(update) : null
return () => {
disposed = true
unsubscribe?.()
}
}, [])
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.getPersonalWechatSendCapability !== 'function') return
let disposed = false
void api
.getPersonalWechatSendCapability()
.then((capability) => {
if (!disposed) setSendCapability(capability)
})
.catch(() => {
if (!disposed) setSendCapability(null)
})
return () => {
disposed = true
}
}, [])
const groupOptions = React.useMemo(() => {
const optionMap = new Map<string, { roomId: string; groupName: string; count: number }>()
for (const event of state.events) {
const roomId = event.roomId.trim()
if (!roomId) continue
const existing = optionMap.get(roomId)
if (existing) {
existing.count += 1
} else {
optionMap.set(roomId, {
roomId,
groupName: event.groupName || roomId,
count: 1
})
}
}
return Array.from(optionMap.values()).sort((left, right) =>
left.groupName.localeCompare(right.groupName, 'zh-CN')
)
}, [state.events])
React.useEffect(() => {
if (selectedRoomId === 'all') return
if (!groupOptions.some((group) => group.roomId === selectedRoomId)) setSelectedRoomId('all')
}, [groupOptions, selectedRoomId])
const visibleEvents = React.useMemo(
() =>
selectedRoomId === 'all'
? state.events
: state.events.filter((event) => event.roomId === selectedRoomId),
[selectedRoomId, state.events]
)
const todayExitCount = React.useMemo(() => {
const start = dayStart(Date.now())
return state.events.filter((event) => event.detectedAt >= start).length
}, [state.events])
const setupEmpty =
state.monitorSelectionConfigured === true && !(state.monitoredRoomIds || []).length
const notificationTemplate = state.notificationTemplate || GROUP_EXIT_NOTIFICATION_TEMPLATE
const openManage = (): void => {
const availableRoomIds = new Set(groups.map(contactRoomId))
const defaultIds = state.monitorSelectionConfigured ? state.monitoredRoomIds || [] : []
const selectedIds = new Set(defaultIds.filter((roomId) => availableRoomIds.has(roomId)))
const notificationIds = new Set(
(state.notificationRoomIds || []).filter((roomId) => selectedIds.has(roomId))
)
setSelectedManageRoomIds(selectedIds)
setSelectedManageNotificationRoomIds(notificationIds)
setManageKeyword('')
setManageFilter('all')
setError('')
setTemplateError('')
setView('manage')
}
const checkNow = async (): Promise<void> => {
const api = window.api
if (checking || !dbReady || typeof api.checkGroupExitMonitorNow !== 'function') return
setChecking(true)
setError('')
try {
setState(await api.checkGroupExitMonitorNow())
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setChecking(false)
}
}
const clearEvents = async (): Promise<void> => {
const api = window.api
if (!state.events.length || typeof api.clearGroupExitMonitorEvents !== 'function') return
setError('')
try {
setState(await api.clearGroupExitMonitorEvents())
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const saveGroups = async (): Promise<void> => {
const api = window.api
if (saving || !dbReady || typeof api.setGroupExitMonitorGroups !== 'function') return
setSaving(true)
setError('')
try {
const selectedIds = groups
.map(contactRoomId)
.filter((roomId) => selectedManageRoomIds.has(roomId))
const notificationIds = selectedIds.filter((roomId) =>
selectedManageNotificationRoomIds.has(roomId)
)
const nextState = await api.setGroupExitMonitorGroups(selectedIds, notificationIds)
setState(nextState)
setView('events')
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setSaving(false)
}
}
const saveNotificationTemplate = async (template: string): Promise<boolean> => {
const api = window.api
if (templateSaving || typeof api.setGroupExitMonitorNotificationTemplate !== 'function') {
setTemplateError('当前版本不支持保存退群监测模板')
return false
}
setTemplateSaving(true)
setTemplateError('')
try {
const nextState = await api.setGroupExitMonitorNotificationTemplate(template)
setState(nextState)
return true
} catch (reason) {
setTemplateError(reason instanceof Error ? reason.message : String(reason))
return false
} finally {
setTemplateSaving(false)
}
}
const toggleManageGroup = (roomId: string): void => {
const wasSelected = selectedManageRoomIds.has(roomId)
setSelectedManageRoomIds((current) => {
const next = new Set(current)
if (next.has(roomId)) next.delete(roomId)
else next.add(roomId)
return next
})
if (wasSelected) {
setSelectedManageNotificationRoomIds((notifications) => {
const nextNotifications = new Set(notifications)
nextNotifications.delete(roomId)
return nextNotifications
})
}
}
const toggleAllManageGroups = (): void => {
const allRoomIds = groups.map(contactRoomId)
const shouldSelect = allRoomIds.some((roomId) => !selectedManageRoomIds.has(roomId))
setSelectedManageRoomIds((current) => {
const next = new Set(current)
allRoomIds.forEach((roomId) => {
if (shouldSelect) next.add(roomId)
else next.delete(roomId)
})
return next
})
if (!shouldSelect) setSelectedManageNotificationRoomIds(new Set())
}
const toggleManageNotification = (roomId: string): void => {
if (!selectedManageRoomIds.has(roomId)) return
setSelectedManageNotificationRoomIds((current) => {
const next = new Set(current)
if (next.has(roomId)) next.delete(roomId)
else next.add(roomId)
return next
})
}
if (view === 'manage') {
return (
<ManageGroups
groups={groups}
selectedRoomIds={selectedManageRoomIds}
notificationRoomIds={selectedManageNotificationRoomIds}
sendCapability={sendCapability}
keyword={manageKeyword}
listFilter={manageFilter}
saving={saving}
dbReady={dbReady}
onKeywordChange={setManageKeyword}
onListFilterChange={setManageFilter}
onToggle={toggleManageGroup}
onToggleNotification={toggleManageNotification}
onToggleAll={toggleAllManageGroups}
onSave={() => void saveGroups()}
onCancel={() => setView('events')}
notificationTemplate={notificationTemplate}
templateSaving={templateSaving}
templateError={templateError}
onSaveTemplate={saveNotificationTemplate}
/>
)
}
const statusLabel = !dbReady
? '数据库未连接'
: state.running && state.nativeMonitorActive
? '实时监听已启用'
: state.running
? '已连接,等待原生监听'
: '等待启动'
return (
<div className="exit-monitor-page">
<header className="exit-monitor-header">
<div>
<span className="exit-monitor-eyebrow">群成员变化</span>
<h1>退群监控</h1>
<p>实时关注微信群成员变化,成员数量减少时自动留下退群动态。</p>
</div>
<div className="exit-monitor-header-actions">
<span className={`exit-monitor-status ${state.running ? 'running' : ''}`}>
<span className="exit-monitor-status-dot" aria-hidden="true" />
{statusLabel}
</span>
<SendCapabilityStatus capability={sendCapability} />
<Button variant="outline" size="sm" onClick={openManage}>
管理群聊
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
disabled={!dbReady || checking}
>
{checking ? '检查中...' : '立即检查'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void clearEvents()}
disabled={!state.events.length}
>
清空记录
</Button>
</div>
</header>
{setupEmpty ? (
<section className="exit-monitor-setup-empty" aria-labelledby="exit-monitor-setup-title">
<div className="exit-monitor-setup-icon" aria-hidden="true">
<svg viewBox="0 0 48 48" focusable="false">
<path d="M10 13.5h28v22H10z" />
<path d="M16 20h16M16 26h10" />
<path d="M34 9v9M29.5 13.5h9" />
</svg>
</div>
<h2 id="exit-monitor-setup-title">还没有设置监控群聊</h2>
<p>选择需要关注的群聊,TraceMemo 会在成员数量变化时及时提醒你。</p>
<Button onClick={openManage}>选择群聊</Button>
</section>
) : (
<>
<div className="exit-monitor-summary" aria-label="监控状态">
<div>
<strong>
{state.monitorSelectionConfigured
? (state.monitoredRoomIds || []).length
: state.monitoredGroupCount}
</strong>
<span>已监控群聊</span>
</div>
<div>
<strong>{todayExitCount}</strong>
<span>今日退群</span>
</div>
<div>
<strong>{formatLastChecked(state.lastCheckedAt)}</strong>
<span>最近检查</span>
</div>
</div>
{error ? <p className="exit-monitor-error">{error}</p> : null}
<section className="exit-monitor-events" aria-labelledby="exit-monitor-events-title">
<div className="exit-monitor-section-heading">
<div>
<span className="exit-monitor-eyebrow">实时事件</span>
<h2 id="exit-monitor-events-title">退群动态</h2>
</div>
<div className="exit-monitor-section-tools">
{state.unreadCount > 0 ? (
<span className="exit-monitor-unread">{state.unreadCount} 条未读</span>
) : null}
<Select
value={selectedRoomId}
onValueChange={setSelectedRoomId}
disabled={!groupOptions.length}
>
<SelectTrigger aria-label="筛选退群群聊" className="exit-monitor-filter-trigger">
<SelectValue placeholder="筛选群聊" />
</SelectTrigger>
<SelectContent className="z-modal">
<SelectItem value="all">全部有退群记录的群聊</SelectItem>
{groupOptions.map((group) => (
<SelectItem key={group.roomId} value={group.roomId}>
{group.groupName}({group.count} 条)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="exit-monitor-event-list">
{visibleEvents.length ? (
visibleEvents.map((event) => (
<article key={event.id} className="exit-monitor-event">
<span className="exit-monitor-event-dot" aria-hidden="true" />
<div className="exit-monitor-event-body">
<div className="exit-monitor-event-heading">
<strong>{event.message}</strong>
<time dateTime={new Date(event.detectedAt).toISOString()}>
{formatDetectedAt(event.detectedAt)}
</time>
</div>
{event.previousCount > event.currentCount ? (
<p className="exit-monitor-event-count">
群人数 {event.previousCount} 人 → {event.currentCount} 人
</p>
) : null}
<dl className="exit-monitor-event-details">
<div>
<dt>微信名</dt>
<dd>{event.wechatName || '未读取到'}</dd>
</div>
<div>
<dt>群备注</dt>
<dd>{event.groupRemark || '未设置'}</dd>
</div>
{event.contactRemark ? (
<div>
<dt>通讯录备注</dt>
<dd>{event.contactRemark}</dd>
</div>
) : null}
<div>
<dt>微信号</dt>
<dd>{event.memberWxid || '未读取到'}</dd>
</div>
<div>
<dt>退群时间</dt>
<dd>{formatDetectedAt(event.detectedAt)}</dd>
</div>
</dl>
</div>
</article>
))
) : (
<EmptyState
className="exit-monitor-events-empty"
title={state.events.length ? '该群暂无退群记录' : '暂无退群记录'}
description={
state.events.length && selectedRoomId !== 'all'
? '请选择其他有退群记录的群聊。'
: dbReady
? '成员数量减少后,退群动态会自动显示在这里。'
: '连接微信数据库后,退群事件会显示在这里。'
}
action={
!state.events.length && dbReady ? (
<Button variant="outline" size="sm" onClick={() => void checkNow()}>
立即检查
</Button>
) : undefined
}
/>
)}
</div>
</section>
</>
)}
</div>
)
}
@@ -0,0 +1,713 @@
.primary-nav-mark {
position: relative;
}
.primary-nav-badge {
position: absolute;
right: -9px;
top: -7px;
box-sizing: border-box;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 2px solid var(--wxex-bg-sidebar);
border-radius: 999px;
background: var(--wxex-danger);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 13px;
text-align: center;
}
.exit-monitor-page,
.exit-monitor-manage-page {
box-sizing: border-box;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 26px;
overflow: auto;
padding: 34px clamp(22px, 4vw, 64px) 44px;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
}
.exit-monitor-header,
.exit-monitor-manage-header,
.exit-monitor-section-heading,
.exit-monitor-manage-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.exit-monitor-header h1,
.exit-monitor-manage-header h1,
.exit-monitor-section-heading h2,
.exit-monitor-manage-toolbar h2 {
margin: 3px 0 0;
color: var(--wxex-text-primary);
font-weight: 700;
}
.exit-monitor-header h1,
.exit-monitor-manage-header h1 {
font-size: 26px;
line-height: 34px;
}
.exit-monitor-section-heading h2,
.exit-monitor-manage-toolbar h2 {
font-size: 18px;
line-height: 26px;
}
.exit-monitor-header p,
.exit-monitor-manage-header p {
max-width: 620px;
margin: 7px 0 0;
color: var(--wxex-text-secondary);
font-size: 13px;
line-height: 20px;
}
.exit-monitor-eyebrow {
color: var(--wxex-text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
line-height: 16px;
text-transform: uppercase;
}
.exit-monitor-header-actions,
.exit-monitor-section-tools,
.exit-monitor-manage-tools {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.exit-monitor-status,
.exit-monitor-send-status,
.exit-monitor-unread,
.exit-monitor-manage-count {
display: inline-flex;
min-height: 28px;
box-sizing: border-box;
align-items: center;
border: 1px solid var(--wxex-border);
border-radius: 999px;
padding: 4px 10px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-status.running {
border-color: color-mix(in srgb, var(--wxex-success) 45%, var(--wxex-border));
color: var(--wxex-success);
}
.exit-monitor-send-status.ready {
border-color: color-mix(in srgb, var(--wxex-success) 45%, var(--wxex-border));
color: var(--wxex-success);
}
.exit-monitor-send-status.unready {
border-color: var(--wxex-border);
background: color-mix(in srgb, var(--wxex-bg-elevated) 72%, transparent);
color: var(--wxex-text-muted);
}
.exit-monitor-status-dot {
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 999px;
background: currentColor;
}
.exit-monitor-unread {
border-color: color-mix(in srgb, var(--wxex-danger) 45%, var(--wxex-border));
color: var(--wxex-danger);
}
.exit-monitor-manage-count {
align-self: flex-start;
border-color: color-mix(in srgb, var(--wxex-brand) 30%, var(--wxex-border));
background: color-mix(in srgb, var(--wxex-brand-soft) 52%, transparent);
color: var(--wxex-text-secondary);
}
.exit-monitor-manage-header-status {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 8px;
}
.exit-monitor-template-popover {
width: min(390px, calc(100vw - 32px));
}
.exit-monitor-template-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.exit-monitor-template-heading h2 {
margin: 2px 0 0;
color: var(--wxex-text-primary);
font-size: 15px;
font-weight: 700;
line-height: 22px;
}
.exit-monitor-template-status {
flex: 0 0 auto;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-editor {
margin: 14px 0 0;
min-height: 190px;
resize: vertical;
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 20px;
}
.exit-monitor-template-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-popover p {
margin: 10px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-error {
color: var(--wxex-danger) !important;
}
.exit-monitor-template-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.exit-monitor-manage-count strong {
margin: 0 4px;
color: var(--wxex-brand);
font-weight: 700;
}
.exit-monitor-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid var(--wxex-border);
border-bottom: 1px solid var(--wxex-border);
}
.exit-monitor-summary > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
padding: 15px 20px;
border-right: 1px solid var(--wxex-border);
}
.exit-monitor-summary > div:last-child {
border-right: 0;
}
.exit-monitor-summary strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-summary span {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-error {
margin: -12px 0 0;
color: var(--wxex-danger);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-events {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 14px;
}
.exit-monitor-event-list {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
}
.exit-monitor-event {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
gap: 14px;
box-sizing: border-box;
padding: 16px 18px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.exit-monitor-event-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 999px;
background: var(--wxex-danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--wxex-danger) 12%, transparent);
}
.exit-monitor-event-body {
min-width: 0;
}
.exit-monitor-event-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.exit-monitor-event-heading strong {
display: block;
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-event-heading time {
flex: 0 0 auto;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-event-count {
display: block;
margin: 5px 0 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-event-count span {
padding: 0 4px;
color: var(--wxex-danger);
}
.exit-monitor-event-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px 24px;
margin: 12px 0 0;
padding-top: 11px;
border-top: 1px solid var(--wxex-border);
}
.exit-monitor-event-details > div {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: baseline;
}
.exit-monitor-event-details dt,
.exit-monitor-event-details dd {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-event-details dt {
color: var(--wxex-text-muted);
}
.exit-monitor-event-details dd {
margin: 0;
color: var(--wxex-text-secondary);
}
.exit-monitor-events-empty,
.exit-monitor-manage-empty {
width: 100%;
min-height: 220px;
border-color: var(--wxex-border);
border-style: solid;
background: color-mix(in srgb, var(--wxex-bg-elevated) 62%, transparent);
}
.exit-monitor-setup-empty {
display: flex;
min-height: 380px;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 24px;
text-align: center;
}
.exit-monitor-setup-icon {
display: grid;
width: 64px;
height: 64px;
place-items: center;
margin-bottom: 4px;
border: 1px solid color-mix(in srgb, var(--wxex-brand) 25%, var(--wxex-border));
border-radius: 16px;
background: color-mix(in srgb, var(--wxex-brand-soft) 70%, transparent);
color: var(--wxex-brand);
}
.exit-monitor-setup-icon svg {
width: 34px;
height: 34px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.6;
}
.exit-monitor-setup-empty h2 {
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
line-height: 26px;
}
.exit-monitor-setup-empty p {
max-width: 420px;
color: var(--wxex-text-secondary);
font-size: 13px;
line-height: 20px;
}
.exit-monitor-back {
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
margin: 0 0 16px;
padding: 0;
border: 0;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 12px;
line-height: 18px;
}
.exit-monitor-back:hover {
color: var(--wxex-brand);
}
.exit-monitor-back span {
font-size: 20px;
line-height: 16px;
}
.exit-monitor-manage-page {
gap: 24px;
}
.exit-monitor-manage-section {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
}
.exit-monitor-manage-tools {
align-items: flex-start;
}
.exit-monitor-group-search {
width: min(260px, 42vw);
min-width: 180px;
}
.exit-monitor-group-filter {
width: 112px;
}
.exit-monitor-group-list {
min-width: 0;
overflow: auto;
border-top: 1px solid var(--wxex-border);
border-bottom: 1px solid var(--wxex-border);
}
.exit-monitor-group-row {
display: grid;
min-width: 0;
grid-template-columns: 18px 40px minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 13px 14px;
border-bottom: 1px solid var(--wxex-border);
background: transparent;
transition: background-color 120ms ease;
}
.exit-monitor-group-row:last-child {
border-bottom: 0;
}
.exit-monitor-group-row:hover {
background: color-mix(in srgb, var(--wxex-brand-soft) 35%, transparent);
}
.exit-monitor-group-row.selected {
background: color-mix(in srgb, var(--wxex-brand-soft) 62%, transparent);
}
.exit-monitor-group-avatar {
display: grid;
width: 40px;
height: 40px;
place-items: center;
overflow: hidden;
border-radius: 10px;
background: color-mix(in srgb, var(--wxex-brand-soft) 78%, var(--wxex-bg-elevated));
color: var(--wxex-brand);
font-size: 16px;
font-weight: 700;
}
.exit-monitor-group-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.exit-monitor-group-row-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.exit-monitor-group-row-body strong,
.exit-monitor-group-row-body span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-group-row-body strong {
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 20px;
}
.exit-monitor-group-row-body span {
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 17px;
}
.exit-monitor-group-row-status {
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-group-row.selected .exit-monitor-group-row-status {
color: var(--wxex-brand);
}
.exit-monitor-group-notification {
display: inline-flex;
min-width: 82px;
align-items: center;
justify-content: flex-end;
gap: 7px;
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-group-notification:has([data-disabled]) {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.65;
}
.exit-monitor-manage-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-top: 16px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-manage-footer > div {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
@media (max-width: 760px) {
.exit-monitor-page,
.exit-monitor-manage-page {
padding: 24px 16px 30px;
}
.exit-monitor-header,
.exit-monitor-manage-header,
.exit-monitor-section-heading,
.exit-monitor-manage-toolbar {
flex-direction: column;
}
.exit-monitor-header-actions,
.exit-monitor-section-tools,
.exit-monitor-manage-tools {
width: 100%;
justify-content: flex-start;
}
.exit-monitor-manage-count {
align-self: flex-start;
}
.exit-monitor-manage-header-status {
justify-content: flex-start;
}
.exit-monitor-filter-trigger {
min-width: min(100%, 240px);
}
.exit-monitor-summary {
grid-template-columns: 1fr;
}
.exit-monitor-summary > div {
border-right: 0;
border-bottom: 1px solid var(--wxex-border);
}
.exit-monitor-summary > div:last-child {
border-bottom: 0;
}
.exit-monitor-event-heading {
display: block;
}
.exit-monitor-event-heading time {
display: block;
margin-top: 3px;
}
.exit-monitor-event-details {
grid-template-columns: 1fr;
}
.exit-monitor-group-search {
width: min(100%, 320px);
}
.exit-monitor-group-filter {
width: min(100%, 160px);
}
.exit-monitor-group-row {
grid-template-columns: 18px 40px minmax(0, 1fr) auto;
gap: 8px 10px;
}
.exit-monitor-group-row-status {
grid-column: 3;
grid-row: 2;
}
.exit-monitor-group-notification {
grid-column: 4;
grid-row: 2;
min-width: 0;
}
.exit-monitor-manage-footer {
align-items: flex-start;
flex-direction: column;
}
.exit-monitor-manage-footer > div {
width: 100%;
justify-content: flex-end;
}
}
+1
View File
@@ -16,4 +16,5 @@
@use './settings-advanced';
@use './settings-preferences';
@use './settings-text-to-speech';
@use './group-exit-monitor';
@use './theme';
+163
View File
@@ -0,0 +1,163 @@
export interface GroupExitMonitorMember {
wxid: string
nickname?: string
groupNickname?: string
wechatNickname?: string
remark?: string
avatar?: string
}
export interface GroupExitMonitorEvent {
id: string
contactId: string
roomId: string
groupName: string
memberWxid: string
memberName: string
/** 联系人表中的微信昵称。 */
wechatName?: string
/** 成员在本群的备注。 */
groupRemark?: string
/** 通讯录备注。 */
contactRemark?: string
previousCount: number
currentCount: number
delta: number
message: string
detectedAt: number
}
/** 管理页预览和自动通知共用的模板。 */
export const GROUP_EXIT_NOTIFICATION_TEMPLATE = [
'[退群监测]',
'',
'用户: {user}',
'',
'群备注: {groupRemark}',
'',
'微信号: {wxid}',
'',
'退群时间: {time}'
].join('\n')
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH = 2_000
export const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS = [
'user',
'groupRemark',
'wxid',
'time'
] as const
export interface GroupExitNotificationTemplateValidation {
valid: boolean
template?: string
error?: string
}
const GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDER_PATTERN = /\{([^{}]+)\}/g
const supportedTemplatePlaceholders = new Set<string>(GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDERS)
export function validateGroupExitNotificationTemplate(
value: unknown
): GroupExitNotificationTemplateValidation {
const template = typeof value === 'string' ? value.replace(/\r\n?/g, '\n').trim() : ''
if (!template) return { valid: false, error: '模板不能为空' }
if (template.length > GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH) {
return {
valid: false,
error: `模板不能超过 ${GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH} 个字符`
}
}
const unsupported = Array.from(
template.matchAll(GROUP_EXIT_NOTIFICATION_TEMPLATE_PLACEHOLDER_PATTERN),
(match) => match[1]
).filter((placeholder) => !supportedTemplatePlaceholders.has(placeholder))
if (unsupported.length) {
return { valid: false, error: `不支持的占位符: ${Array.from(new Set(unsupported)).join(', ')}` }
}
return { valid: true, template }
}
export function normalizeGroupExitNotificationTemplate(value: unknown): string {
const result = validateGroupExitNotificationTemplate(value)
return result.valid && result.template ? result.template : GROUP_EXIT_NOTIFICATION_TEMPLATE
}
export function formatGroupExitMonitorTime(timestamp: number): string {
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) return '未知'
const pad = (value: number): string => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(
date.getHours()
)}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
/** 根据模板生成通知文本。 */
export function renderGroupExitMonitorNotification(
event: GroupExitMonitorEvent,
template = GROUP_EXIT_NOTIFICATION_TEMPLATE
): string {
const user =
event.wechatName?.trim() || event.memberName?.trim() || event.memberWxid || '未读取到'
const groupRemark = event.groupRemark?.trim() || '未设置'
const wxid = event.memberWxid?.trim() || '未读取到'
const values = {
user,
groupRemark,
wxid,
time: formatGroupExitMonitorTime(event.detectedAt)
}
return normalizeGroupExitNotificationTemplate(template).replace(
/\{(user|groupRemark|wxid|time)\}/g,
(placeholder) => {
if (placeholder === '{user}') return values.user
if (placeholder === '{groupRemark}') return values.groupRemark
if (placeholder === '{wxid}') return values.wxid
return values.time
}
)
}
export interface GroupExitMonitorState {
events: GroupExitMonitorEvent[]
running: boolean
nativeMonitorActive: boolean
monitoredGroupCount: number
/** 是否已设置监控范围。 */
monitorSelectionConfigured?: boolean
/** 管理页选中的群。 */
monitoredRoomIds?: string[]
/** 需要发送通知的群。 */
notificationRoomIds?: string[]
/** 自动通知模板。 */
notificationTemplate?: string
lastCheckedAt?: number
lastReadAt: number
unreadCount: number
}
export function groupExitMemberName(member: GroupExitMonitorMember): string {
return (
member.groupNickname?.trim() ||
member.wechatNickname?.trim() ||
member.remark?.trim() ||
member.nickname?.trim() ||
member.wxid
)
}
/** 返回前一份快照中已经不在当前快照的成员。 */
export function findRemovedGroupMembers(
previous: GroupExitMonitorMember[],
next: GroupExitMonitorMember[]
): GroupExitMonitorMember[] {
const nextWxids = new Set(next.map((member) => member.wxid).filter(Boolean))
const seen = new Set<string>()
return previous.filter(
(member) =>
Boolean(member.wxid) &&
!nextWxids.has(member.wxid) &&
!seen.has(member.wxid) &&
seen.add(member.wxid)
)
}
@@ -0,0 +1,331 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import type { ReactElement } from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace'
import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor'
import type { Contact } from '../../src/shared/types'
import { TooltipProvider } from '../../src/renderer/src/components/ui'
const state: GroupExitMonitorState = {
events: [
{
id: 'event-1',
contactId: 'group-md5',
roomId: '研发群@chatroom',
groupName: '研发群',
memberWxid: 'wxid_alice',
memberName: '小艾',
wechatName: '小艾微信名',
groupRemark: '小艾群备注',
contactRemark: '小艾通讯录备注',
previousCount: 240,
currentCount: 239,
delta: -1,
message: '小艾退出了研发群',
detectedAt: 1_756_600_000_000
},
{
id: 'event-2',
contactId: 'group-design-md5',
roomId: '设计群@chatroom',
groupName: '设计群',
memberWxid: 'wxid_bob',
memberName: '小博',
wechatName: '小博微信名',
groupRemark: '',
previousCount: 80,
currentCount: 79,
delta: -1,
message: '小博退出了设计群',
detectedAt: 1_756_600_100_000
}
],
running: true,
nativeMonitorActive: true,
monitoredGroupCount: 3,
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom', '设计群@chatroom'],
notificationRoomIds: [],
lastCheckedAt: 1_756_600_000_000,
lastReadAt: 0,
unreadCount: 1
}
const groups: Contact[] = [
{
m_nsUsrName: '研发群@chatroom',
m_nsNickName: '研发群',
md5: 'group-md5',
type: 'group'
},
{
m_nsUsrName: '设计群@chatroom',
m_nsNickName: '设计群',
md5: 'group-design-md5',
type: 'group'
}
]
describe('GroupExitMonitorWorkspace', () => {
const renderWorkspace = (element: ReactElement): void => {
render(<TooltipProvider>{element}</TooltipProvider>)
}
beforeEach(() => {
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue({ ...state, events: [] })
} as typeof window.api
})
it('renders a semantic exit notification and member count delta', async () => {
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
await waitFor(() => expect(screen.getByText('小艾退出了研发群')).toBeVisible())
expect(screen.getByText(/240 人 → 239 人/)).toBeVisible()
expect(screen.getByText('小艾微信名')).toBeVisible()
expect(screen.getByText('小艾群备注')).toBeVisible()
expect(screen.getByText('小艾通讯录备注')).toBeVisible()
expect(screen.getByText('wxid_alice')).toBeVisible()
expect(screen.getByText('实时监听已启用')).toBeVisible()
})
it('filters to groups that have exit events and clears the history', async () => {
const user = userEvent.setup()
renderWorkspace(<GroupExitMonitorWorkspace dbReady />)
await waitFor(() => expect(screen.getByText('小博退出了设计群')).toBeVisible())
await user.click(screen.getByRole('combobox', { name: '筛选退群群聊' }))
await user.click(await screen.findByRole('option', { name: /研发群/ }))
expect(screen.getByText('小艾退出了研发群')).toBeVisible()
expect(screen.queryByText('小博退出了设计群')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '清空记录' }))
await waitFor(() => expect(screen.queryByText('小艾退出了研发群')).not.toBeInTheDocument())
expect(screen.getByText('暂无退群记录')).toBeVisible()
})
it('opens the management view, selects a group, and saves the monitoring scope', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
const savedState: GroupExitMonitorState = {
...configuredState,
monitoredRoomIds: ['研发群@chatroom'],
monitoredGroupCount: 1
}
const setGroups = vi.fn().mockResolvedValue(savedState)
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(configuredState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorGroups: setGroups,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(savedState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(savedState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await waitFor(() => expect(screen.getByText('还没有设置监控群聊')).toBeVisible())
await user.click(screen.getByRole('button', { name: '选择群聊' }))
expect(screen.getByRole('heading', { name: '管理群聊' })).toBeVisible()
await user.click(screen.getByRole('checkbox', { name: '监控研发群' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() => expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], []))
expect(screen.getByRole('heading', { name: '退群监控' })).toBeVisible()
expect(screen.getByText('已监控群聊')).toBeVisible()
})
it('filters group rows by selection and persists notification targets separately', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: ['研发群@chatroom'],
notificationRoomIds: [],
monitoredGroupCount: 1,
unreadCount: 0
}
const savedState: GroupExitMonitorState = {
...configuredState,
notificationRoomIds: ['研发群@chatroom']
}
const setGroups = vi.fn().mockResolvedValue(savedState)
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(configuredState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue({
supported: true,
ready: true,
status: 'ready',
capabilities: { text: true, image: true, voice: true },
senderStatus: {} as never,
message: '个人微信已准备好发送日报'
}),
setGroupExitMonitorGroups: setGroups,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(savedState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(savedState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
await user.click(screen.getByRole('combobox', { name: '筛选群聊状态' }))
await user.click(await screen.findByRole('option', { name: '未选择' }))
expect(screen.getByRole('checkbox', { name: '监控设计群' })).toBeVisible()
expect(screen.queryByRole('checkbox', { name: '监控研发群' })).not.toBeInTheDocument()
await user.click(screen.getByRole('combobox', { name: '筛选群聊状态' }))
await user.click(await screen.findByRole('option', { name: '全部' }))
await user.click(screen.getByRole('checkbox', { name: '是否通知研发群' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() =>
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom'], ['研发群@chatroom'])
)
})
it('selects every group even when a search term is active', async () => {
const user = userEvent.setup()
const configuredState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: true,
monitoredRoomIds: [],
notificationRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
const setGroups = vi.fn().mockResolvedValue({ ...configuredState, monitoredGroupCount: 2 })
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(configuredState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorGroups: setGroups,
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(configuredState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(configuredState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '选择群聊' }))
await user.type(screen.getByRole('textbox', { name: '搜索群聊' }), '研发')
await user.click(screen.getByRole('button', { name: '全选' }))
await user.click(screen.getByRole('button', { name: '保存监控群聊' }))
await waitFor(() =>
expect(setGroups).toHaveBeenCalledWith(['研发群@chatroom', '设计群@chatroom'], [])
)
})
it('shows the send capability and explains an unavailable state on hover', async () => {
const user = userEvent.setup()
const capability = {
supported: true,
ready: false,
status: 'needs_binding' as const,
capabilities: { text: false, image: false, voice: false },
senderStatus: {} as never,
message: '请先绑定个人微信'
}
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(capability),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
const status = screen.getByText('发送能力未就绪', { exact: true })
expect(status).toHaveClass('unready')
await user.hover(status)
await waitFor(() => expect(screen.getByRole('tooltip')).toHaveTextContent('请先绑定个人微信'))
})
it('shows the notification template from the management page header', async () => {
const user = userEvent.setup()
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue({
supported: true,
ready: false,
status: 'needs_binding',
capabilities: { text: false, image: false, voice: false },
senderStatus: {} as never,
message: '请先绑定个人微信'
}),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
expect(screen.getByRole('heading', { name: '退群监测模板' })).toBeVisible()
const template = screen.getByLabelText('退群监测模板内容')
expect((template as HTMLTextAreaElement).value).toContain('用户: {user}')
expect((template as HTMLTextAreaElement).value).toContain('群备注: {groupRemark}')
})
it('edits and persists the notification template', async () => {
const user = userEvent.setup()
const savedTemplate = '[退群监测]\n用户: {user}\n群: {groupRemark}'
const setTemplate = vi.fn().mockResolvedValue({ ...state, notificationTemplate: savedTemplate })
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(state),
onGroupExitMonitorState: vi.fn(() => () => undefined),
getPersonalWechatSendCapability: vi.fn().mockResolvedValue(null),
setGroupExitMonitorNotificationTemplate: setTemplate,
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(state),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
await user.click(screen.getByRole('button', { name: '查看退群监测模板' }))
const template = screen.getByLabelText('退群监测模板内容')
fireEvent.change(template, { target: { value: savedTemplate } })
await user.click(screen.getByRole('button', { name: '保存模板' }))
await waitFor(() => expect(setTemplate).toHaveBeenCalledWith(savedTemplate))
expect(screen.queryByRole('heading', { name: '退群监测模板' })).not.toBeInTheDocument()
})
it('leaves every monitoring checkbox off for a new installation', async () => {
const user = userEvent.setup()
const newInstallState: GroupExitMonitorState = {
...state,
events: [],
monitorSelectionConfigured: false,
monitoredRoomIds: [],
notificationRoomIds: [],
monitoredGroupCount: 0,
unreadCount: 0
}
window.api = {
getGroupExitMonitorState: vi.fn().mockResolvedValue(newInstallState),
onGroupExitMonitorState: vi.fn(() => () => undefined),
setGroupExitMonitorGroups: vi.fn().mockResolvedValue(newInstallState),
checkGroupExitMonitorNow: vi.fn().mockResolvedValue(newInstallState),
clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(newInstallState)
} as typeof window.api
renderWorkspace(<GroupExitMonitorWorkspace dbReady contacts={groups} />)
await user.click(await screen.findByRole('button', { name: '管理群聊' }))
expect(screen.getByRole('checkbox', { name: '监控研发群' })).not.toBeChecked()
expect(screen.getByRole('checkbox', { name: '监控设计群' })).not.toBeChecked()
})
})
+50 -1
View File
@@ -117,7 +117,7 @@ test('KEY-03 changing one key does not invalidate archive data or unrelated sett
test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => {
const fixture = await launchTestApp()
const labels = ['档案', '问问微信', '日报', 'Agent', '导出', 'API', '设置']
const labels = ['档案', '问问微信', '日报', '退群监控', 'Agent', '导出', 'API', '设置']
try {
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
await expect(navigation).toBeVisible()
@@ -131,6 +131,55 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
}
})
test('NAV-03 exit monitor page shows a member departure event and stays within the viewport', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize({ width: 820, height: 600 })
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await expect(fixture.page.getByRole('heading', { name: '退群监控', exact: true })).toBeVisible()
await expect(fixture.page.getByText('测试成员退出了产品测试群', { exact: true })).toBeVisible()
await expect(fixture.page.getByText(/240 人 → 239 人/)).toBeVisible()
await expect(fixture.page.getByText('实时监听已启用')).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '立即检查' })).toBeEnabled()
await expect(fixture.page.getByRole('button', { name: '清空记录' })).toBeEnabled()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
} finally {
await fixture.close()
}
})
test('NAV-04 exit monitor management can save an empty scope and show the setup state', async () => {
const fixture = await launchTestApp()
try {
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
await expect(fixture.page.getByRole('heading', { name: '管理群聊', exact: true })).toBeVisible()
await expect(fixture.page.getByText('发送能力已就绪', { exact: true })).toBeVisible()
await expect(fixture.page.getByRole('checkbox', { name: '监控产品测试群' })).toBeChecked()
await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click()
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/用户: \{user\}/)
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(/群备注: \{groupRemark\}/)
const customTemplate = '[退群监测]\n用户: {user}\n群备注: {groupRemark}'
await fixture.page.getByLabel('退群监测模板内容').fill(customTemplate)
await fixture.page.getByRole('button', { name: '保存模板' }).click()
await fixture.page.getByRole('button', { name: '查看退群监测模板' }).click()
await expect(fixture.page.getByLabel('退群监测模板内容')).toHaveValue(customTemplate)
await fixture.page.keyboard.press('Escape')
await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click()
await fixture.page.getByRole('checkbox', { name: '监控折叠群聊样本' }).click()
await fixture.page.getByRole('button', { name: '保存监控群聊' }).click()
await expect(fixture.page.getByText('还没有设置监控群聊', { exact: true })).toBeVisible()
await expect(fixture.page.getByRole('button', { name: '选择群聊' })).toBeVisible()
} finally {
await fixture.close()
}
})
test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => {
const fixture = await launchTestApp()
const pageErrors: Error[] = []
+92
View File
@@ -234,6 +234,98 @@ const handle = (channel, fn) => {
ipcMain.handle(channel, async (event, ...args) => fn(...args))
}
const groupExitEvent = {
id: 'fixture-exit-event',
contactId: 'group-regular-md5',
roomId: 'group_regular@chatroom',
groupName: '产品测试群',
memberWxid: 'wxid_fixture_member',
memberName: '测试成员',
wechatName: '测试成员微信名',
groupRemark: '测试成员群备注',
contactRemark: '测试成员通讯录备注',
previousCount: 240,
currentCount: 239,
delta: -1,
message: '测试成员退出了产品测试群',
detectedAt: fixtureNowMs - 60 * 1000
}
let groupExitMonitorState = {
events: [groupExitEvent],
running: connected,
nativeMonitorActive: connected,
monitoredGroupCount: 2,
monitorSelectionConfigured: true,
monitoredRoomIds: ['group_regular@chatroom', 'group_folded@chatroom'],
notificationRoomIds: [],
notificationTemplate:
'[退群监测]\n\n用户: {user}\n\n群备注: {groupRemark}\n\n微信号: {wxid}\n\n退群时间: {time}',
lastCheckedAt: fixtureNowMs - 30 * 1000,
lastReadAt: 0,
unreadCount: 1
}
const cloneGroupExitMonitorState = () => ({
...groupExitMonitorState,
events: groupExitMonitorState.events.map((event) => ({ ...event })),
unreadCount: groupExitMonitorState.events.filter(
(event) => event.detectedAt > groupExitMonitorState.lastReadAt
).length
})
handle('group-exit-monitor:getState', () => cloneGroupExitMonitorState())
handle('group-exit-monitor:setGroups', (roomIds, notificationRoomIds) => {
const selected = Array.isArray(roomIds)
? roomIds.filter((roomId) => typeof roomId === 'string' && roomId.endsWith('@chatroom'))
: []
const notifications = Array.isArray(notificationRoomIds)
? notificationRoomIds.filter(
(roomId) => typeof roomId === 'string' && selected.includes(roomId)
)
: []
groupExitMonitorState = {
...groupExitMonitorState,
monitorSelectionConfigured: true,
monitoredRoomIds: [...new Set(selected)],
notificationRoomIds: [...new Set(notifications)],
monitoredGroupCount: selected.length,
lastCheckedAt: fixtureNowMs
}
return cloneGroupExitMonitorState()
})
handle('group-exit-monitor:setTemplate', (template) => {
if (typeof template !== 'string' || !template.trim()) throw new Error('模板不能为空')
groupExitMonitorState = {
...groupExitMonitorState,
notificationTemplate: template.trim()
}
return cloneGroupExitMonitorState()
})
handle('group-exit-monitor:checkNow', () => {
groupExitMonitorState = {
...groupExitMonitorState,
lastCheckedAt: fixtureNowMs
}
return cloneGroupExitMonitorState()
})
handle('group-exit-monitor:clearEvents', () => {
groupExitMonitorState = {
...groupExitMonitorState,
events: [],
lastReadAt: fixtureNowMs
}
return cloneGroupExitMonitorState()
})
handle('group-exit-monitor:markRead', (readAt) => {
const requestedAt = Number(readAt)
groupExitMonitorState = {
...groupExitMonitorState,
lastReadAt: Math.max(
groupExitMonitorState.lastReadAt,
Number.isFinite(requestedAt) && requestedAt > 0 ? requestedAt : fixtureNowMs
)
}
return cloneGroupExitMonitorState()
})
const scheduledReportTasks = []
const scheduledReportExecutions = []
const scheduledReportNextRun = (scheduleTime) => {
+86
View File
@@ -284,6 +284,92 @@ test('API-02 Agent target segmented control visual @visual', async () => {
})
for (const appearanceTheme of ['light', 'dark'] as const) {
test(`EXIT-01 group exit monitor page ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await expect(
fixture.page.getByRole('heading', { name: '退群监控', exact: true })
).toBeVisible()
await expect(
fixture.page.getByText('测试成员退出了产品测试群', { exact: true })
).toBeVisible()
await expect(fixture.page.getByText(/240 人 → 239 人/)).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(`group-exit-monitor-${appearanceTheme}.png`, {
animations: 'disabled',
caret: 'hide'
})
} finally {
await fixture.close()
}
})
test(`EXIT-02 group exit monitor management ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
await expect(
fixture.page.getByRole('heading', { name: '管理群聊', exact: true })
).toBeVisible()
await expect(fixture.page.getByRole('checkbox', { name: '监控产品测试群' })).toBeChecked()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`group-exit-monitor-manage-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
test(`EXIT-03 group exit monitor setup empty ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
fixture.page.on('pageerror', (error) => pageErrors.push(error))
try {
await fixture.setWindowContentSize(visualViewport)
await fixture.page.getByRole('button', { name: '退群监控' }).click()
await fixture.page.getByRole('button', { name: '管理群聊' }).click()
await fixture.page.getByRole('checkbox', { name: '监控产品测试群' }).click()
await fixture.page.getByRole('checkbox', { name: '监控折叠群聊样本' }).click()
await fixture.page.getByRole('button', { name: '保存监控群聊' }).click()
await expect(fixture.page.getByText('还没有设置监控群聊', { exact: true })).toBeVisible()
expect(
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
).toBe(true)
expect(pageErrors).toEqual([])
await clearScreenshotFocus(fixture.page)
await expect(fixture.page).toHaveScreenshot(
`group-exit-monitor-empty-${appearanceTheme}.png`,
{
animations: 'disabled',
caret: 'hide'
}
)
} finally {
await fixture.close()
}
})
test(`REPORT-00 report configuration controls ${appearanceTheme} visual @visual`, async () => {
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
const pageErrors: Error[] = []
@@ -67,6 +67,25 @@ describe('preload IPC contract', () => {
await api.getAIVisionRuntimeConfig()
expect(invoke).toHaveBeenLastCalledWith('ai:getVisionRuntimeConfig')
await api.getGroupExitMonitorState()
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:getState')
await api.setGroupExitMonitorGroups(['room@chatroom'])
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setGroups', ['room@chatroom'])
await api.setGroupExitMonitorGroups(['room@chatroom'], ['room@chatroom'])
expect(invoke).toHaveBeenLastCalledWith(
'group-exit-monitor:setGroups',
['room@chatroom'],
['room@chatroom']
)
await api.setGroupExitMonitorNotificationTemplate('用户: {user}')
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:setTemplate', '用户: {user}')
await api.checkGroupExitMonitorNow()
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:checkNow')
await api.clearGroupExitMonitorEvents()
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:clearEvents')
await api.markGroupExitMonitorRead(123)
expect(invoke).toHaveBeenLastCalledWith('group-exit-monitor:markRead', 123)
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
force: true,
priority: 0
@@ -163,4 +182,26 @@ describe('preload IPC contract', () => {
unsubscribe()
expect(removeListener).toHaveBeenCalledWith('wcdb-change', listener)
})
it('unsubscribes the same listener registered for group exit state', async () => {
const api = await loadApi()
const callback = vi.fn()
const unsubscribe = api.onGroupExitMonitorState(callback)
expect(on).toHaveBeenCalledWith('group-exit-monitor:state', expect.any(Function))
const listener = on.mock.calls.at(-1)?.[1]
listener(
{},
{
events: [],
running: false,
nativeMonitorActive: false,
monitoredGroupCount: 0,
lastReadAt: 0,
unreadCount: 0
}
)
expect(callback).toHaveBeenCalledOnce()
unsubscribe()
expect(removeListener).toHaveBeenCalledWith('group-exit-monitor:state', listener)
})
})
@@ -0,0 +1,150 @@
import { mkdtempSync, readJsonSync, rmSync } from 'fs-extra'
import { tmpdir } from 'os'
import { join } from 'path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
userData: '',
chat: {
getChatDb: vi.fn(),
getCurrentAccountRoot: vi.fn(() => 'fixture-account'),
isReady: vi.fn(() => true),
getGroupSnapshotAsync: vi.fn()
},
capability: {
getPersonalWechatSendCapability: vi.fn()
},
sender: {
send: vi.fn()
}
}))
vi.mock('electron', () => ({
app: { getPath: () => mocks.userData },
BrowserWindow: { getAllWindows: () => [] }
}))
vi.mock('../../src/main/services/chat-service', () => mocks.chat)
vi.mock('../../src/main/services/personal-wechat-capability-service', () => ({
personalWechatCapabilityService: mocks.capability
}))
vi.mock('../../src/main/services/personal-wechat-send-service', () => ({
personalWechatSendService: mocks.sender
}))
import { GroupExitMonitorService } from '../../src/main/services/group-exit-monitor-service'
const member = { wxid: 'wxid_member', wechatNickname: '微信名', groupNickname: '群内名' }
function installGroupDb(): void {
const client = {
getSessionsAsync: vi
.fn()
.mockResolvedValue([{ username: 'room@chatroom', nickname: '测试群' }]),
md5: vi.fn(() => 'room-md5')
}
mocks.chat.getChatDb.mockReturnValue({ getWcdb4Client: () => client })
}
describe('GroupExitMonitorService', () => {
const temporaryDirectories: string[] = []
beforeEach(() => {
mocks.userData = mkdtempSync(join(tmpdir(), 'tracememo-group-monitor-'))
temporaryDirectories.push(mocks.userData)
mocks.chat.getChatDb.mockReset()
mocks.chat.getCurrentAccountRoot.mockReset().mockReturnValue('fixture-account')
mocks.chat.isReady.mockReset().mockReturnValue(true)
mocks.chat.getGroupSnapshotAsync.mockReset()
mocks.capability.getPersonalWechatSendCapability.mockReset()
mocks.sender.send.mockReset()
})
afterEach(() => {
while (temporaryDirectories.length) {
rmSync(temporaryDirectories.pop()!, { recursive: true, force: true })
}
})
it('persists a validated custom template in the monitor state file', () => {
const service = new GroupExitMonitorService()
const template = '[退群监测]\n用户: {user}'
const state = service.setNotificationTemplate(template)
expect(state.notificationTemplate).toBe(template)
expect(readJsonSync(join(mocks.userData, 'group-exit-monitor.json')).notificationTemplate).toBe(
template
)
expect(() => service.setNotificationTemplate('用户: {unknown}')).toThrow('不支持的占位符')
})
it('keeps the previous baseline when a native snapshot contains null members', async () => {
installGroupDb()
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member]
})
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: null
})
const state = await service.checkNow()
expect(state.events).toEqual([])
expect(state.monitoredGroupCount).toBe(1)
})
it('returns from scope saving before the background baseline read completes', async () => {
installGroupDb()
mocks.chat.getGroupSnapshotAsync.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member]
})
const service = new GroupExitMonitorService()
service.setMonitoredRoomIds(['room@chatroom'])
await service.start(true)
let release!: (snapshot: unknown) => void
mocks.chat.getGroupSnapshotAsync.mockReturnValueOnce(
new Promise((resolve) => {
release = resolve
})
)
const startedAt = Date.now()
const state = await service.setMonitoredRoomIds(['room@chatroom'])
expect(Date.now() - startedAt).toBeLessThan(250)
expect(state.monitoredRoomIds).toEqual(['room@chatroom'])
release({ roomId: 'room@chatroom', members: [member] })
})
it('renders the saved template before sending a group notification', async () => {
installGroupDb()
mocks.capability.getPersonalWechatSendCapability.mockResolvedValue({
ready: true,
capabilities: { text: true }
})
mocks.sender.send.mockResolvedValue({ success: true })
mocks.chat.getGroupSnapshotAsync
.mockResolvedValueOnce({
roomId: 'room@chatroom',
members: [member, { wxid: 'wxid_other', wechatNickname: '另一个人' }]
})
.mockResolvedValueOnce({ roomId: 'room@chatroom', members: [member] })
const service = new GroupExitMonitorService()
service.setNotificationTemplate('退群: {user}/{groupRemark}/{wxid}')
service.setMonitoredRoomIds(['room@chatroom'], ['room@chatroom'])
await service.start(true)
await service.checkNow()
expect(mocks.sender.send).toHaveBeenCalledWith({
type: 'text',
to: 'room@chatroom',
isGroup: true,
text: '退群: 另一个人/未设置/wxid_other'
})
})
})
+104
View File
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import {
findRemovedGroupMembers,
groupExitMemberName,
normalizeGroupExitNotificationTemplate,
renderGroupExitMonitorNotification,
validateGroupExitNotificationTemplate
} from '../../src/shared/group-exit-monitor'
describe('group exit monitor member diff', () => {
it('compares wxid sets and keeps the removed member metadata', () => {
const removed = findRemovedGroupMembers(
[
{ wxid: 'wxid_alice', groupNickname: '小艾' },
{ wxid: 'wxid_bob', nickname: '小博' },
{ wxid: 'wxid_bob', nickname: '重复行' }
],
[{ wxid: 'wxid_alice', groupNickname: '小艾' }]
)
expect(removed).toEqual([{ wxid: 'wxid_bob', nickname: '小博' }])
})
it('uses group nickname before contact fallbacks', () => {
expect(
groupExitMemberName({
wxid: 'wxid_fixture',
nickname: '微信昵称',
wechatNickname: '微信昵称字段',
remark: '备注名',
groupNickname: '群昵称'
})
).toBe('群昵称')
expect(groupExitMemberName({ wxid: 'wxid_fixture' })).toBe('wxid_fixture')
})
it('uses the WeChat nickname when a group nickname is unavailable', () => {
expect(
groupExitMemberName({
wxid: 'wxid_fixture',
wechatNickname: '微信名',
remark: '通讯录备注'
})
).toBe('微信名')
})
it('renders the notification template with a fallback for a missing group remark', () => {
const message = renderGroupExitMonitorNotification({
id: 'event-1',
contactId: 'group-md5',
roomId: '测试群@chatroom',
groupName: '测试群',
memberWxid: 'wxid_fixture',
memberName: '群昵称',
wechatName: '微信名',
groupRemark: '',
previousCount: 240,
currentCount: 239,
delta: -1,
message: '群昵称退出了测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
})
expect(message).toContain('[退群监测]')
expect(message).toContain('用户: 微信名')
expect(message).toContain('群备注: 未设置')
expect(message).toContain('微信号: wxid_fixture')
expect(message).toContain('退群时间: 2026-08-31 00:19:07')
})
it('validates custom notification templates and rejects unknown placeholders', () => {
expect(validateGroupExitNotificationTemplate(' 用户: {user} ')).toEqual({
valid: true,
template: '用户: {user}'
})
expect(validateGroupExitNotificationTemplate('用户: {unknown}')).toEqual({
valid: false,
error: '不支持的占位符: unknown'
})
expect(normalizeGroupExitNotificationTemplate('')).toContain('[退群监测]')
})
it('renders a saved custom template with all supported fields', () => {
const message = renderGroupExitMonitorNotification(
{
id: 'event-2',
contactId: 'group-md5',
roomId: '测试群@chatroom',
groupName: '测试群',
memberWxid: 'wxid_fixture',
memberName: '群昵称',
wechatName: '微信名',
groupRemark: '群内昵称',
previousCount: 2,
currentCount: 1,
delta: -1,
message: '群昵称退出了测试群',
detectedAt: new Date(2026, 7, 31, 0, 19, 7).getTime()
},
'{user}|{groupRemark}|{wxid}|{time}'
)
expect(message).toBe('微信名|群内昵称|wxid_fixture|2026-08-31 00:19:07')
})
})