diff --git a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx index 41c49f3..0e2828e 100644 --- a/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx +++ b/src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace.tsx @@ -22,7 +22,9 @@ import type { Contact } from '../../../../shared/types' import { GROUP_EXIT_NOTIFICATION_TEMPLATE, GROUP_EXIT_NOTIFICATION_TEMPLATE_MAX_LENGTH, + renderGroupExitMonitorNotification, validateGroupExitNotificationTemplate, + type GroupExitMonitorEvent, type GroupExitMonitorState } from '../../../../shared/group-exit-monitor' import type { PersonalWechatSendCapability } from '../../../../shared/personal-wechat' @@ -465,6 +467,8 @@ export function GroupExitMonitorWorkspace({ const [selectedRoomId, setSelectedRoomId] = React.useState('all') const [view, setView] = React.useState<'events' | 'manage'>('events') const [resendingEventId, setResendingEventId] = React.useState(null) + const [copiedEventId, setCopiedEventId] = React.useState('') + const copyResetTimer = React.useRef(null) const [manageKeyword, setManageKeyword] = React.useState('') const [manageFilter, setManageFilter] = React.useState('all') const [selectedManageRoomIds, setSelectedManageRoomIds] = React.useState>( @@ -522,6 +526,13 @@ export function GroupExitMonitorWorkspace({ } }, []) + React.useEffect( + () => () => { + if (copyResetTimer.current !== null) window.clearTimeout(copyResetTimer.current) + }, + [] + ) + const groupOptions = React.useMemo(() => { const optionMap = new Map() for (const event of state.events) { @@ -617,6 +628,28 @@ export function GroupExitMonitorWorkspace({ } } + /** + * 手动复制退群信息:按“管理群聊”里保存的模板拼接当前事件,只写系统剪贴板, + * 不触发任何微信发送,用户自行决定粘贴到哪里。 + */ + const copyEventNotification = async (event: GroupExitMonitorEvent): Promise => { + const api = typeof window !== 'undefined' ? window.api : undefined + const text = renderGroupExitMonitorNotification(event, notificationTemplate) + setError('') + try { + const result = api && typeof api.copyText === 'function' ? await api.copyText(text) : null + if (!result?.success) throw new Error(result?.error || '复制失败,请重试') + setCopiedEventId(event.id) + if (copyResetTimer.current !== null) window.clearTimeout(copyResetTimer.current) + copyResetTimer.current = window.setTimeout(() => { + copyResetTimer.current = null + setCopiedEventId('') + }, 2_000) + } catch (reason) { + setError(reason instanceof Error ? reason.message : String(reason)) + } + } + const saveGroups = async (): Promise => { const api = window.api if (saving || !dbReady || typeof api.setGroupExitMonitorGroups !== 'function') return @@ -868,9 +901,20 @@ export function GroupExitMonitorWorkspace({
{event.message} - +
+ + +
{event.previousCount > event.currentCount ? (

diff --git a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx index 24fdf33..8885d4a 100644 --- a/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx +++ b/src/renderer/src/features/settings/pages/PersonalWechatSendPage.tsx @@ -36,6 +36,10 @@ function formatBytes(value: number): string { return `${(value / 1024 / 1024).toFixed(1)} MB` } +const REPOSITORY_URL = 'https://github.com/Wxw-Gu/TraceMemo' +// README 就是仓库首页,跳转后需要的正是「交流与反馈」这一节(含群二维码)。 +const GROUP_README_URL = `${REPOSITORY_URL}#-交流与反馈` + const capabilityLabel: Record = { unsupported: '暂不支持', unconfigured: '尚未配置', @@ -507,6 +511,23 @@ export function PersonalWechatSendPage({

+ +

发送能力授权

+
+

发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。

+

+ 进群请点击{' '} + + 这里 + {' '} + 跳转。 +

+
) : isMac ? ( <> diff --git a/src/renderer/src/styles/group-exit-monitor.scss b/src/renderer/src/styles/group-exit-monitor.scss index c97acfc..30b351d 100644 --- a/src/renderer/src/styles/group-exit-monitor.scss +++ b/src/renderer/src/styles/group-exit-monitor.scss @@ -337,6 +337,19 @@ white-space: nowrap; } +.exit-monitor-event-side { + display: flex; + flex: 0 0 auto; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.exit-monitor-event-side .exit-monitor-event-copy { + font-size: 11px; + line-height: 18px; +} + .exit-monitor-event-count { display: block; margin: 5px 0 0; @@ -707,6 +720,13 @@ margin-top: 3px; } + .exit-monitor-event-side { + flex-direction: row; + align-items: baseline; + justify-content: flex-start; + gap: 10px; + } + .exit-monitor-event-details { grid-template-columns: 1fr; } diff --git a/tests/component/group-exit-monitor-workspace.test.tsx b/tests/component/group-exit-monitor-workspace.test.tsx index 6a0922f..5547c28 100644 --- a/tests/component/group-exit-monitor-workspace.test.tsx +++ b/tests/component/group-exit-monitor-workspace.test.tsx @@ -4,6 +4,7 @@ import type { ReactElement } from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { GroupExitMonitorWorkspace } from '../../src/renderer/src/features/group-exit-monitor/GroupExitMonitorWorkspace' import type { GroupExitMonitorState } from '../../src/shared/group-exit-monitor' +import { formatGroupExitMonitorTime } from '../../src/shared/group-exit-monitor' import type { Contact } from '../../src/shared/types' import { TooltipProvider } from '../../src/renderer/src/components/ui' @@ -327,6 +328,67 @@ describe('GroupExitMonitorWorkspace', () => { expect(screen.queryByRole('heading', { name: '退群监测模板' })).not.toBeInTheDocument() }) + it('copies the exit notice built from the template saved on the management page', async () => { + const user = userEvent.setup() + const template = [ + '[退群监测]', + '用户: {user}', + '群备注: {groupRemark}', + '微信号: {wxid}', + '人数: {previousCount}->{currentCount}', + '退群时间: {time}' + ].join('\n') + const copyText = vi.fn().mockResolvedValue({ success: true }) + window.api = { + getGroupExitMonitorState: vi + .fn() + .mockResolvedValue({ ...state, notificationTemplate: template }), + onGroupExitMonitorState: vi.fn(() => () => undefined), + copyText, + checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state), + clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state) + } as typeof window.api + + renderWorkspace() + await user.click( + await screen.findByRole('button', { name: '复制退群信息:小艾退出了研发群' }) + ) + + await waitFor(() => expect(copyText).toHaveBeenCalledTimes(1)) + expect(copyText).toHaveBeenCalledWith( + [ + '[退群监测]', + '用户: 小艾微信名', + '群备注: 小艾群备注', + '微信号: wxid_alice', + '人数: 240->239', + `退群时间: ${formatGroupExitMonitorTime(state.events[0].detectedAt)}` + ].join('\n') + ) + expect(await screen.findByText('已复制')).toBeVisible() + }) + + it('reports a clipboard failure and keeps the event list intact', async () => { + const user = userEvent.setup() + const copyText = vi.fn().mockResolvedValue({ success: false, error: '剪贴板不可用' }) + window.api = { + getGroupExitMonitorState: vi.fn().mockResolvedValue(state), + onGroupExitMonitorState: vi.fn(() => () => undefined), + copyText, + checkGroupExitMonitorNow: vi.fn().mockResolvedValue(state), + clearGroupExitMonitorEvents: vi.fn().mockResolvedValue(state) + } as typeof window.api + + renderWorkspace() + await user.click( + await screen.findByRole('button', { name: '复制退群信息:小艾退出了研发群' }) + ) + + expect(await screen.findByText('剪贴板不可用')).toBeVisible() + expect(screen.getByText('小艾退出了研发群')).toBeVisible() + expect(screen.queryByText('已复制')).not.toBeInTheDocument() + }) + it('leaves every monitoring checkbox off for a new installation', async () => { const user = userEvent.setup() const newInstallState: GroupExitMonitorState = { diff --git a/tests/component/text-to-speech-page-windows.test.tsx b/tests/component/text-to-speech-page-windows.test.tsx index 3362cb4..bfb193f 100644 --- a/tests/component/text-to-speech-page-windows.test.tsx +++ b/tests/component/text-to-speech-page-windows.test.tsx @@ -198,4 +198,14 @@ describe('PersonalWechatSendPage on Windows', () => { expect(await screen.findByRole('spinbutton', { name: '微信发送能力端口' })).toBeVisible() expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument() }) + + it('shows the authorization notice with a README link on the Windows WeChat send page', async () => { + render() + + expect(await screen.findByText('发送能力授权')).toBeVisible() + expect(screen.getByText('发送能力属授权制,需要联系群主。请先加入交流群,然后在群内添加群主申请授权。')).toBeVisible() + const readmeLink = screen.getByRole('link', { name: '这里' }) + expect(readmeLink).toHaveAttribute('href', 'https://github.com/Wxw-Gu/TraceMemo#-交流与反馈') + expect(readmeLink).toHaveAttribute('target', '_blank') + }) })