diff --git a/public/禁用更新.png b/public/禁用更新.png new file mode 100644 index 0000000..d6b4621 Binary files /dev/null and b/public/禁用更新.png differ diff --git a/src/main/index.ts b/src/main/index.ts index 433b860..cb0266b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1800,7 +1800,7 @@ app.whenReady().then(async () => { ipcMain.handle('wechat-personal:selectImage', async (event) => { const window = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(window!, { - title: '选择要通过个人微信测试发送的图片', + title: '选择要通过个人微信发送的图片', properties: ['openFile'], filters: [ { name: '图片', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }, @@ -1817,7 +1817,7 @@ app.whenReady().then(async () => { ipcMain.handle('wechat-personal:selectVoice', async (event) => { const window = BrowserWindow.fromWebContents(event.sender) const result = await dialog.showOpenDialog(window!, { - title: '选择要通过个人微信测试发送的语音', + title: '选择要通过个人微信发送的语音', properties: ['openFile'], filters: [ { name: '语音', extensions: ['silk', 'mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'] }, diff --git a/src/main/services/personal-wechat-send-service.ts b/src/main/services/personal-wechat-send-service.ts index 1ee2138..fc7244e 100644 --- a/src/main/services/personal-wechat-send-service.ts +++ b/src/main/services/personal-wechat-send-service.ts @@ -500,7 +500,7 @@ export class PersonalWechatSendService { } if (text.length > 2_000) { const status = await this.getStatus() - return { success: false, status, error: '测试消息不能超过 2000 个字符' } + return { success: false, status, error: '消息不能超过 2000 个字符' } } request = { ...request, to, text } } else { @@ -520,7 +520,7 @@ export class PersonalWechatSendService { return { success: false, status, - error: `测试${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB` + error: `${request.type === 'voice' ? '语音' : '图片'}必须小于 20 MB` } } fileBase64 = ( @@ -725,7 +725,7 @@ export class PersonalWechatSendService { status: { ...base, state: 'unsupported_platform', - message: '个人微信测试发送当前仅支持 Apple Silicon Mac' + message: '个人微信发送当前仅支持 Apple Silicon Mac' } } } diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 5a3c641..189b3e7 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -979,6 +979,11 @@ export class Wcdb4Client { this.cachedSessions = null this.cachedChatTables = null this.sessionsInFlight = null + // Session rows can change their folded/muted flags independently of the + // contact list. Invalidate the status snapshot together with sessions so + // the next hydrated contact load reads the current WeChat state. + this.sessionStatusCache.clear() + this.sessionStatusesUpdatedAt = 0 this.sessionDisplayNamesHydrated = false } diff --git a/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx new file mode 100644 index 0000000..7449460 --- /dev/null +++ b/src/renderer/src/components/chat/PersonalWechatChatComposer.tsx @@ -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(initialMode) + const [text, setText] = useState('') + const [image, setImage] = useState(initialImage) + const [voice, setVoice] = useState(null) + const [voiceSource, setVoiceSource] = useState('generated') + const [voiceText, setVoiceText] = useState('') + const [ttsSettings, setTtsSettings] = useState(null) + const [ttsVoices, setTtsVoices] = useState([]) + const [generatedVoice, setGeneratedVoice] = useState(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(null) + const generatedAudioRef = useRef(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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 => { + 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 ( +
+ { + clearGeneratedVoice() + setMode(value as PersonalWechatComposerMode) + setFeedback(null) + }} + disabled={isBusy} + > + 文字 + 图片 + 语音 + + + {mode === 'text' && ( +
+