diff --git a/electron/main.ts b/electron/main.ts index 3d97bc7..e37551e 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -1458,6 +1458,16 @@ function registerIpcHandlers() { } }) + ipcMain.handle('file:writeBase64', async (_, filePath: string, base64Data: string) => { + try { + const fs = await import('fs') + fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64')) + return { success: true } + } catch (error: any) { + return { success: false, error: error.message } + } + }) + ipcMain.handle('shell:openPath', async (_, path: string) => { const { shell } = await import('electron') return shell.openPath(path) diff --git a/electron/preload.ts b/electron/preload.ts index e93114a..634435c 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -77,7 +77,8 @@ contextBridge.exposeInMainWorld('electronAPI', { // 文件操作 file: { delete: (filePath: string) => ipcRenderer.invoke('file:delete', filePath), - copy: (sourcePath: string, destPath: string) => ipcRenderer.invoke('file:copy', sourcePath, destPath) + copy: (sourcePath: string, destPath: string) => ipcRenderer.invoke('file:copy', sourcePath, destPath), + writeBase64: (filePath: string, base64Data: string) => ipcRenderer.invoke('file:writeBase64', filePath, base64Data) }, // Shell diff --git a/src/App.tsx b/src/App.tsx index 3b6b829..8fca0a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -295,7 +295,7 @@ function App() { // 启动时自动检查配置并连接数据库 useEffect(() => { // 独立窗口不需要自动连接主数据库 - if (isChatWindow || isGroupAnalyticsWindow || isMomentsWindow || isAnnualReportWindow || isAgreementWindow || isAISummaryWindow || location.pathname === '/image-viewer-window') return + if (isChatWindow || isGroupAnalyticsWindow || isMomentsWindow || isAnnualReportWindow || isAgreementWindow || isAISummaryWindow || isWelcomeWindow || location.pathname === '/image-viewer-window') return const autoConnect = async () => { try { @@ -363,7 +363,7 @@ function App() { } autoConnect() - }, [isChatWindow, isGroupAnalyticsWindow]) + }, [isChatWindow, isGroupAnalyticsWindow, isMomentsWindow, isAnnualReportWindow, isAgreementWindow, isAISummaryWindow, isWelcomeWindow, location.pathname, navigate, setDbConnected]) // 独立聊天窗口 - 只显示聊天页面,无侧边栏 if (isChatWindow) { diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 9e0d875..e3b9ce7 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -3501,7 +3501,7 @@ video::-webkit-media-controls-fullscreen-button { // 复制成功提示 .copy-toast { position: fixed; - bottom: 80px; + top: 80px; left: 50%; transform: translateX(-50%); background: var(--card-bg); @@ -3523,9 +3523,23 @@ video::-webkit-media-controls-fullscreen-button { } } +.copy-toast.top-toast.success { + border-color: rgba(16, 185, 129, 0.28); + box-shadow: 0 8px 24px rgba(16, 185, 129, 0.18); +} + +.copy-toast.top-toast.error { + border-color: rgba(239, 68, 68, 0.28); + box-shadow: 0 8px 24px rgba(239, 68, 68, 0.18); + + svg { + color: var(--danger, #ef4444); + } +} + @keyframes slideUpToast { from { - transform: translateX(-50%) translateY(20px); + transform: translateX(-50%) translateY(-20px); opacity: 0; } diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index c0f259d..b123f3d 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -293,7 +293,7 @@ function ChatPage(_props: ChatPageProps) { }, []) const [selectedMessages, setSelectedMessages] = useState>(new Set()) const [showEnlargeView, setShowEnlargeView] = useState<{ message: Message; content: string } | null>(null) - const [copyToast, setCopyToast] = useState(false) + const [topToast, setTopToast] = useState<{ text: string; success: boolean } | null>(null) const [showMessageInfo, setShowMessageInfo] = useState(null) // 消息信息弹窗 const [showDatePicker, setShowDatePicker] = useState(false) // 日期选择器弹窗 const [selectedDate, setSelectedDate] = useState('') // 选中的日期 (YYYY-MM-DD) @@ -326,15 +326,63 @@ function ChatPage(_props: ChatPageProps) { const [batchImageDates, setBatchImageDates] = useState([]) const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) + const showTopToast = useCallback((text: string, success = true) => { + setTopToast({ text, success }) + setTimeout(() => setTopToast(null), 2000) + }, []) + const copyText = useCallback(async (text: string) => { try { await navigator.clipboard.writeText(text || '') - setCopyToast(true) - setTimeout(() => setCopyToast(false), 2000) + showTopToast('已复制', true) } catch (e) { console.error('复制失败:', e) } - }, []) + }, [showTopToast]) + + const exportVoiceMessage = useCallback(async (message: Message, session: ChatSession) => { + try { + const voiceResult = await window.electronAPI.chat.getVoiceData( + session.username, + String(message.localId), + message.createTime + ) + + if (!voiceResult.success || !voiceResult.data) { + alert(voiceResult.error || '获取语音数据失败') + return + } + + const downloadsPath = await window.electronAPI.app.getDownloadsPath() + const safeSessionName = String(session.displayName || session.username || 'voice') + .replace(/[<>:"/\\|?*]/g, '_') + .replace(/\s+/g, ' ') + .trim() || 'voice' + const timestamp = new Date(message.createTime * 1000) + const pad = (value: number) => String(value).padStart(2, '0') + const fileName = `${safeSessionName}_${timestamp.getFullYear()}${pad(timestamp.getMonth() + 1)}${pad(timestamp.getDate())}_${pad(timestamp.getHours())}${pad(timestamp.getMinutes())}${pad(timestamp.getSeconds())}_${message.localId}.wav` + + const saveResult = await window.electronAPI.dialog.saveFile({ + title: '导出语音文件', + defaultPath: `${downloadsPath}\\${fileName}`, + filters: [{ name: 'WAV 音频', extensions: ['wav'] }] + }) + + if (saveResult.canceled || !saveResult.filePath) { + return + } + + const writeResult = await window.electronAPI.file.writeBase64(saveResult.filePath, voiceResult.data) + if (!writeResult.success) { + alert(writeResult.error || '导出语音文件失败') + return + } + + showTopToast('语音文件导出成功', true) + } catch (e) { + showTopToast(`导出语音文件失败: ${String(e)}`, false) + } + }, [showTopToast]) // 检查图片密钥配置(XOR 和 AES 都需要配置) useEffect(() => { @@ -1910,7 +1958,23 @@ function ChatPage(_props: ChatPageProps) { // 计算菜单位置,确保不超出屏幕 const menuWidth = 160 - const menuHeight = 120 + let menuItemCount = 1 + if (message.localType !== 34 && message.localType !== 3 && message.localType !== 43) { + menuItemCount += 2 + } + if (message.localType !== 3 && message.localType !== 43) { + menuItemCount += 1 + } + if (message.localType === 34) { + menuItemCount += 1 + } + if (handlers?.reTranscribe) { + menuItemCount += 1 + } + if (handlers?.editStt) { + menuItemCount += 1 + } + const menuHeight = menuItemCount * 38 + 12 let x = e.clientX let y = e.clientY @@ -2097,8 +2161,7 @@ function ChatPage(_props: ChatPageProps) { try { await navigator.clipboard.writeText(contextMenu.message.parsedContent || '') closeContextMenu() - setCopyToast(true) - setTimeout(() => setCopyToast(false), 2000) + showTopToast('已复制', true) } catch (e) { console.error('复制失败:', e) closeContextMenu() @@ -2144,6 +2207,19 @@ function ChatPage(_props: ChatPageProps) { )} + {contextMenu.message.localType === 34 && ( +
{ + closeContextMenu() + void exportVoiceMessage(contextMenu.message, contextMenu.session) + }} + > + + 导出语音文件 +
+ )} + {/* 语音消息:重新转文字 */} {contextMenu.handlers?.reTranscribe && (
- - 已复制 + {/* 顶部气泡提示 */} + {topToast && createPortal( +
+ {topToast.success ? : } + {topToast.text}
, document.body )} diff --git a/src/pages/SettingsPage.tsx b/src/pages/SettingsPage.tsx index a17db77..ac026f3 100644 --- a/src/pages/SettingsPage.tsx +++ b/src/pages/SettingsPage.tsx @@ -46,7 +46,7 @@ const sttModelTypeOptions = [ function SettingsPage() { const [searchParams] = useSearchParams() const location = useLocation() - const { setDbConnected, setLoading, setMyWxid: setCurrentWxid } = useAppStore() + const { setDbConnected, setLoading, setMyWxid: setCurrentWxid, userInfo } = useAppStore() const { currentTheme, themeMode, setTheme, setThemeMode, appIcon, setAppIcon } = useThemeStore() const { status: activationStatus, checkStatus: checkActivationStatus } = useActivationStore() @@ -201,15 +201,36 @@ function SettingsPage() { const isMac = platformInfo.platform === 'darwin' const biometricLabel = isMac ? 'Touch ID' : 'Windows Hello' - const buildAccountPayload = () => ({ - wxid: wxid.trim(), - dbPath: dbPath.trim(), - decryptKey: decryptKey.trim(), - cachePath: cachePath.trim(), - imageXorKey: imageXorKey.trim(), - imageAesKey: imageAesKey.trim(), - displayName: wxid.trim() || '未命名账号' - }) + const getAccountDisplayName = (account?: AccountProfile | null) => { + if (!account) return '未命名账号' + + const activeNickname = account.id === activeAccountId ? userInfo?.nickName?.trim() : '' + if (activeNickname) return activeNickname + + const savedName = account.displayName?.trim() + if (savedName && savedName !== '未命名账号') return savedName + + return account.wxid?.trim() || '未命名账号' + } + + const buildAccountPayload = () => { + const currentAccount = accountsList.find(item => item.id === editingAccountId) + const currentDisplayName = currentAccount?.displayName?.trim() + const preferredDisplayName = userInfo?.nickName?.trim() + || (currentDisplayName && currentDisplayName !== '未命名账号' ? currentDisplayName : '') + || wxid.trim() + || '未命名账号' + + return { + wxid: wxid.trim(), + dbPath: dbPath.trim(), + decryptKey: decryptKey.trim(), + cachePath: cachePath.trim(), + imageXorKey: imageXorKey.trim(), + imageAesKey: imageAesKey.trim(), + displayName: preferredDisplayName + } + } const applyAccountToForm = (account: AccountProfile | null) => { setEditingAccountId(account?.id || '') @@ -236,6 +257,26 @@ function SettingsPage() { return { accounts, activeAccount, editingAccount } } + useEffect(() => { + const syncActiveAccountDisplayName = async () => { + const activeNickname = userInfo?.nickName?.trim() + if (!activeNickname || !activeAccountId) return + + const activeAccount = accountsList.find(item => item.id === activeAccountId) + if (!activeAccount) return + + const savedName = activeAccount.displayName?.trim() + if (savedName && savedName !== activeAccount.wxid && savedName !== '未命名账号') return + + const updated = await configService.updateAccount(activeAccount.id, { displayName: activeNickname }) + if (!updated) return + + await refreshAccountsState(editingAccountId || activeAccount.id) + } + + void syncActiveAccountDisplayName() + }, [userInfo?.nickName, activeAccountId, accountsList]) + useEffect(() => { loadConfig() loadDefaultExportPath() @@ -949,7 +990,7 @@ function SettingsPage() { setDbConnected(true, target.dbPath) setCurrentWxid(target.wxid) await refreshAccountsState(target.id) - showMessage(`已切换到账号:${target.displayName}`, true) + showMessage(`已切换到账号:${getAccountDisplayName(target)}`, true) } catch (e) { showMessage(`切换账号失败: ${e}`, false) } finally { @@ -962,7 +1003,7 @@ function SettingsPage() { setSecurityConfirm({ show: true, title: '删除账号', - message: `删除账号 ${account.displayName || account.wxid}?此操作仅删除配置,不删除本地解密数据。`, + message: `删除账号 ${getAccountDisplayName(account)}?此操作仅删除配置,不删除本地解密数据。`, onConfirm: async () => { const result = await configService.deleteAccount(account.id, false) if (result.success) { @@ -980,7 +1021,7 @@ function SettingsPage() { setSecurityConfirm({ show: true, title: '删除账号并清理本地数据', - message: `将删除账号 ${account.displayName || account.wxid} 的配置,并尝试删除该账号对应的本地解密数据库缓存。`, + message: `将删除账号 ${getAccountDisplayName(account)} 的配置,并尝试删除该账号对应的本地解密数据库缓存。`, onConfirm: async () => { const result = await configService.deleteAccount(account.id, true) if (result.success) { @@ -1452,7 +1493,7 @@ function SettingsPage() {

账号管理

- 当前激活账号:{accountsList.find(item => item.id === activeAccountId)?.displayName || '未设置'} + 当前激活账号:{getAccountDisplayName(accountsList.find(item => item.id === activeAccountId) || null) || '未设置'}
{accountsList.length > 0 ? (
@@ -1463,10 +1504,10 @@ function SettingsPage() { onClick={() => handleSelectAccountForEdit(account)} >
- {account.displayName} + {getAccountDisplayName(account)} {account.id === activeAccountId ? '(当前激活)' : ''}
-
{account.wxid || '未设置 wxid'}
+
微信 ID:{account.wxid || '未设置'}
{account.dbPath || '未设置数据库路径'}
))} diff --git a/src/pages/WelcomePage.scss b/src/pages/WelcomePage.scss index 25e3423..ce55b62 100644 --- a/src/pages/WelcomePage.scss +++ b/src/pages/WelcomePage.scss @@ -25,7 +25,6 @@ .welcome-page.is-standalone .window-controls { position: absolute; top: 12px; - left: 18px; display: inline-flex; gap: 8px; padding: 6px; @@ -37,6 +36,14 @@ -webkit-app-region: no-drag; } +.welcome-page.is-standalone.is-mac .window-controls { + left: 18px; +} + +.welcome-page.is-standalone.is-windows .window-controls { + right: 18px; +} + .welcome-page.is-standalone .window-btn { width: 28px; height: 28px; diff --git a/src/pages/WelcomePage.tsx b/src/pages/WelcomePage.tsx index 39b4f97..cbd879b 100644 --- a/src/pages/WelcomePage.tsx +++ b/src/pages/WelcomePage.tsx @@ -202,8 +202,8 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { }, [dbPath, cachePath, wxid, decryptKey, imageXorKey, imageAesKey, isAddAccountMode]) const currentStep = steps[stepIndex] - const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}` const showWindowControls = standalone + const rootClassName = `welcome-page${isClosing ? ' is-closing' : ''}${standalone ? ' is-standalone' : ''}${showWindowControls ? (isMac ? ' is-mac' : ' is-windows') : ''}` useEffect(() => { if (currentStep.id !== 'db') return @@ -221,6 +221,34 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { window.electronAPI.window.close() } + const renderWindowControls = () => { + if (!showWindowControls) return null + + return ( +
+ {isMac ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ) + } + const handleResetCachePath = async () => { try { const result = await window.electronAPI.dbPath.getBestCachePath() @@ -654,19 +682,10 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { } } - if (isDbConnected) { + if (isDbConnected && !isAddAccountMode) { return (
- {showWindowControls && ( -
- - -
- )} + {renderWindowControls()}
@@ -697,16 +716,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) { return (
- {showWindowControls && ( -
- - -
- )} + {renderWindowControls()} {/* Hook 安装成功气泡提示 */} {showHookSuccessToast && ( diff --git a/src/types/electron.d.ts b/src/types/electron.d.ts index 97c812d..5d72c20 100644 --- a/src/types/electron.d.ts +++ b/src/types/electron.d.ts @@ -101,6 +101,7 @@ export interface ElectronAPI { file: { delete: (filePath: string) => Promise<{ success: boolean; error?: string }> copy: (sourcePath: string, destPath: string) => Promise<{ success: boolean; error?: string }> + writeBase64: (filePath: string, base64Data: string) => Promise<{ success: boolean; error?: string }> } shell: { openPath: (path: string) => Promise