feat: 优化档案搜索与头像

新增退群监控开关
发送限速
This commit is contained in:
Wxw-Gu
2026-09-03 18:12:29 +08:00
parent 3462d49516
commit 719ffa0a24
33 changed files with 1025 additions and 64 deletions
+10 -29
View File
@@ -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
View File
@@ -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;
}
}
+16
View File
@@ -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_')
)
)
)
}
+17
View File
@@ -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))
)
}