feat: 增加退群监控功能

This commit is contained in:
Wxw-Gu
2026-08-31 21:06:53 +08:00
parent 6cbf9af662
commit e59332b514
22 changed files with 3321 additions and 30 deletions
+3
View File
@@ -41,6 +41,7 @@ import { switchGeneratedReportTemplate } from './utils/report-template-switch'
import { runtimePlatform } from './utils/runtime-environment'
import { useToast } from './components/ui'
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -1908,6 +1909,8 @@ function App(): React.ReactElement {
return renderArchiveWorkspace()
case 'report':
return renderReportWorkspace()
case 'exit-monitor':
return <GroupExitMonitorWorkspace dbReady={isDatabaseConnected} contacts={contacts} />
case 'agent-hub':
return <AgentHubWorkspace />
case 'api':
@@ -1,5 +1,6 @@
import React from 'react'
import { AppPage, PRIMARY_NAV_ITEMS } from './navigation'
import type { GroupExitMonitorState } from '../../../../shared/group-exit-monitor'
interface NavIconProps {
page: AppPage
@@ -37,6 +38,14 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
<path d="M9.5 16.5h4" />
</svg>
)
case 'exit-monitor':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M5.5 19V6.5h8V19" />
<path d="M9.5 12h10" />
<path d="m16.5 9 3 3-3 3" />
</svg>
)
case 'export':
return (
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -84,6 +93,36 @@ export function PrimaryNavigation({
activePage,
onPageChange
}: PrimaryNavigationProps): React.ReactElement {
const [unreadExitCount, setUnreadExitCount] = React.useState(0)
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.getGroupExitMonitorState !== 'function') return
let disposed = false
const update = (state: GroupExitMonitorState): void => {
if (disposed) return
setUnreadExitCount(activePage === 'exit-monitor' ? 0 : state.unreadCount)
}
void api
.getGroupExitMonitorState()
.then(update)
.catch(() => undefined)
const unsubscribe =
typeof api.onGroupExitMonitorState === 'function' ? api.onGroupExitMonitorState(update) : null
return () => {
disposed = true
unsubscribe?.()
}
}, [activePage])
React.useEffect(() => {
if (activePage !== 'exit-monitor') return
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.markGroupExitMonitorRead !== 'function') return
setUnreadExitCount(0)
void api.markGroupExitMonitorRead().catch(() => undefined)
}, [activePage])
return (
<nav className="primary-navigation" aria-label="一级导航">
{PRIMARY_NAV_ITEMS.map((item) => (
@@ -96,6 +135,9 @@ export function PrimaryNavigation({
>
<span className="primary-nav-mark" aria-hidden>
<NavIcon page={item.id} />
{item.id === 'exit-monitor' && unreadExitCount > 0 && (
<span className="primary-nav-badge">{Math.min(unreadExitCount, 99)}</span>
)}
</span>
<span className="primary-nav-label">{item.label}</span>
</button>
@@ -1,4 +1,12 @@
export type AppPage = 'archive' | 'search' | 'report' | 'agent-hub' | 'export' | 'api' | 'settings'
export type AppPage =
| 'archive'
| 'search'
| 'report'
| 'exit-monitor'
| 'agent-hub'
| 'export'
| 'api'
| 'settings'
export interface NavigationItem {
id: AppPage
@@ -9,6 +17,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
{ id: 'archive', label: '档案' },
{ id: 'search', label: '问问微信' },
{ id: 'report', label: '日报' },
{ id: 'exit-monitor', label: '退群监控' },
{ id: 'agent-hub', label: 'Agent' },
{ id: 'export', label: '导出' },
{ id: 'api', label: 'API' },
@@ -0,0 +1,838 @@
import React from 'react'
import {
Button,
Checkbox,
EmptyState,
Input,
Popover,
PopoverContent,
PopoverTrigger,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Textarea,
Tooltip,
TooltipContent,
TooltipTrigger
} from '../../components/ui'
import type { Contact } from '../../../../shared/types'
import {
GROUP_EXIT_NOTIFICATION_TEMPLATE,
GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH,
validateGroupExitNotificationTemplate,
type GroupExitMonitorState
} from '../../../../shared/group-exit-monitor'
import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat'
type GroupSelectionFilter = 'all' | 'selected' | 'unselected'
interface GroupExitMonitorWorkspaceProps {
dbReady: boolean
contacts?: Contact[]
}
const EMPTY_STATE: GroupExitMonitorState = {
events: [],
running: false,
nativeMonitorActive: false,
monitoredGroupCount: 0,
monitorSelectionConfigured: true,
monitoredRoomIds: [],
notificationRoomIds: [],
lastReadAt: 0,
unreadCount: 0
}
const formatDetectedAt = (timestamp: number): string =>
new Date(timestamp).toLocaleString('zh-CN', { hour12: false })
const dayStart = (timestamp: number): number => {
const date = new Date(timestamp)
date.setHours(0, 0, 0, 0)
return date.getTime()
}
const formatLastChecked = (timestamp?: number): string => {
if (!timestamp) return '尚未检查'
const elapsed = Math.max(0, Date.now() - timestamp)
if (elapsed < 60_000) return '刚刚'
if (elapsed < 60 * 60_000) return `${Math.max(1, Math.floor(elapsed / 60_000))} 分钟前`
return formatDetectedAt(timestamp)
}
const contactDisplayName = (contact: Contact): string =>
contact.m_nsNickName?.trim() || contact.remark?.trim() || contact.m_nsUsrName || '未命名群聊'
const isGroupContact = (contact: Contact): boolean =>
contact.type === 'group' || contact.m_nsUsrName?.trim().endsWith('@chatroom') === true
const contactRoomId = (contact: Contact): string => contact.m_nsUsrName.trim()
const sendCapabilityLabel = (capability: PersonalWechatSendCapability | null): string => {
if (!capability) return '发送能力检查中'
if (capability.ready && capability.capabilities.text) return '发送能力已就绪'
if (capability.status === 'unsupported') return '发送能力不可用'
return '发送能力未就绪'
}
const sendCapabilityTone = (capability: PersonalWechatSendCapability | null): string =>
capability?.ready && capability.capabilities.text ? 'ready' : 'unready'
function SendCapabilityStatus({
capability,
className = ''
}: {
capability: PersonalWechatSendCapability | null
className?: string
}): React.ReactElement {
const label = sendCapabilityLabel(capability)
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className={`exit-monitor-send-status ${sendCapabilityTone(capability)} ${className}`}
tabIndex={0}
aria-label={label}
>
<span className="exit-monitor-status-dot" aria-hidden="true" />
{label}
</span>
</TooltipTrigger>
<TooltipContent side="bottom">{capability?.message || '正在读取微信发送能力'}</TooltipContent>
</Tooltip>
)
}
function GroupExitNotificationTemplate({
template,
saving,
error,
onSave
}: {
template: string
saving: boolean
error: string
onSave: (template: string) => Promise<boolean>
}): React.ReactElement {
const [open, setOpen] = React.useState(false)
const [draft, setDraft] = React.useState(template)
const [validationError, setValidationError] = React.useState('')
React.useEffect(() => {
if (!open) setDraft(template)
}, [open, template])
const handleOpenChange = (nextOpen: boolean): void => {
setOpen(nextOpen)
setValidationError('')
if (nextOpen) setDraft(template)
}
const saveDraft = async (): Promise<void> => {
const result = validateGroupExitNotificationTemplate(draft)
if (!result.valid || !result.template) {
setValidationError(result.error || '模板无效')
return
}
setValidationError('')
const saved = await onSave(result.template)
if (saved) setOpen(false)
}
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" aria-label="查看退群监测模板">
模板
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="exit-monitor-template-popover">
<div className="exit-monitor-template-heading">
<div>
<span className="exit-monitor-eyebrow">消息格式</span>
<h2>退群监测模板</h2>
</div>
<span className="exit-monitor-template-status">发送时使用</span>
</div>
<Textarea
id="exit-monitor-notification-template"
aria-label="退群监测模板内容"
value={draft}
rows={10}
maxLength={GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
onChange={(event) => setDraft(event.target.value)}
className="exit-monitor-template-editor"
/>
<div className="exit-monitor-template-meta">
<span>
支持 {`{user}`}、{`{groupRemark}`}、{`{wxid}`}、{`{time}`}
</span>
<span>
{draft.length}/{GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH}
</span>
</div>
<p>退群时会替换占位符;群备注指退群前的群内昵称,没有群备注时显示“未设置”。</p>
{validationError || error ? (
<p className="exit-monitor-template-error">{validationError || error}</p>
) : null}
<div className="exit-monitor-template-actions">
<Button size="sm" onClick={() => void saveDraft()} disabled={saving}>
{saving ? '保存中...' : '保存模板'}
</Button>
</div>
</PopoverContent>
</Popover>
)
}
function GroupAvatar({ contact }: { contact: Contact }): React.ReactElement {
const name = contactDisplayName(contact)
const initial = name.charAt(0) || '?'
return (
<span className="exit-monitor-group-avatar">
{contact.avatar ? <img src={contact.avatar} alt="" aria-hidden="true" /> : initial}
</span>
)
}
interface ManageGroupsProps {
groups: Contact[]
selectedRoomIds: Set<string>
notificationRoomIds: Set<string>
sendCapability: PersonalWechatSendCapability | null
keyword: string
listFilter: GroupSelectionFilter
saving: boolean
dbReady: boolean
onKeywordChange: (keyword: string) => void
onListFilterChange: (filter: GroupSelectionFilter) => void
onToggle: (roomId: string) => void
onToggleNotification: (roomId: string) => void
onToggleAll: () => void
onSave: () => void
onCancel: () => void
notificationTemplate: string
templateSaving: boolean
templateError: string
onSaveTemplate: (template: string) => Promise<boolean>
}
function ManageGroups({
groups,
selectedRoomIds,
notificationRoomIds,
sendCapability,
keyword,
listFilter,
saving,
dbReady,
onKeywordChange,
onListFilterChange,
onToggle,
onToggleNotification,
onToggleAll,
onSave,
onCancel,
notificationTemplate,
templateSaving,
templateError,
onSaveTemplate
}: ManageGroupsProps): React.ReactElement {
const lowerKeyword = keyword.trim().toLowerCase()
const filteredGroups = React.useMemo(() => {
const keywordGroups = lowerKeyword
? groups.filter((contact) =>
[contactDisplayName(contact), contactRoomId(contact), contact.remark].some((value) =>
value?.toLowerCase().includes(lowerKeyword)
)
)
: groups
return keywordGroups.filter((group) => {
const selected = selectedRoomIds.has(contactRoomId(group))
return listFilter === 'all' || (listFilter === 'selected' ? selected : !selected)
})
}, [groups, listFilter, lowerKeyword, selectedRoomIds])
const allGroupsSelected =
groups.length > 0 && groups.every((group) => selectedRoomIds.has(contactRoomId(group)))
return (
<div className="exit-monitor-manage-page">
<header className="exit-monitor-manage-header">
<div>
<button type="button" className="exit-monitor-back" onClick={onCancel}>
<span aria-hidden="true">‹</span>
返回退群监控
</button>
<span className="exit-monitor-eyebrow">监控范围</span>
<h1>管理群聊</h1>
<p>选择需要持续关注成员变化的群聊,保存后会重新建立成员快照。</p>
</div>
<div className="exit-monitor-manage-header-status">
<GroupExitNotificationTemplate
template={notificationTemplate}
saving={templateSaving}
error={templateError}
onSave={onSaveTemplate}
/>
<SendCapabilityStatus capability={sendCapability} />
<div className="exit-monitor-manage-count" aria-live="polite">
已选择 <strong>{selectedRoomIds.size}</strong> 个群聊
</div>
</div>
</header>
<section
className="exit-monitor-manage-section"
aria-labelledby="exit-monitor-manage-list-title"
>
<div className="exit-monitor-manage-toolbar">
<div>
<span className="exit-monitor-eyebrow">群聊列表</span>
<h2 id="exit-monitor-manage-list-title">选择监控群聊</h2>
</div>
<div className="exit-monitor-manage-tools">
<Select
value={listFilter}
onValueChange={(value) => onListFilterChange(value as GroupSelectionFilter)}
>
<SelectTrigger aria-label="筛选群聊状态" className="exit-monitor-group-filter">
<SelectValue placeholder="筛选状态" />
</SelectTrigger>
<SelectContent className="z-modal">
<SelectItem value="all">全部</SelectItem>
<SelectItem value="selected">已选择</SelectItem>
<SelectItem value="unselected">未选择</SelectItem>
</SelectContent>
</Select>
<label className="sr-only" htmlFor="exit-monitor-group-search">
搜索群聊
</label>
<Input
id="exit-monitor-group-search"
value={keyword}
onChange={(event) => onKeywordChange(event.target.value)}
placeholder="搜索群聊"
className="exit-monitor-group-search"
/>
<Button variant="ghost" size="sm" onClick={onToggleAll} disabled={!groups.length}>
{allGroupsSelected ? '取消全选' : '全选'}
</Button>
</div>
</div>
{!dbReady || !groups.length ? (
<EmptyState
className="exit-monitor-manage-empty"
title={dbReady ? '没有可监控的群聊' : '数据库未连接'}
description={
dbReady
? '当前账号没有读取到群聊,会话列表刷新后可以重新尝试。'
: '连接微信数据库后才能选择监控群聊。'
}
/>
) : filteredGroups.length ? (
<div className="exit-monitor-group-list">
{filteredGroups.map((contact) => {
const roomId = contactRoomId(contact)
const name = contactDisplayName(contact)
const checked = selectedRoomIds.has(roomId)
const notificationChecked = notificationRoomIds.has(roomId)
return (
<div key={roomId} className={`exit-monitor-group-row ${checked ? 'selected' : ''}`}>
<Checkbox
checked={checked}
onCheckedChange={() => onToggle(roomId)}
aria-label={`监控${name}`}
/>
<GroupAvatar contact={contact} />
<span className="exit-monitor-group-row-body">
<strong>{name}</strong>
<span>
{contact.remark && contact.remark !== name ? contact.remark : roomId}
</span>
</span>
<span className="exit-monitor-group-row-status">
{checked ? '监控中' : '未选择'}
</span>
<label className="exit-monitor-group-notification">
<Checkbox
checked={notificationChecked}
disabled={!checked}
onCheckedChange={() => onToggleNotification(roomId)}
aria-label={`是否通知${name}`}
/>
<span>通知群聊</span>
</label>
</div>
)
})}
</div>
) : (
<EmptyState
className="exit-monitor-manage-empty"
title="没有匹配的群聊"
description="换个关键词试试,或清空搜索条件查看全部群聊。"
/>
)}
</section>
<footer className="exit-monitor-manage-footer">
<span>保存后将从新的成员快照开始记录退群动态;通知仅发送到已勾选群聊。</span>
<div>
<Button variant="ghost" onClick={onCancel} disabled={saving}>
取消
</Button>
<Button onClick={onSave} disabled={!dbReady || saving}>
{saving ? '保存中...' : '保存监控群聊'}
</Button>
</div>
</footer>
</div>
)
}
export function GroupExitMonitorWorkspace({
dbReady,
contacts = []
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
const [checking, setChecking] = React.useState(false)
const [saving, setSaving] = React.useState(false)
const [error, setError] = React.useState('')
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
null
)
const [templateSaving, setTemplateSaving] = React.useState(false)
const [templateError, setTemplateError] = React.useState('')
const [selectedRoomId, setSelectedRoomId] = React.useState('all')
const [view, setView] = React.useState<'events' | 'manage'>('events')
const [manageKeyword, setManageKeyword] = React.useState('')
const [manageFilter, setManageFilter] = React.useState<GroupSelectionFilter>('all')
const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState<Set<string>>(
() => new Set()
)
const [selectedManageNotificationRoomIds, setSelectedManageNotificationRoomIds] = React.useState<
Set<string>
>(() => new Set())
const groups = React.useMemo(() => {
const seen = new Set<string>()
return contacts.filter(isGroupContact).filter((contact) => {
const roomId = contactRoomId(contact)
if (!roomId || seen.has(roomId)) return false
seen.add(roomId)
return true
})
}, [contacts])
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.getGroupExitMonitorState !== 'function') return
let disposed = false
const update = (next: GroupExitMonitorState): void => {
if (!disposed) setState(next)
}
void api
.getGroupExitMonitorState()
.then(update)
.catch((reason) => {
if (!disposed) setError(reason instanceof Error ? reason.message : String(reason))
})
const unsubscribe =
typeof api.onGroupExitMonitorState === 'function' ? api.onGroupExitMonitorState(update) : null
return () => {
disposed = true
unsubscribe?.()
}
}, [])
React.useEffect(() => {
const api = typeof window !== 'undefined' ? window.api : undefined
if (!api || typeof api.getPersonalWechatSendCapability !== 'function') return
let disposed = false
void api
.getPersonalWechatSendCapability()
.then((capability) => {
if (!disposed) setSendCapability(capability)
})
.catch(() => {
if (!disposed) setSendCapability(null)
})
return () => {
disposed = true
}
}, [])
const groupOptions = React.useMemo(() => {
const optionMap = new Map<string, { roomId: string; groupName: string; count: number }>()
for (const event of state.events) {
const roomId = event.roomId.trim()
if (!roomId) continue
const existing = optionMap.get(roomId)
if (existing) {
existing.count += 1
} else {
optionMap.set(roomId, {
roomId,
groupName: event.groupName || roomId,
count: 1
})
}
}
return Array.from(optionMap.values()).sort((left, right) =>
left.groupName.localeCompare(right.groupName, 'zh-CN')
)
}, [state.events])
React.useEffect(() => {
if (selectedRoomId === 'all') return
if (!groupOptions.some((group) => group.roomId === selectedRoomId)) setSelectedRoomId('all')
}, [groupOptions, selectedRoomId])
const visibleEvents = React.useMemo(
() =>
selectedRoomId === 'all'
? state.events
: state.events.filter((event) => event.roomId === selectedRoomId),
[selectedRoomId, state.events]
)
const todayExitCount = React.useMemo(() => {
const start = dayStart(Date.now())
return state.events.filter((event) => event.detectedAt >= start).length
}, [state.events])
const setupEmpty =
state.monitorSelectionConfigured === true && !(state.monitoredRoomIds || []).length
const notificationTemplate = state.notificationTemplate || GROUP_EXIT_NOTIFICATION_TEMPLATE
const openManage = (): void => {
const availableRoomIds = new Set(groups.map(contactRoomId))
const defaultIds = state.monitorSelectionConfigured ? state.monitoredRoomIds || [] : []
const selectedIds = new Set(defaultIds.filter((roomId) => availableRoomIds.has(roomId)))
const notificationIds = new Set(
(state.notificationRoomIds || []).filter((roomId) => selectedIds.has(roomId))
)
setSelectedManageRoomIds(selectedIds)
setSelectedManageNotificationRoomIds(notificationIds)
setManageKeyword('')
setManageFilter('all')
setError('')
setTemplateError('')
setView('manage')
}
const checkNow = async (): Promise<void> => {
const api = window.api
if (checking || !dbReady || typeof api.checkGroupExitMonitorNow !== 'function') return
setChecking(true)
setError('')
try {
setState(await api.checkGroupExitMonitorNow())
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setChecking(false)
}
}
const clearEvents = async (): Promise<void> => {
const api = window.api
if (!state.events.length || typeof api.clearGroupExitMonitorEvents !== 'function') return
setError('')
try {
setState(await api.clearGroupExitMonitorEvents())
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
}
}
const saveGroups = async (): Promise<void> => {
const api = window.api
if (saving || !dbReady || typeof api.setGroupExitMonitorGroups !== 'function') return
setSaving(true)
setError('')
try {
const selectedIds = groups
.map(contactRoomId)
.filter((roomId) => selectedManageRoomIds.has(roomId))
const notificationIds = selectedIds.filter((roomId) =>
selectedManageNotificationRoomIds.has(roomId)
)
const nextState = await api.setGroupExitMonitorGroups(selectedIds, notificationIds)
setState(nextState)
setView('events')
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setSaving(false)
}
}
const saveNotificationTemplate = async (template: string): Promise<boolean> => {
const api = window.api
if (templateSaving || typeof api.setGroupExitMonitorNotificationTemplate !== 'function') {
setTemplateError('当前版本不支持保存退群监测模板')
return false
}
setTemplateSaving(true)
setTemplateError('')
try {
const nextState = await api.setGroupExitMonitorNotificationTemplate(template)
setState(nextState)
return true
} catch (reason) {
setTemplateError(reason instanceof Error ? reason.message : String(reason))
return false
} finally {
setTemplateSaving(false)
}
}
const toggleManageGroup = (roomId: string): void => {
const wasSelected = selectedManageRoomIds.has(roomId)
setSelectedManageRoomIds((current) => {
const next = new Set(current)
if (next.has(roomId)) next.delete(roomId)
else next.add(roomId)
return next
})
if (wasSelected) {
setSelectedManageNotificationRoomIds((notifications) => {
const nextNotifications = new Set(notifications)
nextNotifications.delete(roomId)
return nextNotifications
})
}
}
const toggleAllManageGroups = (): void => {
const allRoomIds = groups.map(contactRoomId)
const shouldSelect = allRoomIds.some((roomId) => !selectedManageRoomIds.has(roomId))
setSelectedManageRoomIds((current) => {
const next = new Set(current)
allRoomIds.forEach((roomId) => {
if (shouldSelect) next.add(roomId)
else next.delete(roomId)
})
return next
})
if (!shouldSelect) setSelectedManageNotificationRoomIds(new Set())
}
const toggleManageNotification = (roomId: string): void => {
if (!selectedManageRoomIds.has(roomId)) return
setSelectedManageNotificationRoomIds((current) => {
const next = new Set(current)
if (next.has(roomId)) next.delete(roomId)
else next.add(roomId)
return next
})
}
if (view === 'manage') {
return (
<ManageGroups
groups={groups}
selectedRoomIds={selectedManageRoomIds}
notificationRoomIds={selectedManageNotificationRoomIds}
sendCapability={sendCapability}
keyword={manageKeyword}
listFilter={manageFilter}
saving={saving}
dbReady={dbReady}
onKeywordChange={setManageKeyword}
onListFilterChange={setManageFilter}
onToggle={toggleManageGroup}
onToggleNotification={toggleManageNotification}
onToggleAll={toggleAllManageGroups}
onSave={() => void saveGroups()}
onCancel={() => setView('events')}
notificationTemplate={notificationTemplate}
templateSaving={templateSaving}
templateError={templateError}
onSaveTemplate={saveNotificationTemplate}
/>
)
}
const statusLabel = !dbReady
? '数据库未连接'
: state.running && state.nativeMonitorActive
? '实时监听已启用'
: state.running
? '已连接,等待原生监听'
: '等待启动'
return (
<div className="exit-monitor-page">
<header className="exit-monitor-header">
<div>
<span className="exit-monitor-eyebrow">群成员变化</span>
<h1>退群监控</h1>
<p>实时关注微信群成员变化,成员数量减少时自动留下退群动态。</p>
</div>
<div className="exit-monitor-header-actions">
<span className={`exit-monitor-status ${state.running ? 'running' : ''}`}>
<span className="exit-monitor-status-dot" aria-hidden="true" />
{statusLabel}
</span>
<SendCapabilityStatus capability={sendCapability} />
<Button variant="outline" size="sm" onClick={openManage}>
管理群聊
</Button>
<Button
variant="outline"
size="sm"
onClick={() => void checkNow()}
disabled={!dbReady || checking}
>
{checking ? '检查中...' : '立即检查'}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => void clearEvents()}
disabled={!state.events.length}
>
清空记录
</Button>
</div>
</header>
{setupEmpty ? (
<section className="exit-monitor-setup-empty" aria-labelledby="exit-monitor-setup-title">
<div className="exit-monitor-setup-icon" aria-hidden="true">
<svg viewBox="0 0 48 48" focusable="false">
<path d="M10 13.5h28v22H10z" />
<path d="M16 20h16M16 26h10" />
<path d="M34 9v9M29.5 13.5h9" />
</svg>
</div>
<h2 id="exit-monitor-setup-title">还没有设置监控群聊</h2>
<p>选择需要关注的群聊,TraceMemo 会在成员数量变化时及时提醒你。</p>
<Button onClick={openManage}>选择群聊</Button>
</section>
) : (
<>
<div className="exit-monitor-summary" aria-label="监控状态">
<div>
<strong>
{state.monitorSelectionConfigured
? (state.monitoredRoomIds || []).length
: state.monitoredGroupCount}
</strong>
<span>已监控群聊</span>
</div>
<div>
<strong>{todayExitCount}</strong>
<span>今日退群</span>
</div>
<div>
<strong>{formatLastChecked(state.lastCheckedAt)}</strong>
<span>最近检查</span>
</div>
</div>
{error ? <p className="exit-monitor-error">{error}</p> : null}
<section className="exit-monitor-events" aria-labelledby="exit-monitor-events-title">
<div className="exit-monitor-section-heading">
<div>
<span className="exit-monitor-eyebrow">实时事件</span>
<h2 id="exit-monitor-events-title">退群动态</h2>
</div>
<div className="exit-monitor-section-tools">
{state.unreadCount > 0 ? (
<span className="exit-monitor-unread">{state.unreadCount} 条未读</span>
) : null}
<Select
value={selectedRoomId}
onValueChange={setSelectedRoomId}
disabled={!groupOptions.length}
>
<SelectTrigger aria-label="筛选退群群聊" className="exit-monitor-filter-trigger">
<SelectValue placeholder="筛选群聊" />
</SelectTrigger>
<SelectContent className="z-modal">
<SelectItem value="all">全部有退群记录的群聊</SelectItem>
{groupOptions.map((group) => (
<SelectItem key={group.roomId} value={group.roomId}>
{group.groupName}({group.count} 条)
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="exit-monitor-event-list">
{visibleEvents.length ? (
visibleEvents.map((event) => (
<article key={event.id} className="exit-monitor-event">
<span className="exit-monitor-event-dot" aria-hidden="true" />
<div className="exit-monitor-event-body">
<div className="exit-monitor-event-heading">
<strong>{event.message}</strong>
<time dateTime={new Date(event.detectedAt).toISOString()}>
{formatDetectedAt(event.detectedAt)}
</time>
</div>
{event.previousCount > event.currentCount ? (
<p className="exit-monitor-event-count">
群人数 {event.previousCount} 人 → {event.currentCount} 人
</p>
) : null}
<dl className="exit-monitor-event-details">
<div>
<dt>微信名</dt>
<dd>{event.wechatName || '未读取到'}</dd>
</div>
<div>
<dt>群备注</dt>
<dd>{event.groupRemark || '未设置'}</dd>
</div>
{event.contactRemark ? (
<div>
<dt>通讯录备注</dt>
<dd>{event.contactRemark}</dd>
</div>
) : null}
<div>
<dt>微信号</dt>
<dd>{event.memberWxid || '未读取到'}</dd>
</div>
<div>
<dt>退群时间</dt>
<dd>{formatDetectedAt(event.detectedAt)}</dd>
</div>
</dl>
</div>
</article>
))
) : (
<EmptyState
className="exit-monitor-events-empty"
title={state.events.length ? '该群暂无退群记录' : '暂无退群记录'}
description={
state.events.length && selectedRoomId !== 'all'
? '请选择其他有退群记录的群聊。'
: dbReady
? '成员数量减少后,退群动态会自动显示在这里。'
: '连接微信数据库后,退群事件会显示在这里。'
}
action={
!state.events.length && dbReady ? (
<Button variant="outline" size="sm" onClick={() => void checkNow()}>
立即检查
</Button>
) : undefined
}
/>
)}
</div>
</section>
</>
)}
</div>
)
}
@@ -0,0 +1,713 @@
.primary-nav-mark {
position: relative;
}
.primary-nav-badge {
position: absolute;
right: -9px;
top: -7px;
box-sizing: border-box;
min-width: 17px;
height: 17px;
padding: 0 4px;
border: 2px solid var(--wxex-bg-sidebar);
border-radius: 999px;
background: var(--wxex-danger);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 13px;
text-align: center;
}
.exit-monitor-page,
.exit-monitor-manage-page {
box-sizing: border-box;
display: flex;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
flex-direction: column;
gap: 26px;
overflow: auto;
padding: 34px clamp(22px, 4vw, 64px) 44px;
background: var(--wxex-bg-main);
color: var(--wxex-text-primary);
}
.exit-monitor-header,
.exit-monitor-manage-header,
.exit-monitor-section-heading,
.exit-monitor-manage-toolbar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 24px;
}
.exit-monitor-header h1,
.exit-monitor-manage-header h1,
.exit-monitor-section-heading h2,
.exit-monitor-manage-toolbar h2 {
margin: 3px 0 0;
color: var(--wxex-text-primary);
font-weight: 700;
}
.exit-monitor-header h1,
.exit-monitor-manage-header h1 {
font-size: 26px;
line-height: 34px;
}
.exit-monitor-section-heading h2,
.exit-monitor-manage-toolbar h2 {
font-size: 18px;
line-height: 26px;
}
.exit-monitor-header p,
.exit-monitor-manage-header p {
max-width: 620px;
margin: 7px 0 0;
color: var(--wxex-text-secondary);
font-size: 13px;
line-height: 20px;
}
.exit-monitor-eyebrow {
color: var(--wxex-text-muted);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
line-height: 16px;
text-transform: uppercase;
}
.exit-monitor-header-actions,
.exit-monitor-section-tools,
.exit-monitor-manage-tools {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.exit-monitor-status,
.exit-monitor-send-status,
.exit-monitor-unread,
.exit-monitor-manage-count {
display: inline-flex;
min-height: 28px;
box-sizing: border-box;
align-items: center;
border: 1px solid var(--wxex-border);
border-radius: 999px;
padding: 4px 10px;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-status.running {
border-color: color-mix(in srgb, var(--wxex-success) 45%, var(--wxex-border));
color: var(--wxex-success);
}
.exit-monitor-send-status.ready {
border-color: color-mix(in srgb, var(--wxex-success) 45%, var(--wxex-border));
color: var(--wxex-success);
}
.exit-monitor-send-status.unready {
border-color: var(--wxex-border);
background: color-mix(in srgb, var(--wxex-bg-elevated) 72%, transparent);
color: var(--wxex-text-muted);
}
.exit-monitor-status-dot {
width: 7px;
height: 7px;
margin-right: 7px;
border-radius: 999px;
background: currentColor;
}
.exit-monitor-unread {
border-color: color-mix(in srgb, var(--wxex-danger) 45%, var(--wxex-border));
color: var(--wxex-danger);
}
.exit-monitor-manage-count {
align-self: flex-start;
border-color: color-mix(in srgb, var(--wxex-brand) 30%, var(--wxex-border));
background: color-mix(in srgb, var(--wxex-brand-soft) 52%, transparent);
color: var(--wxex-text-secondary);
}
.exit-monitor-manage-header-status {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: flex-end;
gap: 8px;
}
.exit-monitor-template-popover {
width: min(390px, calc(100vw - 32px));
}
.exit-monitor-template-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.exit-monitor-template-heading h2 {
margin: 2px 0 0;
color: var(--wxex-text-primary);
font-size: 15px;
font-weight: 700;
line-height: 22px;
}
.exit-monitor-template-status {
flex: 0 0 auto;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-editor {
margin: 14px 0 0;
min-height: 190px;
resize: vertical;
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 20px;
}
.exit-monitor-template-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-top: 6px;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-popover p {
margin: 10px 0 0;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
}
.exit-monitor-template-error {
color: var(--wxex-danger) !important;
}
.exit-monitor-template-actions {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.exit-monitor-manage-count strong {
margin: 0 4px;
color: var(--wxex-brand);
font-weight: 700;
}
.exit-monitor-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
border-top: 1px solid var(--wxex-border);
border-bottom: 1px solid var(--wxex-border);
}
.exit-monitor-summary > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 3px;
padding: 15px 20px;
border-right: 1px solid var(--wxex-border);
}
.exit-monitor-summary > div:last-child {
border-right: 0;
}
.exit-monitor-summary strong {
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 20px;
font-weight: 700;
line-height: 28px;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-summary span {
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-error {
margin: -12px 0 0;
color: var(--wxex-danger);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-events {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 14px;
}
.exit-monitor-event-list {
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
}
.exit-monitor-event {
display: grid;
grid-template-columns: 8px minmax(0, 1fr);
gap: 14px;
box-sizing: border-box;
padding: 16px 18px;
border: 1px solid var(--wxex-border);
border-radius: var(--wxex-radius-md);
background: var(--wxex-bg-elevated);
}
.exit-monitor-event-dot {
width: 8px;
height: 8px;
margin-top: 6px;
border-radius: 999px;
background: var(--wxex-danger);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--wxex-danger) 12%, transparent);
}
.exit-monitor-event-body {
min-width: 0;
}
.exit-monitor-event-heading {
display: flex;
min-width: 0;
align-items: baseline;
justify-content: space-between;
gap: 16px;
}
.exit-monitor-event-heading strong {
display: block;
min-width: 0;
overflow: hidden;
color: var(--wxex-text-primary);
font-size: 14px;
font-weight: 600;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-event-heading time {
flex: 0 0 auto;
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-event-count {
display: block;
margin: 5px 0 0;
color: var(--wxex-text-secondary);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-event-count span {
padding: 0 4px;
color: var(--wxex-danger);
}
.exit-monitor-event-details {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 7px 24px;
margin: 12px 0 0;
padding-top: 11px;
border-top: 1px solid var(--wxex-border);
}
.exit-monitor-event-details > div {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: 8px;
align-items: baseline;
}
.exit-monitor-event-details dt,
.exit-monitor-event-details dd {
min-width: 0;
overflow: hidden;
font-size: 12px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-event-details dt {
color: var(--wxex-text-muted);
}
.exit-monitor-event-details dd {
margin: 0;
color: var(--wxex-text-secondary);
}
.exit-monitor-events-empty,
.exit-monitor-manage-empty {
width: 100%;
min-height: 220px;
border-color: var(--wxex-border);
border-style: solid;
background: color-mix(in srgb, var(--wxex-bg-elevated) 62%, transparent);
}
.exit-monitor-setup-empty {
display: flex;
min-height: 380px;
flex: 1 1 auto;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px 24px;
text-align: center;
}
.exit-monitor-setup-icon {
display: grid;
width: 64px;
height: 64px;
place-items: center;
margin-bottom: 4px;
border: 1px solid color-mix(in srgb, var(--wxex-brand) 25%, var(--wxex-border));
border-radius: 16px;
background: color-mix(in srgb, var(--wxex-brand-soft) 70%, transparent);
color: var(--wxex-brand);
}
.exit-monitor-setup-icon svg {
width: 34px;
height: 34px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.6;
}
.exit-monitor-setup-empty h2 {
color: var(--wxex-text-primary);
font-size: 18px;
font-weight: 700;
line-height: 26px;
}
.exit-monitor-setup-empty p {
max-width: 420px;
color: var(--wxex-text-secondary);
font-size: 13px;
line-height: 20px;
}
.exit-monitor-back {
display: flex;
align-items: center;
gap: 4px;
width: fit-content;
margin: 0 0 16px;
padding: 0;
border: 0;
background: transparent;
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 12px;
line-height: 18px;
}
.exit-monitor-back:hover {
color: var(--wxex-brand);
}
.exit-monitor-back span {
font-size: 20px;
line-height: 16px;
}
.exit-monitor-manage-page {
gap: 24px;
}
.exit-monitor-manage-section {
display: flex;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
flex-direction: column;
gap: 18px;
}
.exit-monitor-manage-tools {
align-items: flex-start;
}
.exit-monitor-group-search {
width: min(260px, 42vw);
min-width: 180px;
}
.exit-monitor-group-filter {
width: 112px;
}
.exit-monitor-group-list {
min-width: 0;
overflow: auto;
border-top: 1px solid var(--wxex-border);
border-bottom: 1px solid var(--wxex-border);
}
.exit-monitor-group-row {
display: grid;
min-width: 0;
grid-template-columns: 18px 40px minmax(0, 1fr) auto auto;
gap: 12px;
align-items: center;
padding: 13px 14px;
border-bottom: 1px solid var(--wxex-border);
background: transparent;
transition: background-color 120ms ease;
}
.exit-monitor-group-row:last-child {
border-bottom: 0;
}
.exit-monitor-group-row:hover {
background: color-mix(in srgb, var(--wxex-brand-soft) 35%, transparent);
}
.exit-monitor-group-row.selected {
background: color-mix(in srgb, var(--wxex-brand-soft) 62%, transparent);
}
.exit-monitor-group-avatar {
display: grid;
width: 40px;
height: 40px;
place-items: center;
overflow: hidden;
border-radius: 10px;
background: color-mix(in srgb, var(--wxex-brand-soft) 78%, var(--wxex-bg-elevated));
color: var(--wxex-brand);
font-size: 16px;
font-weight: 700;
}
.exit-monitor-group-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.exit-monitor-group-row-body {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.exit-monitor-group-row-body strong,
.exit-monitor-group-row-body span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.exit-monitor-group-row-body strong {
color: var(--wxex-text-primary);
font-size: 13px;
font-weight: 600;
line-height: 20px;
}
.exit-monitor-group-row-body span {
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 17px;
}
.exit-monitor-group-row-status {
color: var(--wxex-text-muted);
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-group-row.selected .exit-monitor-group-row-status {
color: var(--wxex-brand);
}
.exit-monitor-group-notification {
display: inline-flex;
min-width: 82px;
align-items: center;
justify-content: flex-end;
gap: 7px;
color: var(--wxex-text-secondary);
cursor: pointer;
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.exit-monitor-group-notification:has([data-disabled]) {
color: var(--wxex-text-muted);
cursor: not-allowed;
opacity: 0.65;
}
.exit-monitor-manage-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding-top: 16px;
border-top: 1px solid var(--wxex-border);
color: var(--wxex-text-muted);
font-size: 12px;
line-height: 18px;
}
.exit-monitor-manage-footer > div {
display: flex;
flex: 0 0 auto;
gap: 8px;
}
@media (max-width: 760px) {
.exit-monitor-page,
.exit-monitor-manage-page {
padding: 24px 16px 30px;
}
.exit-monitor-header,
.exit-monitor-manage-header,
.exit-monitor-section-heading,
.exit-monitor-manage-toolbar {
flex-direction: column;
}
.exit-monitor-header-actions,
.exit-monitor-section-tools,
.exit-monitor-manage-tools {
width: 100%;
justify-content: flex-start;
}
.exit-monitor-manage-count {
align-self: flex-start;
}
.exit-monitor-manage-header-status {
justify-content: flex-start;
}
.exit-monitor-filter-trigger {
min-width: min(100%, 240px);
}
.exit-monitor-summary {
grid-template-columns: 1fr;
}
.exit-monitor-summary > div {
border-right: 0;
border-bottom: 1px solid var(--wxex-border);
}
.exit-monitor-summary > div:last-child {
border-bottom: 0;
}
.exit-monitor-event-heading {
display: block;
}
.exit-monitor-event-heading time {
display: block;
margin-top: 3px;
}
.exit-monitor-event-details {
grid-template-columns: 1fr;
}
.exit-monitor-group-search {
width: min(100%, 320px);
}
.exit-monitor-group-filter {
width: min(100%, 160px);
}
.exit-monitor-group-row {
grid-template-columns: 18px 40px minmax(0, 1fr) auto;
gap: 8px 10px;
}
.exit-monitor-group-row-status {
grid-column: 3;
grid-row: 2;
}
.exit-monitor-group-notification {
grid-column: 4;
grid-row: 2;
min-width: 0;
}
.exit-monitor-manage-footer {
align-items: flex-start;
flex-direction: column;
}
.exit-monitor-manage-footer > div {
width: 100%;
justify-content: flex-end;
}
}
+1
View File
@@ -16,4 +16,5 @@
@use './settings-advanced';
@use './settings-preferences';
@use './settings-text-to-speech';
@use './group-exit-monitor';
@use './theme';