From 7a7dfe79f14e527bd43ea7b5b1972c8162314145 Mon Sep 17 00:00:00 2001 From: ILoveBingLu Date: Tue, 7 Apr 2026 21:42:27 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=96=B0=E5=A2=9E=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E5=BC=95=E5=AF=BC=E3=80=81=E8=B4=A6=E5=8F=B7=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E5=90=8D=E4=B8=8E=E8=AF=AD=E9=9F=B3=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E4=BD=93=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交主要优化了账号引导、设置页账号展示以及聊天页语音消息导出相关体验。 1. 修复“设置 - 数据解密 - 新增账号引导”在已有账号场景下误继承主窗口数据库已连接状态的问题,避免引导窗口一打开就直接显示“已连接数据库”,恢复新增账号流程的正常进入路径。 2. 调整独立引导窗口的系统控件布局,使其符合平台习惯:macOS 采用左侧“关闭 | 最小化”,Windows 采用右侧“最小化 | 关闭”。 3. 优化设置页“账号管理”的展示逻辑,优先显示用户昵称等更易理解的名称,而不是直接显示 wxid;同时补齐 displayName 的保留与同步逻辑,避免保存或切换账号后名称又退回 wxid。 4. 为聊天页语音消息增加右键“导出语音文件”能力,导出格式为 wav,并补充最小必要的文件写入 IPC 通道,打通从渲染进程到主进程的保存链路。 5. 将语音导出成功提示由系统 alert 调整为页面内自定义顶部气泡提示,并统一复用到复制成功/失败等反馈场景,减少打断感,提升交互一致性。 整体上,这次修改主要是修复几个明显影响理解和操作流畅度的前端交互问题,让新增账号、账号识别和语音导出这几个高频路径更符合用户预期。 --- electron/main.ts | 10 ++++ electron/preload.ts | 3 +- src/App.tsx | 4 +- src/pages/ChatPage.scss | 18 ++++++- src/pages/ChatPage.tsx | 100 ++++++++++++++++++++++++++++++++----- src/pages/SettingsPage.tsx | 73 +++++++++++++++++++++------ src/pages/WelcomePage.scss | 9 +++- src/pages/WelcomePage.tsx | 54 ++++++++++++-------- src/types/electron.d.ts | 1 + 9 files changed, 216 insertions(+), 56 deletions(-) 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