mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-07 23:03:51 +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'
|
} from './services/bootstrap-cache'
|
||||||
import { installSafeConsole } from './safe-log'
|
import { installSafeConsole } from './safe-log'
|
||||||
import { agentHubService } from './services/agent-hub-service'
|
import { agentHubService } from './services/agent-hub-service'
|
||||||
|
import { groupExitMonitorService } from './services/group-exit-monitor-service'
|
||||||
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
import { personalWechatSendService } from './services/personal-wechat-send-service'
|
||||||
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
import { getPersonalWechatSendCapability } from './services/personal-wechat-capability-service'
|
||||||
import { scheduledReportService } from './services/scheduled-report-service'
|
import { scheduledReportService } from './services/scheduled-report-service'
|
||||||
@@ -746,6 +747,7 @@ app.whenReady().then(async () => {
|
|||||||
chat.getCurrentAccountRoot() === selectedRoot
|
chat.getCurrentAccountRoot() === selectedRoot
|
||||||
) {
|
) {
|
||||||
console.log('[WCDB4] db:init reuse current connection')
|
console.log('[WCDB4] db:init reuse current connection')
|
||||||
|
void groupExitMonitorService.start(true)
|
||||||
return { success: true, monitoring: true }
|
return { success: true, monitoring: true }
|
||||||
}
|
}
|
||||||
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
const nextWechatDb = await WechatDb.create(key, selectedRoot)
|
||||||
@@ -753,6 +755,7 @@ app.whenReady().then(async () => {
|
|||||||
await nextWechatDb.closeAsync()
|
await nextWechatDb.closeAsync()
|
||||||
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
return { success: false, error: '应用正在退出,数据库连接已取消', monitoring: false }
|
||||||
}
|
}
|
||||||
|
groupExitMonitorService.stop()
|
||||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||||
if (resolvedRoot) {
|
if (resolvedRoot) {
|
||||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||||
@@ -774,11 +777,13 @@ app.whenReady().then(async () => {
|
|||||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||||
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
const monitoring = await wcdb4Client.startMonitor((type, json) => {
|
||||||
wcdb4Client.invalidateSessionCache()
|
wcdb4Client.invalidateSessionCache()
|
||||||
|
groupExitMonitorService.notifyDatabaseChanged(json)
|
||||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||||
for (const window of BrowserWindow.getAllWindows()) {
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
void groupExitMonitorService.start(monitoring)
|
||||||
const recentSession = sessions[0]
|
const recentSession = sessions[0]
|
||||||
if (recentSession?.username) {
|
if (recentSession?.username) {
|
||||||
void wcdb4Client
|
void wcdb4Client
|
||||||
@@ -1123,6 +1128,24 @@ app.whenReady().then(async () => {
|
|||||||
return snapshot
|
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('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||||
ipcMain.handle(
|
ipcMain.handle(
|
||||||
'knowledge:search',
|
'knowledge:search',
|
||||||
@@ -1734,12 +1757,21 @@ app.whenReady().then(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => {
|
ipcMain.handle('db:reopenWithRoot', async (_, accountRoot: string) => {
|
||||||
|
groupExitMonitorService.stop()
|
||||||
const ok = chat.reopenWithRoot(accountRoot)
|
const ok = chat.reopenWithRoot(accountRoot)
|
||||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||||
const client = chat.getChatDb()?.getWcdb4Client()
|
const client = chat.getChatDb()?.getWcdb4Client()
|
||||||
if (client) {
|
if (client) {
|
||||||
voiceService = new VoiceService(client)
|
voiceService = new VoiceService(client)
|
||||||
voiceRecognition?.connect(voiceService, client.getAccountRoot())
|
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,避免自动获取扫描到旧目录
|
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
|
||||||
const settings = loadSettings()
|
const settings = loadSettings()
|
||||||
@@ -1773,6 +1805,7 @@ app.whenReady().then(async () => {
|
|||||||
voiceBatchService?.cancel()
|
voiceBatchService?.cancel()
|
||||||
voiceRecognition?.disconnect()
|
voiceRecognition?.disconnect()
|
||||||
voiceService = null
|
voiceService = null
|
||||||
|
groupExitMonitorService.stop()
|
||||||
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
||||||
return { success: true }
|
return { success: true }
|
||||||
})
|
})
|
||||||
@@ -2027,6 +2060,7 @@ app.on('before-quit', (event) => {
|
|||||||
void (async () => {
|
void (async () => {
|
||||||
agentHubService.stop()
|
agentHubService.stop()
|
||||||
scheduledReportService.stop()
|
scheduledReportService.stop()
|
||||||
|
groupExitMonitorService.stop()
|
||||||
flushBootstrapCacheWritesSync()
|
flushBootstrapCacheWritesSync()
|
||||||
const [, nativeCallsDrained] = await Promise.all([
|
const [, nativeCallsDrained] = await Promise.all([
|
||||||
apiServer.stop().catch(() => undefined),
|
apiServer.stop().catch(() => undefined),
|
||||||
|
|||||||
@@ -583,8 +583,8 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
|||||||
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||||
|
|
||||||
const members = wcdb4Client
|
const rawMembers = wcdb4Client.getGroupMembers(roomId)
|
||||||
.getGroupMembers(roomId)
|
const members = (Array.isArray(rawMembers) ? rawMembers : [])
|
||||||
.filter((member) => member?.m_nsUsrName)
|
.filter((member) => member?.m_nsUsrName)
|
||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
wxid: member.m_nsUsrName,
|
wxid: member.m_nsUsrName,
|
||||||
@@ -604,7 +604,8 @@ export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnaps
|
|||||||
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||||
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
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)
|
.filter((member) => member?.m_nsUsrName)
|
||||||
.map((member) => ({
|
.map((member) => ({
|
||||||
wxid: member.m_nsUsrName,
|
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
|
this.sessionDisplayNamesHydrated = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 清理群备注缓存,不影响会话缓存。 */
|
||||||
|
invalidateGroupNicknameCache(): void {
|
||||||
|
this.groupNicknameCache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
getChatTables(): { name: string; db_number: string }[] {
|
getChatTables(): { name: string; db_number: string }[] {
|
||||||
if (this.cachedChatTables) return this.cachedChatTables
|
if (this.cachedChatTables) return this.cachedChatTables
|
||||||
const sessions =
|
const sessions =
|
||||||
@@ -2740,14 +2745,15 @@ export class Wcdb4Client {
|
|||||||
])
|
])
|
||||||
const content = this.decodeMessageContent(contentRaw, compressRaw)
|
const content = this.decodeMessageContent(contentRaw, compressRaw)
|
||||||
const contentSender = this.extractSenderFromMessageContent(content)
|
const contentSender = this.extractSenderFromMessageContent(content)
|
||||||
const sender = this.pickString(row, [
|
const sender =
|
||||||
'sender_username',
|
this.pickString(row, [
|
||||||
'senderUsername',
|
'sender_username',
|
||||||
'sender',
|
'senderUsername',
|
||||||
'fromUsername',
|
'sender',
|
||||||
'from_username',
|
'fromUsername',
|
||||||
'WCDB_CT_sender_username'
|
'from_username',
|
||||||
]) || contentSender
|
'WCDB_CT_sender_username'
|
||||||
|
]) || contentSender
|
||||||
const createTime = this.pickNumber(row, [
|
const createTime = this.pickNumber(row, [
|
||||||
'create_time',
|
'create_time',
|
||||||
'createTime',
|
'createTime',
|
||||||
@@ -2795,21 +2801,32 @@ export class Wcdb4Client {
|
|||||||
'mesDes',
|
'mesDes',
|
||||||
'WCDB_CT_is_send'
|
'WCDB_CT_is_send'
|
||||||
])
|
])
|
||||||
const parsedIsSend = rawIsSend === undefined || rawIsSend === null
|
const parsedIsSend =
|
||||||
? false
|
rawIsSend === undefined || rawIsSend === null
|
||||||
: this.pickBoolean(row, [
|
? false
|
||||||
'computed_is_send',
|
: this.pickBoolean(row, [
|
||||||
'computedIsSend',
|
'computed_is_send',
|
||||||
'is_send',
|
'computedIsSend',
|
||||||
'isSend',
|
'is_send',
|
||||||
'mesDes',
|
'isSend',
|
||||||
'WCDB_CT_is_send'
|
'mesDes',
|
||||||
])
|
'WCDB_CT_is_send'
|
||||||
const isSelfSender = sender ? this.getMyUsernameCandidates().some((candidate) => {
|
])
|
||||||
const left = String(candidate || '').trim().toLowerCase()
|
const isSelfSender = sender
|
||||||
const right = String(sender || '').trim().toLowerCase()
|
? this.getMyUsernameCandidates().some((candidate) => {
|
||||||
return Boolean(left && right && (left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`)))
|
const left = String(candidate || '')
|
||||||
}) : false
|
.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
|
const isSend = isSelfSender || parsedIsSend
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2828,11 +2845,14 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
/** 解决群里自己的消息被识别成 user */
|
/** 解决群里自己的消息被识别成 user */
|
||||||
private extractSenderFromMessageContent(content: string): string {
|
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 ''
|
if (!firstLine || firstLine.length > 128 || /[<>]/.test(firstLine)) return ''
|
||||||
const candidate = firstLine.replace(/:$/, '').trim()
|
const candidate = firstLine.replace(/:$/, '').trim()
|
||||||
if (!candidate || /\s/.test(candidate)) return ''
|
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 : ''
|
return this.getMyUsernameCandidates().includes(candidate) ? candidate : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Vendored
+11
@@ -79,6 +79,7 @@ import type {
|
|||||||
PersonalWechatVoiceEncodingEnvironmentResult
|
PersonalWechatVoiceEncodingEnvironmentResult
|
||||||
} from '../shared/personal-wechat-voice-runtime'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
|
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||||
import type {
|
import type {
|
||||||
AppUpdateCheckResult,
|
AppUpdateCheckResult,
|
||||||
AppUpdateInstallResult,
|
AppUpdateInstallResult,
|
||||||
@@ -254,6 +255,16 @@ declare global {
|
|||||||
avatar: string
|
avatar: string
|
||||||
}[]
|
}[]
|
||||||
} | null>
|
} | 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>
|
search: (keyword: string) => Promise<string | null>
|
||||||
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
searchKnowledge: (request: KnowledgeSearchIpcRequest) => Promise<KnowledgeSearchIpcResult>
|
||||||
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
|
runAiSearch: (request: AiSearchPipelineRequest) => Promise<AiSearchPipelineResult>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import type {
|
|||||||
} from '../shared/personal-wechat-voice-runtime'
|
} from '../shared/personal-wechat-voice-runtime'
|
||||||
import type { AppLogEntry } from '../shared/app-log'
|
import type { AppLogEntry } from '../shared/app-log'
|
||||||
import type { AppUpdateState } from '../shared/app-update'
|
import type { AppUpdateState } from '../shared/app-update'
|
||||||
|
import type { GroupExitMonitorState } from '../shared/group-exit-monitor'
|
||||||
import type { CacheSummary } from '../shared/cache'
|
import type { CacheSummary } from '../shared/cache'
|
||||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||||
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
import type { ImageDecoderSelectionResult, ImageDecoderStatus } from '../shared/image-decryption'
|
||||||
@@ -128,6 +129,29 @@ const api = {
|
|||||||
options?: { limit?: number }
|
options?: { limit?: number }
|
||||||
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime, options),
|
||||||
getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5),
|
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),
|
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||||
searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> =>
|
searchKnowledge: (request: KnowledgeSearchIpcRequest): Promise<KnowledgeSearchIpcResult> =>
|
||||||
ipcRenderer.invoke('knowledge:search', request),
|
ipcRenderer.invoke('knowledge:search', request),
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { switchGeneratedReportTemplate } from './utils/report-template-switch'
|
|||||||
import { runtimePlatform } from './utils/runtime-environment'
|
import { runtimePlatform } from './utils/runtime-environment'
|
||||||
import { useToast } from './components/ui'
|
import { useToast } from './components/ui'
|
||||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||||
|
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||||
|
|
||||||
const SIDEBAR_MIN_WIDTH = 260
|
const SIDEBAR_MIN_WIDTH = 260
|
||||||
const SIDEBAR_MAX_WIDTH = 380
|
const SIDEBAR_MAX_WIDTH = 380
|
||||||
@@ -1908,6 +1909,8 @@ function App(): React.ReactElement {
|
|||||||
return renderArchiveWorkspace()
|
return renderArchiveWorkspace()
|
||||||
case 'report':
|
case 'report':
|
||||||
return renderReportWorkspace()
|
return renderReportWorkspace()
|
||||||
|
case 'exit-monitor':
|
||||||
|
return <GroupExitMonitorWorkspace dbReady={isDatabaseConnected} contacts={contacts} />
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
return <AgentHubWorkspace />
|
return <AgentHubWorkspace />
|
||||||
case 'api':
|
case 'api':
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { AppPage, PRIMARY_NAV_ITEMS } from './navigation'
|
import { AppPage, PRIMARY_NAV_ITEMS } from './navigation'
|
||||||
|
import type { GroupExitMonitorState } from '../../../../shared/group-exit-monitor'
|
||||||
|
|
||||||
interface NavIconProps {
|
interface NavIconProps {
|
||||||
page: AppPage
|
page: AppPage
|
||||||
@@ -37,6 +38,14 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
|
|||||||
<path d="M9.5 16.5h4" />
|
<path d="M9.5 16.5h4" />
|
||||||
</svg>
|
</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':
|
case 'export':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
@@ -84,6 +93,36 @@ export function PrimaryNavigation({
|
|||||||
activePage,
|
activePage,
|
||||||
onPageChange
|
onPageChange
|
||||||
}: PrimaryNavigationProps): React.ReactElement {
|
}: 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 (
|
return (
|
||||||
<nav className="primary-navigation" aria-label="一级导航">
|
<nav className="primary-navigation" aria-label="一级导航">
|
||||||
{PRIMARY_NAV_ITEMS.map((item) => (
|
{PRIMARY_NAV_ITEMS.map((item) => (
|
||||||
@@ -96,6 +135,9 @@ export function PrimaryNavigation({
|
|||||||
>
|
>
|
||||||
<span className="primary-nav-mark" aria-hidden>
|
<span className="primary-nav-mark" aria-hidden>
|
||||||
<NavIcon page={item.id} />
|
<NavIcon page={item.id} />
|
||||||
|
{item.id === 'exit-monitor' && unreadExitCount > 0 && (
|
||||||
|
<span className="primary-nav-badge">{Math.min(unreadExitCount, 99)}</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="primary-nav-label">{item.label}</span>
|
<span className="primary-nav-label">{item.label}</span>
|
||||||
</button>
|
</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 {
|
export interface NavigationItem {
|
||||||
id: AppPage
|
id: AppPage
|
||||||
@@ -9,6 +17,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'archive', label: '档案' },
|
{ id: 'archive', label: '档案' },
|
||||||
{ id: 'search', label: '问问微信' },
|
{ id: 'search', label: '问问微信' },
|
||||||
{ id: 'report', label: '日报' },
|
{ id: 'report', label: '日报' },
|
||||||
|
{ id: 'exit-monitor', label: '退群监控' },
|
||||||
{ id: 'agent-hub', label: 'Agent' },
|
{ id: 'agent-hub', label: 'Agent' },
|
||||||
{ id: 'export', label: '导出' },
|
{ id: 'export', label: '导出' },
|
||||||
{ id: 'api', label: 'API' },
|
{ 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-advanced';
|
||||||
@use './settings-preferences';
|
@use './settings-preferences';
|
||||||
@use './settings-text-to-speech';
|
@use './settings-text-to-speech';
|
||||||
|
@use './group-exit-monitor';
|
||||||
@use './theme';
|
@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 () => {
|
test('NAV-01 NAV-02 every top-level page is unique and switchable', async () => {
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
const labels = ['档案', '问问微信', '日报', 'Agent', '导出', 'API', '设置']
|
const labels = ['档案', '问问微信', '日报', '退群监控', 'Agent', '导出', 'API', '设置']
|
||||||
try {
|
try {
|
||||||
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
|
const navigation = fixture.page.getByRole('navigation', { name: '一级导航' })
|
||||||
await expect(navigation).toBeVisible()
|
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 () => {
|
test('CHAT-01 archive More menu is keyboard-safe and keeps the page usable', async () => {
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
const pageErrors: Error[] = []
|
const pageErrors: Error[] = []
|
||||||
|
|||||||
@@ -234,6 +234,98 @@ const handle = (channel, fn) => {
|
|||||||
ipcMain.handle(channel, async (event, ...args) => fn(...args))
|
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 scheduledReportTasks = []
|
||||||
const scheduledReportExecutions = []
|
const scheduledReportExecutions = []
|
||||||
const scheduledReportNextRun = (scheduleTime) => {
|
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) {
|
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 () => {
|
test(`REPORT-00 report configuration controls ${appearanceTheme} visual @visual`, async () => {
|
||||||
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
const fixture = await launchTestApp({ now: visualNow, appearanceTheme })
|
||||||
const pageErrors: Error[] = []
|
const pageErrors: Error[] = []
|
||||||
|
|||||||
@@ -67,6 +67,25 @@ describe('preload IPC contract', () => {
|
|||||||
await api.getAIVisionRuntimeConfig()
|
await api.getAIVisionRuntimeConfig()
|
||||||
expect(invoke).toHaveBeenLastCalledWith('ai:getVisionRuntimeConfig')
|
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', {
|
await api.getImage('fixture-md5', 'fixture.dat', 'fixture-session', {
|
||||||
force: true,
|
force: true,
|
||||||
priority: 0
|
priority: 0
|
||||||
@@ -163,4 +182,26 @@ describe('preload IPC contract', () => {
|
|||||||
unsubscribe()
|
unsubscribe()
|
||||||
expect(removeListener).toHaveBeenCalledWith('wcdb-change', listener)
|
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