mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-06 13:54:10 +08:00
fix: 优化微信发送日志展示
This commit is contained in:
@@ -38,6 +38,7 @@ export class WechatActionLogService {
|
|||||||
category: 'wechat_send' as const,
|
category: 'wechat_send' as const,
|
||||||
source: record.origin,
|
source: record.origin,
|
||||||
purpose: record.purpose,
|
purpose: record.purpose,
|
||||||
|
...(record.triggerType ? { triggerType: record.triggerType } : {}),
|
||||||
timestamp: record.finishedAt || record.startedAt || record.createdAt,
|
timestamp: record.finishedAt || record.startedAt || record.createdAt,
|
||||||
recipientType: record.recipientType,
|
recipientType: record.recipientType,
|
||||||
recipientId: record.recipientId,
|
recipientId: record.recipientId,
|
||||||
@@ -46,7 +47,9 @@ export class WechatActionLogService {
|
|||||||
contentPreview: record.contentPreview,
|
contentPreview: record.contentPreview,
|
||||||
status: record.sendStatus,
|
status: record.sendStatus,
|
||||||
errorCode: record.errorCode,
|
errorCode: record.errorCode,
|
||||||
reason: record.decisionReason
|
reason: record.decisionReason,
|
||||||
|
...(record.executionId ? { executionId: record.executionId } : {}),
|
||||||
|
...(record.idempotencyKey ? { idempotencyKey: record.idempotencyKey } : {})
|
||||||
}))
|
}))
|
||||||
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))
|
.sort((left, right) => Date.parse(right.timestamp) - Date.parse(left.timestamp))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ import { runtimePlatform, supportsPersonalWechatSend } from './utils/runtime-env
|
|||||||
import { useToast } from './components/ui'
|
import { useToast } from './components/ui'
|
||||||
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
import { AppUpdatePrompt } from './features/app-update/AppUpdatePrompt'
|
||||||
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
import { GroupExitMonitorWorkspace } from './features/group-exit-monitor/GroupExitMonitorWorkspace'
|
||||||
import { LogsWorkspace } from './features/logs/LogsWorkspace'
|
|
||||||
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
import { selectContactAvatarRefreshUsernames } from './utils/contact-avatar'
|
||||||
import {
|
import {
|
||||||
buildContactSearchIndex,
|
buildContactSearchIndex,
|
||||||
@@ -2053,8 +2052,6 @@ function App(): React.ReactElement {
|
|||||||
onOpenSendSettings={openWechatSendSettings}
|
onOpenSendSettings={openWechatSendSettings}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'logs':
|
|
||||||
return <LogsWorkspace />
|
|
||||||
case 'agent-hub':
|
case 'agent-hub':
|
||||||
return <AgentHubWorkspace />
|
return <AgentHubWorkspace />
|
||||||
case 'api':
|
case 'api':
|
||||||
|
|||||||
@@ -46,13 +46,6 @@ function NavIcon({ page }: NavIconProps): React.ReactElement {
|
|||||||
<path d="m16.5 9 3 3-3 3" />
|
<path d="m16.5 9 3 3-3 3" />
|
||||||
</svg>
|
</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':
|
case 'export':
|
||||||
return (
|
return (
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export type AppPage =
|
|||||||
| 'search'
|
| 'search'
|
||||||
| 'report'
|
| 'report'
|
||||||
| 'exit-monitor'
|
| 'exit-monitor'
|
||||||
| 'logs'
|
|
||||||
| 'agent-hub'
|
| 'agent-hub'
|
||||||
| 'export'
|
| 'export'
|
||||||
| 'api'
|
| 'api'
|
||||||
@@ -19,7 +18,6 @@ export const PRIMARY_NAV_ITEMS: NavigationItem[] = [
|
|||||||
{ id: 'search', label: '问问微信' },
|
{ id: 'search', label: '问问微信' },
|
||||||
{ id: 'report', label: '日报' },
|
{ id: 'report', label: '日报' },
|
||||||
{ id: 'exit-monitor', label: '退群监控' },
|
{ id: 'exit-monitor', label: '退群监控' },
|
||||||
// { id: 'logs', label: '日志' },
|
|
||||||
{ id: 'agent-hub', label: 'Agent' },
|
{ id: 'agent-hub', label: 'Agent' },
|
||||||
{ id: 'export', label: '导出' },
|
{ id: 'export', label: '导出' },
|
||||||
{ id: 'api', label: 'API' },
|
{ id: 'api', label: 'API' },
|
||||||
|
|||||||
@@ -15,9 +15,21 @@ import {
|
|||||||
type StatusFilter = 'all' | WechatActionStatus
|
type StatusFilter = 'all' | WechatActionStatus
|
||||||
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
|
type PurposeFilter = 'all' | 'member_left_notification' | 'scheduled_report' | 'other'
|
||||||
|
|
||||||
|
const sourceLabel = (source: ActionLogEntry['source']): string => {
|
||||||
|
if (source === 'member_monitor') return '退群监控'
|
||||||
|
if (source === 'scheduled_report') return '定时日报'
|
||||||
|
if (source === 'user_tts') return '文字转语音'
|
||||||
|
if (source === 'unknown') return '未知来源'
|
||||||
|
return source || '未知来源'
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerLabel = (triggerType: ActionLogEntry['triggerType']): string =>
|
||||||
|
triggerType === 'user' ? '用户触发' : '自动化'
|
||||||
|
|
||||||
const purposeLabel = (purpose: WechatActionPurpose): string => {
|
const purposeLabel = (purpose: WechatActionPurpose): string => {
|
||||||
if (purpose === 'member_left_notification') return '退群通知'
|
if (purpose === 'member_left_notification') return '退群通知'
|
||||||
if (purpose === 'scheduled_report') return '定时日报'
|
if (purpose === 'scheduled_report') return '定时日报'
|
||||||
|
if (purpose === 'tts_voice') return '文字转语音'
|
||||||
return purpose || '其它动作'
|
return purpose || '其它动作'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,15 +39,10 @@ const contentTypeLabel = (type: ActionLogEntry['contentType']): string => {
|
|||||||
return '文字'
|
return '文字'
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusDetails = (
|
const statusDetails = (entry: ActionLogEntry): { label: string; className: string } => {
|
||||||
entry: ActionLogEntry
|
|
||||||
): { label: string; className: string } => {
|
|
||||||
if (entry.status === 'sent') return { label: '已发送', className: 'text-success' }
|
if (entry.status === 'sent') return { label: '已发送', className: 'text-success' }
|
||||||
if (entry.status === 'blocked') return { label: '被阻止', className: 'text-warning' }
|
if (entry.status === 'blocked') return { label: '被阻止', className: 'text-warning' }
|
||||||
if (
|
if (entry.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' || entry.errorCode === 'SEND_NOT_READY') {
|
||||||
entry.errorCode === 'SEND_CAPABILITY_UNAVAILABLE' ||
|
|
||||||
entry.errorCode === 'SEND_NOT_READY'
|
|
||||||
) {
|
|
||||||
return { label: '发送能力不可用', className: 'text-warning' }
|
return { label: '发送能力不可用', className: 'text-warning' }
|
||||||
}
|
}
|
||||||
return { label: '失败', className: 'text-destructive' }
|
return { label: '失败', className: 'text-destructive' }
|
||||||
@@ -49,6 +56,9 @@ const matchesPurpose = (entry: ActionLogEntry, filter: PurposeFilter): boolean =
|
|||||||
return entry.purpose === filter
|
return entry.purpose === filter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const dateBoundary = (value: string, endOfDay: boolean): number =>
|
||||||
|
new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00'}`).getTime()
|
||||||
|
|
||||||
export function LogsWorkspace(): React.ReactElement {
|
export function LogsWorkspace(): React.ReactElement {
|
||||||
const [entries, setEntries] = React.useState<ActionLogEntry[]>([])
|
const [entries, setEntries] = React.useState<ActionLogEntry[]>([])
|
||||||
const [loading, setLoading] = React.useState(true)
|
const [loading, setLoading] = React.useState(true)
|
||||||
@@ -56,6 +66,8 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
const [keyword, setKeyword] = React.useState('')
|
const [keyword, setKeyword] = React.useState('')
|
||||||
const [status, setStatus] = React.useState<StatusFilter>('all')
|
const [status, setStatus] = React.useState<StatusFilter>('all')
|
||||||
const [purpose, setPurpose] = React.useState<PurposeFilter>('all')
|
const [purpose, setPurpose] = React.useState<PurposeFilter>('all')
|
||||||
|
const [startDate, setStartDate] = React.useState('')
|
||||||
|
const [endDate, setEndDate] = React.useState('')
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let disposed = false
|
let disposed = false
|
||||||
@@ -77,15 +89,28 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
|
|
||||||
const visibleEntries = React.useMemo(() => {
|
const visibleEntries = React.useMemo(() => {
|
||||||
const query = keyword.trim().toLocaleLowerCase()
|
const query = keyword.trim().toLocaleLowerCase()
|
||||||
|
const startTime = startDate ? dateBoundary(startDate, false) : Number.NEGATIVE_INFINITY
|
||||||
|
const endTime = endDate ? dateBoundary(endDate, true) : Number.POSITIVE_INFINITY
|
||||||
return entries.filter((entry) => {
|
return entries.filter((entry) => {
|
||||||
if (status !== 'all' && entry.status !== status) return false
|
if (status !== 'all' && entry.status !== status) return false
|
||||||
if (!matchesPurpose(entry, purpose)) return false
|
if (!matchesPurpose(entry, purpose)) return false
|
||||||
|
const timestamp = Date.parse(entry.timestamp)
|
||||||
|
if (timestamp < startTime || timestamp > endTime) return false
|
||||||
if (!query) return true
|
if (!query) return true
|
||||||
return [entry.recipientName, entry.recipientId, entry.contentPreview]
|
return [
|
||||||
|
entry.id,
|
||||||
|
entry.source,
|
||||||
|
entry.purpose,
|
||||||
|
entry.executionId,
|
||||||
|
entry.idempotencyKey,
|
||||||
|
entry.recipientName,
|
||||||
|
entry.recipientId,
|
||||||
|
entry.contentPreview
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((value) => value!.toLocaleLowerCase().includes(query))
|
.some((value) => value!.toLocaleLowerCase().includes(query))
|
||||||
})
|
})
|
||||||
}, [entries, keyword, purpose, status])
|
}, [endDate, entries, keyword, purpose, startDate, status])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full min-w-0 overflow-y-auto bg-canvas">
|
<div className="h-full min-w-0 overflow-y-auto bg-canvas">
|
||||||
@@ -94,7 +119,7 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
<h1 className="text-xl font-semibold text-foreground">日志</h1>
|
<h1 className="text-xl font-semibold text-foreground">日志</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="grid gap-3 sm:grid-cols-[minmax(220px,1fr)_180px_180px]">
|
<div className="grid gap-3 sm:grid-cols-[minmax(220px,1fr)_180px_180px] lg:grid-cols-[minmax(220px,1fr)_180px_180px_150px_150px]">
|
||||||
<Input
|
<Input
|
||||||
type="search"
|
type="search"
|
||||||
aria-label="搜索日志"
|
aria-label="搜索日志"
|
||||||
@@ -124,6 +149,18 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
<SelectItem value="blocked">被阻止</SelectItem>
|
<SelectItem value="blocked">被阻止</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
aria-label="开始日期"
|
||||||
|
value={startDate}
|
||||||
|
onChange={(event) => setStartDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
aria-label="结束日期"
|
||||||
|
value={endDate}
|
||||||
|
onChange={(event) => setEndDate(event.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -140,12 +177,22 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="grid gap-3 rounded-md border border-border-subtle bg-surface px-4 py-3 shadow-surface"
|
className="grid min-w-0 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="flex flex-wrap items-start justify-between gap-2">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<strong className="text-sm text-foreground">{purposeLabel(entry.purpose)}</strong>
|
<strong className="text-sm text-foreground">
|
||||||
<p className="mt-1 break-words text-sm text-foreground">发送给 {recipient}</p>
|
{purposeLabel(entry.purpose)}
|
||||||
|
</strong>
|
||||||
|
<p className="mt-1 min-w-0 text-sm text-foreground">
|
||||||
|
发送给{' '}
|
||||||
|
<span
|
||||||
|
className="inline-block max-w-[min(65vw,28rem)] truncate whitespace-nowrap align-bottom"
|
||||||
|
title={recipient}
|
||||||
|
>
|
||||||
|
{recipient}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-xs">
|
<div className="text-right text-xs">
|
||||||
<strong className={result.className}>{result.label}</strong>
|
<strong className={result.className}>{result.label}</strong>
|
||||||
@@ -154,17 +201,64 @@ export function LogsWorkspace(): React.ReactElement {
|
|||||||
</time>
|
</time>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-1 text-xs text-muted-foreground sm:grid-cols-[140px_1fr]">
|
<div className="grid min-w-0 gap-1 text-sm text-foreground">
|
||||||
<span>
|
<p className="whitespace-normal break-words">
|
||||||
{entry.recipientType === 'group' ? '群聊' : '联系人'} · {entry.recipientId}
|
|
||||||
</span>
|
|
||||||
<span className="break-words">
|
|
||||||
{contentTypeLabel(entry.contentType)} · {entry.contentPreview || '无内容预览'}
|
{contentTypeLabel(entry.contentType)} · {entry.contentPreview || '无内容预览'}
|
||||||
</span>
|
</p>
|
||||||
|
{entry.reason ? (
|
||||||
|
<p className="whitespace-normal break-words text-xs text-muted-foreground">
|
||||||
|
{entry.reason}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{triggerLabel(entry.triggerType)} · {sourceLabel(entry.source)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{entry.reason ? (
|
<details className="min-w-0 border-t border-border-subtle pt-2 text-xs text-muted-foreground">
|
||||||
<p className="break-words text-xs text-muted-foreground">{entry.reason}</p>
|
<summary className="cursor-pointer select-none">查看详情</summary>
|
||||||
) : null}
|
<dl className="mt-2 grid min-w-0 gap-1 sm:grid-cols-[140px_minmax(0,1fr)]">
|
||||||
|
<dt>接收对象 ID</dt>
|
||||||
|
<dd className="min-w-0 truncate whitespace-nowrap" title={entry.recipientId}>
|
||||||
|
{entry.recipientId}
|
||||||
|
</dd>
|
||||||
|
<dt>操作 ID</dt>
|
||||||
|
<dd className="min-w-0 truncate whitespace-nowrap" title={entry.id}>
|
||||||
|
{entry.id}
|
||||||
|
</dd>
|
||||||
|
{entry.executionId ? (
|
||||||
|
<>
|
||||||
|
<dt>执行 ID</dt>
|
||||||
|
<dd
|
||||||
|
className="min-w-0 truncate whitespace-nowrap"
|
||||||
|
title={entry.executionId}
|
||||||
|
>
|
||||||
|
{entry.executionId}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{entry.idempotencyKey ? (
|
||||||
|
<>
|
||||||
|
<dt>幂等键</dt>
|
||||||
|
<dd
|
||||||
|
className="min-w-0 truncate whitespace-nowrap font-mono"
|
||||||
|
title={entry.idempotencyKey}
|
||||||
|
>
|
||||||
|
{entry.idempotencyKey}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<dt>来源</dt>
|
||||||
|
<dd className="min-w-0 truncate whitespace-nowrap" title={entry.source}>
|
||||||
|
{entry.source}
|
||||||
|
</dd>
|
||||||
|
<dt>用途</dt>
|
||||||
|
<dd className="min-w-0 truncate whitespace-nowrap" title={entry.purpose}>
|
||||||
|
{entry.purpose}
|
||||||
|
</dd>
|
||||||
|
<dt>触发方式</dt>
|
||||||
|
<dd>{entry.triggerType || 'automation'}</dd>
|
||||||
|
</dl>
|
||||||
|
</details>
|
||||||
</article>
|
</article>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { AboutPage } from './pages/AboutPage'
|
|||||||
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage'
|
||||||
import { TextToSpeechPage } from './pages/TextToSpeechPage'
|
import { TextToSpeechPage } from './pages/TextToSpeechPage'
|
||||||
import { PersonalWechatSendPage } from './pages/PersonalWechatSendPage'
|
import { PersonalWechatSendPage } from './pages/PersonalWechatSendPage'
|
||||||
|
import { LogsWorkspace } from '../logs/LogsWorkspace'
|
||||||
import type { Contact } from '../../../../shared/types'
|
import type { Contact } from '../../../../shared/types'
|
||||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||||
|
|
||||||
@@ -102,6 +103,8 @@ export function SettingsWorkspace({
|
|||||||
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
onOpenTextToSpeechSettings={onOpenTextToSpeechSettings}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
case 'wechat-action-logs':
|
||||||
|
return <LogsWorkspace />
|
||||||
case 'text-to-speech':
|
case 'text-to-speech':
|
||||||
return <TextToSpeechPage onNotice={onNotice} />
|
return <TextToSpeechPage onNotice={onNotice} />
|
||||||
case 'personal-wechat-send':
|
case 'personal-wechat-send':
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
|||||||
label: '智能能力',
|
label: '智能能力',
|
||||||
items: [
|
items: [
|
||||||
{ id: 'wechat-send', label: '微信发送' },
|
{ id: 'wechat-send', label: '微信发送' },
|
||||||
|
{ id: 'wechat-action-logs', label: '发送日志' },
|
||||||
{ id: 'voice-recognition', label: '语音转文字' },
|
{ id: 'voice-recognition', label: '语音转文字' },
|
||||||
{ id: 'text-to-speech', label: '文字转语音' },
|
{ id: 'text-to-speech', label: '文字转语音' },
|
||||||
{ id: 'ai-model', label: 'AI 模型' }
|
{ id: 'ai-model', label: 'AI 模型' }
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type SettingsCategoryId =
|
|||||||
| 'image-key'
|
| 'image-key'
|
||||||
| 'voice-recognition'
|
| 'voice-recognition'
|
||||||
| 'wechat-send'
|
| 'wechat-send'
|
||||||
|
| 'wechat-action-logs'
|
||||||
| 'text-to-speech'
|
| 'text-to-speech'
|
||||||
| 'personal-wechat-send'
|
| 'personal-wechat-send'
|
||||||
| 'ai-model'
|
| 'ai-model'
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import type {
|
|||||||
WechatActionOrigin,
|
WechatActionOrigin,
|
||||||
WechatActionPurpose,
|
WechatActionPurpose,
|
||||||
WechatActionRecipient,
|
WechatActionRecipient,
|
||||||
WechatActionStatus
|
WechatActionStatus,
|
||||||
|
WechatActionTriggerType
|
||||||
} from './wechat-action'
|
} from './wechat-action'
|
||||||
|
|
||||||
export interface ActionLogEntry {
|
export interface ActionLogEntry {
|
||||||
@@ -12,6 +13,7 @@ export interface ActionLogEntry {
|
|||||||
category: 'wechat_send'
|
category: 'wechat_send'
|
||||||
source: WechatActionOrigin
|
source: WechatActionOrigin
|
||||||
purpose: WechatActionPurpose
|
purpose: WechatActionPurpose
|
||||||
|
triggerType?: WechatActionTriggerType
|
||||||
timestamp: string
|
timestamp: string
|
||||||
recipientType: WechatActionRecipient['type']
|
recipientType: WechatActionRecipient['type']
|
||||||
recipientId: string
|
recipientId: string
|
||||||
@@ -21,4 +23,6 @@ export interface ActionLogEntry {
|
|||||||
status: WechatActionStatus
|
status: WechatActionStatus
|
||||||
errorCode?: WechatActionErrorCode
|
errorCode?: WechatActionErrorCode
|
||||||
reason?: string
|
reason?: string
|
||||||
|
executionId?: string
|
||||||
|
idempotencyKey?: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from '@testing-library/react'
|
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { LogsWorkspace } from '../../src/renderer/src/features/logs/LogsWorkspace'
|
import { LogsWorkspace } from '../../src/renderer/src/features/logs/LogsWorkspace'
|
||||||
@@ -10,19 +10,23 @@ const entries: ActionLogEntry[] = [
|
|||||||
category: 'wechat_send',
|
category: 'wechat_send',
|
||||||
source: 'member_monitor',
|
source: 'member_monitor',
|
||||||
purpose: 'member_left_notification',
|
purpose: 'member_left_notification',
|
||||||
|
triggerType: 'automation',
|
||||||
timestamp: '2026-09-03T08:30:21.000Z',
|
timestamp: '2026-09-03T08:30:21.000Z',
|
||||||
recipientType: 'group',
|
recipientType: 'group',
|
||||||
recipientId: '123456@chatroom',
|
recipientId: '123456@chatroom',
|
||||||
recipientName: '测试 q1',
|
recipientName: '测试 q1',
|
||||||
contentType: 'text',
|
contentType: 'text',
|
||||||
contentPreview: 'Shinven 已退出群聊',
|
contentPreview: 'Shinven 已退出群聊',
|
||||||
status: 'sent'
|
status: 'sent',
|
||||||
|
executionId: 'execution-member-1',
|
||||||
|
idempotencyKey: 'member_left_notification:member-log'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'report-log',
|
id: 'report-log',
|
||||||
category: 'wechat_send',
|
category: 'wechat_send',
|
||||||
source: 'scheduled_report',
|
source: 'scheduled_report',
|
||||||
purpose: 'scheduled_report',
|
purpose: 'scheduled_report',
|
||||||
|
triggerType: 'automation',
|
||||||
timestamp: '2026-09-03T07:00:00.000Z',
|
timestamp: '2026-09-03T07:00:00.000Z',
|
||||||
recipientType: 'group',
|
recipientType: 'group',
|
||||||
recipientId: 'report@chatroom',
|
recipientId: 'report@chatroom',
|
||||||
@@ -31,7 +35,26 @@ const entries: ActionLogEntry[] = [
|
|||||||
contentPreview: 'daily-report.png',
|
contentPreview: 'daily-report.png',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
errorCode: 'SEND_CAPABILITY_UNAVAILABLE',
|
errorCode: 'SEND_CAPABILITY_UNAVAILABLE',
|
||||||
reason: '个人微信发送能力不可用'
|
reason: '个人微信发送能力不可用',
|
||||||
|
executionId: 'execution-report-1',
|
||||||
|
idempotencyKey: 'scheduled_report:report-log'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blocked-log',
|
||||||
|
category: 'wechat_send',
|
||||||
|
source: 'user_tts',
|
||||||
|
purpose: 'tts_voice',
|
||||||
|
triggerType: 'user',
|
||||||
|
timestamp: '2026-09-03T06:00:00.000Z',
|
||||||
|
recipientType: 'contact',
|
||||||
|
recipientId: 'wxid_long_recipient_1234567890@chatroom.example',
|
||||||
|
recipientName: '文件传输助手',
|
||||||
|
contentType: 'voice',
|
||||||
|
contentPreview: 'voice_xxx.silk 中文正文可以自然换行',
|
||||||
|
status: 'blocked',
|
||||||
|
executionId: 'execution-blocked-1234567890',
|
||||||
|
idempotencyKey:
|
||||||
|
'member_left_notification:49023470180@chatroom:2026-09-03T06:00:00.000Z:very-long-tail'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -49,6 +72,18 @@ describe('LogsWorkspace', () => {
|
|||||||
await waitFor(() => expect(screen.getByText(/Shinven 已退出群聊/)).toBeVisible())
|
await waitFor(() => expect(screen.getByText(/Shinven 已退出群聊/)).toBeVisible())
|
||||||
expect(screen.getByText(/daily-report.png/)).toBeVisible()
|
expect(screen.getByText(/daily-report.png/)).toBeVisible()
|
||||||
expect(screen.getByText('发送能力不可用')).toBeVisible()
|
expect(screen.getByText('发送能力不可用')).toBeVisible()
|
||||||
|
expect(screen.getByText(/自动化 · 退群监控/)).toBeVisible()
|
||||||
|
expect(screen.getByText('被阻止')).toBeVisible()
|
||||||
|
expect(screen.queryByText('执行 ID:execution-member-1')).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const details = screen.getAllByText('查看详情')[0]
|
||||||
|
await user.click(details)
|
||||||
|
expect(screen.getByText('execution-member-1')).toBeVisible()
|
||||||
|
expect(screen.getByText('member_left_notification:member-log')).toBeVisible()
|
||||||
|
expect(screen.getByText('member_left_notification:member-log')).toHaveClass(
|
||||||
|
'whitespace-nowrap',
|
||||||
|
'font-mono'
|
||||||
|
)
|
||||||
|
|
||||||
const search = screen.getByRole('searchbox', { name: '搜索日志' })
|
const search = screen.getByRole('searchbox', { name: '搜索日志' })
|
||||||
await user.type(search, '123456')
|
await user.type(search, '123456')
|
||||||
@@ -64,5 +99,52 @@ describe('LogsWorkspace', () => {
|
|||||||
await user.click(screen.getByRole('combobox', { name: '筛选日志类型' }))
|
await user.click(screen.getByRole('combobox', { name: '筛选日志类型' }))
|
||||||
await user.click(await screen.findByRole('option', { name: '退群通知' }))
|
await user.click(await screen.findByRole('option', { name: '退群通知' }))
|
||||||
expect(screen.getByText('没有匹配的日志')).toBeVisible()
|
expect(screen.getByText('没有匹配的日志')).toBeVisible()
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('combobox', { name: '筛选日志类型' }))
|
||||||
|
await user.click(await screen.findByRole('option', { name: '全部类型' }))
|
||||||
|
await user.click(screen.getByRole('combobox', { name: '筛选日志状态' }))
|
||||||
|
await user.click(await screen.findByRole('option', { name: '全部状态' }))
|
||||||
|
const startDate = document.querySelector<HTMLInputElement>('input[aria-label="开始日期"]')
|
||||||
|
const endDate = document.querySelector<HTMLInputElement>('input[aria-label="结束日期"]')
|
||||||
|
expect(startDate).not.toBeNull()
|
||||||
|
expect(endDate).not.toBeNull()
|
||||||
|
await user.clear(startDate!)
|
||||||
|
await user.type(startDate!, '2026-09-03')
|
||||||
|
await user.clear(endDate!)
|
||||||
|
await user.type(endDate!, '2026-09-03')
|
||||||
|
expect(screen.getByText(/Shinven 已退出群聊/)).toBeVisible()
|
||||||
|
expect(screen.getByText(/daily-report.png/)).toBeVisible()
|
||||||
|
|
||||||
|
await user.clear(startDate!)
|
||||||
|
await user.type(startDate!, '2026-09-04')
|
||||||
|
expect(screen.getByText('没有匹配的日志')).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps long identifiers on one line and exposes complete values in details', async () => {
|
||||||
|
Object.defineProperty(window, 'api', {
|
||||||
|
configurable: true,
|
||||||
|
value: { listWechatActionLogs: vi.fn().mockResolvedValue(entries) } as unknown as typeof window.api
|
||||||
|
})
|
||||||
|
render(<LogsWorkspace />)
|
||||||
|
|
||||||
|
const blockedCard = (await screen.findByText('被阻止')).closest('article')
|
||||||
|
expect(blockedCard).not.toBeNull()
|
||||||
|
const card = within(blockedCard!)
|
||||||
|
const recipient = card.getByTitle('wxid_long_recipient_1234567890@chatroom.example')
|
||||||
|
expect(recipient).toHaveClass('truncate', 'whitespace-nowrap')
|
||||||
|
const key = card.getByTitle(
|
||||||
|
'member_left_notification:49023470180@chatroom:2026-09-03T06:00:00.000Z:very-long-tail'
|
||||||
|
)
|
||||||
|
expect(key).toHaveClass('truncate', 'whitespace-nowrap', 'font-mono')
|
||||||
|
expect(card.getByText('语音 · voice_xxx.silk 中文正文可以自然换行')).toHaveClass(
|
||||||
|
'whitespace-normal',
|
||||||
|
'break-words'
|
||||||
|
)
|
||||||
|
|
||||||
|
await userEvent.click(card.getByText('查看详情'))
|
||||||
|
expect(card.getByText('wxid_long_recipient_1234567890@chatroom.example')).toBeVisible()
|
||||||
|
expect(
|
||||||
|
card.getByText('member_left_notification:49023470180@chatroom:2026-09-03T06:00:00.000Z:very-long-tail')
|
||||||
|
).toBeVisible()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -143,6 +143,26 @@ test('NAV-01 NAV-02 every top-level page is unique and switchable', async () =>
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('LOGS-01 shows Action Gateway audit details and filters by date', async () => {
|
||||||
|
const fixture = await launchTestApp({ now: Date.parse('2026-09-12T10:00:00+08:00') })
|
||||||
|
try {
|
||||||
|
await fixture.page.getByRole('navigation', { name: '一级导航' }).getByRole('button', { name: '设置' }).click()
|
||||||
|
await fixture.page.getByRole('button', { name: '发送日志' }).click()
|
||||||
|
await expect(fixture.page.getByRole('heading', { name: '日志', exact: true })).toBeVisible()
|
||||||
|
await expect(
|
||||||
|
fixture.page.locator('article').filter({ hasText: '产品测试群' })
|
||||||
|
).toBeVisible()
|
||||||
|
await fixture.page.getByText('查看详情').first().click()
|
||||||
|
await expect(fixture.page.getByText('fixture-execution', { exact: true })).toBeVisible()
|
||||||
|
await expect(fixture.page.getByText(/自动化 · 定时日报/)).toBeVisible()
|
||||||
|
|
||||||
|
await fixture.page.getByLabel('开始日期').fill('2026-09-13')
|
||||||
|
await expect(fixture.page.getByText('没有匹配的日志')).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await fixture.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
test('NAV-03 exit monitor page shows a member departure event and stays within the viewport', async () => {
|
test('NAV-03 exit monitor page shows a member departure event and stays within the viewport', async () => {
|
||||||
const fixture = await launchTestApp()
|
const fixture = await launchTestApp()
|
||||||
const pageErrors: Error[] = []
|
const pageErrors: Error[] = []
|
||||||
|
|||||||
@@ -701,6 +701,24 @@ handle('db:getContactAvatars', (usernames) =>
|
|||||||
.map((contact) => [contact.m_nsUsrName, contact.avatar])
|
.map((contact) => [contact.m_nsUsrName, contact.avatar])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
handle('wechat-action-log:list', () => [
|
||||||
|
{
|
||||||
|
id: 'fixture-action-log',
|
||||||
|
category: 'wechat_send',
|
||||||
|
source: 'scheduled_report',
|
||||||
|
purpose: 'scheduled_report',
|
||||||
|
triggerType: 'automation',
|
||||||
|
timestamp: new Date(fixtureNowMs - 60_000).toISOString(),
|
||||||
|
recipientType: 'group',
|
||||||
|
recipientId: 'group_regular@chatroom',
|
||||||
|
recipientName: '产品测试群',
|
||||||
|
contentType: 'image',
|
||||||
|
contentPreview: 'fixture-report.png',
|
||||||
|
status: 'sent',
|
||||||
|
executionId: 'fixture-execution',
|
||||||
|
idempotencyKey: 'scheduled_report:fixture-execution'
|
||||||
|
}
|
||||||
|
])
|
||||||
handle('settings:getSelf', () => ({ ready: true, info: fixture.self }))
|
handle('settings:getSelf', () => ({ ready: true, info: fixture.self }))
|
||||||
handle('db:getCachedMessages', (md5) => fixture.messages[md5] || [])
|
handle('db:getCachedMessages', (md5) => fixture.messages[md5] || [])
|
||||||
handle('db:getCachedMessagePage', (md5) => ({
|
handle('db:getCachedMessagePage', (md5) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user