修复新增账号引导、账号显示名与语音导出体验

本次提交主要优化了账号引导、设置页账号展示以及聊天页语音消息导出相关体验。

1. 修复“设置 - 数据解密 - 新增账号引导”在已有账号场景下误继承主窗口数据库已连接状态的问题,避免引导窗口一打开就直接显示“已连接数据库”,恢复新增账号流程的正常进入路径。
2. 调整独立引导窗口的系统控件布局,使其符合平台习惯:macOS 采用左侧“关闭 | 最小化”,Windows 采用右侧“最小化 | 关闭”。
3. 优化设置页“账号管理”的展示逻辑,优先显示用户昵称等更易理解的名称,而不是直接显示 wxid;同时补齐 displayName 的保留与同步逻辑,避免保存或切换账号后名称又退回 wxid。
4. 为聊天页语音消息增加右键“导出语音文件”能力,导出格式为 wav,并补充最小必要的文件写入 IPC 通道,打通从渲染进程到主进程的保存链路。
5. 将语音导出成功提示由系统 alert 调整为页面内自定义顶部气泡提示,并统一复用到复制成功/失败等反馈场景,减少打断感,提升交互一致性。

整体上,这次修改主要是修复几个明显影响理解和操作流畅度的前端交互问题,让新增账号、账号识别和语音导出这几个高频路径更符合用户预期。
This commit is contained in:
ILoveBingLu
2026-04-07 21:42:27 +08:00
parent 4228379aa7
commit 7a7dfe79f1
9 changed files with 216 additions and 56 deletions
+2 -2
View File
@@ -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) {
+16 -2
View File
@@ -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;
}
+88 -12
View File
@@ -293,7 +293,7 @@ function ChatPage(_props: ChatPageProps) {
}, [])
const [selectedMessages, setSelectedMessages] = useState<Set<number>>(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<Message | null>(null) // 消息信息弹窗
const [showDatePicker, setShowDatePicker] = useState(false) // 日期选择器弹窗
const [selectedDate, setSelectedDate] = useState<string>('') // 选中的日期 (YYYY-MM-DD)
@@ -326,15 +326,63 @@ function ChatPage(_props: ChatPageProps) {
const [batchImageDates, setBatchImageDates] = useState<string[]>([])
const [batchImageSelectedDates, setBatchImageSelectedDates] = useState<Set<string>>(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) {
</div>
)}
{contextMenu.message.localType === 34 && (
<div
className="context-menu-item"
onClick={() => {
closeContextMenu()
void exportVoiceMessage(contextMenu.message, contextMenu.session)
}}
>
<Download size={16} />
<span></span>
</div>
)}
{/* 语音消息:重新转文字 */}
{contextMenu.handlers?.reTranscribe && (
<div
@@ -2284,11 +2360,11 @@ function ChatPage(_props: ChatPageProps) {
document.body
)}
{/* 复制成功提示 */}
{copyToast && createPortal(
<div className="copy-toast">
<Check size={16} />
<span></span>
{/* 顶部气泡提示 */}
{topToast && createPortal(
<div className={`copy-toast top-toast ${topToast.success ? 'success' : 'error'}`}>
{topToast.success ? <Check size={16} /> : <AlertCircle size={16} />}
<span>{topToast.text}</span>
</div>,
document.body
)}
+57 -16
View File
@@ -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() {
<h3 className="section-title"></h3>
<div className="form-group">
<div className="form-hint" style={{ marginBottom: '10px' }}>
{accountsList.find(item => item.id === activeAccountId)?.displayName || '未设置'}
{getAccountDisplayName(accountsList.find(item => item.id === activeAccountId) || null) || '未设置'}
</div>
{accountsList.length > 0 ? (
<div className="wxid-options">
@@ -1463,10 +1504,10 @@ function SettingsPage() {
onClick={() => handleSelectAccountForEdit(account)}
>
<div className="wxid-option-name">
{account.displayName}
{getAccountDisplayName(account)}
{account.id === activeAccountId ? '(当前激活)' : ''}
</div>
<div className="field-hint">{account.wxid || '未设置 wxid'}</div>
<div className="field-hint"> ID{account.wxid || '未设置'}</div>
<div className="field-hint">{account.dbPath || '未设置数据库路径'}</div>
</button>
))}
+8 -1
View File
@@ -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;
+32 -22
View File
@@ -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 (
<div className="window-controls">
{isMac ? (
<>
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
</>
) : (
<>
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
</>
)}
</div>
)
}
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 (
<div className={rootClassName}>
{showWindowControls && (
<div className="window-controls">
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
</div>
)}
{renderWindowControls()}
<div className="welcome-shell">
<div className="connected-panel">
<div className="connected-icon">
@@ -697,16 +716,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
return (
<div className={rootClassName}>
{showWindowControls && (
<div className="window-controls">
<button type="button" className="window-btn is-close" onClick={handleCloseWindow} aria-label="关闭">
<X size={14} />
</button>
<button type="button" className="window-btn" onClick={handleMinimize} aria-label="最小化">
<Minus size={14} />
</button>
</div>
)}
{renderWindowControls()}
{/* Hook 安装成功气泡提示 */}
{showHookSuccessToast && (
+1
View File
@@ -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<string>