feat: 增加windows发送能力

抽离功能到设置页面
抽离文字转语音功能
This commit is contained in:
电摇小子
2026-08-31 02:44:24 +08:00
parent 698251796f
commit 5acfa14475
33 changed files with 2226 additions and 905 deletions
@@ -4,6 +4,13 @@ import type React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const getStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const downloadRuntime = vi.fn()
@@ -73,9 +80,12 @@ function renderDialog(
}
async function startComposer(): Promise<void> {
const bind = await screen.findByRole('button', { name: '绑定微信' })
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
await screen.findByText('绑定个人微信')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
const detect = await screen.findByRole('button', { name: '重新检测' })
fireEvent.click(detect)
const start = await screen.findByRole('button', { name: '开始发送' })
@@ -170,8 +180,8 @@ describe('PersonalWechatSendDialog', () => {
message: '尚未绑定当前微信'
})
renderDialog()
expect(await screen.findByText('准备语音模型')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '下载模型' })).toBeEnabled()
expect(await screen.findByText('准备 OneBot 运行时')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '下载运行时' })).toBeEnabled()
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
expect(screen.getByText('能力检测')).toBeInTheDocument()
@@ -250,7 +260,7 @@ describe('PersonalWechatSendDialog', () => {
})
fireEvent.click(screen.getByRole('button', { name: '生成语音' }))
await waitFor(() => expect(screen.getByText('语音已生成')).toBeInTheDocument())
fireEvent.click(screen.getByRole('button', { name: '发送2' }))
fireEvent.click(screen.getByRole('button', { name: '空白语音?重新处理' }))
await waitFor(() =>
expect(sendMessage).toHaveBeenCalledWith({
type: 'voice',
@@ -286,8 +296,8 @@ describe('PersonalWechatSendDialog', () => {
canSendVoice: false
})
renderDialog()
await screen.findByRole('button', { name: '下载模型' })
fireEvent.click(screen.getByRole('button', { name: '下载模型' }))
await screen.findByRole('button', { name: '下载运行时' })
fireEvent.click(screen.getByRole('button', { name: '下载运行时' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await waitFor(() => expect(rebind).toHaveBeenCalledOnce())
@@ -320,8 +330,11 @@ describe('PersonalWechatSendDialog', () => {
})
renderDialog()
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
await screen.findByText('✓ 微信已绑定')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
fireEvent.click(await screen.findByRole('button', { name: '重新检测' }))
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
@@ -340,8 +353,12 @@ describe('PersonalWechatSendDialog', () => {
message: '等待消息初始化'
})
renderDialog()
fireEvent.click(await screen.findByRole('button', { name: '绑定微信' }))
await screen.findByText('✓ 微信已绑定')
await screen.findByText('绑定个人微信')
const bind = screen.queryByRole('button', { name: '绑定微信' })
if (bind) {
fireEvent.click(bind)
await screen.findByText('✓ 微信已绑定')
}
const detect = await screen.findByRole('button', { name: '重新检测' })
expect(detect).toBeEnabled()
fireEvent.click(detect)
@@ -0,0 +1,142 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: true,
isWindows: false,
runtimePlatform: 'darwin',
supportsPersonalWechatSend: true
}))
const getCapability = vi.fn()
const getSenderStatus = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const downloadRuntime = vi.fn()
const cancelRuntimeDownload = vi.fn()
const removeRuntime = vi.fn()
const openRuntimeDirectory = vi.fn()
const rebindSender = vi.fn()
const senderStatus = {
state: 'online' as const,
platform: 'darwin',
arch: 'arm64',
sipDisabled: true,
wechatRunning: true,
wechatPid: 4668,
boundWechatPid: 4668,
oneBotPid: 5401,
endpoint: '127.0.0.1:58080',
endpointReady: true,
wechatVersion: '4.1.11.53',
runtimeReady: true,
attachReady: true,
baseAddress: '0x114ef8000',
baseAddressReady: true,
textHookInstalled: true,
textHookReady: true,
imageHookInstalled: true,
imageHookReady: true,
messageListenerReady: true,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: '个人微信已绑定'
}
const readyRuntime = {
version: 'v0.0.18',
state: 'ready' as const,
downloadedBytes: 66_599_785,
totalBytes: 66_599_785,
progress: 1,
platform: 'darwin' as NodeJS.Platform,
architecture: 'arm64',
supported: true,
removable: true,
directory: '/tmp/personal-wechat-runtime'
}
const capability = {
supported: true,
ready: true,
status: 'ready' as const,
capabilities: { text: true, image: true, voice: true },
senderStatus,
message: '个人微信发送能力已就绪'
}
describe('PersonalWechatSendPage on macOS', () => {
beforeEach(() => {
getCapability.mockReset().mockResolvedValue(capability)
getSenderStatus.mockReset().mockResolvedValue(senderStatus)
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
cancelRuntimeDownload.mockReset().mockResolvedValue({ success: true })
removeRuntime.mockReset().mockResolvedValue({
...readyRuntime,
state: 'missing',
downloadedBytes: 0,
progress: 0,
removable: false,
directory: undefined
})
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
rebindSender.mockReset().mockResolvedValue(senderStatus)
vi.spyOn(window, 'confirm').mockReturnValue(true)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getPersonalWechatSendCapability: getCapability,
getPersonalWechatSenderStatus: getSenderStatus,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
downloadPersonalWechatRuntime: downloadRuntime,
cancelPersonalWechatRuntimeDownload: cancelRuntimeDownload,
removePersonalWechatRuntime: removeRuntime,
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
rebindPersonalWechatSender: rebindSender
}
})
})
it('keeps OneBot runtime management on the WeChat send page', async () => {
const user = userEvent.setup()
render(<PersonalWechatSendPage onNotice={vi.fn()} />)
expect(await screen.findByText('OneBot v0.0.18')).toBeVisible()
expect(screen.getAllByText('已就绪')).not.toHaveLength(0)
await user.click(screen.getByRole('button', { name: '打开目录' }))
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '卸载组件' }))
await waitFor(() => expect(removeRuntime).toHaveBeenCalledOnce())
await user.click(screen.getByRole('button', { name: '支持版本' }))
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(versionsDialog).toBeVisible()
expect(versionsDialog).toHaveTextContent('4.1.11.53')
})
it('downloads a missing macOS runtime from the send page', async () => {
getRuntimeStatus.mockResolvedValue({
...readyRuntime,
state: 'missing',
downloadedBytes: 0,
progress: 0,
removable: false,
directory: undefined
})
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
await userEvent.setup().click(await screen.findByRole('button', { name: '下载组件' }))
await waitFor(() => expect(downloadRuntime).toHaveBeenCalledOnce())
expect(onNotice).toHaveBeenCalledWith('微信发送组件已准备好')
})
})
+2
View File
@@ -609,6 +609,8 @@ describe('daily report controls', () => {
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
const detect = screen.queryByRole('button', { name: '重新检测' })
if (detect) fireEvent.click(detect)
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
expect(screen.getByText('测试群日报.png')).toBeVisible()
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
@@ -0,0 +1,197 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
import { PersonalWechatSendPage } from '../../src/renderer/src/features/settings/pages/PersonalWechatSendPage'
import { TextToSpeechPage } from '../../src/renderer/src/features/settings/pages/TextToSpeechPage'
vi.mock('../../src/renderer/src/utils/runtime-environment', () => ({
isMac: false,
isWindows: true,
runtimePlatform: 'win32',
supportsPersonalWechatSend: true
}))
const getTextToSpeechSettings = vi.fn()
const listTextToSpeechVoices = vi.fn()
const openFishAudioApiKeys = vi.fn()
const getSettings = vi.fn()
const setSettings = vi.fn()
const getPersonalWechatSenderStatus = vi.fn()
const getPersonalWechatRuntimeStatus = vi.fn()
const checkPersonalWechatSenderStatus = vi.fn()
const textToSpeechSettings = {
success: true,
settings: {
provider: 'fish-audio' as const,
hasApiKey: false,
hasStoredApiKey: false,
hasEnvironmentApiKey: false,
keySource: 'none' as const,
encryptionAvailable: true,
selectedVoiceId: '',
outputFormat: 'mp3' as const,
model: 's2.1-pro-free' as const,
phase: 'idle' as const
},
voices: []
}
const windowsStatus = {
state: 'online' as const,
platform: 'win32',
arch: 'x64',
sipDisabled: true,
wechatRunning: true,
runtimeReady: true,
endpoint: '127.0.0.1:4567',
endpointReady: true,
attachReady: true,
baseAddressReady: true,
textHookInstalled: false,
textHookReady: false,
imageHookInstalled: false,
imageHookReady: false,
messageListenerReady: false,
canSend: true,
canSendText: true,
canSendImage: true,
canSendVoice: true,
message: 'Windows 微信发送能力已连接,可以发送消息'
}
describe('PersonalWechatSendPage on Windows', () => {
beforeEach(() => {
getTextToSpeechSettings.mockReset().mockResolvedValue(textToSpeechSettings)
listTextToSpeechVoices.mockReset()
openFishAudioApiKeys.mockReset().mockResolvedValue({ success: true })
getSettings.mockReset().mockResolvedValue({ settings: { windowsWechatPort: '' } })
setSettings.mockReset().mockResolvedValue({
settings: { windowsWechatPort: '4567' }
})
getPersonalWechatSenderStatus.mockReset()
getPersonalWechatRuntimeStatus.mockReset().mockResolvedValue(null)
checkPersonalWechatSenderStatus.mockReset().mockResolvedValue(windowsStatus)
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getTextToSpeechSettings,
listTextToSpeechVoices,
openFishAudioApiKeys,
getSettings,
setSettings,
getPersonalWechatSenderStatus,
getPersonalWechatRuntimeStatus,
checkPersonalWechatSenderStatus
}
})
})
it('keeps an unconfigured port empty and saves it only after a successful check', async () => {
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
const portInput = await screen.findByRole('spinbutton', { name: '微信发送能力端口' })
expect(portInput).toHaveValue(null)
expect(screen.getByRole('button', { name: '检测' })).toBeEnabled()
expect(getPersonalWechatSenderStatus).not.toHaveBeenCalled()
fireEvent.change(portInput, { target: { value: '4567' } })
fireEvent.click(screen.getByRole('button', { name: '检测' }))
await waitFor(() => expect(checkPersonalWechatSenderStatus).toHaveBeenCalledWith('4567'))
expect(screen.getByRole('button', { name: '保存' })).toBeEnabled()
expect(portInput).toHaveValue(4567)
fireEvent.click(screen.getByRole('button', { name: '保存' }))
await waitFor(() => expect(setSettings).toHaveBeenCalledWith({ windowsWechatPort: '4567' }))
expect(screen.getByRole('button', { name: '检测' })).toBeEnabled()
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已保存')
})
it('clears a saved port and resets the connection state', async () => {
getSettings.mockResolvedValue({ settings: { windowsWechatPort: '4567' } })
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
const onNotice = vi.fn()
render(<PersonalWechatSendPage onNotice={onNotice} />)
const portInput = await screen.findByRole('spinbutton', { name: '微信发送能力端口' })
expect(portInput).toHaveValue(4567)
expect(screen.getByRole('button', { name: '清除端口' })).toBeEnabled()
fireEvent.click(screen.getByRole('button', { name: '清除端口' }))
await waitFor(() => expect(setSettings).toHaveBeenCalledWith({ windowsWechatPort: '' }))
expect(portInput).toHaveValue(null)
expect(screen.getByRole('button', { name: '清除端口' })).toBeDisabled()
expect(screen.getByRole('button', { name: '检测' })).toBeEnabled()
expect(onNotice).toHaveBeenCalledWith('微信发送能力端口已清除')
})
it('keeps the Windows send dialog free of macOS OneBot controls', async () => {
getPersonalWechatSenderStatus.mockResolvedValue({
...windowsStatus,
state: 'error',
canSend: false,
canSendText: false,
canSendImage: false,
canSendVoice: false,
message: '未检测到 Windows 微信发送能力,请先启动并登录微信'
})
render(
<PersonalWechatSendDialog
contact={{
m_nsUsrName: 'fixture-user',
m_nsNickName: '文件传输助手',
md5: 'fixture-md5',
type: 'user'
}}
isGroupChat={false}
onClose={vi.fn()}
onOpenPersonalWechatSettings={vi.fn()}
/>
)
expect(
await screen.findByText('未检测到 Windows 微信发送能力,请先启动并登录微信')
).toBeVisible()
expect(getPersonalWechatRuntimeStatus).not.toHaveBeenCalled()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByRole('switch', { name: '保留 OneBot 进程' })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: '语音发送诊断' })).not.toBeInTheDocument()
expect(screen.getByRole('heading', { name: '个人微信发送' })).toBeVisible()
expect(screen.getByRole('region', { name: '发送消息' })).toBeVisible()
})
it('opens the Windows send dialog in text mode by default', async () => {
getPersonalWechatSenderStatus.mockResolvedValue(windowsStatus)
render(
<PersonalWechatSendDialog
contact={{
m_nsUsrName: 'fixture-user',
m_nsNickName: '文件传输助手',
md5: 'fixture-md5',
type: 'user'
}}
isGroupChat={false}
onClose={vi.fn()}
/>
)
expect(await screen.findByRole('radio', { name: '文字' })).toHaveAttribute(
'data-state',
'checked'
)
expect(screen.getByRole('textbox', { name: '消息内容' })).toBeVisible()
})
it('keeps the Windows voice page focused on speech settings', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByText('API 设置')).toBeVisible()
expect(screen.queryByText(/OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByText('微信发送组件')).not.toBeInTheDocument()
})
})
+6 -78
View File
@@ -14,10 +14,6 @@ const getSettings = vi.fn()
const saveSettings = vi.fn()
const listVoices = vi.fn()
const openApiKeys = vi.fn()
const getRuntimeStatus = vi.fn()
const onRuntimeProgress = vi.fn(() => vi.fn())
const openRuntimeDirectory = vi.fn()
const removeRuntime = vi.fn()
const response = {
success: true,
@@ -55,7 +51,6 @@ const response = {
describe('TextToSpeechPage', () => {
beforeEach(() => {
sessionStorage.clear()
getSettings.mockReset().mockResolvedValue(response)
listVoices.mockReset().mockResolvedValue({
success: true,
@@ -75,42 +70,13 @@ describe('TextToSpeechPage', () => {
}
}))
openApiKeys.mockReset().mockResolvedValue({ success: true })
getRuntimeStatus.mockReset().mockResolvedValue({
version: 'v0.0.18',
state: 'ready',
downloadedBytes: 100,
totalBytes: 100,
progress: 1,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: true,
directory: '/tmp/personal-wechat-runtime'
})
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
openRuntimeDirectory.mockReset().mockResolvedValue({ success: true })
removeRuntime.mockReset().mockResolvedValue({
version: 'v0.0.18',
state: 'missing',
downloadedBytes: 0,
totalBytes: 100,
progress: 0,
platform: 'darwin',
architecture: 'arm64',
supported: true,
removable: false
})
Object.defineProperty(window, 'api', {
configurable: true,
value: {
getTextToSpeechSettings: getSettings,
saveTextToSpeechSettings: saveSettings,
listTextToSpeechVoices: listVoices,
openFishAudioApiKeys: openApiKeys,
getPersonalWechatRuntimeStatus: getRuntimeStatus,
onPersonalWechatRuntimeProgress: onRuntimeProgress,
openPersonalWechatRuntimeDirectory: openRuntimeDirectory,
removePersonalWechatRuntime: removeRuntime
openFishAudioApiKeys: openApiKeys
}
})
})
@@ -148,7 +114,7 @@ describe('TextToSpeechPage', () => {
it('opens the official Fish Audio API key page through the main process', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
await screen.findByText('微信发送组件')
await screen.findByText('API 设置')
fireEvent.click(screen.getByRole('button', { name: '前往 api.fish.audio 获取 Key' }))
await waitFor(() => expect(openApiKeys).toHaveBeenCalledTimes(1))
})
@@ -170,49 +136,11 @@ describe('TextToSpeechPage', () => {
await waitFor(() => expect(saveSettings).toHaveBeenCalledWith({ clearApiKey: true }))
})
it('keeps runtime directory and refresh actions wired to the existing API', async () => {
const user = userEvent.setup()
it('keeps the speech page free of personal WeChat runtime controls', async () => {
render(<TextToSpeechPage onNotice={vi.fn()} />)
await user.click(await screen.findByRole('button', { name: '打开目录' }))
expect(openRuntimeDirectory).toHaveBeenCalledOnce()
await user.click(screen.getByRole('button', { name: '重新检测' }))
expect(getRuntimeStatus).toHaveBeenCalledTimes(2)
})
it('opens supported versions from both triggers and restores focus after closing', async () => {
const user = userEvent.setup()
render(<TextToSpeechPage onNotice={vi.fn()} />)
const guideTrigger = await screen.findByRole('button', { name: '查看支持版本' })
await user.click(guideTrigger)
expect(screen.getByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(screen.getByText('请安装下列完整版本之一。')).toBeVisible()
expect(screen.getByRole('link', { name: /下载微信历史版本/ })).toHaveAttribute(
'href',
'https://github.com/zsbai/wechat-versions/releases'
)
expect(screen.getByText('4.1.6.12')).toBeVisible()
expect(screen.getByText('4.1.11.53')).toBeVisible()
await user.keyboard('{Escape}')
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(guideTrigger).toHaveFocus()
const runtimeTrigger = screen.getByRole('button', { name: '支持版本' })
await user.click(runtimeTrigger)
const dialog = screen.getByRole('dialog', { name: '支持的微信版本' })
expect(dialog).toBeVisible()
await user.click(dialog.previousElementSibling as HTMLElement)
expect(screen.queryByRole('dialog', { name: '支持的微信版本' })).not.toBeInTheDocument()
expect(runtimeTrigger).toHaveFocus()
})
it('keeps the session handoff that opens supported versions automatically', async () => {
sessionStorage.setItem('wxe:show-supported-wechat-versions', '1')
render(<TextToSpeechPage onNotice={vi.fn()} />)
expect(await screen.findByRole('dialog', { name: '支持的微信版本' })).toBeVisible()
expect(sessionStorage.getItem('wxe:show-supported-wechat-versions')).toBeNull()
expect(await screen.findByText('API 设置')).toBeVisible()
expect(screen.queryByText(/微信|OneBot/i)).not.toBeInTheDocument()
expect(screen.queryByText('支持的微信版本')).not.toBeInTheDocument()
})
})