diff --git a/src/main/services/wechat-action-log-service.ts b/src/main/services/wechat-action-log-service.ts index 3f0e517..264225a 100644 --- a/src/main/services/wechat-action-log-service.ts +++ b/src/main/services/wechat-action-log-service.ts @@ -38,6 +38,7 @@ export class WechatActionLogService { category: 'wechat_send' as const, source: record.origin, purpose: record.purpose, + ...(record.triggerType ? { triggerType: record.triggerType } : {}), timestamp: record.finishedAt || record.startedAt || record.createdAt, recipientType: record.recipientType, recipientId: record.recipientId, @@ -46,7 +47,9 @@ export class WechatActionLogService { contentPreview: record.contentPreview, status: record.sendStatus, 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)) } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 19e3ae6..db6c574 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -45,7 +45,6 @@ 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 { buildContactSearchIndex, @@ -2053,8 +2052,6 @@ function App(): React.ReactElement { onOpenSendSettings={openWechatSendSettings} /> ) - case 'logs': - return case 'agent-hub': return case 'api': diff --git a/src/renderer/src/components/layout/PrimaryNavigation.tsx b/src/renderer/src/components/layout/PrimaryNavigation.tsx index c9d90eb..7aa9cac 100644 --- a/src/renderer/src/components/layout/PrimaryNavigation.tsx +++ b/src/renderer/src/components/layout/PrimaryNavigation.tsx @@ -46,13 +46,6 @@ function NavIcon({ page }: NavIconProps): React.ReactElement { ) - case 'logs': - return ( - - ) case 'export': return (
@@ -94,7 +119,7 @@ export function LogsWorkspace(): React.ReactElement {

日志

-
+
被阻止 + setStartDate(event.target.value)} + /> + setEndDate(event.target.value)} + />
{loading ? ( @@ -140,12 +177,22 @@ export function LogsWorkspace(): React.ReactElement { return (
- {purposeLabel(entry.purpose)} -

发送给 {recipient}

+ + {purposeLabel(entry.purpose)} + +

+ 发送给{' '} + + {recipient} + +

{result.label} @@ -154,17 +201,64 @@ export function LogsWorkspace(): React.ReactElement {
-
- - {entry.recipientType === 'group' ? '群聊' : '联系人'} · {entry.recipientId} - - +
+

{contentTypeLabel(entry.contentType)} · {entry.contentPreview || '无内容预览'} - +

+ {entry.reason ? ( +

+ {entry.reason} +

+ ) : null} +

+ {triggerLabel(entry.triggerType)} · {sourceLabel(entry.source)} +

- {entry.reason ? ( -

{entry.reason}

- ) : null} +
+ 查看详情 +
+
接收对象 ID
+
+ {entry.recipientId} +
+
操作 ID
+
+ {entry.id} +
+ {entry.executionId ? ( + <> +
执行 ID
+
+ {entry.executionId} +
+ + ) : null} + {entry.idempotencyKey ? ( + <> +
幂等键
+
+ {entry.idempotencyKey} +
+ + ) : null} +
来源
+
+ {entry.source} +
+
用途
+
+ {entry.purpose} +
+
触发方式
+
{entry.triggerType || 'automation'}
+
+
) })} diff --git a/src/renderer/src/features/settings/SettingsWorkspace.tsx b/src/renderer/src/features/settings/SettingsWorkspace.tsx index 769d866..9e84a96 100644 --- a/src/renderer/src/features/settings/SettingsWorkspace.tsx +++ b/src/renderer/src/features/settings/SettingsWorkspace.tsx @@ -14,6 +14,7 @@ import { AboutPage } from './pages/AboutPage' import { VoiceRecognitionPage } from './pages/VoiceRecognitionPage' import { TextToSpeechPage } from './pages/TextToSpeechPage' import { PersonalWechatSendPage } from './pages/PersonalWechatSendPage' +import { LogsWorkspace } from '../logs/LogsWorkspace' import type { Contact } from '../../../../shared/types' import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider' @@ -102,6 +103,8 @@ export function SettingsWorkspace({ onOpenTextToSpeechSettings={onOpenTextToSpeechSettings} /> ) + case 'wechat-action-logs': + return case 'text-to-speech': return case 'personal-wechat-send': diff --git a/src/renderer/src/features/settings/model/settingsNavigation.ts b/src/renderer/src/features/settings/model/settingsNavigation.ts index abd7a36..5218a05 100644 --- a/src/renderer/src/features/settings/model/settingsNavigation.ts +++ b/src/renderer/src/features/settings/model/settingsNavigation.ts @@ -18,6 +18,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [ label: '智能能力', items: [ { id: 'wechat-send', label: '微信发送' }, + { id: 'wechat-action-logs', label: '发送日志' }, { id: 'voice-recognition', label: '语音转文字' }, { id: 'text-to-speech', label: '文字转语音' }, { id: 'ai-model', label: 'AI 模型' } diff --git a/src/renderer/src/features/settings/model/types.ts b/src/renderer/src/features/settings/model/types.ts index e57a0e1..1b4af93 100644 --- a/src/renderer/src/features/settings/model/types.ts +++ b/src/renderer/src/features/settings/model/types.ts @@ -4,6 +4,7 @@ export type SettingsCategoryId = | 'image-key' | 'voice-recognition' | 'wechat-send' + | 'wechat-action-logs' | 'text-to-speech' | 'personal-wechat-send' | 'ai-model' diff --git a/src/shared/action-log.ts b/src/shared/action-log.ts index 4c0e835..0e4af24 100644 --- a/src/shared/action-log.ts +++ b/src/shared/action-log.ts @@ -4,7 +4,8 @@ import type { WechatActionOrigin, WechatActionPurpose, WechatActionRecipient, - WechatActionStatus + WechatActionStatus, + WechatActionTriggerType } from './wechat-action' export interface ActionLogEntry { @@ -12,6 +13,7 @@ export interface ActionLogEntry { category: 'wechat_send' source: WechatActionOrigin purpose: WechatActionPurpose + triggerType?: WechatActionTriggerType timestamp: string recipientType: WechatActionRecipient['type'] recipientId: string @@ -21,4 +23,6 @@ export interface ActionLogEntry { status: WechatActionStatus errorCode?: WechatActionErrorCode reason?: string + executionId?: string + idempotencyKey?: string } diff --git a/tests/component/logs-workspace.test.tsx b/tests/component/logs-workspace.test.tsx index 8f36302..fc9c698 100644 --- a/tests/component/logs-workspace.test.tsx +++ b/tests/component/logs-workspace.test.tsx @@ -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 { describe, expect, it, vi } from 'vitest' import { LogsWorkspace } from '../../src/renderer/src/features/logs/LogsWorkspace' @@ -10,19 +10,23 @@ const entries: ActionLogEntry[] = [ category: 'wechat_send', source: 'member_monitor', purpose: 'member_left_notification', + triggerType: 'automation', timestamp: '2026-09-03T08:30:21.000Z', recipientType: 'group', recipientId: '123456@chatroom', recipientName: '测试 q1', contentType: 'text', contentPreview: 'Shinven 已退出群聊', - status: 'sent' + status: 'sent', + executionId: 'execution-member-1', + idempotencyKey: 'member_left_notification:member-log' }, { id: 'report-log', category: 'wechat_send', source: 'scheduled_report', purpose: 'scheduled_report', + triggerType: 'automation', timestamp: '2026-09-03T07:00:00.000Z', recipientType: 'group', recipientId: 'report@chatroom', @@ -31,7 +35,26 @@ const entries: ActionLogEntry[] = [ contentPreview: 'daily-report.png', status: 'failed', 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()) expect(screen.getByText(/daily-report.png/)).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: '搜索日志' }) await user.type(search, '123456') @@ -64,5 +99,52 @@ describe('LogsWorkspace', () => { await user.click(screen.getByRole('combobox', { name: '筛选日志类型' })) await user.click(await screen.findByRole('option', { name: '退群通知' })) 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('input[aria-label="开始日期"]') + const endDate = document.querySelector('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() + + 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() }) }) diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 1bd02c4..c5ef78f 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -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 () => { const fixture = await launchTestApp() const pageErrors: Error[] = [] diff --git a/tests/e2e/support/electron-main.cjs b/tests/e2e/support/electron-main.cjs index 98264f5..ad4eac8 100644 --- a/tests/e2e/support/electron-main.cjs +++ b/tests/e2e/support/electron-main.cjs @@ -701,6 +701,24 @@ handle('db:getContactAvatars', (usernames) => .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('db:getCachedMessages', (md5) => fixture.messages[md5] || []) handle('db:getCachedMessagePage', (md5) => ({