mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-05 21:05:39 +08:00
feat: 优化档案搜索与头像
新增退群监控开关 发送限速
This commit is contained in:
+10
-29
@@ -43,6 +43,9 @@ import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-env
|
||||
import { useToast } from './components/ui'
|
||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||
import { LogsWorkspace } from './features/logs/LogsWorkspace'
|
||||
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
||||
import { filterContacts } from './utils/contact-search'
|
||||
|
||||
const SIDEBAR_MIN_WIDTH = 260
|
||||
const SIDEBAR_MAX_WIDTH = 380
|
||||
@@ -137,16 +140,6 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||
return true
|
||||
}
|
||||
|
||||
const filterContactList = (list: Contact[], keyword: string): Contact[] => {
|
||||
const lower = keyword.trim().toLowerCase()
|
||||
if (!lower) return list
|
||||
return list.filter((contact) =>
|
||||
[contact.m_nsNickName, contact.m_nsUsrName, contact.remark, contact.wechatNickname].some(
|
||||
(value) => value?.toLowerCase().includes(lower)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
type GroupSnapshot = {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
@@ -598,7 +591,7 @@ function App(): React.ReactElement {
|
||||
options?.onProgress?.('正在加载联系人...', 35)
|
||||
const list = await window.api.getContacts()
|
||||
setContacts(list)
|
||||
setFilteredContacts(filterContactList(list, options?.filterKeyword || ''))
|
||||
setFilteredContacts(filterContacts(list, options?.filterKeyword || ''))
|
||||
const runId = ++contactAvatarHydrationRunRef.current
|
||||
const hydrate = (): Promise<void> => hydrateContactAvatars(list, runId, options?.onProgress)
|
||||
if (options?.waitForAvatars) {
|
||||
@@ -615,21 +608,7 @@ function App(): React.ReactElement {
|
||||
runId: number,
|
||||
onProgress?: (message: string, percent?: number) => void
|
||||
): Promise<void> => {
|
||||
const usernames = Array.from(
|
||||
new Set(
|
||||
list
|
||||
.map((contact) => contact.m_nsUsrName)
|
||||
.filter((username, index) => {
|
||||
const contact = list[index]
|
||||
return (
|
||||
username &&
|
||||
!contact.avatar &&
|
||||
!username.startsWith('Group_') &&
|
||||
!username.startsWith('Unknown_')
|
||||
)
|
||||
})
|
||||
)
|
||||
)
|
||||
const usernames = selectContactAvatarRefreshUsernames(list)
|
||||
onProgress?.(
|
||||
usernames.length ? `正在加载头像 0/${usernames.length}...` : '头像缓存已就绪',
|
||||
usernames.length ? 55 : 90
|
||||
@@ -641,7 +620,7 @@ function App(): React.ReactElement {
|
||||
const chunk = usernames.slice(index, index + chunkSize)
|
||||
if (chunk.length === 0) continue
|
||||
try {
|
||||
const avatars = await window.api.getContactAvatars(chunk)
|
||||
const avatars = await window.api.getContactAvatars(chunk, { refresh: true })
|
||||
if (runId !== contactAvatarHydrationRunRef.current) return
|
||||
loadedCount += chunk.length
|
||||
onProgress?.(
|
||||
@@ -1052,7 +1031,7 @@ function App(): React.ReactElement {
|
||||
if (!contact) return
|
||||
|
||||
try {
|
||||
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
|
||||
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName], { refresh: true })
|
||||
const avatar = avatars[contact.m_nsUsrName]
|
||||
if (avatar) {
|
||||
const updateContact = (item: Contact): Contact =>
|
||||
@@ -1457,7 +1436,7 @@ function App(): React.ReactElement {
|
||||
])
|
||||
|
||||
const handleSearchContacts = (keyword: string): void => {
|
||||
setFilteredContacts(filterContactList(contacts, keyword))
|
||||
setFilteredContacts(filterContacts(contacts, keyword))
|
||||
}
|
||||
|
||||
const handleRefreshContacts = async (filterKeyword: string): Promise<void> => {
|
||||
@@ -1944,6 +1923,8 @@ function App(): React.ReactElement {
|
||||
onOpenSendSettings={openWechatSendSettings}
|
||||
/>
|
||||
)
|
||||
case 'logs':
|
||||
return <LogsWorkspace />
|
||||
case 'agent-hub':
|
||||
return <AgentHubWorkspace />
|
||||
case 'api':
|
||||
|
||||
@@ -16,22 +16,29 @@ export function ConversationItem({
|
||||
const wxid = contact.m_nsUsrName
|
||||
const displayName = nickname || wxid || '未命名会话'
|
||||
const initial = (displayName || wxid || '?').charAt(0)
|
||||
const [repairedAvatar, setRepairedAvatar] = useState<{ username: string; source: string }>()
|
||||
const [repairedAvatar, setRepairedAvatar] = useState<{
|
||||
username: string
|
||||
source: string
|
||||
replaces: string
|
||||
}>()
|
||||
const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>()
|
||||
const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined
|
||||
const repairedSource =
|
||||
repairedAvatar?.username === wxid && repairedAvatar.replaces === contact.avatar
|
||||
? repairedAvatar.source
|
||||
: undefined
|
||||
const avatar = repairedSource || contact.avatar
|
||||
const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar
|
||||
|
||||
const handleAvatarError = (): void => {
|
||||
if (!avatar || avatarFailed) return
|
||||
setFailedAvatar({ username: wxid, source: avatar })
|
||||
if (contact.type !== 'group' || avatar.startsWith('data:')) return
|
||||
if (avatar.startsWith('data:')) return
|
||||
void window.api
|
||||
.getContactAvatars([wxid])
|
||||
.getContactAvatars([wxid], { refresh: true })
|
||||
.then((avatars) => {
|
||||
const fallback = avatars[wxid]
|
||||
if (!fallback || fallback === avatar) return
|
||||
setRepairedAvatar({ username: wxid, source: fallback })
|
||||
setRepairedAvatar({ username: wxid, source: fallback, replaces: avatar })
|
||||
setFailedAvatar(undefined)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -46,6 +46,13 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
|
||||
<path d="m16.5 9 3 3-3 3" />
|
||||
</svg>
|
||||
)
|
||||
case 'logs':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M6 5.5h12v13H6z" />
|
||||
<path d="M9 9h6M9 12h6M9 15h4" />
|
||||
</svg>
|
||||
)
|
||||
case 'export':
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
|
||||
@@ -3,6 +3,7 @@ export type AppPage =
|
||||
| 'search'
|
||||
| 'report'
|
||||
| 'exit-monitor'
|
||||
| 'logs'
|
||||
| 'agent-hub'
|
||||
| 'export'
|
||||
| 'api'
|
||||
@@ -18,6 +19,7 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
||||
{ id: 'search', label: '问问微信' },
|
||||
{ id: 'report', label: '日报' },
|
||||
{ id: 'exit-monitor', label: '退群监控' },
|
||||
// { id: 'logs', label: '日志' },
|
||||
{ id: 'agent-hub', label: 'Agent' },
|
||||
{ id: 'export', label: '导出' },
|
||||
{ id: 'api', label: 'API' },
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -36,6 +37,7 @@ interface GroupExitMonitorWorkspaceProps {
|
||||
|
||||
const EMPTY_STATE: GroupExitMonitorState = {
|
||||
events: [],
|
||||
enabled: true,
|
||||
running: false,
|
||||
nativeMonitorActive: false,
|
||||
monitoredGroupCount: 0,
|
||||
@@ -443,6 +445,7 @@ export function GroupExitMonitorWorkspace({
|
||||
}: GroupExitMonitorWorkspaceProps): React.ReactElement {
|
||||
const [state, setState] = React.useState<GroupExitMonitorState>(EMPTY_STATE)
|
||||
const [checking, setChecking] = React.useState(false)
|
||||
const [togglingEnabled, setTogglingEnabled] = React.useState(false)
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [error, setError] = React.useState('')
|
||||
const [sendCapability, setSendCapability] = React.useState<PersonalWechatSendCapability | null>(
|
||||
@@ -633,6 +636,20 @@ export function GroupExitMonitorWorkspace({
|
||||
}
|
||||
}
|
||||
|
||||
const setMonitorEnabled = async (enabled: boolean): Promise<void> => {
|
||||
const api = window.api
|
||||
if (togglingEnabled || typeof api.setGroupExitMonitorEnabled !== 'function') return
|
||||
setTogglingEnabled(true)
|
||||
setError('')
|
||||
try {
|
||||
setState(await api.setGroupExitMonitorEnabled(enabled))
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : String(reason))
|
||||
} finally {
|
||||
setTogglingEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleManageGroup = (roomId: string): void => {
|
||||
const wasSelected = selectedManageRoomIds.has(roomId)
|
||||
setSelectedManageRoomIds((current) => {
|
||||
@@ -702,6 +719,8 @@ export function GroupExitMonitorWorkspace({
|
||||
|
||||
const statusLabel = !dbReady
|
||||
? '数据库未连接'
|
||||
: !state.enabled
|
||||
? '监控已暂停'
|
||||
: state.running && state.nativeMonitorActive
|
||||
? '实时监听已启用'
|
||||
: state.running
|
||||
@@ -717,6 +736,15 @@ export function GroupExitMonitorWorkspace({
|
||||
<p>实时关注微信群成员变化,成员数量减少时自动留下退群动态。</p>
|
||||
</div>
|
||||
<div className="exit-monitor-header-actions">
|
||||
<label className="flex items-center gap-2 text-sm font-medium">
|
||||
<span>退群监控</span>
|
||||
<Switch
|
||||
checked={state.enabled}
|
||||
disabled={togglingEnabled}
|
||||
onCheckedChange={(checked) => void setMonitorEnabled(checked)}
|
||||
aria-label="退群监控总开关"
|
||||
/>
|
||||
</label>
|
||||
<span className={`exit-monitor-status ${state.running ? 'running' : ''}`}>
|
||||
<span className="exit-monitor-status-dot" aria-hidden="true" />
|
||||
{statusLabel}
|
||||
@@ -729,7 +757,7 @@ export function GroupExitMonitorWorkspace({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void checkNow()}
|
||||
disabled={!dbReady || checking}
|
||||
disabled={!dbReady || !state.enabled || checking}
|
||||
>
|
||||
{checking ? '检查中...' : '立即检查'}
|
||||
</Button>
|
||||
@@ -869,7 +897,7 @@ export function GroupExitMonitorWorkspace({
|
||||
: '连接微信数据库后,退群事件会显示在这里。'
|
||||
}
|
||||
action={
|
||||
!state.events.length && dbReady ? (
|
||||
!state.events.length && dbReady && state.enabled ? (
|
||||
<Button variant="outline" size="sm" onClick={() => void checkNow()}>
|
||||
立即检查
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import React from 'react'
|
||||
import type { ActionLogEntry } from '../../../../shared/action-log'
|
||||
import type { WechatActionPurpose, WechatActionStatus } from '../../../../shared/wechat-action'
|
||||
import {
|
||||
EmptyState,
|
||||
Input,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Spinner
|
||||
} from '../../components/ui'
|
||||
|
||||
type StatusFilter = 'all' | WechatActionStatus
|
||||
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
|
||||
|
||||
const purposeLabel = (purpose: WechatActionPurpose): string => {
|
||||
if (purpose === 'member_left_notification') return '退群通知'
|
||||
if (purpose === 'scheduled_report') return '定时日报'
|
||||
return purpose || '其它动作'
|
||||
}
|
||||
|
||||
const contentTypeLabel = (type: ActionLogEntry['contentType']): string => {
|
||||
if (type === 'image') return '图片'
|
||||
if (type === 'voice') return '语音'
|
||||
return '文字'
|
||||
}
|
||||
|
||||
const statusDetails = (
|
||||
entry: ActionLogEntry
|
||||
): { label: string; className: string } => {
|
||||
if (entry.status === 'sent') return { label: '已发送', className: 'text-success' }
|
||||
if (entry.status === 'blocked') return { label: '被阻止', className: 'text-warning' }
|
||||
if (
|
||||
entry.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
||||
entry.errorCode === 'SEND_NOT_READY'
|
||||
) {
|
||||
return { label: '发送能力不可用', className: 'text-warning' }
|
||||
}
|
||||
return { label: '失败', className: 'text-destructive' }
|
||||
}
|
||||
|
||||
const matchesPurpose = (entry: ActionLogEntry, filter: PurposeFilter): boolean => {
|
||||
if (filter === 'all') return true
|
||||
if (filter === 'other') {
|
||||
return entry.purpose !== 'member_left_notification' && entry.purpose !== 'scheduled_report'
|
||||
}
|
||||
return entry.purpose === filter
|
||||
}
|
||||
|
||||
export function LogsWorkspace(): React.ReactElement {
|
||||
const [entries, setEntries] = React.useState<ActionLogEntry[]>([])
|
||||
const [loading, setLoading] = React.useState(true)
|
||||
const [error, setError] = React.useState('')
|
||||
const [keyword, setKeyword] = React.useState('')
|
||||
const [status, setStatus] = React.useState<StatusFilter>('all')
|
||||
const [purpose, setPurpose] = React.useState<PurposeFilter>('all')
|
||||
|
||||
React.useEffect(() => {
|
||||
let disposed = false
|
||||
void window.api
|
||||
.listWechatActionLogs()
|
||||
.then((items) => {
|
||||
if (!disposed) setEntries(items)
|
||||
})
|
||||
.catch((reason) => {
|
||||
if (!disposed) setError(reason instanceof Error ? reason.message : String(reason))
|
||||
})
|
||||
.finally(() => {
|
||||
if (!disposed) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const visibleEntries = React.useMemo(() => {
|
||||
const query = keyword.trim().toLocaleLowerCase()
|
||||
return entries.filter((entry) => {
|
||||
if (status !== 'all' && entry.status !== status) return false
|
||||
if (!matchesPurpose(entry, purpose)) return false
|
||||
if (!query) return true
|
||||
return [entry.recipientName, entry.recipientId, entry.contentPreview]
|
||||
.filter(Boolean)
|
||||
.some((value) => value!.toLocaleLowerCase().includes(query))
|
||||
})
|
||||
}, [entries, keyword, purpose, status])
|
||||
|
||||
return (
|
||||
<div className="h-full min-w-0 overflow-y-auto bg-canvas">
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-5 px-6 py-6">
|
||||
<header>
|
||||
<h1 className="text-xl font-semibold text-foreground">日志</h1>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-[minmax(220px,1fr)_180px_180px]">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="搜索日志"
|
||||
placeholder="搜索对象、ID 或内容"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
/>
|
||||
<Select value={purpose} onValueChange={(value) => setPurpose(value as PurposeFilter)}>
|
||||
<SelectTrigger aria-label="筛选日志类型">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="member_left_notification">退群通知</SelectItem>
|
||||
<SelectItem value="scheduled_report">定时日报</SelectItem>
|
||||
<SelectItem value="other">其它动作</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={status} onValueChange={(value) => setStatus(value as StatusFilter)}>
|
||||
<SelectTrigger aria-label="筛选日志状态">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="sent">已发送</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
<SelectItem value="blocked">被阻止</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex min-h-40 items-center justify-center">
|
||||
<Spinner label="正在加载日志" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<EmptyState title="日志读取失败" description={error} />
|
||||
) : visibleEntries.length ? (
|
||||
<section className="grid gap-3" aria-label="操作日志列表">
|
||||
{visibleEntries.map((entry) => {
|
||||
const result = statusDetails(entry)
|
||||
const recipient = entry.recipientName || entry.recipientId
|
||||
return (
|
||||
<article
|
||||
key={entry.id}
|
||||
className="grid gap-3 rounded-md border border-border-subtle bg-surface px-4 py-3 shadow-surface"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<strong className="text-sm text-foreground">{purposeLabel(entry.purpose)}</strong>
|
||||
<p className="mt-1 break-words text-sm text-foreground">发送给 {recipient}</p>
|
||||
</div>
|
||||
<div className="text-right text-xs">
|
||||
<strong className={result.className}>{result.label}</strong>
|
||||
<time className="mt-1 block text-muted-foreground" dateTime={entry.timestamp}>
|
||||
{new Date(entry.timestamp).toLocaleString('zh-CN', { hour12: false })}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-[140px_1fr]">
|
||||
<span>
|
||||
{entry.recipientType === 'group' ? '群聊' : '联系人'} · {entry.recipientId}
|
||||
</span>
|
||||
<span className="break-words">
|
||||
{contentTypeLabel(entry.contentType)} · {entry.contentPreview || '无内容预览'}
|
||||
</span>
|
||||
</div>
|
||||
{entry.reason ? (
|
||||
<p className="break-words text-xs text-muted-foreground">{entry.reason}</p>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</section>
|
||||
) : (
|
||||
<EmptyState
|
||||
title={entries.length ? '没有匹配的日志' : '暂无日志'}
|
||||
description={entries.length ? '请调整搜索或筛选条件。' : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+1
-2
@@ -357,7 +357,6 @@ body {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
padding-top: var(--wxex-shell-content-top);
|
||||
background: var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
@@ -369,4 +368,4 @@ body {
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Contact } from '../../../shared/types'
|
||||
|
||||
export function selectContactAvatarRefreshUsernames(contacts: Contact[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
contacts
|
||||
.map((contact) => contact.m_nsUsrName)
|
||||
.filter(
|
||||
(username) =>
|
||||
Boolean(username) &&
|
||||
!username.startsWith('Group_') &&
|
||||
!username.startsWith('Unknown_')
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Contact } from '../../../shared/types'
|
||||
|
||||
export function filterContacts(list: Contact[], keyword: string): Contact[] {
|
||||
const query = keyword.trim().toLocaleLowerCase()
|
||||
if (!query) return list
|
||||
|
||||
return list.filter((contact) =>
|
||||
[
|
||||
contact.remark,
|
||||
contact.wechatNickname,
|
||||
contact.m_nsNickName,
|
||||
contact.alias,
|
||||
contact.wechatId,
|
||||
contact.m_nsUsrName
|
||||
].some((value) => value?.trim().toLocaleLowerCase().includes(query))
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user