mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-09 00:16:32 +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) => {
|
ipcMain.handle('wechat-personal:selectImage', async (event) => {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(window!, {
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
title: '选择要通过个人微信测试发送的图片',
|
title: '选择要通过个人微信发送的图片',
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: [
|
filters: [
|
||||||
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
{ name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] },
|
||||||
@@ -1817,7 +1817,7 @@ app.whenReady().then(async () => {
|
|||||||
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
|
ipcMain.handle('wechat-personal:selectVoice', async (event) => {
|
||||||
const window = BrowserWindow.fromWebContents(event.sender)
|
const window = BrowserWindow.fromWebContents(event.sender)
|
||||||
const result = await dialog.showOpenDialog(window!, {
|
const result = await dialog.showOpenDialog(window!, {
|
||||||
title: '选择要通过个人微信测试发送的语音',
|
title: '选择要通过个人微信发送的语音',
|
||||||
properties: ['openFile'],
|
properties: ['openFile'],
|
||||||
filters: [
|
filters: [
|
||||||
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
|
{ name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] },
|
||||||
|
|||||||
@@ -500,7 +500,7 @@ export class PersonalWechatSendService {
|
|||||||
}
|
}
|
||||||
if (text.length > 2_000) {
|
if (text.length > 2_000) {
|
||||||
const status = await this.getStatus()
|
const status = await this.getStatus()
|
||||||
return { success: false, status, error: '测试消息不能超过 2000 个字符' }
|
return { success: false, status, error: '消息不能超过 2000 个字符' }
|
||||||
}
|
}
|
||||||
request = { ...request, to, text }
|
request = { ...request, to, text }
|
||||||
} else {
|
} else {
|
||||||
@@ -520,7 +520,7 @@ export class PersonalWechatSendService {
|
|||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
status,
|
status,
|
||||||
error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fileBase64 = (
|
fileBase64 = (
|
||||||
@@ -725,7 +725,7 @@ export class PersonalWechatSendService {
|
|||||||
status: {
|
status: {
|
||||||
...base,
|
...base,
|
||||||
state: 'unsupported_platform',
|
state: 'unsupported_platform',
|
||||||
message: '个人微信测试发送当前仅支持 Apple Silicon Mac'
|
message: '个人微信发送当前仅支持 Apple Silicon Mac'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -979,6 +979,11 @@ export class Wcdb4Client {
|
|||||||
this.cachedSessions = null
|
this.cachedSessions = null
|
||||||
this.cachedChatTables = null
|
this.cachedChatTables = null
|
||||||
this.sessionsInFlight = 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
|
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 { Contact } from '../../../../shared/types'
|
||||||
import type { PersonalWechatSenderStatus } from '../../../../shared/personal-wechat'
|
import type {
|
||||||
import type { TextToSpeechSettings, TextToSpeechVoice } from '../../../../shared/text-to-speech'
|
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 {
|
import {
|
||||||
Button,
|
PersonalWechatChatComposer,
|
||||||
Dialog,
|
type ChatMessage,
|
||||||
DialogContent,
|
type PersonalWechatComposerMode
|
||||||
DialogDescription,
|
} from './PersonalWechatChatComposer'
|
||||||
DialogHeader,
|
import { PersonalWechatSetupGuide } from './PersonalWechatSetupGuide'
|
||||||
DialogTitle,
|
|
||||||
SegmentedControl,
|
|
||||||
SegmentedControlItem,
|
|
||||||
Textarea
|
|
||||||
} from '../ui'
|
|
||||||
|
|
||||||
type SendMode = 'image' | 'voice'
|
|
||||||
type VoiceSource = 'generated' | 'file'
|
|
||||||
type SelectedLocalFile = { path: string; name: string }
|
type SelectedLocalFile = { path: string; name: string }
|
||||||
|
|
||||||
interface PersonalWechatSendDialogProps {
|
interface PersonalWechatSendDialogProps {
|
||||||
@@ -23,147 +23,12 @@ interface PersonalWechatSendDialogProps {
|
|||||||
isGroupChat: boolean
|
isGroupChat: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onOpenTextToSpeechSettings?: () => void
|
onOpenTextToSpeechSettings?: () => void
|
||||||
initialMode?: SendMode
|
initialMode?: PersonalWechatComposerMode
|
||||||
initialImage?: SelectedLocalFile | null
|
initialImage?: SelectedLocalFile | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type GeneratedVoice = { filePath: string; audioDataUrl: string }
|
function fallbackStatus(error: unknown): PersonalWechatSenderStatus {
|
||||||
const SHOW_SUPPORTED_WECHAT_VERSIONS_KEY = 'wxe:show-supported-wechat-versions'
|
return {
|
||||||
|
|
||||||
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 || '未知'} 暂不支持。请在设置中查看支持的微信版本,安装完全一致的版本后再试。`
|
|
||||||
}
|
|
||||||
if (status.state === 'runtime_missing') {
|
|
||||||
return '微信发送组件尚未安装。请前往文字转语音设置下载组件后再试。'
|
|
||||||
}
|
|
||||||
return status.message
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PersonalWechatSendDialog({
|
|
||||||
contact,
|
|
||||||
isGroupChat,
|
|
||||||
onClose,
|
|
||||||
onOpenTextToSpeechSettings,
|
|
||||||
initialMode = 'image',
|
|
||||||
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 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 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)
|
|
||||||
try {
|
|
||||||
setStatus(await window.api.getPersonalWechatSenderStatus())
|
|
||||||
} catch (error) {
|
|
||||||
setStatus({
|
|
||||||
state: 'error',
|
state: 'error',
|
||||||
platform: 'unknown',
|
platform: 'unknown',
|
||||||
arch: 'unknown',
|
arch: 'unknown',
|
||||||
@@ -185,225 +50,145 @@ export function PersonalWechatSendDialog({
|
|||||||
canSendVoice: false,
|
canSendVoice: false,
|
||||||
message: '无法检测个人微信发送服务',
|
message: '无法检测个人微信发送服务',
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PersonalWechatSendDialog({
|
||||||
|
contact,
|
||||||
|
isGroupChat,
|
||||||
|
onClose,
|
||||||
|
onOpenTextToSpeechSettings,
|
||||||
|
initialMode = 'text',
|
||||||
|
initialImage = null
|
||||||
|
}: PersonalWechatSendDialogProps): React.ReactElement {
|
||||||
|
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 isBusy = binding || runtimeBusy || sendBusy
|
||||||
|
const setupReady = Boolean(
|
||||||
|
senderStatus?.canSendText && (senderStatus?.canSendImage || senderStatus?.canSendVoice)
|
||||||
|
)
|
||||||
|
|
||||||
|
const refreshStatus = useCallback(async (): Promise<void> => {
|
||||||
|
const requestId = ++requestIdRef.current
|
||||||
|
setDetecting(true)
|
||||||
|
setSendError(null)
|
||||||
|
try {
|
||||||
|
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) {
|
||||||
|
if (requestId === requestIdRef.current) setSenderStatus(fallbackStatus(error))
|
||||||
|
} finally {
|
||||||
|
if (requestId === requestIdRef.current) setDetecting(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshStatus()
|
void refreshStatus()
|
||||||
|
const unsubscribe = window.api.onPersonalWechatRuntimeProgress?.((status) => {
|
||||||
|
setRuntimeProgress(status)
|
||||||
|
setRuntimeStatus(status)
|
||||||
|
if (status.state === 'ready') void refreshStatus()
|
||||||
|
})
|
||||||
|
return unsubscribe
|
||||||
}, [refreshStatus])
|
}, [refreshStatus])
|
||||||
|
|
||||||
useEffect(() => {
|
const handleDownloadRuntime = async (): Promise<void> => {
|
||||||
let active = true
|
if (runtimeBusy) return
|
||||||
void window.api
|
setRuntimeBusy(true)
|
||||||
.getTextToSpeechSettings()
|
setSendError(null)
|
||||||
.then(async (response) => {
|
try {
|
||||||
if (!active) return
|
const result = await window.api.downloadPersonalWechatRuntime()
|
||||||
setTtsSettings(response.settings)
|
setRuntimeStatus(result.status)
|
||||||
if (!response.settings.hasApiKey) return
|
if (!result.success && result.error) setSendError(result.error)
|
||||||
const voicesResponse = await window.api.listTextToSpeechVoices({
|
if (result.success) await refreshStatus()
|
||||||
pageNumber: 1,
|
} catch (error) {
|
||||||
pageSize: 24
|
setSendError(error instanceof Error ? error.message : String(error))
|
||||||
})
|
} finally {
|
||||||
if (!active || !voicesResponse.success) return
|
setRuntimeBusy(false)
|
||||||
setTtsVoices(voicesResponse.items)
|
|
||||||
})
|
|
||||||
.catch(() => undefined)
|
|
||||||
return () => {
|
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
const current = generatedVoiceRef.current
|
|
||||||
generatedVoiceRef.current = null
|
|
||||||
if (current?.filePath) {
|
|
||||||
void window.api.removeGeneratedTextToSpeechAudio(current.filePath).catch(() => undefined)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleBind = async (): Promise<void> => {
|
||||||
const audio = generatedAudioRef.current
|
if (binding) return
|
||||||
return () => audio?.pause()
|
setBinding(true)
|
||||||
}, [generatedVoice])
|
setDetectionAttempted(false)
|
||||||
|
setSendError(null)
|
||||||
const handleRebind = async (): Promise<void> => {
|
|
||||||
if (isBusy) return
|
|
||||||
setIsRebinding(true)
|
|
||||||
setResult(null)
|
|
||||||
try {
|
try {
|
||||||
const nextStatus = await window.api.rebindPersonalWechatSender()
|
const nextStatus = await window.api.rebindPersonalWechatSender()
|
||||||
setStatus(nextStatus)
|
setSenderStatus(nextStatus)
|
||||||
setResult({
|
if (nextStatus.state !== 'online' && nextStatus.message) setSendError(nextStatus.message)
|
||||||
success: nextStatus.attachReady && nextStatus.baseAddressReady,
|
|
||||||
message: nextStatus.message
|
|
||||||
})
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setResult({ success: false, message: error instanceof Error ? error.message : String(error) })
|
setSendError(error instanceof Error ? error.message : String(error))
|
||||||
} finally {
|
} finally {
|
||||||
setIsRebinding(false)
|
setBinding(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectImage = async (): Promise<void> => {
|
const handleDetect = async (): Promise<void> => {
|
||||||
if (isBusy) return
|
setDetectionAttempted(true)
|
||||||
const selection = await window.api.selectPersonalWechatImage()
|
await refreshStatus()
|
||||||
if (!selection.canceled && selection.path) {
|
|
||||||
setImage({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
|
||||||
setResult(null)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectVoice = async (): Promise<void> => {
|
const handleClose = (): void => {
|
||||||
if (isBusy) return
|
if (isBusy || closingRef.current) return
|
||||||
const selection = await window.api.selectPersonalWechatVoice()
|
closingRef.current = true
|
||||||
if (!selection.canceled && selection.path) {
|
const restoreFocus = restoreFocusRef.current
|
||||||
setVoice({ path: selection.path, name: selection.name || selection.path.split('/').pop()! })
|
restoreFocusRef.current = null
|
||||||
setResult(null)
|
onClose()
|
||||||
}
|
queueMicrotask(() => restoreFocus?.focus())
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeMode = (nextMode: SendMode): void => {
|
const handleOpenSettings = (): void => {
|
||||||
if (mode === 'voice' && voiceSource === 'generated' && nextMode !== 'voice') {
|
if (!onOpenTextToSpeechSettings || isBusy) return
|
||||||
clearGeneratedVoice()
|
handleClose()
|
||||||
}
|
onOpenTextToSpeechSettings()
|
||||||
setMode(nextMode)
|
|
||||||
setResult(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const changeVoiceSource = (nextSource: VoiceSource): void => {
|
const handleSend = async (
|
||||||
if (voiceSource === 'generated' && nextSource !== 'generated') clearGeneratedVoice()
|
request: PersonalWechatSendRequest
|
||||||
setVoiceSource(nextSource)
|
): Promise<{ success: boolean; error?: string }> => {
|
||||||
setResult(null)
|
setSendBusy(true)
|
||||||
}
|
setSendError(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)
|
|
||||||
try {
|
try {
|
||||||
const generated = await window.api.synthesizeTextToSpeech({
|
const response = await window.api.sendPersonalWechatMessage({ ...request, to: targetId })
|
||||||
text: voiceText.trim(),
|
setSenderStatus(response.status)
|
||||||
referenceId: ttsSettings!.selectedVoiceId
|
if (!response.success) setSendError(response.error || '发送失败')
|
||||||
})
|
return { success: response.success, error: response.error }
|
||||||
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: '语音已生成,可以先试听,确认后再发送' })
|
|
||||||
} catch (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 {
|
} 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 (
|
return (
|
||||||
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
<Dialog open onOpenChange={(open) => !open && handleClose()}>
|
||||||
<DialogContent
|
<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={() => {
|
onOpenAutoFocus={() => {
|
||||||
restoreFocusRef.current =
|
restoreFocusRef.current =
|
||||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||||
@@ -411,291 +196,91 @@ export function PersonalWechatSendDialog({
|
|||||||
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
onEscapeKeyDown={(event) => isBusy && event.preventDefault()}
|
||||||
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
onPointerDownOutside={(event) => isBusy && event.preventDefault()}
|
||||||
>
|
>
|
||||||
<DialogHeader className="flex-row items-center justify-between space-y-0 pr-10">
|
<DialogHeader className="personal-wechat-chat-header">
|
||||||
<div>
|
<div className="personal-wechat-chat-avatar" aria-hidden>
|
||||||
<span className="text-[11px] font-bold tracking-normal text-primary">实验性功能</span>
|
{displayName.slice(0, 1)}
|
||||||
<DialogTitle className="mt-0.5 text-[19px] leading-[26px] tracking-normal">
|
|
||||||
个人微信测试发送
|
|
||||||
</DialogTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<DialogDescription className="sr-only">
|
<div className="personal-wechat-chat-heading">
|
||||||
向当前微信联系人或群聊测试发送图片和语音。
|
<DialogTitle>{displayName}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isGroupChat ? '群聊' : '联系人'} · {setupReady ? '微信已连接' : '配置微信消息发送'}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`personal-wechat-connection-dot ${setupReady ? 'is-online' : ''}`}
|
||||||
|
aria-label={setupReady ? '微信已连接' : '微信尚未配置'}
|
||||||
|
/>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="personal-wechat-send-device-note" role="note">
|
<div className="personal-wechat-chat-body">
|
||||||
<span aria-hidden>i</span>
|
{setupReady && composerStarted && (
|
||||||
<p>
|
<div className="personal-wechat-message-list" aria-label="消息列表">
|
||||||
<strong>请在其他设备确认发送结果</strong>
|
{messages.length === 0 ? (
|
||||||
通过测试功能发送的消息不会显示在本机微信会话中,请切换到手机、平板等其他设备确认是否送达。
|
<div className="personal-wechat-empty-message">还没有发送消息。</div>
|
||||||
</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>
|
|
||||||
))}
|
|
||||||
</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>
|
messages.map((message) => (
|
||||||
)}
|
|
||||||
</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
|
<div
|
||||||
className={`personal-wechat-tts-readiness ${ttsSettings?.hasApiKey ? 'ready' : ''}`}
|
key={message.id}
|
||||||
|
className={`personal-wechat-message-bubble ${message.outgoing ? 'is-outgoing' : ''}`}
|
||||||
>
|
>
|
||||||
<strong>
|
<span className="personal-wechat-message-kind">
|
||||||
{generatedVoiceReady
|
{message.type === 'text'
|
||||||
? 'API Key 与音色已准备'
|
? '文字'
|
||||||
: ttsSettings?.hasApiKey
|
: message.type === 'image'
|
||||||
? '还需要在设置中选择音色'
|
? '图片'
|
||||||
: '还需要配置语音服务 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>
|
</span>
|
||||||
<div className="personal-wechat-preview-track">
|
<span>{message.text || message.fileName}</span>
|
||||||
<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>
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="personal-wechat-send-note">
|
{(!setupReady || !composerStarted) && senderStatus && (
|
||||||
{mode === 'voice' && voiceSource === 'generated'
|
<PersonalWechatSetupGuide
|
||||||
? '文字生成语音会复用现有媒体上传能力;请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
runtimeStatus={runtimeStatus}
|
||||||
: mode === 'voice'
|
senderStatus={senderStatus}
|
||||||
? '本地语音复用媒体上传 Hook,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'
|
runtimeProgress={runtimeProgress}
|
||||||
: '如果想测试图片,请先在微信中给任意好友手动发送一张普通图片,再点击重新检测。'}
|
runtimeBusy={runtimeBusy}
|
||||||
微信重新登录导致 PID 改变时,请点击“尝试重新绑定”。
|
binding={binding}
|
||||||
</p>
|
detecting={detecting}
|
||||||
|
onDownloadRuntime={() => void handleDownloadRuntime()}
|
||||||
{result && (
|
onBind={() => void handleBind()}
|
||||||
<div className={`personal-wechat-send-result ${result.success ? 'success' : 'error'}`}>
|
detectionAttempted={detectionAttempted}
|
||||||
{result.message}
|
onDetect={() => void handleDetect()}
|
||||||
</div>
|
onStartSending={() => setComposerStarted(true)}
|
||||||
|
onOpenTextToSpeechSettings={handleOpenSettings}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<footer>
|
{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 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>
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</footer>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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,
|
SelectTrigger,
|
||||||
SelectValue
|
SelectValue
|
||||||
} from '../../../components/ui'
|
} from '../../../components/ui'
|
||||||
|
import { PersonalWechatSupportedVersionsContent } from '../../../components/chat/PersonalWechatSupportedVersionsContent'
|
||||||
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
import { isMac, isWindows } from '../../../utils/runtime-environment'
|
||||||
|
|
||||||
const VOICE_PAGE_SIZE = 24
|
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 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> = {
|
const RUNTIME_STATUS_LABELS: Record<PersonalWechatRuntimeStatus['state'], string> = {
|
||||||
missing: '未下载',
|
missing: '未下载',
|
||||||
@@ -539,7 +522,7 @@ export function TextToSpeechPage({
|
|||||||
<span>2</span>
|
<span>2</span>
|
||||||
<div>
|
<div>
|
||||||
<strong>在档案中生成语音</strong>
|
<strong>在档案中生成语音</strong>
|
||||||
<p>打开联系人或群聊的测试发送,选择“语音 → 输入文字生成”。</p>
|
<p>打开联系人或群聊的发送,选择“语音 → 输入文字生成”。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@@ -566,7 +549,11 @@ export function TextToSpeechPage({
|
|||||||
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
|
发送功能需要关闭 SIP 并连接微信进程。关闭 SIP
|
||||||
会降低系统安全性,请确认风险后再使用。
|
会降低系统安全性,请确认风险后再使用。
|
||||||
</li>
|
</li>
|
||||||
<li>微信重新登录或 PID 改变后,需要在测试发送弹窗中点击“尝试重新绑定”。</li>
|
<li>微信重新登录或 PID 改变后,需要在发送窗口中重新检测或绑定。</li>
|
||||||
|
<li>
|
||||||
|
绑定前建议关闭微信自动升级 在微信左下角打开“设置 →
|
||||||
|
通用”,取消勾选“有更新时自动升级微信”。微信自动更新后,版本可能不再兼容发送组件。
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
{personalWechatRuntimeSupported ? (
|
{personalWechatRuntimeSupported ? (
|
||||||
<Button
|
<Button
|
||||||
@@ -941,24 +928,7 @@ export function TextToSpeechPage({
|
|||||||
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
|
<DialogTitle className="text-lg">支持的微信版本</DialogTitle>
|
||||||
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
|
<DialogDescription>请安装下列完整版本之一。</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<a
|
<PersonalWechatSupportedVersionsContent />
|
||||||
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>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</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-header-actions svg,
|
||||||
.chat-content-search svg,
|
.chat-content-search svg,
|
||||||
.data-trust-bar 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 {
|
.tts-hook-warning {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
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 { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||||
import userEvent from '@testing-library/user-event'
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import type React from 'react'
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
import { PersonalWechatSendDialog } from '../../src/renderer/src/components/chat/PersonalWechatSendDialog'
|
||||||
|
|
||||||
const getStatus = vi.fn()
|
const getStatus = vi.fn()
|
||||||
|
const getRuntimeStatus = vi.fn()
|
||||||
|
const downloadRuntime = vi.fn()
|
||||||
|
const onRuntimeProgress = vi.fn(() => vi.fn())
|
||||||
const rebind = vi.fn()
|
const rebind = vi.fn()
|
||||||
const selectImage = vi.fn()
|
const selectImage = vi.fn()
|
||||||
const selectVoice = vi.fn()
|
const selectVoice = vi.fn()
|
||||||
@@ -20,7 +23,6 @@ const contact = {
|
|||||||
md5: 'fixture-md5',
|
md5: 'fixture-md5',
|
||||||
type: 'group' as const
|
type: 'group' as const
|
||||||
}
|
}
|
||||||
|
|
||||||
const readyStatus = {
|
const readyStatus = {
|
||||||
state: 'online' as const,
|
state: 'online' as const,
|
||||||
platform: 'darwin',
|
platform: 'darwin',
|
||||||
@@ -48,42 +50,44 @@ const readyStatus = {
|
|||||||
canSendVoice: true,
|
canSendVoice: true,
|
||||||
message: '个人微信已绑定'
|
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 {
|
function renderDialog(
|
||||||
const [open, setOpen] = useState(false)
|
props: Partial<React.ComponentProps<typeof PersonalWechatSendDialog>> = {}
|
||||||
return (
|
): React.ReactElement {
|
||||||
<>
|
return render(
|
||||||
<button type="button" onClick={() => setOpen(true)}>
|
<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} {...props} />
|
||||||
打开测试发送
|
|
||||||
</button>
|
|
||||||
{open && (
|
|
||||||
<PersonalWechatSendDialog
|
|
||||||
contact={contact}
|
|
||||||
isGroupChat
|
|
||||||
onClose={() => {
|
|
||||||
onClose()
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function startComposer(): Promise<void> {
|
||||||
|
const start = await screen.findByRole('button', { name: '开始发送' })
|
||||||
|
fireEvent.click(start)
|
||||||
|
}
|
||||||
|
|
||||||
describe('PersonalWechatSendDialog', () => {
|
describe('PersonalWechatSendDialog', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
getStatus.mockReset().mockResolvedValue(readyStatus)
|
getStatus.mockReset().mockResolvedValue(readyStatus)
|
||||||
|
getRuntimeStatus.mockReset().mockResolvedValue(readyRuntime)
|
||||||
|
downloadRuntime.mockReset().mockResolvedValue({ success: true, status: readyRuntime })
|
||||||
|
onRuntimeProgress.mockReset().mockReturnValue(vi.fn())
|
||||||
rebind.mockReset().mockResolvedValue(readyStatus)
|
rebind.mockReset().mockResolvedValue(readyStatus)
|
||||||
selectImage.mockReset().mockResolvedValue({
|
selectImage
|
||||||
canceled: false,
|
.mockReset()
|
||||||
path: '/Users/fixture/test.png',
|
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.png', name: 'test.png' })
|
||||||
name: 'test.png'
|
selectVoice
|
||||||
})
|
.mockReset()
|
||||||
selectVoice.mockReset().mockResolvedValue({
|
.mockResolvedValue({ canceled: false, path: '/Users/fixture/test.silk', name: 'test.silk' })
|
||||||
canceled: false,
|
|
||||||
path: '/Users/fixture/test.silk',
|
|
||||||
name: 'test.silk'
|
|
||||||
})
|
|
||||||
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
sendMessage.mockReset().mockResolvedValue({ success: true, status: readyStatus })
|
||||||
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
getTextToSpeechSettings.mockReset().mockResolvedValue({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -125,6 +129,9 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: {
|
value: {
|
||||||
getPersonalWechatSenderStatus: getStatus,
|
getPersonalWechatSenderStatus: getStatus,
|
||||||
|
getPersonalWechatRuntimeStatus: getRuntimeStatus,
|
||||||
|
downloadPersonalWechatRuntime: downloadRuntime,
|
||||||
|
onPersonalWechatRuntimeProgress: onRuntimeProgress,
|
||||||
rebindPersonalWechatSender: rebind,
|
rebindPersonalWechatSender: rebind,
|
||||||
selectPersonalWechatImage: selectImage,
|
selectPersonalWechatImage: selectImage,
|
||||||
selectPersonalWechatVoice: selectVoice,
|
selectPersonalWechatVoice: selectVoice,
|
||||||
@@ -137,14 +144,84 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('switches to voice mode and sends only the selected audio file', async () => {
|
it('shows a user-facing four-step setup and keeps diagnostics collapsed', async () => {
|
||||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
getRuntimeStatus
|
||||||
await screen.findByText('技术交流群')
|
.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('radio', { name: '选择本地文件' }))
|
fireEvent.click(screen.getByRole('radio', { name: '选择本地文件' }))
|
||||||
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
fireEvent.click(screen.getByRole('button', { name: '选择语音' }))
|
||||||
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
expect(await screen.findByText('test.silk')).toBeInTheDocument()
|
||||||
fireEvent.click(screen.getByRole('button', { name: '测试发送语音到群聊' }))
|
fireEvent.click(screen.getByRole('button', { name: '发送消息' }))
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(sendMessage).toHaveBeenCalledWith({
|
expect(sendMessage).toHaveBeenCalledWith({
|
||||||
type: 'voice',
|
type: 'voice',
|
||||||
@@ -155,136 +232,101 @@ describe('PersonalWechatSendDialog', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows the selected TTS voice and keeps generation separate from sending', async () => {
|
it('uses the existing runtime, binding and detection IPC actions', async () => {
|
||||||
const openSettings = vi.fn()
|
getRuntimeStatus
|
||||||
render(
|
.mockResolvedValueOnce({ ...readyRuntime, state: 'missing', progress: 0 })
|
||||||
<PersonalWechatSendDialog
|
.mockResolvedValue(readyRuntime)
|
||||||
contact={contact}
|
getStatus
|
||||||
isGroupChat
|
.mockResolvedValueOnce({
|
||||||
onClose={vi.fn()}
|
...readyStatus,
|
||||||
onOpenTextToSpeechSettings={openSettings}
|
state: 'stopped',
|
||||||
/>
|
runtimeReady: false,
|
||||||
)
|
canSend: false,
|
||||||
await screen.findByText('技术交流群')
|
canSendText: false,
|
||||||
fireEvent.click(screen.getByRole('radio', { name: '语音' }))
|
canSendImage: false,
|
||||||
expect(await screen.findByText('暖阳女声')).toBeInTheDocument()
|
canSendVoice: false
|
||||||
expect(screen.getByRole('textbox', { name: '要生成的文字' })).toHaveValue('1')
|
})
|
||||||
expect(screen.getByRole('button', { name: '生成语音' })).toBeEnabled()
|
.mockResolvedValue({
|
||||||
fireEvent.click(screen.getByRole('button', { name: '前往文字转语音设置' }))
|
...readyStatus,
|
||||||
expect(openSettings).toHaveBeenCalledTimes(1)
|
state: 'stopped',
|
||||||
expect(sendMessage).not.toHaveBeenCalled()
|
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('shows binding diagnostics and only offers image and voice modes', async () => {
|
it('shows a stable bound state after binding', async () => {
|
||||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
getRuntimeStatus.mockResolvedValue(readyRuntime)
|
||||||
|
getStatus
|
||||||
expect(await screen.findByText('PID 4668')).toBeInTheDocument()
|
.mockResolvedValueOnce({
|
||||||
expect(screen.getByText('PID 5401 · 绑定 4668')).toBeInTheDocument()
|
...readyStatus,
|
||||||
expect(screen.getByText('0x114ef8000')).toBeInTheDocument()
|
state: 'stopped',
|
||||||
expect(screen.getByText('已捕获,可发送')).toBeInTheDocument()
|
canSend: false,
|
||||||
expect(screen.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
canSendText: false,
|
||||||
expect(screen.getByRole('radio', { name: '语音' })).toBeVisible()
|
canSendImage: false,
|
||||||
expect(screen.queryByRole('radio', { name: '文字' })).not.toBeInTheDocument()
|
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('supports segmented keyboard navigation for the message type', async () => {
|
it('treats image and voice readiness as one media capability', 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('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('blocks image sending until the media Hook and image directory are initialized', async () => {
|
|
||||||
getStatus.mockResolvedValue({
|
getStatus.mockResolvedValue({
|
||||||
...readyStatus,
|
...readyStatus,
|
||||||
state: 'hook_not_ready',
|
|
||||||
imageHookReady: false,
|
|
||||||
canSendImage: false,
|
canSendImage: false,
|
||||||
message: '请先手动发送图片'
|
canSendVoice: true
|
||||||
})
|
})
|
||||||
render(<PersonalWechatSendDialog contact={contact} isGroupChat onClose={vi.fn()} />)
|
renderDialog()
|
||||||
expect(await screen.findByText('已绑定,等待消息初始化')).toBeInTheDocument()
|
expect(await screen.findByText('图片和语音消息')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('button', { name: '测试发送图片到群聊' })).toBeDisabled()
|
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 () => {
|
it('keeps re-detection available and explains when no new messages are found', async () => {
|
||||||
const user = userEvent.setup()
|
getRuntimeStatus.mockResolvedValue(readyRuntime)
|
||||||
const onClose = vi.fn()
|
getStatus.mockResolvedValue({
|
||||||
render(<Harness onClose={onClose} />)
|
...readyStatus,
|
||||||
const opener = screen.getByRole('button', { name: '打开测试发送' })
|
canSend: false,
|
||||||
|
canSendText: false,
|
||||||
await user.click(opener)
|
canSendImage: false,
|
||||||
await screen.findByText('技术交流群')
|
canSendVoice: false,
|
||||||
await user.keyboard('{Escape}')
|
message: '等待消息初始化'
|
||||||
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
|
})
|
||||||
await waitFor(() => expect(opener).toHaveFocus())
|
renderDialog()
|
||||||
|
const detect = await screen.findByRole('button', { name: '重新检测' })
|
||||||
await user.click(opener)
|
expect(detect).toBeEnabled()
|
||||||
const dialog = await screen.findByRole('dialog', { name: '个人微信测试发送' })
|
fireEvent.click(detect)
|
||||||
await user.click(dialog.previousElementSibling as HTMLElement)
|
expect(
|
||||||
expect(screen.queryByRole('dialog', { name: '个人微信测试发送' })).not.toBeInTheDocument()
|
await screen.findByText(
|
||||||
await waitFor(() => expect(opener).toHaveFocus())
|
'暂未检测到新的消息,请确认已在手机微信中发送文字和图片,然后再次检测。'
|
||||||
expect(onClose).toHaveBeenCalledTimes(2)
|
)
|
||||||
|
).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()
|
const user = userEvent.setup()
|
||||||
sendMessage.mockImplementation(() => new Promise(() => undefined))
|
sendMessage.mockImplementation(() => new Promise(() => undefined))
|
||||||
render(<Harness />)
|
renderDialog()
|
||||||
|
await startComposer()
|
||||||
await user.click(screen.getByRole('button', { name: '打开测试发送' }))
|
await screen.findByRole('textbox', { name: '消息内容' })
|
||||||
await screen.findByText('技术交流群')
|
await user.type(screen.getByRole('textbox', { name: '消息内容' }), '发送中')
|
||||||
await user.click(screen.getByRole('button', { name: '选择图片' }))
|
await user.click(screen.getByRole('button', { name: '发送消息' }))
|
||||||
await user.click(screen.getByRole('button', { name: '测试发送图片到群聊' }))
|
|
||||||
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
expect(screen.getByRole('button', { name: '正在发送…' })).toBeDisabled()
|
||||||
expect(screen.getByRole('radio', { 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: '发送到当前群聊' }))
|
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.getByRole('radio', { name: '图片' })).toHaveAttribute('aria-checked', 'true')
|
||||||
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
expect(screen.getByText('测试群日报.png')).toBeVisible()
|
||||||
expect(screen.getByText('/Users/fixture/测试群日报.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()
|
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||||
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
|
const trigger = fixture.page.getByRole('button', { name: '发送消息' })
|
||||||
await trigger.click()
|
await trigger.click()
|
||||||
const dialog = fixture.page.getByRole('dialog', { name: '个人微信测试发送' })
|
const dialog = fixture.page.getByRole('dialog', { name: '产品测试群' })
|
||||||
await expect(dialog).toBeVisible()
|
await expect(dialog).toBeVisible()
|
||||||
await expect(
|
const startSending = dialog.getByRole('button', { name: '开始发送' })
|
||||||
dialog.locator('.personal-wechat-send-status strong').filter({ hasText: '个人微信已绑定' })
|
if (await startSending.isVisible()) await startSending.click()
|
||||||
).toBeVisible()
|
|
||||||
expect(
|
expect(
|
||||||
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
await fixture.page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
@@ -208,7 +207,7 @@ test('CHAT-02 personal WeChat send dialog is keyboard-safe and fits the viewport
|
|||||||
expect(pageErrors).toEqual([])
|
expect(pageErrors).toEqual([])
|
||||||
|
|
||||||
const imageMode = dialog.getByRole('radio', { name: '图片' })
|
const imageMode = dialog.getByRole('radio', { name: '图片' })
|
||||||
const voiceMode = dialog.getByRole('radio', { name: '语音' })
|
const voiceMode = dialog.getByRole('radio', { name: '语音', exact: true })
|
||||||
await imageMode.focus()
|
await imageMode.focus()
|
||||||
await fixture.page.keyboard.press('ArrowRight')
|
await fixture.page.keyboard.press('ArrowRight')
|
||||||
await expect(voiceMode).toBeFocused()
|
await expect(voiceMode).toBeFocused()
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
|
|||||||
await fixture.setWindowContentSize(visualViewport)
|
await fixture.setWindowContentSize(visualViewport)
|
||||||
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
await fixture.page.getByText('产品测试群', { exact: true }).click()
|
||||||
await fixture.page.getByRole('button', { name: '发送消息' }).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(dialog).toBeVisible()
|
||||||
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
await expect(fixture.page.locator('html')).toHaveAttribute('data-theme', appearanceTheme)
|
||||||
expect(
|
expect(
|
||||||
@@ -406,9 +406,11 @@ for (const appearanceTheme of ['light', 'dark'] as const) {
|
|||||||
caret: 'hide'
|
caret: 'hide'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const startSending = dialog.getByRole('button', { name: '开始发送' })
|
||||||
|
if (await startSending.isVisible()) await startSending.click()
|
||||||
await dialog.getByRole('radio', { name: '语音' }).click()
|
await dialog.getByRole('radio', { name: '语音' }).click()
|
||||||
await expect(dialog.getByRole('textbox', { name: '要生成的文字' })).toBeVisible()
|
await expect(dialog.getByRole('textbox', { name: '语音文字' })).toBeVisible()
|
||||||
await dialog.locator('footer').scrollIntoViewIfNeeded()
|
await dialog.locator('.personal-wechat-composer').scrollIntoViewIfNeeded()
|
||||||
await clearScreenshotFocus(fixture.page)
|
await clearScreenshotFocus(fixture.page)
|
||||||
await expect(fixture.page).toHaveScreenshot(
|
await expect(fixture.page).toHaveScreenshot(
|
||||||
`personal-wechat-voice-controls-${appearanceTheme}.png`,
|
`personal-wechat-voice-controls-${appearanceTheme}.png`,
|
||||||
|
|||||||
@@ -6,6 +6,25 @@ function setPrivate(target: object, key: string, value: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('Wcdb4Client shutdown', () => {
|
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 () => {
|
it('waits for tracked Koffi calls before shutting down the native runtime', async () => {
|
||||||
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
const client = Object.create(Wcdb4Client.prototype) as Wcdb4Client
|
||||||
const shutdown = vi.fn(() => 0)
|
const shutdown = vi.fn(() => 0)
|
||||||
|
|||||||
Reference in New Issue
Block a user