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