mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
feat: 增加退群监控功能
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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),
|
||||
|
||||
@@ -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
@@ -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 : ''
|
||||
}
|
||||
|
||||
|
||||
Vendored
+11
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,5 @@
|
||||
@use './settings-advanced';
|
||||
@use './settings-preferences';
|
||||
@use './settings-text-to-speech';
|
||||
@use './group-exit-monitor';
|
||||
@use './theme';
|
||||
|
||||
@@ -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
@@ -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[] = []
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user