mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-04 03:01:42 +08:00
feat: 修改发送消息流程
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 118 KiB |
+2
-2
@@ -1800,7 +1800,7 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('wechat-personal:selectImage', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
title: '选择要通过个人微信测试发送的图片',
|
||||
title: '选择要通过个人微信发送的图片',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||
@@ -1817,7 +1817,7 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
const result = await dialog.showOpenDialog(window!, {
|
||||
title: '选择要通过个人微信测试发送的语音',
|
||||
title: '选择要通过个人微信发送的语音',
|
||||
properties: ['openFile'],
|
||||
filters: [
|
||||
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
|
||||
|
||||
@@ -500,7 +500,7 @@ export class PersonalWechatSendService {
|
||||
}
|
||||
if (text.length > 2_000) {
|
||||
const status = await this.getStatus()
|
||||
return { success: false, status, error: '测试消息不能超过 2000 个字符' }
|
||||
return { success: false, status, error: '消息不能超过 2000 个字符' }
|
||||
}
|
||||
request = { ...request, to, text }
|
||||
} else {
|
||||
@@ -520,7 +520,7 @@ export class PersonalWechatSendService {
|
||||
return {
|
||||
success: false,
|
||||
status,
|
||||
error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
||||
error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
||||
}
|
||||
}
|
||||
fileBase64 = (
|
||||
@@ -725,7 +725,7 @@ export class PersonalWechatSendService {
|
||||
status: {
|
||||
...base,
|
||||
state: 'unsupported_platform',
|
||||
message: '个人微信测试发送当前仅支持 Apple Silicon Mac'
|
||||
message: '个人微信发送当前仅支持 Apple Silicon Mac'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -979,6 +979,11 @@ export class Wcdb4Client {
|
||||
this.cachedSessions = null
|
||||
this.cachedChatTables = null
|
||||
this.sessionsInFlight = null
|
||||
// Session rows can change their folded/muted flags independently of the
|
||||
// contact list. Invalidate the status snapshot together with sessions so
|
||||
// the next hydrated contact load reads the current WeChat state.
|
||||
this.sessionStatusCache.clear()
|
||||
this.sessionStatusesUpdatedAt = 0
|
||||
this.sessionDisplayNamesHydrated = false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type {
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../../../../shared/personal-wechat'
|
||||
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
|
||||
import { Button, SegmentedControl, SegmentedControlItem, Textarea } from '../ui'
|
||||
|
||||
export type PersonalWechatComposerMode = 'text' | 'image' | 'voice'
|
||||
type VoiceSource = 'generated' | 'file'
|
||||
type SelectedLocalFile = { path: string; name: string }
|
||||
type GeneratedVoice = { filePath: string; audioDataUrl: string }
|
||||
|
||||
export type ChatMessage = {
|
||||
id: string
|
||||
type: PersonalWechatComposerMode | 'system'
|
||||
text?: string
|
||||
fileName?: string
|
||||
outgoing: boolean
|
||||
}
|
||||
|
||||
interface PersonalWechatChatComposerProps {
|
||||
status: PersonalWechatSenderStatus
|
||||
targetId: string
|
||||
isGroupChat: boolean
|
||||
initialMode?: PersonalWechatComposerMode
|
||||
initialImage?: SelectedLocalFile | null
|
||||
onOpenTextToSpeechSettings?: () => void
|
||||
onCancel: () => void
|
||||
onSend: (
|
||||
request: PersonalWechatSendRequest,
|
||||
displayText: string
|
||||
) => Promise<{ success: boolean; error?: string }>
|
||||
onMessage: (message: ChatMessage) => void
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
function formatAudioTime(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) return '0:00'
|
||||
const seconds = Math.floor(value)
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function PersonalWechatChatComposer({
|
||||
status,
|
||||
targetId,
|
||||
isGroupChat,
|
||||
initialMode = 'text',
|
||||
initialImage = null,
|
||||
onOpenTextToSpeechSettings,
|
||||
onCancel,
|
||||
onSend,
|
||||
onMessage,
|
||||
busy
|
||||
}: PersonalWechatChatComposerProps): React.ReactElement {
|
||||
const [mode, setMode] = useState<PersonalWechatComposerMode>(initialMode)
|
||||
const [text, setText] = useState('')
|
||||
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
|
||||
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
|
||||
const [voiceText, setVoiceText] = useState('')
|
||||
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
|
||||
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
|
||||
const [generatedVoice, setGeneratedVoice] = useState<GeneratedVoice | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
|
||||
const [previewCurrentTime, setPreviewCurrentTime] = useState(0)
|
||||
const [previewDuration, setPreviewDuration] = useState(0)
|
||||
const [feedback, setFeedback] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
|
||||
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const selectedTypeReady =
|
||||
mode === 'text'
|
||||
? status.canSendText
|
||||
: mode === 'image'
|
||||
? status.canSendImage
|
||||
: status.canSendVoice
|
||||
const isBusy = busy || isGenerating
|
||||
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getTextToSpeechSettings()
|
||||
.then(async (response) => {
|
||||
if (!active) return
|
||||
setTtsSettings(response.settings)
|
||||
if (!response.settings.hasApiKey) return
|
||||
const voicesResponse = await window.api.listTextToSpeechVoices({
|
||||
pageNumber: 1,
|
||||
pageSize: 24
|
||||
})
|
||||
if (active && voicesResponse.success) setTtsVoices(voicesResponse.items)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const clearGeneratedVoice = useCallback((): void => {
|
||||
const current = generatedVoiceRef.current
|
||||
generatedVoiceRef.current = null
|
||||
generatedAudioRef.current?.pause()
|
||||
setGeneratedVoice(null)
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrentTime(0)
|
||||
setPreviewDuration(0)
|
||||
if (current?.filePath)
|
||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
useEffect(() => () => clearGeneratedVoice(), [clearGeneratedVoice])
|
||||
|
||||
const selectImage = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
const selection = await window.api.selectPersonalWechatImage()
|
||||
if (!selection.canceled && selection.path) {
|
||||
setImage({
|
||||
path: selection.path,
|
||||
name: selection.name || selection.path.split(/[\\/]/).pop() || '图片'
|
||||
})
|
||||
setFeedback(null)
|
||||
}
|
||||
}
|
||||
|
||||
const selectVoice = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
const selection = await window.api.selectPersonalWechatVoice()
|
||||
if (!selection.canceled && selection.path) {
|
||||
setVoice({
|
||||
path: selection.path,
|
||||
name: selection.name || selection.path.split(/[\\/]/).pop() || '语音'
|
||||
})
|
||||
setFeedback(null)
|
||||
}
|
||||
}
|
||||
|
||||
const send = async (
|
||||
request: PersonalWechatSendRequest,
|
||||
displayText: string
|
||||
): Promise<boolean> => {
|
||||
if (isBusy || !selectedTypeReady) return false
|
||||
setFeedback(null)
|
||||
const result = await onSend(request, displayText)
|
||||
setFeedback({
|
||||
success: result.success,
|
||||
message: result.success ? '已发送' : result.error || '发送失败'
|
||||
})
|
||||
if (result.success)
|
||||
onMessage({
|
||||
id: `${Date.now()}-${Math.random()}`,
|
||||
type: request.type,
|
||||
text: request.type === 'text' ? request.text : displayText,
|
||||
fileName: request.type === 'text' ? undefined : displayText,
|
||||
outgoing: true
|
||||
})
|
||||
return result.success
|
||||
}
|
||||
|
||||
const generateVoice = async (): Promise<void> => {
|
||||
if (isBusy || !ttsSettings?.hasApiKey || !ttsSettings.selectedVoiceId || !voiceText.trim())
|
||||
return
|
||||
clearGeneratedVoice()
|
||||
setIsGenerating(true)
|
||||
setFeedback(null)
|
||||
try {
|
||||
const generated = await window.api.synthesizeTextToSpeech({
|
||||
text: voiceText.trim(),
|
||||
referenceId: ttsSettings.selectedVoiceId
|
||||
})
|
||||
if (!generated.success || !generated.filePath || !generated.audioDataUrl) {
|
||||
setFeedback({ success: false, message: generated.error || '语音生成失败' })
|
||||
return
|
||||
}
|
||||
const nextVoice = { filePath: generated.filePath, audioDataUrl: generated.audioDataUrl }
|
||||
generatedVoiceRef.current = nextVoice
|
||||
setGeneratedVoice(nextVoice)
|
||||
} catch (error) {
|
||||
setFeedback({
|
||||
success: false,
|
||||
message: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const sendCurrent = async (): Promise<void> => {
|
||||
if (mode === 'text') {
|
||||
const value = text.trim()
|
||||
if (value)
|
||||
await send({ type: 'text', to: targetId, text: value, isGroup: isGroupChat }, value)
|
||||
} else if (mode === 'image' && image) {
|
||||
await send(
|
||||
{ type: 'image', to: targetId, filePath: image.path, isGroup: isGroupChat },
|
||||
image.name
|
||||
)
|
||||
} else if (mode === 'voice' && voiceSource === 'file' && voice) {
|
||||
await send(
|
||||
{ type: 'voice', to: targetId, filePath: voice.path, isGroup: isGroupChat },
|
||||
voice.name
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const sendGenerated = async (): Promise<void> => {
|
||||
if (!generatedVoiceRef.current) return
|
||||
const result = await send(
|
||||
{
|
||||
type: 'voice',
|
||||
to: targetId,
|
||||
filePath: generatedVoiceRef.current.filePath,
|
||||
isGroup: isGroupChat
|
||||
},
|
||||
voiceText.trim() || '语音消息'
|
||||
)
|
||||
if (result) clearGeneratedVoice()
|
||||
}
|
||||
|
||||
const canSend = Boolean(
|
||||
selectedTypeReady &&
|
||||
!isBusy &&
|
||||
((mode === 'text' && text.trim()) ||
|
||||
(mode === 'image' && image) ||
|
||||
(mode === 'voice' &&
|
||||
((voiceSource === 'file' && voice) || (voiceSource === 'generated' && generatedVoice))))
|
||||
)
|
||||
const generatedReady = Boolean(
|
||||
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
|
||||
)
|
||||
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
||||
|
||||
return (
|
||||
<section className="personal-wechat-composer" aria-label="发送消息">
|
||||
<SegmentedControl
|
||||
className="personal-wechat-composer-mode"
|
||||
aria-label="消息类型"
|
||||
value={mode}
|
||||
onValueChange={(value) => {
|
||||
clearGeneratedVoice()
|
||||
setMode(value as PersonalWechatComposerMode)
|
||||
setFeedback(null)
|
||||
}}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SegmentedControlItem value="text">文字</SegmentedControlItem>
|
||||
<SegmentedControlItem value="image">图片</SegmentedControlItem>
|
||||
<SegmentedControlItem value="voice">语音</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
|
||||
{mode === 'text' && (
|
||||
<div className="personal-wechat-text-editor">
|
||||
<Textarea
|
||||
aria-label="消息内容"
|
||||
placeholder="输入消息"
|
||||
value={text}
|
||||
maxLength={2000}
|
||||
rows={3}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
setText(event.target.value)
|
||||
setFeedback(null)
|
||||
}}
|
||||
/>
|
||||
<span>{text.length} / 2000</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'image' && (
|
||||
<div className="personal-wechat-file-editor">
|
||||
<div>
|
||||
<strong>{image?.name || '还没有选择图片'}</strong>
|
||||
<small>{image ? image.path : '支持 PNG、JPG、GIF 和 WebP,最大 20 MB'}</small>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => void selectImage()} disabled={isBusy}>
|
||||
{image ? '重新选择' : '选择图片'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'voice' && (
|
||||
<div className="personal-wechat-voice-editor">
|
||||
<SegmentedControl
|
||||
aria-label="语音来源"
|
||||
value={voiceSource}
|
||||
onValueChange={(value) => {
|
||||
clearGeneratedVoice()
|
||||
setVoiceSource(value as VoiceSource)
|
||||
setFeedback(null)
|
||||
}}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SegmentedControlItem value="generated">输入文字生成</SegmentedControlItem>
|
||||
<SegmentedControlItem value="file">选择本地文件</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
{voiceSource === 'generated' ? (
|
||||
<>
|
||||
<div className="personal-wechat-voice-heading">
|
||||
<span>{selectedTtsVoice?.name || '尚未选择音色'}</span>
|
||||
{onOpenTextToSpeechSettings && (
|
||||
<Button variant="link" size="sm" onClick={onOpenTextToSpeechSettings}>
|
||||
语音设置
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label="语音文字"
|
||||
placeholder="输入要生成的文字"
|
||||
value={voiceText}
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => {
|
||||
clearGeneratedVoice()
|
||||
setVoiceText(event.target.value)
|
||||
setFeedback(null)
|
||||
}}
|
||||
/>
|
||||
{generatedVoice ? (
|
||||
<div className="personal-wechat-generated-result">
|
||||
<audio
|
||||
ref={generatedAudioRef}
|
||||
src={generatedVoice.audioDataUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
||||
onTimeUpdate={(event) => setPreviewCurrentTime(event.currentTarget.currentTime)}
|
||||
onPause={() => setIsPreviewPlaying(false)}
|
||||
onPlay={() => setIsPreviewPlaying(true)}
|
||||
onEnded={() => {
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrentTime(0)
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<strong>语音已生成</strong>
|
||||
<span>
|
||||
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
|
||||
</span>
|
||||
<div className="personal-wechat-preview-track">
|
||||
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
const audio = generatedAudioRef.current
|
||||
if (!audio) return
|
||||
if (audio.paused) {
|
||||
await audio.play()
|
||||
setIsPreviewPlaying(true)
|
||||
} else {
|
||||
audio.pause()
|
||||
}
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{isPreviewPlaying ? '暂停' : '试听'}
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => void sendGenerated()} disabled={!canSend}>
|
||||
{busy ? '发送中…' : '发送'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void generateVoice()}
|
||||
disabled={!generatedReady || isBusy}
|
||||
>
|
||||
{isGenerating ? '正在生成…' : '生成语音'}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="personal-wechat-file-editor">
|
||||
<div>
|
||||
<strong>{voice?.name || '还没有选择语音文件'}</strong>
|
||||
<small>{voice ? voice.path : '支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC'}</small>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void selectVoice()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{voice ? '重新选择' : '选择语音'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="personal-wechat-composer-footer">
|
||||
{feedback && (
|
||||
<span className={feedback.success ? 'is-success' : 'is-error'}>{feedback.message}</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={isBusy}>
|
||||
取消
|
||||
</Button>
|
||||
{!(mode === 'voice' && voiceSource === 'generated' && generatedVoice) && (
|
||||
<Button size="sm" onClick={() => void sendCurrent()} disabled={!canSend}>
|
||||
{busy ? '正在发送…' : '发送消息'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -1,21 +1,21 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
|
||||
import type {
|
||||
PersonalWechatSendRequest,
|
||||
PersonalWechatSenderStatus
|
||||
} from '../../../../shared/personal-wechat'
|
||||
import type {
|
||||
PersonalWechatRuntimeProgressEvent,
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../../../../shared/personal-wechat-runtime'
|
||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
SegmentedControl,
|
||||
SegmentedControlItem,
|
||||
Textarea
|
||||
} from '../ui'
|
||||
PersonalWechatChatComposer,
|
||||
type ChatMessage,
|
||||
type PersonalWechatComposerMode
|
||||
} from './PersonalWechatChatComposer'
|
||||
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||
|
||||
type SendMode = 'image' | 'voice'
|
||||
type VoiceSource = 'generated' | 'file'
|
||||
type SelectedLocalFile = { path: string; name: string }
|
||||
|
||||
interface PersonalWechatSendDialogProps {
|
||||
@@ -23,48 +23,34 @@ interface PersonalWechatSendDialogProps {
|
||||
isGroupChat: boolean
|
||||
onClose: () => void
|
||||
onOpenTextToSpeechSettings?: () => void
|
||||
initialMode?: SendMode
|
||||
initialMode?: PersonalWechatComposerMode
|
||||
initialImage?: SelectedLocalFile | null
|
||||
}
|
||||
|
||||
type GeneratedVoice = { filePath: string; audioDataUrl: string }
|
||||
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
||||
|
||||
function formatAudioTime(value: number): string {
|
||||
if (!Number.isFinite(value) || value < 0) return '0:00'
|
||||
const seconds = Math.floor(value)
|
||||
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function statusLabel(status: PersonalWechatSenderStatus | null): string {
|
||||
if (!status) return '正在检测'
|
||||
if (status.state === 'online') return '个人微信已绑定'
|
||||
if (status.state === 'stopped') return '尚未绑定'
|
||||
if (status.state === 'runtime_missing') return '运行时未安装'
|
||||
if (status.state === 'hook_not_ready') return '已绑定,等待消息初始化'
|
||||
if (status.state === 'unsupported_version') return '微信版本不匹配'
|
||||
if (status.state === 'wechat_not_running') return '微信未运行'
|
||||
if (status.state === 'sip_enabled') return 'SIP 未关闭'
|
||||
if (status.state === 'unsupported_platform') return '当前平台不支持'
|
||||
if (status.state === 'starting') return '正在连接'
|
||||
if (status.state === 'rebinding') return '正在重新绑定'
|
||||
if (status.state === 'error') return '绑定异常'
|
||||
return '正在检测'
|
||||
}
|
||||
|
||||
function readyText(ready: boolean, readyLabel = '正常', waitingLabel = '等待初始化'): string {
|
||||
return ready ? readyLabel : waitingLabel
|
||||
}
|
||||
|
||||
function statusDescription(status: PersonalWechatSenderStatus | null): string {
|
||||
if (!status) return '正在检查微信版本、进程和 OneBot Hook…'
|
||||
if (status.state === 'unsupported_version') {
|
||||
return `当前微信版本 ${status.wechatVersion || '未知'} 暂不支持。请在设置中查看支持的微信版本,安装完全一致的版本后再试。`
|
||||
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
||||
return {
|
||||
state: 'error',
|
||||
platform: 'unknown',
|
||||
arch: 'unknown',
|
||||
sipDisabled: false,
|
||||
wechatRunning: false,
|
||||
runtimeReady: false,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: false,
|
||||
attachReady: false,
|
||||
baseAddressReady: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: '无法检测个人微信发送服务',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
if (status.state === 'runtime_missing') {
|
||||
return '微信发送组件尚未安装。请前往文字转语音设置下载组件后再试。'
|
||||
}
|
||||
return status.message
|
||||
}
|
||||
|
||||
export function PersonalWechatSendDialog({
|
||||
@@ -72,338 +58,137 @@ export function PersonalWechatSendDialog({
|
||||
isGroupChat,
|
||||
onClose,
|
||||
onOpenTextToSpeechSettings,
|
||||
initialMode = 'image',
|
||||
initialMode = 'text',
|
||||
initialImage = null
|
||||
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||
const [status, setStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||
const [mode, setMode] = useState<SendMode>(initialMode)
|
||||
const [image, setImage] = useState<SelectedLocalFile | null>(initialImage)
|
||||
const [voice, setVoice] = useState<SelectedLocalFile | null>(null)
|
||||
const [voiceSource, setVoiceSource] = useState<VoiceSource>('generated')
|
||||
const [voiceText, setVoiceText] = useState('1')
|
||||
const [ttsSettings, setTtsSettings] = useState<TextToSpeechSettings | null>(null)
|
||||
const [ttsVoices, setTtsVoices] = useState<TextToSpeechVoice[]>([])
|
||||
const [generatedVoice, setGeneratedVoice] = useState<GeneratedVoice | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [isPreviewPlaying, setIsPreviewPlaying] = useState(false)
|
||||
const [previewCurrentTime, setPreviewCurrentTime] = useState(0)
|
||||
const [previewDuration, setPreviewDuration] = useState(0)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [isRebinding, setIsRebinding] = useState(false)
|
||||
const [result, setResult] = useState<{ success: boolean; message: string } | null>(null)
|
||||
const generatedVoiceRef = useRef<GeneratedVoice | null>(null)
|
||||
const generatedAudioRef = useRef<HTMLAudioElement | null>(null)
|
||||
const [senderStatus, setSenderStatus] = useState<PersonalWechatSenderStatus | null>(null)
|
||||
const [runtimeStatus, setRuntimeStatus] = useState<PersonalWechatRuntimeStatus | null>(null)
|
||||
const [runtimeProgress, setRuntimeProgress] = useState<PersonalWechatRuntimeProgressEvent | null>(
|
||||
null
|
||||
)
|
||||
const [detecting, setDetecting] = useState(true)
|
||||
const [binding, setBinding] = useState(false)
|
||||
const [runtimeBusy, setRuntimeBusy] = useState(false)
|
||||
const [sendBusy, setSendBusy] = useState(false)
|
||||
const [detectionAttempted, setDetectionAttempted] = useState(false)
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [sendError, setSendError] = useState<string | null>(null)
|
||||
const [composerStarted, setComposerStarted] = useState(Boolean(initialImage))
|
||||
const requestIdRef = useRef(0)
|
||||
const restoreFocusRef = useRef<HTMLElement | null>(null)
|
||||
const closingRef = useRef(false)
|
||||
const displayName = contact.m_nsNickName || contact.m_nsUsrName || '未命名会话'
|
||||
const targetId = contact.m_nsUsrName
|
||||
const selectedTypeReady = mode === 'voice' ? status?.canSendVoice : status?.canSendImage
|
||||
const hasContent =
|
||||
mode === 'voice'
|
||||
? voiceSource === 'file'
|
||||
? Boolean(voice?.path)
|
||||
: Boolean(voiceText.trim())
|
||||
: Boolean(image?.path)
|
||||
const isBusy = isSending || isGenerating || isRebinding
|
||||
const generatedVoiceReady = Boolean(
|
||||
ttsSettings?.hasApiKey && ttsSettings.selectedVoiceId && voiceText.trim()
|
||||
const isBusy = binding || runtimeBusy || sendBusy
|
||||
const setupReady = Boolean(
|
||||
senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice)
|
||||
)
|
||||
const canSubmit = Boolean(
|
||||
selectedTypeReady && hasContent && !isBusy && (mode !== 'voice' || voiceSource !== 'generated')
|
||||
)
|
||||
const canGenerate = Boolean(generatedVoiceReady && !isBusy)
|
||||
const canSendGenerated = Boolean(status?.canSendVoice && generatedVoice && !isBusy)
|
||||
const previewProgress = previewDuration > 0 ? (previewCurrentTime / previewDuration) * 100 : 0
|
||||
const selectedTtsVoice = ttsVoices.find((item) => item.id === ttsSettings?.selectedVoiceId)
|
||||
const statusTone = useMemo(() => {
|
||||
if (status?.attachReady && status.baseAddressReady) return 'ready'
|
||||
if (!status || status.state === 'checking' || status.state === 'starting') return 'checking'
|
||||
return 'blocked'
|
||||
}, [status])
|
||||
|
||||
const clearGeneratedVoice = useCallback((removeFile = true): void => {
|
||||
const current = generatedVoiceRef.current
|
||||
generatedVoiceRef.current = null
|
||||
generatedAudioRef.current?.pause()
|
||||
setGeneratedVoice(null)
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrentTime(0)
|
||||
setPreviewDuration(0)
|
||||
if (removeFile && current?.filePath) {
|
||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
if (isBusy || closingRef.current) return
|
||||
closingRef.current = true
|
||||
const restoreFocus = restoreFocusRef.current
|
||||
restoreFocusRef.current = null
|
||||
clearGeneratedVoice()
|
||||
onClose()
|
||||
queueMicrotask(() => restoreFocus?.focus())
|
||||
}, [clearGeneratedVoice, isBusy, onClose])
|
||||
|
||||
const handleOpenTextToSpeechSettings = (showSupportedVersions = false): void => {
|
||||
if (!onOpenTextToSpeechSettings || isBusy) return
|
||||
if (showSupportedVersions) {
|
||||
try {
|
||||
sessionStorage.setItem(SHOW_SUPPORTED_WECHAT_VERSIONS_KEY, '1')
|
||||
} catch {
|
||||
// The settings page can still open if session storage is unavailable.
|
||||
}
|
||||
}
|
||||
clearGeneratedVoice()
|
||||
onClose()
|
||||
onOpenTextToSpeechSettings()
|
||||
}
|
||||
|
||||
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||
setResult(null)
|
||||
const requestId = ++requestIdRef.current
|
||||
setDetecting(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
setStatus(await window.api.getPersonalWechatSenderStatus())
|
||||
const [nextRuntime, nextSender] = await Promise.all([
|
||||
window.api.getPersonalWechatRuntimeStatus?.() || Promise.resolve(null),
|
||||
window.api.getPersonalWechatSenderStatus()
|
||||
])
|
||||
if (requestId !== requestIdRef.current) return
|
||||
setRuntimeStatus(nextRuntime)
|
||||
setRuntimeProgress(nextRuntime?.state === 'downloading' ? nextRuntime : null)
|
||||
setSenderStatus(nextSender)
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
state: 'error',
|
||||
platform: 'unknown',
|
||||
arch: 'unknown',
|
||||
sipDisabled: false,
|
||||
wechatRunning: false,
|
||||
runtimeReady: false,
|
||||
endpoint: '127.0.0.1:58080',
|
||||
endpointReady: false,
|
||||
attachReady: false,
|
||||
baseAddressReady: false,
|
||||
textHookInstalled: false,
|
||||
textHookReady: false,
|
||||
imageHookInstalled: false,
|
||||
imageHookReady: false,
|
||||
messageListenerReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: '无法检测个人微信发送服务',
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setDetecting(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refreshStatus()
|
||||
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
|
||||
setRuntimeProgress(status)
|
||||
setRuntimeStatus(status)
|
||||
if (status.state === 'ready') void refreshStatus()
|
||||
})
|
||||
return unsubscribe
|
||||
}, [refreshStatus])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api
|
||||
.getTextToSpeechSettings()
|
||||
.then(async (response) => {
|
||||
if (!active) return
|
||||
setTtsSettings(response.settings)
|
||||
if (!response.settings.hasApiKey) return
|
||||
const voicesResponse = await window.api.listTextToSpeechVoices({
|
||||
pageNumber: 1,
|
||||
pageSize: 24
|
||||
})
|
||||
if (!active || !voicesResponse.success) return
|
||||
setTtsVoices(voicesResponse.items)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
return () => {
|
||||
active = false
|
||||
const handleDownloadRuntime = async (): Promise<void> => {
|
||||
if (runtimeBusy) return
|
||||
setRuntimeBusy(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
const result = await window.api.downloadPersonalWechatRuntime()
|
||||
setRuntimeStatus(result.status)
|
||||
if (!result.success && result.error) setSendError(result.error)
|
||||
if (result.success) await refreshStatus()
|
||||
} catch (error) {
|
||||
setSendError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setRuntimeBusy(false)
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const current = generatedVoiceRef.current
|
||||
generatedVoiceRef.current = null
|
||||
if (current?.filePath) {
|
||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const audio = generatedAudioRef.current
|
||||
return () => audio?.pause()
|
||||
}, [generatedVoice])
|
||||
|
||||
const handleRebind = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
setIsRebinding(true)
|
||||
setResult(null)
|
||||
const handleBind = async (): Promise<void> => {
|
||||
if (binding) return
|
||||
setBinding(true)
|
||||
setDetectionAttempted(false)
|
||||
setSendError(null)
|
||||
try {
|
||||
const nextStatus = await window.api.rebindPersonalWechatSender()
|
||||
setStatus(nextStatus)
|
||||
setResult({
|
||||
success: nextStatus.attachReady && nextStatus.baseAddressReady,
|
||||
message: nextStatus.message
|
||||
})
|
||||
setSenderStatus(nextStatus)
|
||||
if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message)
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
setSendError(error instanceof Error ? error.message : String(error))
|
||||
} finally {
|
||||
setIsRebinding(false)
|
||||
setBinding(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectImage = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
const selection = await window.api.selectPersonalWechatImage()
|
||||
if (!selection.canceled && selection.path) {
|
||||
setImage({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
||||
setResult(null)
|
||||
}
|
||||
const handleDetect = async (): Promise<void> => {
|
||||
setDetectionAttempted(true)
|
||||
await refreshStatus()
|
||||
}
|
||||
|
||||
const handleSelectVoice = async (): Promise<void> => {
|
||||
if (isBusy) return
|
||||
const selection = await window.api.selectPersonalWechatVoice()
|
||||
if (!selection.canceled && selection.path) {
|
||||
setVoice({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
||||
setResult(null)
|
||||
}
|
||||
const handleClose = (): void => {
|
||||
if (isBusy || closingRef.current) return
|
||||
closingRef.current = true
|
||||
const restoreFocus = restoreFocusRef.current
|
||||
restoreFocusRef.current = null
|
||||
onClose()
|
||||
queueMicrotask(() => restoreFocus?.focus())
|
||||
}
|
||||
|
||||
const changeMode = (nextMode: SendMode): void => {
|
||||
if (mode === 'voice' && voiceSource === 'generated' && nextMode !== 'voice') {
|
||||
clearGeneratedVoice()
|
||||
}
|
||||
setMode(nextMode)
|
||||
setResult(null)
|
||||
const handleOpenSettings = (): void => {
|
||||
if (!onOpenTextToSpeechSettings || isBusy) return
|
||||
handleClose()
|
||||
onOpenTextToSpeechSettings()
|
||||
}
|
||||
|
||||
const changeVoiceSource = (nextSource: VoiceSource): void => {
|
||||
if (voiceSource === 'generated' && nextSource !== 'generated') clearGeneratedVoice()
|
||||
setVoiceSource(nextSource)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const changeVoiceText = (nextText: string): void => {
|
||||
if (generatedVoiceRef.current) clearGeneratedVoice()
|
||||
setVoiceText(nextText)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleGenerateVoice = async (): Promise<void> => {
|
||||
if (!canGenerate) return
|
||||
clearGeneratedVoice()
|
||||
setIsGenerating(true)
|
||||
setResult(null)
|
||||
const handleSend = async (
|
||||
request: PersonalWechatSendRequest
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
setSendBusy(true)
|
||||
setSendError(null)
|
||||
try {
|
||||
const generated = await window.api.synthesizeTextToSpeech({
|
||||
text: voiceText.trim(),
|
||||
referenceId: ttsSettings!.selectedVoiceId
|
||||
})
|
||||
if (!generated.success || !generated.filePath || !generated.audioDataUrl) {
|
||||
setResult({ success: false, message: generated.error || '语音生成失败' })
|
||||
return
|
||||
}
|
||||
const nextVoice = {
|
||||
filePath: generated.filePath,
|
||||
audioDataUrl: generated.audioDataUrl
|
||||
}
|
||||
generatedVoiceRef.current = nextVoice
|
||||
setGeneratedVoice(nextVoice)
|
||||
setResult({ success: true, message: '语音已生成,可以先试听,确认后再发送' })
|
||||
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
|
||||
setSenderStatus(response.status)
|
||||
if (!response.success) setSendError(response.error || '发送失败')
|
||||
return { success: response.success, error: response.error }
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
setSendError(message)
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
setSendBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleGeneratedPreview = async (): Promise<void> => {
|
||||
const audio = generatedAudioRef.current
|
||||
if (!audio) return
|
||||
if (!audio.paused) {
|
||||
audio.pause()
|
||||
setIsPreviewPlaying(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await audio.play()
|
||||
setIsPreviewPlaying(true)
|
||||
} catch {
|
||||
setResult({ success: false, message: '语音试听播放失败' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSendGeneratedVoice = async (): Promise<void> => {
|
||||
const current = generatedVoiceRef.current
|
||||
if (!canSendGenerated || !current) return
|
||||
setIsSending(true)
|
||||
setResult(null)
|
||||
generatedAudioRef.current?.pause()
|
||||
setIsPreviewPlaying(false)
|
||||
try {
|
||||
const response = await window.api.sendPersonalWechatMessage({
|
||||
type: 'voice',
|
||||
to: targetId,
|
||||
filePath: current.filePath,
|
||||
isGroup: isGroupChat
|
||||
})
|
||||
setStatus(response.status)
|
||||
setResult({
|
||||
success: response.success,
|
||||
message: response.success
|
||||
? '语音已提交给微信;请从另一设备或群成员处确认实际送达'
|
||||
: response.error || '发送失败'
|
||||
})
|
||||
if (response.success) clearGeneratedVoice()
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = async (): Promise<void> => {
|
||||
if (!canSubmit || (mode === 'voice' && voiceSource === 'generated')) return
|
||||
setIsSending(true)
|
||||
setResult(null)
|
||||
try {
|
||||
const response = await window.api.sendPersonalWechatMessage(
|
||||
mode === 'voice'
|
||||
? { type: 'voice', to: targetId, filePath: voice!.path, isGroup: isGroupChat }
|
||||
: { type: 'image', to: targetId, filePath: image!.path, isGroup: isGroupChat }
|
||||
)
|
||||
setStatus(response.status)
|
||||
setResult({
|
||||
success: response.success,
|
||||
message: response.success
|
||||
? `${mode === 'voice' ? '语音' : '图片'}已提交给微信;请从另一设备或群成员处确认实际送达`
|
||||
: response.error || '发送失败'
|
||||
})
|
||||
} catch (error) {
|
||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
||||
} finally {
|
||||
setIsSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const statusItems = [
|
||||
['微信进程', status?.wechatPid ? `PID ${status.wechatPid}` : '未检测到'],
|
||||
[
|
||||
'OneBot',
|
||||
status?.oneBotPid
|
||||
? `PID ${status.oneBotPid}${status.boundWechatPid ? ` · 绑定 ${status.boundWechatPid}` : ''}`
|
||||
: '未启动'
|
||||
],
|
||||
[
|
||||
'接口',
|
||||
`${status?.endpoint || '127.0.0.1:58080'} · ${readyText(Boolean(status?.endpointReady), '监听中', '未监听')}`
|
||||
],
|
||||
['基址扫描', status?.baseAddress || readyText(Boolean(status?.baseAddressReady))],
|
||||
['图片 Hook', readyText(Boolean(status?.imageHookReady), '已捕获,可发送', '等待手动发图片')],
|
||||
['语音能力', readyText(Boolean(status?.canSendVoice), '可发送', '等待媒体 Hook 初始化')],
|
||||
['消息监听', readyText(Boolean(status?.messageListenerReady), '正常', '等待收到微信消息')]
|
||||
]
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||
<DialogContent
|
||||
className="personal-wechat-send-dialog max-h-[calc(100vh-3rem)] max-w-[620px] gap-4 overflow-y-auto p-[22px]"
|
||||
className="personal-wechat-send-dialog max-h-[calc(100vh-2rem)] max-w-[720px] gap-0 overflow-y-auto p-0"
|
||||
onOpenAutoFocus={() => {
|
||||
restoreFocusRef.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
@@ -411,291 +196,91 @@ export function PersonalWechatSendDialog({
|
||||
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
||||
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
|
||||
<div>
|
||||
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
||||
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
|
||||
个人微信测试发送
|
||||
</DialogTitle>
|
||||
<DialogHeader className="personal-wechat-chat-header">
|
||||
<div className="personal-wechat-chat-avatar" aria-hidden>
|
||||
{displayName.slice(0, 1)}
|
||||
</div>
|
||||
<DialogDescription className="sr-only">
|
||||
向当前微信联系人或群聊测试发送图片和语音。
|
||||
</DialogDescription>
|
||||
<div className="personal-wechat-chat-heading">
|
||||
<DialogTitle>{displayName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<span
|
||||
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
|
||||
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
|
||||
/>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="personal-wechat-send-device-note" role="note">
|
||||
<span aria-hidden>i</span>
|
||||
<p>
|
||||
<strong>请在其他设备确认发送结果</strong>
|
||||
通过测试功能发送的消息不会显示在本机微信会话中,请切换到手机、平板等其他设备确认是否送达。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="personal-wechat-send-target">
|
||||
<span>{isGroupChat ? '发送到群聊' : '发送给联系人'}</span>
|
||||
<strong>{displayName}</strong>
|
||||
<code>{targetId}</code>
|
||||
</div>
|
||||
|
||||
<div className={`personal-wechat-send-status ${statusTone}`}>
|
||||
<span className="personal-wechat-send-status-dot" />
|
||||
<div>
|
||||
<strong>{statusLabel(status)}</strong>
|
||||
<p>{statusDescription(status)}</p>
|
||||
{status?.wechatVersion && <small>微信版本:{status.wechatVersion}</small>}
|
||||
{status?.error &&
|
||||
status.state !== 'unsupported_version' &&
|
||||
status.state !== 'runtime_missing' && <small className="error">{status.error}</small>}
|
||||
</div>
|
||||
<div className="personal-wechat-send-status-actions">
|
||||
{(status?.state === 'unsupported_version' || status?.state === 'runtime_missing') &&
|
||||
onOpenTextToSpeechSettings ? (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
onClick={() =>
|
||||
handleOpenTextToSpeechSettings(status.state === 'unsupported_version')
|
||||
}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{status.state === 'runtime_missing' ? '前往下载组件' : '查看支持版本'}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
onClick={() => void refreshStatus()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
重新检测
|
||||
</Button>
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="h-auto p-0"
|
||||
onClick={() => void handleRebind()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{isRebinding ? '绑定中…' : '尝试重新绑定'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dl className="personal-wechat-send-diagnostics">
|
||||
{statusItems.map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
<div className="personal-wechat-chat-body">
|
||||
{setupReady && composerStarted && (
|
||||
<div className="personal-wechat-message-list" aria-label="消息列表">
|
||||
{messages.length === 0 ? (
|
||||
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||
>
|
||||
<span className="personal-wechat-message-kind">
|
||||
{message.type === 'text'
|
||||
? '文字'
|
||||
: message.type === 'image'
|
||||
? '图片'
|
||||
: '语音'}
|
||||
</span>
|
||||
<span>{message.text || message.fileName}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
|
||||
<SegmentedControl
|
||||
className="personal-wechat-send-mode grid w-full grid-cols-2"
|
||||
aria-label="测试消息类型"
|
||||
value={mode}
|
||||
onValueChange={(value) => changeMode(value as SendMode)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SegmentedControlItem value="image" className="w-full">
|
||||
图片
|
||||
</SegmentedControlItem>
|
||||
<SegmentedControlItem value="voice" className="w-full">
|
||||
语音
|
||||
</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
|
||||
{mode === 'image' ? (
|
||||
<div className="personal-wechat-send-image-picker">
|
||||
<span>图片</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleSelectImage()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{image ? '重新选择图片' : '选择图片'}
|
||||
</Button>
|
||||
{image ? (
|
||||
<div>
|
||||
<strong>{image.name}</strong>
|
||||
<small>{image.path}</small>
|
||||
</div>
|
||||
) : (
|
||||
<small>支持 PNG、JPG、GIF 和 WebP,最大 20 MB</small>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="personal-wechat-voice-composer">
|
||||
<SegmentedControl
|
||||
className="personal-wechat-voice-source grid w-full grid-cols-2"
|
||||
aria-label="语音来源"
|
||||
value={voiceSource}
|
||||
onValueChange={(value) => changeVoiceSource(value as VoiceSource)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<SegmentedControlItem value="generated" className="w-full">
|
||||
输入文字生成
|
||||
</SegmentedControlItem>
|
||||
<SegmentedControlItem value="file" className="w-full">
|
||||
选择本地文件
|
||||
</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
|
||||
{voiceSource === 'generated' ? (
|
||||
<div className="personal-wechat-generated-voice">
|
||||
<div className="personal-wechat-generated-voice-heading">
|
||||
<div>
|
||||
<span>文字生成语音</span>
|
||||
<strong>{selectedTtsVoice?.name || '尚未选择音色'}</strong>
|
||||
</div>
|
||||
{onOpenTextToSpeechSettings ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleOpenTextToSpeechSettings()}
|
||||
>
|
||||
前往文字转语音设置
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="personal-wechat-send-editor">
|
||||
<span>要生成的文字</span>
|
||||
<Textarea
|
||||
aria-label="要生成的文字"
|
||||
value={voiceText}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
disabled={isBusy}
|
||||
onChange={(event) => changeVoiceText(event.target.value)}
|
||||
/>
|
||||
<small>{voiceText.length} / 1000</small>
|
||||
</label>
|
||||
<div
|
||||
className={`personal-wechat-tts-readiness ${ttsSettings?.hasApiKey ? 'ready' : ''}`}
|
||||
>
|
||||
<strong>
|
||||
{generatedVoiceReady
|
||||
? 'API Key 与音色已准备'
|
||||
: ttsSettings?.hasApiKey
|
||||
? '还需要在设置中选择音色'
|
||||
: '还需要配置语音服务 API Key'}
|
||||
</strong>
|
||||
<span>点击右下角“生成语音”,生成完成后可以试听,再决定是否发送。</span>
|
||||
</div>
|
||||
{isGenerating ? (
|
||||
<div className="personal-wechat-generation-progress" aria-live="polite">
|
||||
<div>
|
||||
<strong>正在生成语音…</strong>
|
||||
<span>通常需要几秒,请稍候</span>
|
||||
</div>
|
||||
<div className="personal-wechat-generation-track">
|
||||
<span />
|
||||
</div>
|
||||
</div>
|
||||
) : generatedVoice ? (
|
||||
<div className="personal-wechat-generated-result">
|
||||
<audio
|
||||
ref={generatedAudioRef}
|
||||
src={generatedVoice.audioDataUrl}
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(event) => setPreviewDuration(event.currentTarget.duration)}
|
||||
onTimeUpdate={(event) =>
|
||||
setPreviewCurrentTime(event.currentTarget.currentTime)
|
||||
}
|
||||
onPause={() => setIsPreviewPlaying(false)}
|
||||
onPlay={() => setIsPreviewPlaying(true)}
|
||||
onEnded={() => {
|
||||
setIsPreviewPlaying(false)
|
||||
setPreviewCurrentTime(0)
|
||||
}}
|
||||
/>
|
||||
<div className="personal-wechat-generated-result-copy">
|
||||
<strong>语音生成完成</strong>
|
||||
<span>
|
||||
{formatAudioTime(previewCurrentTime)} / {formatAudioTime(previewDuration)}
|
||||
</span>
|
||||
<div className="personal-wechat-preview-track">
|
||||
<span style={{ width: `${Math.min(100, previewProgress)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="personal-wechat-generated-result-actions">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleToggleGeneratedPreview()}
|
||||
disabled={isSending}
|
||||
>
|
||||
{isPreviewPlaying ? '暂停' : '播放'}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSendGeneratedVoice()}
|
||||
disabled={!canSendGenerated}
|
||||
>
|
||||
{isSending ? '正在发送…' : `发送到${isGroupChat ? '群聊' : '联系人'}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="personal-wechat-send-image-picker">
|
||||
<span>本地语音文件</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleSelectVoice()}
|
||||
disabled={isBusy}
|
||||
>
|
||||
{voice ? '重新选择语音' : '选择语音'}
|
||||
</Button>
|
||||
{voice ? (
|
||||
<div>
|
||||
<strong>{voice.name}</strong>
|
||||
<small>{voice.path}</small>
|
||||
</div>
|
||||
) : (
|
||||
<small>支持 SILK、MP3、WAV、M4A、AAC、OGG 和 FLAC,最大 20 MB</small>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="personal-wechat-send-note">
|
||||
{mode === 'voice' && voiceSource === 'generated'
|
||||
? '文字生成语音会复用现有媒体上传能力;请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
||||
: mode === 'voice'
|
||||
? '本地语音复用媒体上传 Hook,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
||||
: '如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'}
|
||||
微信重新登录导致 PID 改变时,请点击“尝试重新绑定”。
|
||||
</p>
|
||||
|
||||
{result && (
|
||||
<div className={`personal-wechat-send-result ${result.success ? 'success' : 'error'}`}>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<footer>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
||||
取消
|
||||
</Button>
|
||||
{mode === 'voice' && voiceSource === 'generated' ? (
|
||||
<Button disabled={!canGenerate} onClick={() => void handleGenerateVoice()}>
|
||||
{isGenerating ? '正在生成语音…' : generatedVoice ? '重新生成语音' : '生成语音'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button disabled={!canSubmit} onClick={handleSend}>
|
||||
{isSending
|
||||
? '正在发送…'
|
||||
: `测试发送${mode === 'voice' ? '语音' : '图片'}到${isGroupChat ? '群聊' : '联系人'}`}
|
||||
</Button>
|
||||
)}
|
||||
</footer>
|
||||
|
||||
{(!setupReady || !composerStarted) && senderStatus && (
|
||||
<PersonalWechatSetupGuide
|
||||
runtimeStatus={runtimeStatus}
|
||||
senderStatus={senderStatus}
|
||||
runtimeProgress={runtimeProgress}
|
||||
runtimeBusy={runtimeBusy}
|
||||
binding={binding}
|
||||
detecting={detecting}
|
||||
onDownloadRuntime={() => void handleDownloadRuntime()}
|
||||
onBind={() => void handleBind()}
|
||||
detectionAttempted={detectionAttempted}
|
||||
onDetect={() => void handleDetect()}
|
||||
onStartSending={() => setComposerStarted(true)}
|
||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{setupReady && composerStarted && (
|
||||
<PersonalWechatChatComposer
|
||||
status={senderStatus!}
|
||||
targetId={targetId}
|
||||
isGroupChat={isGroupChat}
|
||||
initialMode={initialMode}
|
||||
initialImage={initialImage}
|
||||
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||
onCancel={handleClose}
|
||||
onSend={handleSend}
|
||||
onMessage={(message) => setMessages((current) => [...current, message])}
|
||||
busy={sendBusy}
|
||||
/>
|
||||
)}
|
||||
{sendError && (
|
||||
<div className="personal-wechat-global-error" role="alert">
|
||||
{sendError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!setupReady || !composerStarted) && (
|
||||
<div className="personal-wechat-chat-footer">
|
||||
<Button variant="outline" onClick={handleClose} disabled={isBusy}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
import { useState } from 'react'
|
||||
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
||||
import type {
|
||||
PersonalWechatRuntimeProgressEvent,
|
||||
PersonalWechatRuntimeStatus
|
||||
} from '../../../../shared/personal-wechat-runtime'
|
||||
import { Button, Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui'
|
||||
import { PersonalWechatSupportedVersionsContent } from './PersonalWechatSupportedVersionsContent'
|
||||
|
||||
interface PersonalWechatSetupGuideProps {
|
||||
runtimeStatus: PersonalWechatRuntimeStatus | null
|
||||
senderStatus: PersonalWechatSenderStatus | null
|
||||
runtimeProgress: PersonalWechatRuntimeProgressEvent | null
|
||||
runtimeBusy: boolean
|
||||
binding: boolean
|
||||
detecting: boolean
|
||||
detectionAttempted: boolean
|
||||
onDownloadRuntime: () => void
|
||||
onBind: () => void
|
||||
onDetect: () => void
|
||||
onStartSending: () => void
|
||||
onOpenTextToSpeechSettings?: () => void
|
||||
}
|
||||
|
||||
function capabilityLabel(ready: boolean, detectionAttempted: boolean): string {
|
||||
if (ready) return '已就绪'
|
||||
return detectionAttempted ? '未检测' : '待检测'
|
||||
}
|
||||
|
||||
function diagnosticValue(value: unknown): string {
|
||||
if (value === undefined || value === null || value === '') return '未检测到'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function bindingHint(status: PersonalWechatSenderStatus): string {
|
||||
if (status.state === 'wechat_not_running') return '请先启动并登录微信。'
|
||||
if (status.state === 'unsupported_platform') return '当前系统暂不支持个人微信发送。'
|
||||
if (status.state === 'unsupported_version')
|
||||
return '当前微信版本暂不支持,请查看语音设置中的支持版本。'
|
||||
if (status.state === 'runtime_missing') return '请先完成语音模型准备。'
|
||||
if (status.state === 'error') return '连接微信时遇到问题,请稍后重试。'
|
||||
return '请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。'
|
||||
}
|
||||
|
||||
function isWechatBound(status: PersonalWechatSenderStatus): boolean {
|
||||
if (status.state === 'online') return true
|
||||
return Boolean(
|
||||
status.state === 'hook_not_ready' &&
|
||||
status.wechatPid &&
|
||||
status.boundWechatPid === status.wechatPid &&
|
||||
status.attachReady &&
|
||||
status.baseAddressReady
|
||||
)
|
||||
}
|
||||
|
||||
export function PersonalWechatSetupGuide({
|
||||
runtimeStatus,
|
||||
senderStatus,
|
||||
runtimeProgress,
|
||||
runtimeBusy,
|
||||
binding,
|
||||
detecting,
|
||||
detectionAttempted,
|
||||
onDownloadRuntime,
|
||||
onBind,
|
||||
onDetect,
|
||||
onStartSending,
|
||||
onOpenTextToSpeechSettings
|
||||
}: PersonalWechatSetupGuideProps): React.ReactElement {
|
||||
const [showSupportedVersions, setShowSupportedVersions] = useState(false)
|
||||
const runtimeReady = runtimeStatus?.state === 'ready' || senderStatus?.runtimeReady === true
|
||||
const runtimeDownloading = runtimeBusy || runtimeStatus?.state === 'downloading'
|
||||
const connected = senderStatus ? isWechatBound(senderStatus) : false
|
||||
const canSendText = Boolean(senderStatus?.canSendText)
|
||||
const canSendImage = Boolean(senderStatus?.canSendImage)
|
||||
const canSendVoice = Boolean(senderStatus?.canSendVoice)
|
||||
const mediaReady = canSendImage || canSendVoice
|
||||
const verificationComplete = canSendText && mediaReady
|
||||
const allReady = verificationComplete
|
||||
const progress = runtimeProgress || runtimeStatus
|
||||
const progressPercent = Math.max(0, Math.min(100, Math.round((progress?.progress || 0) * 100)))
|
||||
|
||||
return (
|
||||
<section className="personal-wechat-setup" aria-label="微信消息功能配置">
|
||||
<div className="personal-wechat-setup-heading">
|
||||
<div>
|
||||
<span className="personal-wechat-eyebrow">首次使用</span>
|
||||
<h2>先准备好微信消息功能</h2>
|
||||
<p>只需几步,TraceMemo 就能像普通聊天窗口一样发送文字、图片和语音。</p>
|
||||
</div>
|
||||
{allReady && <span className="personal-wechat-setup-complete">已完成</span>}
|
||||
</div>
|
||||
|
||||
<ol className="personal-wechat-steps">
|
||||
<li
|
||||
className={runtimeReady ? 'is-complete' : runtimeDownloading ? 'is-active' : 'is-current'}
|
||||
>
|
||||
<span className="personal-wechat-step-number">{runtimeReady ? '✓' : '1'}</span>
|
||||
<div className="personal-wechat-step-content">
|
||||
<strong>准备语音模型</strong>
|
||||
<p>发送语音消息需要本地模型,首次使用时下载一次即可。</p>
|
||||
{runtimeDownloading ? (
|
||||
<div className="personal-wechat-download-progress" aria-live="polite">
|
||||
<div>
|
||||
<span>正在准备模型</span>
|
||||
<span>{progressPercent}%</span>
|
||||
</div>
|
||||
<div className="personal-wechat-progress-track">
|
||||
<span style={{ width: `${progressPercent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
) : runtimeReady ? (
|
||||
<span className="personal-wechat-step-status">✓ 语音模型已准备</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={onDownloadRuntime}
|
||||
disabled={runtimeBusy || runtimeStatus?.state === 'unsupported'}
|
||||
>
|
||||
下载模型
|
||||
</Button>
|
||||
)}
|
||||
{runtimeStatus?.error && !runtimeDownloading && !runtimeReady && (
|
||||
<p className="personal-wechat-step-error">模型准备失败,请重试或查看语音设置。</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
className={
|
||||
connected
|
||||
? 'is-complete'
|
||||
: binding
|
||||
? 'is-active'
|
||||
: runtimeReady
|
||||
? 'is-current'
|
||||
: 'is-pending'
|
||||
}
|
||||
>
|
||||
<span className="personal-wechat-step-number">{connected ? '✓' : '2'}</span>
|
||||
<div className="personal-wechat-step-content">
|
||||
<strong>绑定个人微信</strong>
|
||||
<p>请启动并登录当前微信,TraceMemo 会自动绑定正在使用的账号。</p>
|
||||
{connected ? (
|
||||
<span className="personal-wechat-step-status">✓ 微信已绑定</span>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onBind}
|
||||
disabled={!runtimeReady || binding || runtimeDownloading}
|
||||
>
|
||||
{binding ? '正在绑定…' : '绑定微信'}
|
||||
</Button>
|
||||
)}
|
||||
{!connected && senderStatus && (
|
||||
<p className="personal-wechat-step-hint">{bindingHint(senderStatus)}</p>
|
||||
)}
|
||||
{!connected && (
|
||||
<p className="personal-wechat-step-warning" role="note">
|
||||
绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。
|
||||
<br />
|
||||
微信总是自动更新?请在微信左下角打开“设置 → 通用”,取消勾选“有更新时自动升级微信”,否则版本变化后可能无法绑定。
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="link"
|
||||
size="sm"
|
||||
onClick={() => setShowSupportedVersions(true)}
|
||||
>
|
||||
查看支持的微信版本
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
className={verificationComplete ? 'is-complete' : connected ? 'is-current' : 'is-pending'}
|
||||
>
|
||||
<span className="personal-wechat-step-number">{verificationComplete ? '✓' : '3'}</span>
|
||||
<div className="personal-wechat-step-content">
|
||||
<strong>验证消息能力</strong>
|
||||
<p>
|
||||
请打开手机微信,给任意好友手动发送:
|
||||
<br />① 一条文字消息
|
||||
<br />② 一张图片
|
||||
</p>
|
||||
<p className="personal-wechat-step-hint">
|
||||
TraceMemo 需要通过你主动发送的消息初始化微信消息能力。完成后点击“重新检测”。
|
||||
</p>
|
||||
{!verificationComplete && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={onDetect}
|
||||
disabled={detecting || binding}
|
||||
>
|
||||
{detecting ? '正在检测…' : '重新检测'}
|
||||
</Button>
|
||||
)}
|
||||
{detectionAttempted && !detecting && !verificationComplete && (
|
||||
<p className="personal-wechat-step-hint" role="status">
|
||||
暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li
|
||||
className={allReady ? 'is-complete' : verificationComplete ? 'is-current' : 'is-pending'}
|
||||
>
|
||||
<span className="personal-wechat-step-number">{allReady ? '✓' : '4'}</span>
|
||||
<div className="personal-wechat-step-content">
|
||||
<strong>能力检测</strong>
|
||||
<div className="personal-wechat-capabilities" aria-label="微信消息能力">
|
||||
{[
|
||||
['文字消息', canSendText],
|
||||
['图片和语音消息', mediaReady]
|
||||
].map(([label, ready]) => (
|
||||
<span key={String(label)} className={ready ? 'is-ready' : ''}>
|
||||
<b aria-hidden>{ready ? '✓' : '−'}</b>
|
||||
{label}
|
||||
<small>{capabilityLabel(Boolean(ready), detectionAttempted)}</small>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{allReady ? (
|
||||
<>
|
||||
<span className="personal-wechat-step-status">微信消息发送已配置完成</span>
|
||||
<Button size="sm" onClick={onStartSending}>
|
||||
开始发送
|
||||
</Button>
|
||||
</>
|
||||
) : verificationComplete ? (
|
||||
<>
|
||||
<p className="personal-wechat-step-hint">
|
||||
语音能力还未就绪,请在微信中完成一次媒体消息初始化后重新检测。
|
||||
</p>
|
||||
<Button size="sm" variant="outline" onClick={onDetect} disabled={detecting}>
|
||||
{detecting ? '正在检测…' : '重新检测能力'}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
{!runtimeReady && runtimeStatus?.state === 'unsupported' && onOpenTextToSpeechSettings && (
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
className="personal-wechat-setup-link"
|
||||
onClick={onOpenTextToSpeechSettings}
|
||||
>
|
||||
查看语音设置
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={showSupportedVersions} onOpenChange={setShowSupportedVersions}>
|
||||
<DialogContent className="max-h-[calc(100vh-3rem)] max-w-[620px] overflow-y-auto">
|
||||
<DialogHeader className="pr-8">
|
||||
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
|
||||
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<PersonalWechatSupportedVersionsContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<details className="personal-wechat-diagnostics">
|
||||
<summary>高级诊断</summary>
|
||||
<p>仅用于排查连接问题,普通使用无需关注这些信息。</p>
|
||||
<dl>
|
||||
{[
|
||||
['微信进程', senderStatus?.wechatPid ? `PID ${senderStatus.wechatPid}` : '未检测到'],
|
||||
['OneBot', senderStatus?.oneBotPid ? `PID ${senderStatus.oneBotPid}` : '未启动'],
|
||||
['绑定进程', diagnosticValue(senderStatus?.boundWechatPid)],
|
||||
[
|
||||
'接口监听',
|
||||
`${diagnosticValue(senderStatus?.endpoint)} · ${senderStatus?.endpointReady ? '监听中' : '未监听'}`
|
||||
],
|
||||
[
|
||||
'基址扫描',
|
||||
senderStatus?.baseAddress || (senderStatus?.baseAddressReady ? '已完成' : '未完成')
|
||||
],
|
||||
['文字 Hook', senderStatus?.textHookReady ? '已就绪' : '未就绪'],
|
||||
['图片 Hook', senderStatus?.imageHookReady ? '已就绪' : '未就绪'],
|
||||
['语音能力', senderStatus?.canSendVoice ? '可发送' : '未就绪'],
|
||||
['消息监听', senderStatus?.messageListenerReady ? '正常' : '未就绪'],
|
||||
['微信版本', diagnosticValue(senderStatus?.wechatVersion)],
|
||||
[
|
||||
'运行时',
|
||||
runtimeStatus?.version
|
||||
? `${runtimeStatus.version} · ${runtimeStatus.state}`
|
||||
: diagnosticValue(senderStatus?.runtimeReady)
|
||||
]
|
||||
].map(([label, value]) => (
|
||||
<div key={label}>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</details>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type React from 'react'
|
||||
|
||||
export const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
|
||||
|
||||
export const BUNDLED_WECHAT_VERSIONS = [
|
||||
'4.1.6.12',
|
||||
'4.1.6.46',
|
||||
'4.1.6.47',
|
||||
'4.1.7.31',
|
||||
'4.1.7.55',
|
||||
'4.1.7.57',
|
||||
'4.1.8.28',
|
||||
'4.1.8.29',
|
||||
'4.1.8.104',
|
||||
'4.1.8.107',
|
||||
'4.1.9.52',
|
||||
'4.1.9.55',
|
||||
'4.1.9.58',
|
||||
'4.1.10.53',
|
||||
'4.1.11.53'
|
||||
] as const
|
||||
|
||||
export function PersonalWechatSupportedVersionsContent(): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<a
|
||||
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
|
||||
href={WECHAT_VERSION_DOWNLOAD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载微信历史版本 ↗
|
||||
</a>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{BUNDLED_WECHAT_VERSIONS.map((version) => (
|
||||
<span
|
||||
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
|
||||
key={version}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -19,28 +19,11 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '../../../components/ui'
|
||||
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
|
||||
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
||||
|
||||
const VOICE_PAGE_SIZE = 24
|
||||
const WECHAT_VERSION_DOWNLOAD_URL = 'https://github.com/zsbai/wechat-versions/releases'
|
||||
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
||||
const BUNDLED_WECHAT_VERSIONS = [
|
||||
'4.1.6.12',
|
||||
'4.1.6.46',
|
||||
'4.1.6.47',
|
||||
'4.1.7.31',
|
||||
'4.1.7.55',
|
||||
'4.1.7.57',
|
||||
'4.1.8.28',
|
||||
'4.1.8.29',
|
||||
'4.1.8.104',
|
||||
'4.1.8.107',
|
||||
'4.1.9.52',
|
||||
'4.1.9.55',
|
||||
'4.1.9.58',
|
||||
'4.1.10.53',
|
||||
'4.1.11.53'
|
||||
] as const
|
||||
|
||||
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
|
||||
missing: '未下载',
|
||||
@@ -539,7 +522,7 @@ export function TextToSpeechPage({
|
||||
<span>2</span>
|
||||
<div>
|
||||
<strong>在档案中生成语音</strong>
|
||||
<p>打开联系人或群聊的测试发送,选择“语音 → 输入文字生成”。</p>
|
||||
<p>打开联系人或群聊的发送,选择“语音 → 输入文字生成”。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
@@ -566,7 +549,11 @@ export function TextToSpeechPage({
|
||||
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
|
||||
会降低系统安全性,请确认风险后再使用。
|
||||
</li>
|
||||
<li>微信重新登录或 PID 改变后,需要在测试发送弹窗中点击“尝试重新绑定”。</li>
|
||||
<li>微信重新登录或 PID 改变后,需要在发送窗口中重新检测或绑定。</li>
|
||||
<li>
|
||||
绑定前建议关闭微信自动升级 在微信左下角打开“设置 →
|
||||
通用”,取消勾选“有更新时自动升级微信”。微信自动更新后,版本可能不再兼容发送组件。
|
||||
</li>
|
||||
</ul>
|
||||
{personalWechatRuntimeSupported ? (
|
||||
<Button
|
||||
@@ -941,24 +928,7 @@ export function TextToSpeechPage({
|
||||
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
|
||||
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<a
|
||||
className="w-fit rounded-md border border-primary/30 bg-primary/10 px-3 py-2 text-xs font-semibold text-primary hover:border-primary"
|
||||
href={WECHAT_VERSION_DOWNLOAD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
下载微信历史版本 ↗
|
||||
</a>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
{BUNDLED_WECHAT_VERSIONS.map((version) => (
|
||||
<span
|
||||
className="rounded-md border border-border bg-background px-2 py-2 text-center text-xs text-muted-foreground"
|
||||
key={version}
|
||||
>
|
||||
{version}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<PersonalWechatSupportedVersionsContent />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
|
||||
@@ -564,6 +564,515 @@
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
min-height: 68px;
|
||||
padding: 14px 20px;
|
||||
border-bottom: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
}
|
||||
|
||||
.personal-wechat-chat-avatar {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: 0 0 auto;
|
||||
place-items: center;
|
||||
border-radius: 9px;
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.personal-wechat-chat-heading {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
overflow: hidden;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 14px;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 2px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-connection-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-text-muted);
|
||||
|
||||
&.is-online {
|
||||
background: var(--wxex-success);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--wxex-success) 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-chat-body {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
}
|
||||
|
||||
.personal-wechat-message-list {
|
||||
display: flex;
|
||||
min-height: 96px;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: color-mix(in srgb, var(--wxex-bg-main) 92%, var(--wxex-bg-sidebar));
|
||||
}
|
||||
|
||||
.personal-wechat-system-message,
|
||||
.personal-wechat-empty-message {
|
||||
align-self: center;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.personal-wechat-empty-message {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.personal-wechat-message-bubble {
|
||||
display: grid;
|
||||
max-width: min(76%, 440px);
|
||||
gap: 3px;
|
||||
align-self: flex-start;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 10px 10px 10px 3px;
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 8px 10px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
|
||||
&.is-outgoing {
|
||||
align-self: flex-end;
|
||||
border-color: color-mix(in srgb, var(--wxex-brand) 25%, transparent);
|
||||
border-radius: 10px 10px 3px 10px;
|
||||
background: color-mix(in srgb, var(--wxex-brand) 10%, var(--wxex-bg-elevated));
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-message-kind {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.personal-wechat-setup {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 18px 20px 20px;
|
||||
}
|
||||
|
||||
.personal-wechat-setup-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
h2,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 3px;
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-eyebrow {
|
||||
color: var(--wxex-brand);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.personal-wechat-setup-complete,
|
||||
.personal-wechat-step-status {
|
||||
color: var(--wxex-success);
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.personal-wechat-steps {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
|
||||
> li {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
padding-bottom: 11px;
|
||||
|
||||
&:not(:last-child)::after {
|
||||
position: absolute;
|
||||
top: 23px;
|
||||
bottom: 0;
|
||||
left: 11px;
|
||||
width: 1px;
|
||||
background: var(--wxex-border);
|
||||
content: '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-step-number {
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
width: 23px;
|
||||
height: 23px;
|
||||
place-items: center;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: 50%;
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.personal-wechat-steps > li.is-current .personal-wechat-step-number,
|
||||
.personal-wechat-steps > li.is-active .personal-wechat-step-number {
|
||||
border-color: var(--wxex-brand);
|
||||
background: var(--wxex-brand-soft);
|
||||
color: var(--wxex-brand);
|
||||
}
|
||||
|
||||
.personal-wechat-steps > li.is-complete .personal-wechat-step-number {
|
||||
border-color: color-mix(in srgb, var(--wxex-success) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--wxex-success) 12%, var(--wxex-bg-elevated));
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
|
||||
.personal-wechat-step-content {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
|
||||
> strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-step-hint {
|
||||
color: var(--wxex-text-muted) !important;
|
||||
}
|
||||
|
||||
.personal-wechat-step-warning {
|
||||
margin: 0;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: color-mix(in srgb, var(--wxex-warning) 10%, var(--wxex-bg-elevated));
|
||||
padding: 9px 11px;
|
||||
color: var(--wxex-text-secondary) !important;
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.personal-wechat-step-error,
|
||||
.personal-wechat-global-error {
|
||||
color: var(--wxex-danger) !important;
|
||||
}
|
||||
|
||||
.personal-wechat-download-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-width: 360px;
|
||||
|
||||
> div:first-child {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-progress-track,
|
||||
.personal-wechat-preview-track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 4px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--wxex-brand) 14%, transparent);
|
||||
|
||||
> span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: var(--wxex-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-capabilities {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 7px;
|
||||
|
||||
> span {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
padding: 8px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 11px;
|
||||
|
||||
&.is-ready {
|
||||
border-color: color-mix(in srgb, var(--wxex-success) 30%, transparent);
|
||||
color: var(--wxex-text-primary);
|
||||
}
|
||||
|
||||
&:not(.is-ready) {
|
||||
background: color-mix(in srgb, var(--wxex-bg-main) 70%, var(--wxex-bg-elevated));
|
||||
}
|
||||
}
|
||||
|
||||
b {
|
||||
color: var(--wxex-text-muted);
|
||||
|
||||
.is-ready & {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
}
|
||||
|
||||
small {
|
||||
grid-column: 2;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-diagnostics {
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
padding-top: 12px;
|
||||
|
||||
summary {
|
||||
cursor: pointer;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
> p {
|
||||
margin: 8px 0;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1px;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: var(--wxex-border);
|
||||
}
|
||||
|
||||
dl > div {
|
||||
min-width: 0;
|
||||
background: var(--wxex-bg-main);
|
||||
padding: 7px 9px;
|
||||
}
|
||||
|
||||
dt {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
dd {
|
||||
overflow: hidden;
|
||||
margin: 2px 0 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-composer {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
background: var(--wxex-bg-elevated);
|
||||
padding: 14px 20px 18px;
|
||||
}
|
||||
|
||||
.personal-wechat-composer-mode {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.personal-wechat-text-editor,
|
||||
.personal-wechat-voice-editor,
|
||||
.personal-wechat-file-editor {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.personal-wechat-text-editor textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.personal-wechat-text-editor > span {
|
||||
justify-self: end;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.personal-wechat-file-editor {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.personal-wechat-file-editor > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.personal-wechat-file-editor strong,
|
||||
.personal-wechat-file-editor small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.personal-wechat-file-editor strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.personal-wechat-file-editor small {
|
||||
margin-top: 3px;
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.personal-wechat-voice-heading,
|
||||
.personal-wechat-composer-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.personal-wechat-voice-heading > span {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.personal-wechat-generated-result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--wxex-brand) 28%, transparent);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: color-mix(in srgb, var(--wxex-brand) 7%, var(--wxex-bg-elevated));
|
||||
padding: 9px;
|
||||
|
||||
audio {
|
||||
display: none;
|
||||
}
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: var(--wxex-text-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-composer-footer > span {
|
||||
flex: 1;
|
||||
font-size: 11px;
|
||||
|
||||
&.is-success {
|
||||
color: var(--wxex-success);
|
||||
}
|
||||
&.is-error {
|
||||
color: var(--wxex-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.personal-wechat-global-error {
|
||||
border-top: 1px solid color-mix(in srgb, var(--wxex-danger) 18%, transparent);
|
||||
padding: 10px 20px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.personal-wechat-chat-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
padding: 12px 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.personal-wechat-capabilities {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.personal-wechat-chat-header,
|
||||
.personal-wechat-message-list,
|
||||
.personal-wechat-setup,
|
||||
.personal-wechat-composer {
|
||||
padding-inline: 14px;
|
||||
}
|
||||
.personal-wechat-generated-result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-header-actions svg,
|
||||
.chat-content-search svg,
|
||||
.data-trust-bar svg {
|
||||
|
||||
@@ -96,6 +96,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
.tts-auto-update-guide {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
border-radius: var(--wxex-radius-md);
|
||||
background: color-mix(in srgb, var(--wxex-warning) 9%, var(--wxex-bg-elevated));
|
||||
padding: 12px 13px;
|
||||
|
||||
strong {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: min(100%, 640px);
|
||||
max-height: 280px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-md);
|
||||
object-fit: contain;
|
||||
object-position: left top;
|
||||
}
|
||||
}
|
||||
|
||||
.tts-hook-warning {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState } from 'react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
||||
|
||||
const getStatus = vi.fn()
|
||||
const getRuntimeStatus = vi.fn()
|
||||
const downloadRuntime = vi.fn()
|
||||
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||
const rebind = vi.fn()
|
||||
const selectImage = vi.fn()
|
||||
const selectVoice = vi.fn()
|
||||
@@ -20,7 +23,6 @@ const contact = {
|
||||
md5: 'fixture-md5',
|
||||
type: 'group' as const
|
||||
}
|
||||
|
||||
const readyStatus = {
|
||||
state: 'online' as const,
|
||||
platform: 'darwin',
|
||||
@@ -48,42 +50,44 @@ const readyStatus = {
|
||||
canSendVoice: true,
|
||||
message: '个人微信已绑定'
|
||||
}
|
||||
const readyRuntime = {
|
||||
version: 'v0.0.18',
|
||||
state: 'ready' as const,
|
||||
downloadedBytes: 1,
|
||||
totalBytes: 1,
|
||||
progress: 1,
|
||||
platform: 'darwin' as NodeJS.Platform,
|
||||
architecture: 'arm64',
|
||||
supported: true,
|
||||
removable: true
|
||||
}
|
||||
|
||||
function Harness({ onClose = vi.fn() }: { onClose?: () => void }): React.ReactElement {
|
||||
const [open, setOpen] = useState(false)
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={() => setOpen(true)}>
|
||||
打开测试发送
|
||||
</button>
|
||||
{open && (
|
||||
<PersonalWechatSendDialog
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
onClose={() => {
|
||||
onClose()
|
||||
setOpen(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
function renderDialog(
|
||||
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
|
||||
): React.ReactElement {
|
||||
return render(
|
||||
<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
async function startComposer(): Promise<void> {
|
||||
const start = await screen.findByRole('button', { name: '开始发送' })
|
||||
fireEvent.click(start)
|
||||
}
|
||||
|
||||
describe('PersonalWechatSendDialog', () => {
|
||||
beforeEach(() => {
|
||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
||||
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
|
||||
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||
selectImage.mockReset().mockResolvedValue({
|
||||
canceled: false,
|
||||
path: '/Users/fixture/test.png',
|
||||
name: 'test.png'
|
||||
})
|
||||
selectVoice.mockReset().mockResolvedValue({
|
||||
canceled: false,
|
||||
path: '/Users/fixture/test.silk',
|
||||
name: 'test.silk'
|
||||
})
|
||||
selectImage
|
||||
.mockReset()
|
||||
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.png', name: 'test.png' })
|
||||
selectVoice
|
||||
.mockReset()
|
||||
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.silk', name: 'test.silk' })
|
||||
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
||||
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||
success: true,
|
||||
@@ -125,6 +129,9 @@ describe('PersonalWechatSendDialog', () => {
|
||||
configurable: true,
|
||||
value: {
|
||||
getPersonalWechatSenderStatus: getStatus,
|
||||
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||
downloadPersonalWechatRuntime: downloadRuntime,
|
||||
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
||||
rebindPersonalWechatSender: rebind,
|
||||
selectPersonalWechatImage: selectImage,
|
||||
selectPersonalWechatVoice: selectVoice,
|
||||
@@ -137,14 +144,84 @@ describe('PersonalWechatSendDialog', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('switches to voice mode and sends only the selected audio file', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
it('shows a user-facing four-step setup and keeps diagnostics collapsed', async () => {
|
||||
getRuntimeStatus
|
||||
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
||||
.mockResolvedValue(readyRuntime)
|
||||
getStatus.mockResolvedValue({
|
||||
...readyStatus,
|
||||
state: 'stopped',
|
||||
runtimeReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: '尚未绑定当前微信'
|
||||
})
|
||||
renderDialog()
|
||||
expect(await screen.findByText('准备语音模型')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '下载模型' })).toBeEnabled()
|
||||
expect(screen.getByText('绑定个人微信')).toBeInTheDocument()
|
||||
expect(screen.getByText('验证消息能力')).toBeInTheDocument()
|
||||
expect(screen.getByText('能力检测')).toBeInTheDocument()
|
||||
expect(screen.getByText('图片和语音消息')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看支持的微信版本' }))
|
||||
const versionsDialog = screen.getByRole('dialog', { name: '支持的微信版本' })
|
||||
expect(versionsDialog).toBeInTheDocument()
|
||||
expect(versionsDialog).toHaveTextContent('4.1.11.53')
|
||||
expect(
|
||||
screen.getByText(
|
||||
'绑定微信可能导致当前微信异常闪退,这是正常现象。若微信退出,请重新启动微信后,再回到这里重新检测/绑定。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.queryByLabelText('消息列表')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('TraceMemo 消息发送')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('PID 4668')).not.toBeVisible()
|
||||
fireEvent.click(screen.getByText('高级诊断'))
|
||||
expect(screen.getByText('PID 4668')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sends text through the existing message API and echoes it in the chat', async () => {
|
||||
renderDialog()
|
||||
await startComposer()
|
||||
await userEvent
|
||||
.setup()
|
||||
.type(screen.getByRole('textbox', { name: '消息内容' }), '你好 TraceMemo')
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'text',
|
||||
to: 'fixture-room@chatroom',
|
||||
text: '你好 TraceMemo',
|
||||
isGroup: true
|
||||
})
|
||||
)
|
||||
expect(
|
||||
screen.getByLabelText('消息列表').querySelector('.personal-wechat-message-bubble')
|
||||
).toHaveTextContent('你好 TraceMemo')
|
||||
})
|
||||
|
||||
it('supports local image and voice selection', async () => {
|
||||
renderDialog()
|
||||
await startComposer()
|
||||
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
expect(await screen.findByText('test.png')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
to: 'fixture-room@chatroom',
|
||||
filePath: '/Users/fixture/test.png',
|
||||
isGroup: true
|
||||
})
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
||||
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'voice',
|
||||
@@ -155,136 +232,101 @@ describe('PersonalWechatSendDialog', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
|
||||
const openSettings = vi.fn()
|
||||
render(
|
||||
<PersonalWechatSendDialog
|
||||
contact={contact}
|
||||
isGroupChat
|
||||
onClose={vi.fn()}
|
||||
onOpenTextToSpeechSettings={openSettings}
|
||||
/>
|
||||
)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
||||
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
|
||||
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
|
||||
expect(openSettings).toHaveBeenCalledTimes(1)
|
||||
expect(sendMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows binding diagnostics and only offers image and voice modes', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
|
||||
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
|
||||
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
|
||||
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
|
||||
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
|
||||
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports segmented keyboard navigation for the message type', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
|
||||
await screen.findByText('技术交流群')
|
||||
const imageMode = screen.getByRole('radio', { name: '图片' })
|
||||
const voiceMode = screen.getByRole('radio', { name: '语音' })
|
||||
imageMode.focus()
|
||||
await user.keyboard('{ArrowRight}')
|
||||
expect(voiceMode).toHaveFocus()
|
||||
await user.keyboard(' ')
|
||||
expect(voiceMode).toBeChecked()
|
||||
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
|
||||
})
|
||||
|
||||
it('switches to image mode and sends only the selected image', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('radio', { name: '图片' }))
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText(
|
||||
'如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。',
|
||||
{ exact: false }
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
expect(await screen.findByText('test.png')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(sendMessage).toHaveBeenCalledWith({
|
||||
type: 'image',
|
||||
to: 'fixture-room@chatroom',
|
||||
filePath: '/Users/fixture/test.png',
|
||||
isGroup: true
|
||||
it('uses the existing runtime, binding and detection IPC actions', async () => {
|
||||
getRuntimeStatus
|
||||
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
||||
.mockResolvedValue(readyRuntime)
|
||||
getStatus
|
||||
.mockResolvedValueOnce({
|
||||
...readyStatus,
|
||||
state: 'stopped',
|
||||
runtimeReady: false,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false
|
||||
})
|
||||
)
|
||||
.mockResolvedValue({
|
||||
...readyStatus,
|
||||
state: 'stopped',
|
||||
runtimeReady: true,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false
|
||||
})
|
||||
renderDialog()
|
||||
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())
|
||||
})
|
||||
|
||||
it('offers a safe explicit rebind action', async () => {
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
await screen.findByText('技术交流群')
|
||||
fireEvent.click(screen.getByRole('button', { name: '尝试重新绑定' }))
|
||||
await waitFor(() => expect(rebind).toHaveBeenCalledTimes(1))
|
||||
it('shows a stable bound state after binding', async () => {
|
||||
getRuntimeStatus.mockResolvedValue(readyRuntime)
|
||||
getStatus
|
||||
.mockResolvedValueOnce({
|
||||
...readyStatus,
|
||||
state: 'stopped',
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false
|
||||
})
|
||||
.mockResolvedValue(readyStatus)
|
||||
renderDialog()
|
||||
await screen.findByRole('button', { name: '绑定微信' })
|
||||
fireEvent.click(screen.getByRole('button', { name: '绑定微信' }))
|
||||
expect(await screen.findByText('✓ 微信已绑定')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: '绑定微信' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('blocks image sending until the media Hook and image directory are initialized', async () => {
|
||||
it('treats image and voice readiness as one media capability', async () => {
|
||||
getStatus.mockResolvedValue({
|
||||
...readyStatus,
|
||||
state: 'hook_not_ready',
|
||||
imageHookReady: false,
|
||||
canSendImage: false,
|
||||
message: '请先手动发送图片'
|
||||
canSendVoice: true
|
||||
})
|
||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
||||
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
|
||||
renderDialog()
|
||||
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
|
||||
expect(screen.getByText('微信消息发送已配置完成')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: '开始发送' })).toBeEnabled()
|
||||
expect(screen.queryByText('图片消息')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('语音消息')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes with Escape or the overlay and restores focus to the opener', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onClose = vi.fn()
|
||||
render(<Harness onClose={onClose} />)
|
||||
const opener = screen.getByRole('button', { name: '打开测试发送' })
|
||||
|
||||
await user.click(opener)
|
||||
await screen.findByText('技术交流群')
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
|
||||
await user.click(opener)
|
||||
const dialog = await screen.findByRole('dialog', { name: '个人微信测试发送' })
|
||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
||||
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(opener).toHaveFocus())
|
||||
expect(onClose).toHaveBeenCalledTimes(2)
|
||||
it('keeps re-detection available and explains when no new messages are found', async () => {
|
||||
getRuntimeStatus.mockResolvedValue(readyRuntime)
|
||||
getStatus.mockResolvedValue({
|
||||
...readyStatus,
|
||||
canSend: false,
|
||||
canSendText: false,
|
||||
canSendImage: false,
|
||||
canSendVoice: false,
|
||||
message: '等待消息初始化'
|
||||
})
|
||||
renderDialog()
|
||||
const detect = await screen.findByRole('button', { name: '重新检测' })
|
||||
expect(detect).toBeEnabled()
|
||||
fireEvent.click(detect)
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getAllByText('未检测').length).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps the dialog open while a message is being sent', async () => {
|
||||
it('keeps the dialog and controls stable while sending', async () => {
|
||||
const user = userEvent.setup()
|
||||
sendMessage.mockImplementation(() => new Promise(() => undefined))
|
||||
render(<Harness />)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '打开测试发送' }))
|
||||
await screen.findByText('技术交流群')
|
||||
await user.click(screen.getByRole('button', { name: '选择图片' }))
|
||||
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
||||
renderDialog()
|
||||
await startComposer()
|
||||
await screen.findByRole('textbox', { name: '消息内容' })
|
||||
await user.type(screen.getByRole('textbox', { name: '消息内容' }), '发送中')
|
||||
await user.click(screen.getByRole('button', { name: '发送消息' }))
|
||||
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toBeDisabled()
|
||||
expect(screen.getByRole('radio', { name: '语音' })).toBeDisabled()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
await user.click(
|
||||
screen.getByRole('dialog', { name: '个人微信测试发送' }).previousElementSibling as HTMLElement
|
||||
)
|
||||
expect(screen.getByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -608,7 +608,7 @@ describe('daily report controls', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '发送到当前群聊' }))
|
||||
|
||||
expect(await screen.findByRole('dialog', { name: '个人微信测试发送' })).toBeVisible()
|
||||
expect(await screen.findByRole('dialog', { name: '测试群' })).toBeVisible()
|
||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
||||
expect(screen.getByText('/Users/fixture/测试群日报.png')).toBeVisible()
|
||||
|
||||
@@ -190,11 +190,10 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
|
||||
await trigger.click()
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(
|
||||
dialog.locator('.personal-wechat-send-status strong').filter({ hasText: '个人微信已绑定' })
|
||||
).toBeVisible()
|
||||
const startSending = dialog.getByRole('button', { name: '开始发送' })
|
||||
if (await startSending.isVisible()) await startSending.click()
|
||||
expect(
|
||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||
).toBe(true)
|
||||
@@ -208,7 +207,7 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
|
||||
expect(pageErrors).toEqual([])
|
||||
|
||||
const imageMode = dialog.getByRole('radio', { name: '图片' })
|
||||
const voiceMode = dialog.getByRole('radio', { name: '语音' })
|
||||
const voiceMode = dialog.getByRole('radio', { name: '语音', exact: true })
|
||||
await imageMode.focus()
|
||||
await fixture.page.keyboard.press('ArrowRight')
|
||||
await expect(voiceMode).toBeFocused()
|
||||
|
||||
@@ -387,7 +387,7 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
await fixture.setWindowContentSize(visualViewport)
|
||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||
await fixture.page.getByRole('button', { name: '发送消息' }).click()
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
|
||||
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
||||
await expect(dialog).toBeVisible()
|
||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||
expect(
|
||||
@@ -406,9 +406,11 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
|
||||
caret: 'hide'
|
||||
})
|
||||
|
||||
const startSending = dialog.getByRole('button', { name: '开始发送' })
|
||||
if (await startSending.isVisible()) await startSending.click()
|
||||
await dialog.getByRole('radio', { name: '语音' }).click()
|
||||
await expect(dialog.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
|
||||
await dialog.locator('footer').scrollIntoViewIfNeeded()
|
||||
await expect(dialog.getByRole('textbox', { name: '语音文字' })).toBeVisible()
|
||||
await dialog.locator('.personal-wechat-composer').scrollIntoViewIfNeeded()
|
||||
await clearScreenshotFocus(fixture.page)
|
||||
await expect(fixture.page).toHaveScreenshot(
|
||||
`personal-wechat-voice-controls-${appearanceTheme}.png`,
|
||||
|
||||
@@ -6,6 +6,25 @@ function setPrivate(target: object, key: string, value: unknown): void {
|
||||
}
|
||||
|
||||
describe('Wcdb4Client shutdown', () => {
|
||||
it('clears folded contact status snapshots when sessions are invalidated', () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const statusCache = new Map([['group@chatroom', { isFolded: true, isMuted: false }]])
|
||||
setPrivate(client, 'sessionCacheGeneration', 3)
|
||||
setPrivate(client, 'cachedSessions', [{ username: 'group@chatroom' }])
|
||||
setPrivate(client, 'cachedChatTables', [{ name: 'Chat_group', db_number: 'group@chatroom' }])
|
||||
setPrivate(client, 'sessionStatusCache', statusCache)
|
||||
setPrivate(client, 'sessionStatusesUpdatedAt', Date.now())
|
||||
|
||||
client.invalidateSessionCache()
|
||||
|
||||
expect(statusCache.size).toBe(0)
|
||||
expect(client).toMatchObject({
|
||||
cachedSessions: null,
|
||||
cachedChatTables: null,
|
||||
sessionStatusesUpdatedAt: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
|
||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||
const shutdown = vi.fn(() => 0)
|
||||
|
||||
Reference in New Issue
Block a user