diff --git a/README.md b/README.md index f24d402..b96c5a0 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,9 @@ # 密语 CipherTalk [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Version](https://img.shields.io/badge/version-1.0.7-green.svg)](package.json) +[![Version](https://img.shields.io/badge/version-2.0.0-green.svg)](package.json) [![Platform](https://img.shields.io/badge/platform-Windows-lightgrey.svg)]() -[![Telegram](https://img.shields.io/badge/Telegram-Join%20Group%20Chat-blue.svg?logo=telegram)](https://t.me/+hn3QzNc4DbA0MzNl) +[![Telegram](https://img.shields.io/badge/Telegram-Join%20Group%20Chat-blue.svg?logo=telegram)](https://t.me/your_group_link) 基于 Electron + React + TypeScript 构建的聊天记录查看工具界面,基于原项目 [EchoTrace](https://github.com/ycccccccy/echotrace) 重构。 diff --git a/contexts/context.md b/contexts/context.md new file mode 100644 index 0000000..c3d7ceb --- /dev/null +++ b/contexts/context.md @@ -0,0 +1,52 @@ +# 项目核心上下文 (Context) + +## 项目概述 +**项目名称**: 密语 (CipherTalk) +**核心功能**: 微信聊天记录查看、分析与导出工具。支持从 Android 设备(模拟器)中提取并解密微信数据库,提供现代化的查看界面。 +**技术栈**: +- **前端**: React 19, TypeScript, Zustand, SCSS +- **后端/桌面**: Electron 39, NodeJS +- **打包**: Vite, electron-builder +- **关键依赖**: + - `better-sqlite3`: 数据库操作 + - `sherpa-onnx-node`, `sense-voice`: 离线语音转文字 + - `silk-wasm`: 语音解码 + - `dom-to-image-more`, `html2canvas`: 图片生成 + - `echarts`: 数据可视化 + - `jieba-wasm`: 中文分词 + +## 目录结构 +- `src/`: React 前端源码 + - `pages/`: 页面 (Welcome, DataManagement, Chat, etc.) + - `stores/`: 状态管理 (useStore) + - `components/`: UI 组件 + - `services/`: 前端服务逻辑 +- `electron/`: Electron 主进程与 Worker + - `main.ts`: 主入口 + - `transcribeWorker.ts`: 语音转写子线程 +- `scripts/`: 构建与辅助脚本 + +## 核心业务流程 +1. **启动与解密**: + - 用户提供数据库路径与密钥 (或自动扫描)。 + - 使用 SQLCipher (通过 `better-sqlite3` 或 DLL) 解密 `EnMicroMsg.db`。 + - 解密图片/语音等多媒体文件。 +2. **聊天记录查看**: + - 加载会话列表。 + - 渲染消息气泡 (文本、图片、语音、表情)。 + - 虚拟滚动优化长列表。 +3. **高级功能**: + - 语音转文字 (本地 SenseVoice 模型)。 + - 聊天记录导出。 + - 数据统计与可视化。 + +## 关键配置与规范 +- **语言**: 中文优先 (代码注释、Commit 信息)。 +- **样式**: SCSS 模块化,使用 CSS 变量管理主题。 +- **状态管理**: Zustand store 存放全局状态(当前会话、数据库连接状态等)。 +- **IPC 通信**: Electron 主进程与渲染进程通过 IPC 交换数据(如数据库查询结果、文件操作)。 + +## 当前开发重点 +- 提升应用的健壮性与错误处理。 +- 优化长列表和大量数据下的性能。 +- 完善 CI/CD 和自动化工作流。 diff --git a/electron/main.ts b/electron/main.ts index 9e65bdd..a7027a3 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -19,6 +19,7 @@ import { exportService, ExportOptions } from './services/exportService' import { activationService } from './services/activationService' import { LogService } from './services/logService' import { videoService } from './services/videoService' +import { voiceTranscribeService } from './services/voiceTranscribeService' // 注册自定义协议为特权协议(必须在 app ready 之前) protocol.registerSchemesAsPrivileged([ @@ -47,17 +48,17 @@ autoUpdater.disableDifferentialDownload = true // 禁用差分更新,强制 function isNewerVersion(version1: string, version2: string): boolean { const v1Parts = version1.split('.').map(Number) const v2Parts = version2.split('.').map(Number) - + // 补齐版本号位数 const maxLength = Math.max(v1Parts.length, v2Parts.length) while (v1Parts.length < maxLength) v1Parts.push(0) while (v2Parts.length < maxLength) v2Parts.push(0) - + for (let i = 0; i < maxLength; i++) { if (v1Parts[i] > v2Parts[i]) return true if (v1Parts[i] < v2Parts[i]) return false } - + return false // 版本相同 } @@ -133,7 +134,7 @@ function createWindow() { // 开发环境加载 vite 服务器 if (process.env.VITE_DEV_SERVER_URL) { win.loadURL(process.env.VITE_DEV_SERVER_URL) - + // 开发环境下按 F12 或 Ctrl+Shift+I 打开开发者工具 win.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { @@ -203,7 +204,7 @@ function createChatWindow() { // 加载聊天页面 if (process.env.VITE_DEV_SERVER_URL) { chatWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/chat-window`) - + chatWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (chatWindow?.webContents.isDevToolsOpened()) { @@ -215,7 +216,7 @@ function createChatWindow() { } }) } else { - chatWindow.loadFile(join(__dirname, '../dist/index.html'), { + chatWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/chat-window', query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' } }) @@ -279,7 +280,7 @@ function createGroupAnalyticsWindow() { // 加载群聊分析页面 if (process.env.VITE_DEV_SERVER_URL) { groupAnalyticsWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/group-analytics-window`) - + groupAnalyticsWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (groupAnalyticsWindow?.webContents.isDevToolsOpened()) { @@ -291,7 +292,7 @@ function createGroupAnalyticsWindow() { } }) } else { - groupAnalyticsWindow.loadFile(join(__dirname, '../dist/index.html'), { + groupAnalyticsWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/group-analytics-window', query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' } }) @@ -352,7 +353,7 @@ function createAnnualReportWindow(year: number) { // 加载年度报告页面,带年份参数 if (process.env.VITE_DEV_SERVER_URL) { annualReportWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/annual-report-window?year=${year}`) - + annualReportWindow.webContents.on('before-input-event', (event, input) => { if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { if (annualReportWindow?.webContents.isDevToolsOpened()) { @@ -364,7 +365,7 @@ function createAnnualReportWindow(year: number) { } }) } else { - annualReportWindow.loadFile(join(__dirname, '../dist/index.html'), { + annualReportWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: `/annual-report-window?year=${year}`, query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' } }) @@ -425,7 +426,7 @@ function createAgreementWindow() { if (process.env.VITE_DEV_SERVER_URL) { agreementWindow.loadURL(`${process.env.VITE_DEV_SERVER_URL}?${themeParams}#/agreement-window`) } else { - agreementWindow.loadFile(join(__dirname, '../dist/index.html'), { + agreementWindow.loadFile(join(__dirname, '../dist/index.html'), { hash: '/agreement-window', query: { theme: configService?.get('theme') || 'cloud-dancer', mode: configService?.get('themeMode') || 'light' } }) @@ -483,6 +484,242 @@ function createPurchaseWindow() { return purchaseWindow } +/** + * 创建独立的图片查看窗口 + */ +function createImageViewerWindow(imagePath: string) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const win = new BrowserWindow({ + width: 800, + height: 600, + minWidth: 400, + minHeight: 300, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false // 允许加载本地文件 + }, + titleBarStyle: 'hidden', // 无边框 + titleBarOverlay: { + color: '#00000000', + symbolColor: '#ffffff', + height: 32 + }, + show: false, + backgroundColor: '#000000', // 黑色背景 + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + // 获取主题参数 + const themeParams = getThemeQueryParams() + + // 加载图片查看页面 + // 加载图片查看页面 + const imageParam = `imagePath=${encodeURIComponent(imagePath)}` + const queryParams = `${themeParams}&${imageParam}` + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/image-viewer-window?${queryParams}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/image-viewer-window?${queryParams}` + }) + } + + return win +} + +/** + * 创建独立的视频播放窗口 + * 窗口大小会根据视频比例自动调整 + */ +function createVideoPlayerWindow(videoPath: string, videoWidth?: number, videoHeight?: number) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + // 获取屏幕尺寸 + const { screen } = require('electron') + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + + // 计算窗口尺寸,只有标题栏 40px,控制栏悬浮 + let winWidth = 854 + let winHeight = 520 + const titleBarHeight = 40 + + if (videoWidth && videoHeight && videoWidth > 0 && videoHeight > 0) { + const aspectRatio = videoWidth / videoHeight + + const maxWidth = Math.floor(screenWidth * 0.85) + const maxHeight = Math.floor(screenHeight * 0.85) + + if (aspectRatio >= 1) { + // 横向视频 + winWidth = Math.min(videoWidth, maxWidth) + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + + if (winHeight > maxHeight) { + winHeight = maxHeight + winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) + } + } else { + // 竖向视频 + const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) + winHeight = videoDisplayHeight + titleBarHeight + winWidth = Math.floor(videoDisplayHeight * aspectRatio) + + if (winWidth < 300) { + winWidth = 300 + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + } + } + + winWidth = Math.max(winWidth, 360) + winHeight = Math.max(winHeight, 280) + } + + const win = new BrowserWindow({ + width: winWidth, + height: winHeight, + minWidth: 360, + minHeight: 280, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#1a1a1a', + symbolColor: '#ffffff', + height: 40 + }, + show: false, + backgroundColor: '#000000', + autoHideMenuBar: true + }) + + win.once('ready-to-show', () => { + win.show() + }) + + const themeParams = getThemeQueryParams() + const videoParam = `videoPath=${encodeURIComponent(videoPath)}` + const queryParams = `${themeParams}&${videoParam}` + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/video-player-window?${queryParams}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/video-player-window?${queryParams}` + }) + } + + return win +} + +/** + * 创建内置浏览器窗口 + */ +function createBrowserWindow(url: string, title?: string) { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + const win = new BrowserWindow({ + width: 1200, + height: 800, + minWidth: 800, + minHeight: 600, + icon: iconPath, + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + webSecurity: false, + webviewTag: true // 允许使用 标签 + }, + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#1a1a1a', + symbolColor: '#ffffff', + height: 40 + }, + show: false, + backgroundColor: '#ffffff', + title: title || '浏览器' + }) + + win.once('ready-to-show', () => { + win.show() + }) + + // 获取主题参数 + const themeParams = getThemeQueryParams() + const urlParam = `url=${encodeURIComponent(url)}` + const titleParam = title ? `&title=${encodeURIComponent(title)}` : '' + const queryParams = `${themeParams}&${urlParam}${titleParam}` + + if (process.env.VITE_DEV_SERVER_URL) { + win.loadURL(`${process.env.VITE_DEV_SERVER_URL}#/browser-window?${queryParams}`) + + win.webContents.on('before-input-event', (event, input) => { + if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) { + if (win.webContents.isDevToolsOpened()) { + win.webContents.closeDevTools() + } else { + win.webContents.openDevTools() + } + event.preventDefault() + } + }) + } else { + // 生产环境,加载 browser-window 路由 + win.loadFile(join(__dirname, '../dist/index.html'), { + hash: `/browser-window?${queryParams}` + }) + } + + return win +} + // 注册 IPC 处理器 function registerIpcHandlers() { // 配置相关 @@ -560,7 +797,7 @@ function registerIpcHandlers() { if (result && result.updateInfo) { const currentVersion = app.getVersion() const latestVersion = result.updateInfo.version - + // 使用语义化版本比较 if (isNewerVersion(latestVersion, currentVersion)) { return { @@ -579,7 +816,7 @@ function registerIpcHandlers() { ipcMain.handle('app:downloadAndInstall', async (event) => { const win = BrowserWindow.fromWebContents(event.sender) - + // 监听下载进度 autoUpdater.on('download-progress', (progress) => { win?.webContents.send('app:downloadProgress', progress.percent) @@ -599,6 +836,19 @@ function registerIpcHandlers() { }) // 窗口控制 + ipcMain.on('window:splashReady', () => { + console.log('[Startup] 收到 splashReady 信号') + splashReady = true + }) + + // 查询启动时是否已经成功连接数据库(一次性查询,查询后重置) + ipcMain.handle('app:getStartupDbConnected', async () => { + const connected = startupDbConnected + // 重置标志,防止后续重复查询 + startupDbConnected = false + return connected + }) + ipcMain.on('window:minimize', (event) => { BrowserWindow.fromWebContents(event.sender)?.minimize() }) @@ -616,15 +866,83 @@ function registerIpcHandlers() { BrowserWindow.fromWebContents(event.sender)?.close() }) + // 打开图片查看窗口 + ipcMain.handle('window:openImageViewerWindow', (_, imagePath: string) => { + createImageViewerWindow(imagePath) + }) + + // 打开视频播放窗口 + ipcMain.handle('window:openVideoPlayerWindow', (_, videoPath: string, videoWidth?: number, videoHeight?: number) => { + createVideoPlayerWindow(videoPath, videoWidth, videoHeight) + }) + + // 根据视频尺寸调整窗口大小 + ipcMain.handle('window:resizeToFitVideo', (event, videoWidth: number, videoHeight: number) => { + const win = BrowserWindow.fromWebContents(event.sender) + if (!win || !videoWidth || !videoHeight) return + + const { screen } = require('electron') + const primaryDisplay = screen.getPrimaryDisplay() + const { width: screenWidth, height: screenHeight } = primaryDisplay.workAreaSize + + // 只有标题栏 40px,控制栏悬浮在视频上 + const titleBarHeight = 40 + const aspectRatio = videoWidth / videoHeight + + const maxWidth = Math.floor(screenWidth * 0.85) + const maxHeight = Math.floor(screenHeight * 0.85) + + let winWidth: number + let winHeight: number + + if (aspectRatio >= 1) { + // 横向视频 - 以宽度为基准 + winWidth = Math.min(videoWidth, maxWidth) + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + + if (winHeight > maxHeight) { + winHeight = maxHeight + winWidth = Math.floor((winHeight - titleBarHeight) * aspectRatio) + } + } else { + // 竖向视频 - 以高度为基准 + const videoDisplayHeight = Math.min(videoHeight, maxHeight - titleBarHeight) + winHeight = videoDisplayHeight + titleBarHeight + winWidth = Math.floor(videoDisplayHeight * aspectRatio) + + // 确保宽度不会太窄 + if (winWidth < 300) { + winWidth = 300 + winHeight = Math.floor(winWidth / aspectRatio) + titleBarHeight + } + } + + winWidth = Math.max(winWidth, 360) + winHeight = Math.max(winHeight, 280) + + // 调整窗口大小并居中 + win.setSize(winWidth, winHeight) + win.center() + }) + + // 打开内置浏览器窗口 + ipcMain.handle('window:openBrowserWindow', (_, url: string, title?: string) => { + createBrowserWindow(url, title) + }) + // 更新窗口控件主题色 ipcMain.on('window:setTitleBarOverlay', (event, options: { symbolColor: string }) => { const win = BrowserWindow.fromWebContents(event.sender) if (win) { - win.setTitleBarOverlay({ - color: '#00000000', - symbolColor: options.symbolColor, - height: 40 - }) + try { + win.setTitleBarOverlay({ + color: '#00000000', + symbolColor: options.symbolColor, + height: 40 + }) + } catch (e) { + // 忽略错误 - 某些窗口(如启动屏)没有启用 titleBarOverlay + } } }) @@ -744,7 +1062,7 @@ function registerIpcHandlers() { keyLength: hexKey ? hexKey.length : 0, isAutoConnect } - + if (logLevel === 'warn') { logService?.warn('WCDB', `${logPrefix}数据库连接失败`, errorInfo) } else { @@ -804,6 +1122,31 @@ function registerIpcHandlers() { return dataManagementService.decryptSingleImage(filePath) }) + ipcMain.handle('dataManagement:checkForUpdates', async () => { + return dataManagementService.checkForUpdates() + }) + + ipcMain.handle('dataManagement:enableAutoUpdate', async (_, intervalSeconds?: number) => { + dataManagementService.enableAutoUpdate(intervalSeconds) + return { success: true } + }) + + ipcMain.handle('dataManagement:disableAutoUpdate', async () => { + dataManagementService.disableAutoUpdate() + return { success: true } + }) + + ipcMain.handle('dataManagement:autoIncrementalUpdate', async (_, silent?: boolean) => { + return dataManagementService.autoIncrementalUpdate(silent) + }) + + // 监听更新可用事件 + dataManagementService.onUpdateAvailable((hasUpdate) => { + BrowserWindow.getAllWindows().forEach(win => { + win.webContents.send('dataManagement:updateAvailable', hasUpdate) + }) + }) + // 图片解密相关 ipcMain.handle('imageDecrypt:batchDetectXorKey', async (_, dirPath: string) => { try { @@ -896,9 +1239,9 @@ function registerIpcHandlers() { ) if (result.success) { - logService?.info('ImageKey', '图片密钥获取成功', { + logService?.info('ImageKey', '图片密钥获取成功', { hasXorKey: result.xorKey !== undefined, - hasAesKey: !!result.aesKey + hasAesKey: !!result.aesKey }) } else { logService?.error('ImageKey', '图片密钥获取失败', { error: result.error }) @@ -933,6 +1276,14 @@ function registerIpcHandlers() { return result }) + ipcMain.handle('chat:getContacts', async () => { + const result = await chatService.getContacts() + if (!result.success) { + logService?.warn('Chat', '获取通讯录失败', { error: result.error }) + } + return result + }) + ipcMain.handle('chat:getMessages', async (_, sessionId: string, offset?: number, limit?: number) => { const result = await chatService.getMessages(sessionId, offset, limit) if (!result.success) { @@ -991,6 +1342,14 @@ function registerIpcHandlers() { return result }) + ipcMain.handle('chat:getVoiceData', async (_, sessionId: string, msgId: string, createTime?: number) => { + const result = await chatService.getVoiceData(sessionId, msgId, createTime) + if (!result.success) { + logService?.warn('Chat', '获取语音数据失败', { sessionId, msgId, createTime, error: result.error }) + } + return result + }) + // 导出相关 ipcMain.handle('export:exportSessions', async (_, sessionIds: string[], outputDir: string, options: ExportOptions) => { return exportService.exportSessions(sessionIds, outputDir, options) @@ -1000,6 +1359,10 @@ function registerIpcHandlers() { return exportService.exportSessionToChatLab(sessionId, outputPath, options) }) + ipcMain.handle('export:exportContacts', async (_, outputDir: string, options: any) => { + return exportService.exportContacts(outputDir, options) + }) + // 数据分析相关 ipcMain.handle('analytics:getOverallStatistics', async () => { return analyticsService.getOverallStatistics() @@ -1223,7 +1586,7 @@ function registerIpcHandlers() { if (!logService) { return { success: false, error: '日志服务未初始化' } } - + let logLevel: number switch (level.toUpperCase()) { case 'DEBUG': @@ -1241,7 +1604,7 @@ function registerIpcHandlers() { default: return { success: false, error: '无效的日志级别' } } - + logService.setLogLevel(logLevel) return { success: true } } catch (e) { @@ -1254,7 +1617,7 @@ function registerIpcHandlers() { if (!logService) { return { success: false, error: '日志服务未初始化' } } - + const level = logService.getLogLevel() const levelNames = ['DEBUG', 'INFO', 'WARN', 'ERROR'] return { success: true, level: levelNames[level] } @@ -1262,10 +1625,288 @@ function registerIpcHandlers() { return { success: false, error: String(e) } } }) + + // ========== 语音转文字 (STT) ========== + + // 获取模型状态 + ipcMain.handle('stt:getModelStatus', async () => { + try { + return await voiceTranscribeService.getModelStatus() + } catch (e) { + return { success: false, error: String(e) } + } + }) + + // 下载模型 + ipcMain.handle('stt:downloadModel', async (event) => { + try { + const win = BrowserWindow.fromWebContents(event.sender) + return await voiceTranscribeService.downloadModel((progress) => { + win?.webContents.send('stt:downloadProgress', progress) + }) + } catch (e) { + return { success: false, error: String(e) } + } + }) + + // 转写音频 + ipcMain.handle('stt:transcribe', async (event, wavBase64: string, sessionId: string, createTime: number, force?: boolean) => { + try { + // 先查缓存 + if (!force) { + const cached = voiceTranscribeService.getCachedTranscript(sessionId, createTime) + if (cached) { + return { success: true, transcript: cached, cached: true } + } + } + + const wavData = Buffer.from(wavBase64, 'base64') + + const win = BrowserWindow.fromWebContents(event.sender) + const result = await voiceTranscribeService.transcribeWavBuffer(wavData, (text) => { + win?.webContents.send('stt:partialResult', text) + }) + + // 转写成功,保存缓存 + if (result.success && result.transcript) { + voiceTranscribeService.saveTranscriptCache(sessionId, createTime, result.transcript) + } + + return result + } catch (e) { + console.error('[Main] stt:transcribe 异常:', e) + return { success: false, error: String(e) } + } + }) + + // 获取缓存的转写结果 + ipcMain.handle('stt:getCachedTranscript', async (_, sessionId: string, createTime: number) => { + try { + const transcript = voiceTranscribeService.getCachedTranscript(sessionId, createTime) + return { success: true, transcript } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + // 更新转写缓存 + ipcMain.handle('stt:updateTranscript', async (_, sessionId: string, createTime: number, transcript: string) => { + try { + voiceTranscribeService.saveTranscriptCache(sessionId, createTime, transcript) + return { success: true } + } catch (e) { + return { success: false, error: String(e) } + } + }) + + // 清除模型 + ipcMain.handle('stt:clearModel', async () => { + return await voiceTranscribeService.clearModel() + }) } // 主窗口引用 let mainWindow: BrowserWindow | null = null +// 启动屏窗口引用 +let splashWindow: BrowserWindow | null = null +// 启动屏就绪状态 +let splashReady = false +// 启动时是否已成功连接数据库(用于通知主窗口跳过重复连接) +let startupDbConnected = false + +/** + * 创建启动屏窗口 + */ +function createSplashWindow(): BrowserWindow { + const isDev = !!process.env.VITE_DEV_SERVER_URL + const iconPath = isDev + ? join(__dirname, '../public/icon.ico') + : join(process.resourcesPath, 'icon.ico') + + console.log('[Startup] 创建启动屏窗口...') + + const splash = new BrowserWindow({ + width: 420, + height: 320, + icon: iconPath, + frame: false, + transparent: true, // 启用透明,让 CSS 圆角生效 + alwaysOnTop: true, + resizable: false, + skipTaskbar: true, // 不显示在任务栏 + hasShadow: false, // Windows 上透明窗口需要禁用阴影 + show: true, // 直接显示窗口 + webPreferences: { + preload: join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false + }, + backgroundColor: '#00000000' // 完全透明的背景色 + }) + + splash.center() + + // 加载启动屏页面 + const splashUrl = process.env.VITE_DEV_SERVER_URL + ? `${process.env.VITE_DEV_SERVER_URL}#/splash` + : null + + console.log('[Startup] 启动屏 URL:', splashUrl || 'file://...#/splash') + + // 监听页面加载完成 + splash.webContents.on('did-finish-load', () => { + console.log('[Startup] 启动屏页面加载完成 (did-finish-load)') + }) + + splash.webContents.on('did-fail-load', (event, errorCode, errorDescription) => { + console.error('[Startup] 启动屏页面加载失败:', errorCode, errorDescription) + }) + + // 加载页面(服务器已在 checkAndConnectOnStartup 中确保就绪) + if (process.env.VITE_DEV_SERVER_URL) { + splash.loadURL(splashUrl!).then(() => { + console.log('[Startup] 启动屏页面加载成功') + }).catch(err => { + console.error('[Startup] loadURL 错误:', err) + }) + } else { + splash.loadFile(join(__dirname, '../dist/index.html'), { + hash: '/splash' + }).catch(err => { + console.error('[Startup] loadFile 错误:', err) + }) + } + + return splash +} + +/** + * 优雅地关闭启动屏(带动画效果) + */ +async function closeSplashWindow(): Promise { + if (!splashWindow || splashWindow.isDestroyed()) { + splashWindow = null + return + } + + // 通知渲染进程播放淡出动画 + splashWindow.webContents.send('splash:fadeOut') + + // 等待动画完成(300ms) + await new Promise(resolve => setTimeout(resolve, 350)) + + // 关闭窗口 + if (splashWindow && !splashWindow.isDestroyed()) { + splashWindow.close() + splashWindow = null + } +} + +/** + * 检查是否需要显示启动屏并连接数据库 + */ +async function checkAndConnectOnStartup(): Promise { + // 初始化配置服务(如果还没初始化) + if (!configService) { + configService = new ConfigService() + } + + // 检查配置是否完整 + const wxid = configService.get('myWxid') + const dbPath = configService.get('dbPath') + const decryptKey = configService.get('decryptKey') + + console.log('[Startup] 检查配置:', { wxid: !!wxid, dbPath: !!dbPath, decryptKey: !!decryptKey }) + + // 如果配置不完整,直接返回,不显示启动屏 + if (!wxid || !dbPath || !decryptKey) { + console.log('[Startup] 配置不完整,跳过启动屏') + return false + } + + // 开发环境下:等待 Vite 服务器就绪后再显示启动屏 + if (process.env.VITE_DEV_SERVER_URL) { + console.log('[Startup] 开发环境,等待 Vite 服务器就绪...') + const serverUrl = process.env.VITE_DEV_SERVER_URL + + // 等待服务器就绪(最多等待 15 秒) + const waitForServer = async (url: string, maxWait = 15000, interval = 300): Promise => { + const start = Date.now() + while (Date.now() - start < maxWait) { + try { + const response = await net.fetch(url) + if (response.ok) { + console.log('[Startup] Vite 服务器已就绪') + return true + } + } catch (e) { + // 服务器还没就绪,继续等待 + } + await new Promise(resolve => setTimeout(resolve, interval)) + } + console.log('[Startup] 等待 Vite 服务器超时') + return false + } + + const serverReady = await waitForServer(serverUrl) + if (!serverReady) { + // 服务器未就绪,跳过启动屏,直接连接数据库 + console.log('[Startup] Vite 服务器未就绪,跳过启动屏') + try { + const result = await chatService.connect() + startupDbConnected = result.success + return result.success + } catch (e) { + return false + } + } + // 服务器已就绪,继续显示启动屏(走下面的通用逻辑) + } + + console.log('[Startup] 配置完整,准备显示启动屏') + + // 生产环境:配置完整,显示启动屏 + splashWindow = createSplashWindow() + splashReady = false + + // 创建连接 Promise,等待启动屏加载完成后再执行 + return new Promise(async (resolve) => { + // 等待启动屏加载完成(通过 IPC 通知) + const checkReady = setInterval(() => { + if (splashReady) { + clearInterval(checkReady) + console.log('[Startup] 启动屏已加载完成,开始连接数据库') + // 启动屏已加载完成,开始连接数据库 + chatService.connect().then(async (result) => { + console.log('[Startup] 数据库连接结果:', result.success) + // 优雅地关闭启动屏(带动画) + await closeSplashWindow() + // 记录启动时连接状态 + startupDbConnected = result.success + resolve(result.success) + }).catch(async (e) => { + console.error('启动时连接数据库失败:', e) + // 优雅地关闭启动屏 + await closeSplashWindow() + resolve(false) + }) + } + }, 100) + + // 超时保护:30秒后强制关闭启动屏(开发环境可能需要更长时间) + setTimeout(async () => { + clearInterval(checkReady) + if (splashWindow && !splashWindow.isDestroyed()) { + console.log('[Startup] 启动屏超时,强制关闭') + await closeSplashWindow() + } + if (!splashReady) { + console.log('[Startup] 启动屏未就绪,超时返回') + resolve(false) + } + }, 30000) + }) +} // 启动时自动检测更新 function checkForUpdatesOnStartup() { @@ -1279,7 +1920,7 @@ function checkForUpdatesOnStartup() { if (result && result.updateInfo) { const currentVersion = app.getVersion() const latestVersion = result.updateInfo.version - + // 使用语义化版本比较 if (isNewerVersion(latestVersion, currentVersion) && mainWindow) { // 通知渲染进程有新版本 @@ -1295,7 +1936,7 @@ function checkForUpdatesOnStartup() { }, 3000) } -app.whenReady().then(() => { +app.whenReady().then(async () => { // 注册自定义协议用于加载本地视频 protocol.handle('local-video', (request) => { // 移除协议前缀并解码 @@ -1305,10 +1946,18 @@ app.whenReady().then(() => { console.log('[Protocol] 加载视频:', filePath) return net.fetch(`file:///${filePath}`) }) - + registerIpcHandlers() + + // 检查是否需要显示启动屏并连接数据库 + const shouldShowSplash = await checkAndConnectOnStartup() + + // 创建主窗口(但不立即显示) mainWindow = createWindow() - + + // 如果显示了启动屏,主窗口会在启动屏关闭后自动显示(通过 ready-to-show 事件) + // 如果没有显示启动屏,主窗口会正常显示(通过 ready-to-show 事件) + // 启动时检测更新 checkForUpdatesOnStartup() diff --git a/electron/preload.ts b/electron/preload.ts index 64b6245..5a13f8e 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -42,6 +42,7 @@ contextBridge.exposeInMainWorld('electronAPI', { getVersion: () => ipcRenderer.invoke('app:getVersion'), checkForUpdates: () => ipcRenderer.invoke('app:checkForUpdates'), downloadAndInstall: () => ipcRenderer.invoke('app:downloadAndInstall'), + getStartupDbConnected: () => ipcRenderer.invoke('app:getStartupDbConnected'), onDownloadProgress: (callback: (progress: number) => void) => { ipcRenderer.on('app:downloadProgress', (_, progress) => callback(progress)) return () => ipcRenderer.removeAllListeners('app:downloadProgress') @@ -64,7 +65,16 @@ contextBridge.exposeInMainWorld('electronAPI', { openPurchaseWindow: () => ipcRenderer.invoke('window:openPurchaseWindow'), isChatWindowOpen: () => ipcRenderer.invoke('window:isChatWindowOpen'), closeChatWindow: () => ipcRenderer.invoke('window:closeChatWindow'), - setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options) + setTitleBarOverlay: (options: { symbolColor: string }) => ipcRenderer.send('window:setTitleBarOverlay', options), + openImageViewerWindow: (imagePath: string) => ipcRenderer.invoke('window:openImageViewerWindow', imagePath), + openVideoPlayerWindow: (videoPath: string, videoWidth?: number, videoHeight?: number) => ipcRenderer.invoke('window:openVideoPlayerWindow', videoPath, videoWidth, videoHeight), + openBrowserWindow: (url: string, title?: string) => ipcRenderer.invoke('window:openBrowserWindow', url, title), + resizeToFitVideo: (videoWidth: number, videoHeight: number) => ipcRenderer.invoke('window:resizeToFitVideo', videoWidth, videoHeight), + splashReady: () => ipcRenderer.send('window:splashReady'), + onSplashFadeOut: (callback: () => void) => { + ipcRenderer.on('splash:fadeOut', () => callback()) + return () => ipcRenderer.removeAllListeners('splash:fadeOut') + } }, // 密钥获取 @@ -92,9 +102,9 @@ contextBridge.exposeInMainWorld('electronAPI', { // WCDB 数据库 wcdb: { - testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) => + testConnection: (dbPath: string, hexKey: string, wxid: string, isAutoConnect?: boolean) => ipcRenderer.invoke('wcdb:testConnection', dbPath, hexKey, wxid, isAutoConnect), - open: (dbPath: string, hexKey: string, wxid: string) => + open: (dbPath: string, hexKey: string, wxid: string) => ipcRenderer.invoke('wcdb:open', dbPath, hexKey, wxid), close: () => ipcRenderer.invoke('wcdb:close') }, @@ -111,24 +121,32 @@ contextBridge.exposeInMainWorld('electronAPI', { decryptImages: (dirPath: string) => ipcRenderer.invoke('dataManagement:decryptImages', dirPath), getImageDirectories: () => ipcRenderer.invoke('dataManagement:getImageDirectories'), decryptSingleImage: (filePath: string) => ipcRenderer.invoke('dataManagement:decryptSingleImage', filePath), + checkForUpdates: () => ipcRenderer.invoke('dataManagement:checkForUpdates'), + enableAutoUpdate: (intervalSeconds?: number) => ipcRenderer.invoke('dataManagement:enableAutoUpdate', intervalSeconds), + disableAutoUpdate: () => ipcRenderer.invoke('dataManagement:disableAutoUpdate'), + autoIncrementalUpdate: (silent?: boolean) => ipcRenderer.invoke('dataManagement:autoIncrementalUpdate', silent), onProgress: (callback: (data: any) => void) => { ipcRenderer.on('dataManagement:progress', (_, data) => callback(data)) return () => ipcRenderer.removeAllListeners('dataManagement:progress') + }, + onUpdateAvailable: (callback: (hasUpdate: boolean) => void) => { + ipcRenderer.on('dataManagement:updateAvailable', (_, hasUpdate) => callback(hasUpdate)) + return () => ipcRenderer.removeAllListeners('dataManagement:updateAvailable') } }, // 图片解密 imageDecrypt: { batchDetectXorKey: (dirPath: string) => ipcRenderer.invoke('imageDecrypt:batchDetectXorKey', dirPath), - decryptImage: (inputPath: string, outputPath: string, xorKey: number, aesKey?: string) => + decryptImage: (inputPath: string, outputPath: string, xorKey: number, aesKey?: string) => ipcRenderer.invoke('imageDecrypt:decryptImage', inputPath, outputPath, xorKey, aesKey) }, // 图片解密(新 API) image: { - decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => + decrypt: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string; force?: boolean }) => ipcRenderer.invoke('image:decrypt', payload), - resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => + resolveCache: (payload: { sessionId?: string; imageMd5?: string; imageDatName?: string }) => ipcRenderer.invoke('image:resolveCache', payload), onUpdateAvailable: (callback: (data: { cacheKey: string; imageMd5?: string; imageDatName?: string }) => void) => { ipcRenderer.on('image:updateAvailable', (_, data) => callback(data)) @@ -160,16 +178,18 @@ contextBridge.exposeInMainWorld('electronAPI', { chat: { connect: () => ipcRenderer.invoke('chat:connect'), getSessions: () => ipcRenderer.invoke('chat:getSessions'), - getMessages: (sessionId: string, offset?: number, limit?: number) => + getContacts: () => ipcRenderer.invoke('chat:getContacts'), + getMessages: (sessionId: string, offset?: number, limit?: number) => ipcRenderer.invoke('chat:getMessages', sessionId, offset, limit), getContact: (username: string) => ipcRenderer.invoke('chat:getContact', username), getContactAvatar: (username: string) => ipcRenderer.invoke('chat:getContactAvatar', username), getMyAvatarUrl: () => ipcRenderer.invoke('chat:getMyAvatarUrl'), getMyUserInfo: () => ipcRenderer.invoke('chat:getMyUserInfo'), - downloadEmoji: (cdnUrl: string, md5?: string) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5), + downloadEmoji: (cdnUrl: string, md5?: string, productId?: string, createTime?: number) => ipcRenderer.invoke('chat:downloadEmoji', cdnUrl, md5, productId, createTime), close: () => ipcRenderer.invoke('chat:close'), refreshCache: () => ipcRenderer.invoke('chat:refreshCache'), - getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId) + getSessionDetail: (sessionId: string) => ipcRenderer.invoke('chat:getSessionDetail', sessionId), + getVoiceData: (sessionId: string, msgId: string, createTime?: number) => ipcRenderer.invoke('chat:getVoiceData', sessionId, msgId, createTime) }, // 数据分析 @@ -196,10 +216,12 @@ contextBridge.exposeInMainWorld('electronAPI', { // 导出 export: { - exportSessions: (sessionIds: string[], outputDir: string, options: any) => + exportSessions: (sessionIds: string[], outputDir: string, options: any) => ipcRenderer.invoke('export:exportSessions', sessionIds, outputDir, options), - exportSession: (sessionId: string, outputPath: string, options: any) => - ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options) + exportSession: (sessionId: string, outputPath: string, options: any) => + ipcRenderer.invoke('export:exportSession', sessionId, outputPath, options), + exportContacts: (outputDir: string, options: any) => + ipcRenderer.invoke('export:exportContacts', outputDir, options) }, // 激活 @@ -225,23 +247,41 @@ contextBridge.exposeInMainWorld('electronAPI', { getLogDirectory: () => ipcRenderer.invoke('log:getLogDirectory'), setLogLevel: (level: string) => ipcRenderer.invoke('log:setLogLevel', level), getLogLevel: () => ipcRenderer.invoke('log:getLogLevel') + }, + + // 语音转文字 (STT) + stt: { + getModelStatus: () => ipcRenderer.invoke('stt:getModelStatus'), + downloadModel: () => ipcRenderer.invoke('stt:downloadModel'), + transcribe: (wavBase64: string, sessionId: string, createTime: number, force?: boolean) => ipcRenderer.invoke('stt:transcribe', wavBase64, sessionId, createTime, force), + onDownloadProgress: (callback: (progress: { modelName: string; downloadedBytes: number; totalBytes?: number; percent?: number }) => void) => { + ipcRenderer.on('stt:downloadProgress', (_, progress) => callback(progress)) + return () => ipcRenderer.removeAllListeners('stt:downloadProgress') + }, + onPartialResult: (callback: (text: string) => void) => { + ipcRenderer.on('stt:partialResult', (_, text) => callback(text)) + return () => ipcRenderer.removeAllListeners('stt:partialResult') + }, + getCachedTranscript: (sessionId: string, createTime: number) => ipcRenderer.invoke('stt:getCachedTranscript', sessionId, createTime), + updateTranscript: (sessionId: string, createTime: number, transcript: string) => ipcRenderer.invoke('stt:updateTranscript', sessionId, createTime, transcript), + clearModel: () => ipcRenderer.invoke('stt:clearModel') } }) -// 主题由 index.html 中的内联脚本处理,这里只负责同步 localStorage -;(async () => { - try { - const theme = await ipcRenderer.invoke('config:get', 'theme') || 'cloud-dancer' - const themeMode = await ipcRenderer.invoke('config:get', 'themeMode') || 'light' - - // 更新 localStorage 以供下次同步使用(主窗口场景) + // 主题由 index.html 中的内联脚本处理,这里只负责同步 localStorage + ; (async () => { try { - localStorage.setItem('theme', theme) - localStorage.setItem('themeMode', themeMode) + const theme = await ipcRenderer.invoke('config:get', 'theme') || 'cloud-dancer' + const themeMode = await ipcRenderer.invoke('config:get', 'themeMode') || 'light' + + // 更新 localStorage 以供下次同步使用(主窗口场景) + try { + localStorage.setItem('theme', theme) + localStorage.setItem('themeMode', themeMode) + } catch (e) { + // localStorage 可能不可用 + } } catch (e) { - // localStorage 可能不可用 + // 忽略错误 } - } catch (e) { - // 忽略错误 - } -})() + })() diff --git a/electron/transcribeWorker.ts b/electron/transcribeWorker.ts new file mode 100644 index 0000000..e8ffdba --- /dev/null +++ b/electron/transcribeWorker.ts @@ -0,0 +1,260 @@ +/** + * 语音转文字工作线程 + * 使用 Sherpa-ONNX 和 SenseVoiceSmall 模型进行本地离线识别 + * 使用完整音频转写以保证最高精度 + */ +import { parentPort, workerData } from 'worker_threads' +import * as fs from 'fs' +import * as os from 'os' + +// 定义 Sherpa-ONNX 类型接口 +interface SherpaRecognizerConfig { + modelConfig: { + senseVoice: { + model: string + language: string + useInverseTextNormalization: number + } + tokens: string + numThreads: number + debug: number + provider: string + } +} + +interface OfflineStream { + acceptWaveform(params: { sampleRate: number; samples: Float32Array }): void + free(): void +} + +interface OfflineRecognizer { + createStream(): OfflineStream + decode(stream: OfflineStream): void + getResult(stream: OfflineStream): { text: string } + free(): void +} + +interface InitParams { + modelPath: string + tokensPath: string + sampleRate: number + language?: string + allowedLanguages?: string[] +} + +// 模块动态加载 +let sherpaDisplay: any = null +let recognizer: OfflineRecognizer | null = null +let initParams: InitParams | null = null +let isInitialized = false + +/** + * 解析 WAV 音频数据,动态查找数据块 + */ +function parseWav(buffer: Buffer): { pcmData: Buffer; error?: string } { + if (buffer.length < 44) { + return { pcmData: Buffer.alloc(0), error: '无效的 WAV 数据:长度不足' } + } + + // 检查 RIFF 头 + if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') { + return { pcmData: Buffer.alloc(0), error: '无效的 WAV 格式:缺少 RIFF/WAVE 头' } + } + + // 查找 data 块 + let offset = 12 + while (offset < buffer.length) { + const chunkId = buffer.toString('ascii', offset, offset + 4) + const chunkSize = buffer.readUInt32LE(offset + 4) + + if (chunkId === 'data') { + const pcmData = buffer.slice(offset + 8, offset + 8 + chunkSize) + return { pcmData } + } + + offset += 8 + chunkSize + } + + return { pcmData: Buffer.alloc(0), error: '无效的 WAV 数据:未找到 data 块' } +} + +async function initRecognizer(params: InitParams): Promise<{ success: boolean; error?: string }> { + if (isInitialized && recognizer) { + return { success: true } + } + + try { + sherpaDisplay = require('sherpa-onnx-node') + + let { modelPath, tokensPath } = params + + // 将 Windows 路径转换为正斜杠格式 + modelPath = modelPath.replace(/\\/g, '/') + tokensPath = tokensPath.replace(/\\/g, '/') + + // 检查文件是否存在 + if (!fs.existsSync(modelPath)) { + return { success: false, error: '模型文件不存在: ' + modelPath } + } + if (!fs.existsSync(tokensPath)) { + return { success: false, error: 'Tokens 文件不存在: ' + tokensPath } + } + + // 动态计算线程数,保留一半核心给系统,最少 1 个 + // 适配高配机器(如 16 核 CPU),移除 4 线程硬限制,让其能利用更多算力 + const cpuCount = os.cpus().length + const numThreads = Math.max(1, Math.floor(cpuCount / 2)) + + const recognizerConfig: SherpaRecognizerConfig = { + modelConfig: { + senseVoice: { + model: modelPath, + language: params.language ?? 'zh', // 使用指定语言或默认中文 + useInverseTextNormalization: 1 + }, + tokens: tokensPath, + numThreads: numThreads, + debug: 0, + provider: 'cpu' + } + } + + recognizer = new sherpaDisplay.OfflineRecognizer(recognizerConfig) + initParams = { ...params, modelPath, tokensPath } + isInitialized = true + + return { success: true } + } catch (error) { + console.error('[TranscribeWorker] 初始化失败:', error) + return { success: false, error: String(error) } + } +} + +/** + * 过滤不允许的语言字符 + */ +function filterText(text: string): string { + if (!text || !initParams?.allowedLanguages || initParams.allowedLanguages.length === 0) return text + + let result = text + const allowed = initParams.allowedLanguages + + // Japanese (Kana) + if (!allowed.includes('ja')) { + result = result.replace(/[\u3040-\u30ff]/g, '') + } + + // Korean (Hangul) + if (!allowed.includes('ko')) { + result = result.replace(/[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f]/g, '') + } + + // Hanzi (ZH/YUE) - Only filter if neither ZH, YUE, nor JA is allowed (JA uses Kanji) + if (!allowed.includes('zh') && !allowed.includes('yue') && !allowed.includes('ja')) { + result = result.replace(/[\u4e00-\u9fff]/g, '') + } + + return result +} + + +/** + * 主转写函数 + */ +async function transcribe( + wavData: Buffer, + sampleRate: number, + onPartial: (text: string) => void +): Promise<{ success: boolean; text?: string; error?: string }> { + if (!recognizer) { + return { success: false, error: '识别器未初始化' } + } + + try { + // 使用解析函数获取 PCM 数据 + const { pcmData, error } = parseWav(wavData) + if (error || !pcmData) { + return { success: false, error: error || 'WAV 解析失败' } + } + + const samples = new Float32Array(pcmData.length / 2) + for (let i = 0; i < samples.length; i++) { + samples[i] = pcmData.readInt16LE(i * 2) / 32768.0 + } + + // 直接使用完整音频进行转写(不分段,保留完整上下文以提高精度) + const stream = recognizer.createStream() + stream.acceptWaveform({ sampleRate, samples }) + recognizer.decode(stream) + const result = recognizer.getResult(stream) + const text = filterText(result.text?.trim() || '') + + // 释放流资源(如果支持) + if (stream.free) stream.free() + + return { success: true, text } + } catch (error) { + console.error('[TranscribeWorker] 识别失败:', error) + return { success: false, error: String(error) } + } +} + +// 启动时初始化 +if (parentPort) { + if (workerData && workerData.modelPath) { + const params = workerData as InitParams & { wavData?: Buffer } + + initRecognizer(params).then(async initResult => { + if (!initResult.success) { + parentPort!.postMessage({ type: 'error', error: initResult.error }) + return + } + + if (params.wavData) { + const wavData = Buffer.from(params.wavData) + + const result = await transcribe( + wavData, + params.sampleRate, + (text) => { + parentPort!.postMessage({ type: 'partial', text }) + } + ) + + if (result.success) { + parentPort!.postMessage({ type: 'final', text: result.text }) + } else { + parentPort!.postMessage({ type: 'error', error: result.error }) + } + } + }) + } + + parentPort.on('message', async (msg: any) => { + if (msg.type === 'init') { + const result = await initRecognizer(msg as InitParams) + parentPort!.postMessage({ type: 'initResult', ...result }) + } else if (msg.type === 'transcribe') { + const wavData = Buffer.from(msg.wavData) + + const result = await transcribe( + wavData, + initParams?.sampleRate || 16000, + (text) => { + parentPort!.postMessage({ + type: 'partial', + text, + requestId: msg.requestId + }) + } + ) + + parentPort!.postMessage({ + type: result.success ? 'final' : 'error', + requestId: msg.requestId, + text: result.text, + error: result.error + }) + } + }) +} diff --git a/package.json b/package.json index dc90c6c..f5c86e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ciphertalk", - "version": "1.0.7", + "version": "2.0.0", "description": "密语 - 微信聊天记录查看工具", "main": "dist-electron/main.js", "scripts": { @@ -14,10 +14,12 @@ }, "dependencies": { "better-sqlite3": "^12.5.0", + "dom-to-image-more": "^3.7.2", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", "electron-store": "^10.0.0", "electron-updater": "^6.3.9", + "ffmpeg-static": "^5.3.0", "fzstd": "^0.1.1", "html2canvas": "^1.4.1", "jieba-wasm": "^2.2.0", @@ -27,7 +29,10 @@ "react": "^19.2.3", "react-dom": "^19.2.3", "react-router-dom": "^7.1.1", + "sherpa-onnx-node": "^1.12.23", + "silk-wasm": "^3.7.1", "wechat-emojis": "^1.0.2", + "xlsx": "^0.18.5", "zustand": "^5.0.2" }, "devDependencies": { @@ -96,6 +101,11 @@ "files": [ "dist/**/*", "dist-electron/**/*" + ], + "asarUnpack": [ + "node_modules/ffmpeg-static/**/*", + "node_modules/silk-wasm/**/*", + "node_modules/sherpa-onnx-node/**/*" ] } -} +} \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index 00dfdfc..8849621 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,10 @@ import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import ActivationPage from './pages/ActivationPage' +import ImageWindow from './pages/ImageWindow' +import VideoWindow from './pages/VideoWindow' +import BrowserWindowPage from './pages/BrowserWindowPage' +import SplashPage from './pages/SplashPage' import { useAppStore } from './stores/appStore' import { useThemeStore } from './stores/themeStore' import { useActivationStore } from './stores/activationStore' @@ -30,14 +34,14 @@ function App() { const { setDbConnected } = useAppStore() const { currentTheme, themeMode, isLoaded, loadTheme } = useThemeStore() const { status: activationStatus, checkStatus: checkActivationStatus, initialized: activationInitialized } = useActivationStore() - + // 协议同意状态 const [showAgreement, setShowAgreement] = useState(false) const [agreementLoading, setAgreementLoading] = useState(true) - + // 激活状态 const [showActivation, setShowActivation] = useState(false) - + // 更新提示状态 const [updateInfo, setUpdateInfo] = useState<{ version: string; releaseNotes: string } | null>(null) @@ -53,7 +57,7 @@ function App() { if (!isLoaded) return document.documentElement.setAttribute('data-theme', currentTheme) document.documentElement.setAttribute('data-mode', themeMode) - + // 更新窗口控件颜色以适配主题 const symbolColor = themeMode === 'dark' ? '#ffffff' : '#1a1a1a' window.electronAPI.window.setTitleBarOverlay({ symbolColor }) @@ -128,7 +132,7 @@ function App() { // 启动时自动检查配置并连接数据库 useEffect(() => { // 独立窗口不需要自动连接主数据库 - if (isChatWindow || isGroupAnalyticsWindow || isAnnualReportWindow || isAgreementWindow) return + if (isChatWindow || isGroupAnalyticsWindow || isAnnualReportWindow || isAgreementWindow || location.pathname === '/image-viewer-window') return const autoConnect = async () => { try { @@ -136,14 +140,31 @@ function App() { const decryptKey = await configService.getDecryptKey() const wxid = await configService.getMyWxid() - // 如果配置完整,自动测试连接 + // 如果配置完整,检查启动时是否已经连接 if (dbPath && decryptKey && wxid) { + // 先检查启动屏阶段是否已经成功连接 + const startupConnected = await window.electronAPI.app.getStartupDbConnected?.() + if (startupConnected) { + console.log('启动时已通过启动屏连接数据库,跳过重复连接') + setDbConnected(true, dbPath) + // 预加载用户信息 + await preloadUserInfo() + // 如果当前在欢迎页,跳转到首页 + if (window.location.hash === '#/' || window.location.hash === '') { + navigate('/home') + } + return + } + + // 启动屏未连接,执行自动连接 console.log('检测到已保存的配置,正在自动连接...') const result = await window.electronAPI.wcdb.testConnection(dbPath, decryptKey, wxid, true) // 标记为自动连接 - + if (result.success) { console.log('自动连接成功') setDbConnected(true, dbPath) + // 预加载用户信息 + await preloadUserInfo() // 如果当前在欢迎页,跳转到首页 if (window.location.hash === '#/' || window.location.hash === '') { navigate('/home') @@ -157,6 +178,27 @@ function App() { } } + // 预加载用户信息 + const preloadUserInfo = async () => { + try { + const result = await window.electronAPI.chat.getMyUserInfo() + if (result.success && result.userInfo) { + useAppStore.getState().setUserInfo({ + wxid: result.userInfo.wxid, + nickName: result.userInfo.nickName, + alias: result.userInfo.alias, + avatarUrl: result.userInfo.avatarUrl + }) + console.log('用户信息预加载完成') + } else { + useAppStore.getState().setUserInfo(null) + } + } catch (e) { + console.error('预加载用户信息失败:', e) + useAppStore.getState().setUserInfo(null) + } + } + autoConnect() }, [isChatWindow, isGroupAnalyticsWindow]) @@ -187,11 +229,31 @@ function App() { ) } + // 独立图片查看窗口 + if (location.pathname === '/image-viewer-window') { + return + } + + // 独立视频播放窗口 + if (location.pathname === '/video-player-window') { + return + } + // 独立协议窗口 if (isAgreementWindow) { return } + // 独立浏览器窗口 + if (location.pathname === '/browser-window') { + return + } + + // 启动屏 + if (location.pathname === '/splash') { + return + } + // 首次启动协议弹窗 - 全屏遮罩,不可关闭 if (showAgreement && !agreementLoading) { return ( @@ -204,66 +266,66 @@ function App() {

欢迎使用密语!在使用本软件前,请仔细阅读并同意以下条款:

- +

一、用户协议

- +

1. 软件性质与用途说明

1.1 本软件是一款技术研究工具,用于读取和分析用户本地设备上已存在的微信数据文件,主要功能包括但不限于:本地数据文件解析、聊天记录查看、数据统计分析、年度报告生成及数据导出。

1.2 本软件仅供用户个人学习、研究和技术交流之目的使用,不得用于任何商业用途。

1.3 本软件仅作为数据查看工具,不具备也不提供任何主动获取、拦截、窃取数据的能力,所有操作均基于用户本地设备上已存在的文件。

- +

2. 使用限制

2.1 用户不得将本软件用于任何违反中华人民共和国法律法规、行政规章、社会公序良俗的用途,包括但不限于侵犯他人隐私权、个人信息权益、商业秘密或其他合法权益。

2.2 用户不得将本软件用于任何形式的商业使用、商业分发、商业服务、盈利活动或变相商业用途。

2.3 用户不得对本软件进行反向工程、反编译、反汇编,或以其他方式试图获取本软件的源代码(法律法规另有明确规定的除外)。

- +

3. 数据归属与用户责任

3.1 用户理解并确认,本软件所读取的微信聊天数据的知识产权及相关权益归属于腾讯公司及相关权利方,本软件及其开发者不主张对该等数据的任何所有权。

3.2 用户应自行确保其使用本软件的行为符合微信及腾讯相关产品的服务条款、用户协议及适用的法律法规。因用户使用本软件而产生的任何法律责任、纠纷、索赔、处罚或损失,均由用户自行承担,与本软件开发者无任何关联。

3.3 用户理解并确认,使用本软件可能涉及对本地数据文件的读取操作,用户应自行评估使用风险并做好数据备份,本软件及其开发者不对任何数据丢失、损坏或不可恢复承担责任。

3.4 用户不得将通过本软件获取的任何数据用于侵犯他人隐私、诽谤、骚扰或其他违法违规用途。

- +

4. 免责声明

4.1 本软件按"现状"提供,开发者不对本软件的适用性、准确性、完整性、稳定性、可靠性作出任何明示或暗示的保证。

4.2 在法律允许的最大范围内,因使用或无法使用本软件所导致的任何直接损失、间接损失、附带损失、后果性损失(包括但不限于数据丢失、业务中断、设备损坏、名誉损失等),均由用户自行承担,开发者不承担任何责任。

4.3 如因不可抗力、第三方原因、系统环境差异、软件冲突或用户自身操作不当导致的任何损失,开发者不承担任何责任。

- +

5. 知识产权声明

5.1 本软件及其相关的所有内容(包括但不限于程序代码、界面设计、图标、文档说明等)的知识产权,均归开发者依法所有。

5.2 未经开发者事先书面许可,任何单位或个人不得以任何形式复制、修改、传播、出租、出售或用于其他侵权行为。

- +

6. 协议的变更与终止

6.1 开发者有权根据需要不定期对本协议进行修订,修订后的协议一经公布即生效。

6.2 若用户在协议变更后继续使用本软件,即视为用户已接受修改后的协议内容。

6.3 用户如不同意协议变更内容,应立即停止使用本软件。

- +

7. 适用法律与争议解决

7.1 本协议的订立、执行、解释及争议解决均适用中华人民共和国大陆地区法律。

7.2 因本协议或本软件使用所引起的任何争议,双方应首先友好协商解决;协商不成的,任何一方均可向开发者所在地有管辖权的人民法院提起诉讼。

- +

二、隐私政策

- +

1. 数据收集声明

1.1 本软件不会以任何形式收集、存储、上传、分析或共享任何用户的个人信息或聊天数据。

1.2 开发者无法也不会获取用户的聊天记录、账号信息或任何本地数据内容。

- +

2. 本地数据处理说明

2.1 本软件的所有核心功能均在用户本地计算机环境中完成。

2.2 所有解密、解析、统计、导出等操作均仅作用于用户本地文件,不会通过网络传输任何数据。

- +

3. 网络请求说明

3.1 本软件仅在用户主动或默认启用"检查更新"功能时,访问网络以获取软件版本更新信息。

3.2 更新检查过程中,不会上传任何用户数据、设备数据或使用行为数据。

- +

4. 数据安全措施

4.1 本软件不建立服务器端数据存储,因此不存在服务器端数据泄露风险。

4.2 用户应自行妥善保管其计算机设备及相关数据环境,因设备安全问题导致的风险由用户自行承担。

- +

5. 用户权利

5.1 用户有权随时停止使用本软件,并自行删除本软件及相关本地文件。

5.2 由于本软件不收集、不保存任何用户数据,开发者无需也无法提供数据查询、更正或删除服务。

- +

如您对本协议或隐私政策有任何疑问,请在使用前自行进行充分评估。再次提醒:一旦使用本软件,即视为您已完全理解并同意本协议的全部内容。

@@ -309,7 +371,7 @@ function App() { )} - +
diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index ac71f7c..b60baf4 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -44,10 +44,10 @@ function Sidebar() { {/* 私聊分析 */} @@ -84,10 +84,10 @@ function Sidebar() { - 导出 + 导出数据 {/* 数据管理 */} diff --git a/src/components/TitleBar.scss b/src/components/TitleBar.scss index f17c998..bbce73a 100644 --- a/src/components/TitleBar.scss +++ b/src/components/TitleBar.scss @@ -3,13 +3,53 @@ background: var(--bg-secondary); display: flex; align-items: center; + justify-content: space-between; padding-left: 16px; + padding-right: 144px; // 为窗口控件留空间 border-bottom: 1px solid var(--border-color); -webkit-app-region: drag; flex-shrink: 0; +} + +.title-bar-left { + display: flex; + align-items: center; gap: 8px; } +.update-indicator { + color: #0078d4; + animation: spin 1s linear infinite; + flex-shrink: 0; + opacity: 1; + margin-left: 8px; + display: inline-block !important; + vertical-align: middle; + + // 确保在不同主题下都可见 + filter: brightness(1.2); + + // 添加轻微的背景色,使其更明显 + padding: 2px; + border-radius: 3px; + background: rgba(0, 120, 212, 0.1); +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.title-bar-right { + display: flex; + align-items: center; + -webkit-app-region: no-drag; +} + .title-logo { width: 20px; height: 20px; @@ -20,4 +60,47 @@ font-size: 15px; font-weight: 500; color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 600px; } + + +// 导出页面标签切换 +.export-tabs { + display: flex; + gap: 2px; + padding: 3px; + background: var(--bg-tertiary); + border-radius: 999px; +} + +.export-tab { + display: flex; + align-items: center; + gap: 5px; + padding: 4px 12px; + background: transparent; + border: none; + border-radius: 999px; + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + + &:hover { + color: var(--text-primary); + } + + &.active { + background: var(--card-bg); + color: var(--text-primary); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); + } + + svg { + flex-shrink: 0; + } +} \ No newline at end of file diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index c10f4a3..e55a411 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,10 +1,44 @@ +import { ReactNode, useEffect } from 'react' +import { RefreshCw } from 'lucide-react' +import { useTitleBarStore } from '../stores/titleBarStore' +import { useUpdateStatusStore } from '../stores/updateStatusStore' import './TitleBar.scss' -function TitleBar() { +interface TitleBarProps { + rightContent?: ReactNode + title?: string +} + +function TitleBar({ rightContent, title }: TitleBarProps) { + const storeRightContent = useTitleBarStore(state => state.rightContent) + const displayContent = rightContent ?? storeRightContent + const isUpdating = useUpdateStatusStore(state => state.isUpdating) + + // 调试:检查状态 + useEffect(() => { + if (isUpdating) { + console.log('[TitleBar] 更新指示器显示') + } + }, [isUpdating]) + return (
- 密语 - CipherTalk +
+ 密语 + {title || 'CipherTalk'} + {isUpdating && ( + + )} +
+ {displayContent && ( +
+ {displayContent} +
+ )}
) } diff --git a/src/components/WhatsNewModal.scss b/src/components/WhatsNewModal.scss new file mode 100644 index 0000000..3db82bf --- /dev/null +++ b/src/components/WhatsNewModal.scss @@ -0,0 +1,181 @@ +.whats-new-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(8px); + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease-out; + + .whats-new-modal { + width: 600px; + max-width: 90%; + background: rgba(255, 255, 255, 0.95); + border-radius: 24px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + overflow: hidden; + display: flex; + flex-direction: column; + animation: scaleUp 0.4s cubic-bezier(0.16, 1, 0.3, 1); + position: relative; + border: 1px solid rgba(255, 255, 255, 0.5); + + .modal-header { + padding: 40px 32px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background: linear-gradient(180deg, #FAF8F5 0%, #FFFFFF 100%); + border-bottom: 1px solid rgba(0, 0, 0, 0.03); + + .version-tag { + display: inline-flex; + align-items: center; + height: 24px; + padding: 0 12px; + background: rgba(139, 115, 85, 0.1); + color: #8B7355; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + margin-bottom: 16px; + text-transform: uppercase; + } + + h2 { + font-size: 26px; + line-height: 1.2; + color: #1a1a1a; + margin: 0 0 8px; + letter-spacing: -0.5px; + background: linear-gradient(135deg, #2c2c2c 0%, #5a5a5a 100%); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + text-align: center; + } + + p { + margin: 0; + color: #888; + font-size: 14px; + text-align: center; + font-weight: 400; + } + } + + .modal-content { + padding: 24px 32px; + + .update-list { + display: flex; + flex-direction: column; + gap: 20px; + } + + .update-item { + display: flex; + gap: 16px; + align-items: flex-start; + + .item-icon { + width: 40px; + height: 40px; + border-radius: 12px; + background: #F9F7F5; + display: flex; + align-items: center; + justify-content: center; + color: #8B7355; + flex-shrink: 0; + } + + .item-info { + h3 { + font-size: 16px; + font-weight: 600; + color: #333; + margin: 0 0 4px; + } + + p { + font-size: 14px; + color: #666; + margin: 0; + line-height: 1.5; + } + } + } + } + + .modal-footer { + padding: 16px 32px 32px; + display: flex; + justify-content: center; + + .start-btn { + background: #2c2c2c; + color: white; + border: none; + padding: 12px 48px; + border-radius: 16px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + + &:hover { + background: #000; + transform: translateY(-2px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15); + } + + &:active { + transform: translateY(0); + } + } + } + + // 装饰元素 + &::before { + content: ''; + position: absolute; + top: -50px; + left: -50px; + width: 150px; + height: 150px; + background: radial-gradient(circle, rgba(139, 115, 85, 0.05) 0%, transparent 70%); + pointer-events: none; + } + } +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes scaleUp { + from { + opacity: 0; + transform: scale(0.9) translateY(20px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} \ No newline at end of file diff --git a/src/components/WhatsNewModal.tsx b/src/components/WhatsNewModal.tsx new file mode 100644 index 0000000..ac897d6 --- /dev/null +++ b/src/components/WhatsNewModal.tsx @@ -0,0 +1,73 @@ +import { Zap, Layout, Monitor, MessageSquareQuote, RefreshCw, Mic, Rocket, Sparkles } from 'lucide-react' +import './WhatsNewModal.scss' + +interface WhatsNewModalProps { + onClose: () => void + version: string +} + +function WhatsNewModal({ onClose, version }: WhatsNewModalProps) { + const updates = [ + { + icon: , + title: '极速启动', + desc: '重构启动流程,显著提升应用加载速度,带来丝滑的入场动画体验。' + }, + { + icon: , + title: '样式自定义', + desc: '支持在设置中切换引用消息样式(需重启聊天窗口生效)。' + }, + { + icon: , + title: '智能同步', + desc: '优化数据库连接机制,支持自动同步最新消息数据(同步过程约需 20 秒)。' + }, + { + icon: , + title: '体验升级', + desc: '支持“拍一拍”系统消息解析,新增头像懒加载与骨架屏,聊天浏览更流畅。' + }, + { + icon: , + title: '语音增强', + desc: '语音转文字支持多模型选择,灵活平衡识别精度与速度,适配更多场景。' + } + ] + + return ( +
+
+
+ 新版本 {version} +

欢迎体验全新的密语

+

我们为您带来了一些令人兴奋的改进

+
+ +
+
+ {updates.map((item, index) => ( +
+
+ {item.icon} +
+
+

{item.title}

+

{item.desc}

+
+
+ ))} +
+
+ +
+ +
+
+
+ ) +} + +export default WhatsNewModal diff --git a/src/pages/AnalyticsPage.tsx b/src/pages/AnalyticsPage.tsx index fb5a316..ca2f250 100644 --- a/src/pages/AnalyticsPage.tsx +++ b/src/pages/AnalyticsPage.tsx @@ -72,7 +72,7 @@ function AnalyticsPage() { ].filter(d => d.value > 0) return { tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' }, - series: [{ type: 'pie', radius: ['40%', '70%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 8, borderColor: '#fff', borderWidth: 2 }, label: { show: true, formatter: '{b}\n{d}%' }, data }] + series: [{ type: 'pie', radius: ['40%', '70%'], avoidLabelOverlap: false, itemStyle: { borderRadius: 8 }, label: { show: true, formatter: '{b}\n{d}%' }, data }] } } diff --git a/src/pages/AnnualReportNewYear.scss b/src/pages/AnnualReportNewYear.scss new file mode 100644 index 0000000..35fb3bd --- /dev/null +++ b/src/pages/AnnualReportNewYear.scss @@ -0,0 +1,604 @@ +@import url('https://fonts.googleapis.com/css2?family=Ma+Shan+Zheng&family=Noto+Serif+SC:wght@300;400;700;900&family=Playfair+Display:ital,wght@0,400;0,700;1,400&display=swap'); + +.ny-report { + // --- 调色盘:御风·赤兔 --- + --vermilion: #C21F30; // 朱砂红 - 核心主色 + --vermilion-dark: #8A101C; // 胭脂红 - 深邃背景 + --ink-black: #0F0F12; // 徽墨 - 主要文字/暗部 + --paper-beige: #F7F5F0; // 宣纸 - 亮部背景 + --gold-dust: #D4AF37; // 泥金 - 高光修饰 + --gold-foil: #F2D68C; // 金箔 - 渐变亮部 + + // --- 排版系统 --- + --font-serif-en: "Playfair Display", serif; + --font-serif-cn: "Noto Serif SC", serif; + --font-calligraphy: "Ma Shan Zheng", cursive; + + width: 100%; + min-height: 100vh; + background-color: var(--vermilion); + color: var(--paper-beige); + position: relative; + overflow-x: hidden; + font-family: var(--font-serif-cn); + -webkit-font-smoothing: antialiased; + + /* 隐藏滚动条但保留功能 */ + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } +} + +// --- 背景层级 --- + +// 1. 纹理层:宣纸/红纸质感 +.ny-texture { + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: url("data:image/svg+xml,%3Csvg width='200' height='200' viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='paper'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='matrix' values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 0.04 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23paper)' opacity='1'/%3E%3C/svg%3E"); + opacity: 0.6; + mix-blend-mode: overlay; +} + +// 2. 也是背景:巨大的装饰汉字(视差滚动) +.ny-bg-character { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-family: var(--font-calligraphy); + font-size: 80vh; + color: rgba(0, 0, 0, 0.1); + z-index: 0; + pointer-events: none; + white-space: nowrap; + line-height: 1; + opacity: 0; + transition: opacity 1s; + + &.active { + opacity: 1; + } + + &.stroke-mode { + color: transparent; + -webkit-text-stroke: 2px rgba(255, 255, 255, 0.08); + } +} + +// 3. 动态光影:流动金沙 +.ny-flow-gold { + position: fixed; + inset: 0; + background: + radial-gradient(circle at 20% 30%, rgba(212, 175, 55, 0.15) 0%, transparent 40%), + radial-gradient(circle at 80% 80%, rgba(194, 31, 48, 0.4) 0%, transparent 50%); + filter: blur(60px); + z-index: 0; + animation: flowLight 20s infinite alternate ease-in-out; +} + +@keyframes flowLight { + 0% { + opacity: 0.5; + transform: scale(1); + } + + 100% { + opacity: 0.8; + transform: scale(1.1); + } +} + +// --- 核心布局:杂志级无边界 --- + +.ny-section { + min-height: 100vh; + position: relative; + z-index: 10; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 40px; + overflow: hidden; + + // 每一屏的颜色主题反转,创造节奏感 + &.theme-dark { + color: var(--paper-beige); + // 背景色在 DOM 层通过 ::before 实现或者父级控制 + + .big-stat-val { + background: linear-gradient(to bottom, #fff, #f0f0f0); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + } + + &.theme-light { + position: relative; + color: var(--ink-black); + + // 白色宣纸背景补丁 + &::before { + content: ''; + position: absolute; + inset: 20px; + background: var(--paper-beige); + z-index: -1; + transform: skewY(-2deg); + border-radius: 4px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2); + } + + .big-stat-val { + background: linear-gradient(to bottom, var(--vermilion), var(--vermilion-dark)); + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + } + + .ny-sub-label { + color: var(--vermilion-dark); + border-color: var(--vermilion); + } + } +} + +// --- 封面:御风 --- +.ny-cover-wrapper { + text-align: center; + position: relative; + z-index: 20; + + .seal-mark { + width: 60px; + height: 60px; + border: 3px solid var(--paper-beige); + color: var(--paper-beige); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-calligraphy); + font-size: 32px; + margin: 0 auto 40px; + border-radius: 4px; + opacity: 0.8; + } + + .main-title { + font-size: clamp(60px, 15vw, 160px); // 超大标题 + font-family: var(--font-calligraphy); + line-height: 1.1; + margin-bottom: 20px; + text-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); + + span { + display: inline-block; + animation: titleIn 1.2s cubic-bezier(0.2, 0.8, 0.2, 1) forwards; + opacity: 0; + transform: translateY(50px); + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0.4s; + } + + &:nth-child(4) { + animation-delay: 0.6s; + } + } + } + + .sub-meta { + font-family: var(--font-serif-en); + font-style: italic; + font-size: 24px; + letter-spacing: 2px; + color: var(--gold-foil); + margin-top: 10px; + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + + &::before, + &::after { + content: ''; + height: 1px; + width: 60px; + background: var(--gold-dust); + } + } +} + +@keyframes titleIn { + to { + opacity: 1; + transform: translateY(0); + } +} + +// --- 排版组件 --- + +// 竖排标题容器 (放在左侧或右侧) +.vertical-title-box { + position: absolute; + top: 15%; + writing-mode: vertical-rl; + text-orientation: mixed; + letter-spacing: 0.5em; + font-family: var(--font-calligraphy); + z-index: 20; + + &.left { + left: 8%; + } + + &.right { + right: 8%; + } + + .cn { + font-size: 48px; + color: var(--gold-foil); + margin-left: 10px; // vertical mode margin + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2); + } + + .en { + font-family: var(--font-serif-en); + font-size: 14px; + text-transform: uppercase; + letter-spacing: 4px; + color: rgba(255, 255, 255, 0.4); + border-right: 1px solid rgba(255, 255, 255, 0.2); + padding-right: 10px; + } +} + +// 内容主轴 +.ny-content-axis { + display: flex; + flex-direction: column; + align-items: center; + position: relative; + width: 100%; + max-width: 800px; + padding: 0 20px; + text-align: center; +} + +// 核心数据展示:以更艺术的方式 +.stat-art { + display: flex; + flex-direction: column; + align-items: center; + margin: 40px 0; + position: relative; + + .stat-label { + font-size: 16px; + letter-spacing: 4px; + text-transform: uppercase; + margin-bottom: 10px; + opacity: 0.8; + font-family: var(--font-serif-cn); + } + + .big-stat-val { + font-family: var(--font-serif-en); + font-size: clamp(80px, 18vw, 200px); + font-weight: 400; // 衬线体不需要太粗 + line-height: 0.9; + letter-spacing: -5px; + + // 艺术字效果:微弱的描边或阴影提升质感 + filter: drop-shadow(0 20px 40px rgba(0, 0, 0, 0.2)); + } + + .stat-unit-cn { + font-size: 24px; + margin-top: -20px; + margin-left: 100px; + font-family: var(--font-calligraphy); + background: var(--vermilion); + color: #fff; + padding: 2px 12px; + border-radius: 4px; + transform: rotate(-5deg); + box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.2); + } +} + +// 叙述性文字 +.narrative-text { + font-size: 20px; + line-height: 1.8; + max-width: 600px; + font-weight: 300; + margin-top: 40px; + + strong { + font-weight: 700; + color: var(--gold-foil); + margin: 0 4px; + font-size: 1.2em; + } +} + +// 具体的组件样式重构 + +// 1. 头像:不再是圆圈,可能是“印章”或“画框”风格 +.art-avatar { + position: relative; + width: 120px; + height: 120px; + margin-bottom: 30px; + + &::after { + content: ''; + position: absolute; + inset: -8px; + border: 1px solid var(--gold-foil); + transform: rotate(45deg); + opacity: 0.6; + transition: transform 10s linear infinite; + } + + img, + .placeholder { + width: 100%; + height: 100%; + object-fit: cover; + clip-path: polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%); // 菱形剪裁 + background: #000; + } + + .placeholder { + display: flex; + align-items: center; + justify-content: center; + font-size: 40px; + color: var(--gold-dust); + font-family: var(--font-calligraphy); + } +} + +// 2. 双向奔赴可视化:传统的“结”或“流线” +.resonance-flow { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + margin: 60px 0; + position: relative; + + &::before { + content: ''; + position: absolute; + top: 50%; + left: 10%; + right: 10%; + height: 1px; + background: rgba(255, 255, 255, 0.2); + z-index: 0; + } + + .flow-node { + z-index: 1; + background: var(--vermilion-dark); + padding: 10px 20px; + border: 1px solid var(--gold-dust); + + .label { + font-size: 12px; + opacity: 0.6; + margin-bottom: 4px; + } + + .val { + font-family: var(--font-serif-en); + font-size: 28px; + color: var(--gold-foil); + } + } + + .flow-center { + width: 60px; + height: 60px; + background: var(--paper-beige); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: var(--vermilion); + font-size: 24px; + z-index: 2; + box-shadow: 0 0 20px var(--vermilion); + + // 旋转动画 + animation: spinSlow 10s linear infinite; + } +} + +@keyframes spinSlow { + to { + transform: rotate(360deg); + } +} + +// 3. 荣誉榜单:竹简风格 或 卷轴风格 +.scroll-rank-list { + width: 100%; + display: flex; + flex-direction: column; + gap: 24px; + + .rank-row { + display: flex; + align-items: flex-end; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); // 浅色模式下的线 + padding-bottom: 12px; + + .rank-idx { + font-family: var(--font-serif-en); + font-size: 48px; + line-height: 0.8; + color: var(--vermilion); + margin-right: 20px; + opacity: 0.8; + font-style: italic; + } + + .rank-avt { + width: 48px; + height: 48px; + margin-right: 16px; + border-radius: 4px; + filter: sepia(0.2); // 复古滤镜 + } + + .rank-info { + flex: 1; + + .name { + font-size: 18px; + font-weight: 700; + margin-bottom: 4px; + } + + .detail { + font-size: 12px; + font-family: var(--font-serif-en); + color: #666; + } + } + + .rank-highlight { + font-family: var(--font-serif-en); + font-size: 24px; + color: var(--vermilion-dark); + } + } +} + +// 4. 常用语:印章矩阵 +.seal-cloud { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 20px; + max-width: 600px; + + .seal-item { + width: 60px; + height: 100px; // 竖长条 + border: 2px solid var(--vermilion); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4px; + position: relative; + background: rgba(255, 255, 255, 0.9); + transition: transform 0.3s; + + &:hover { + transform: translateY(-5px) scale(1.05); + z-index: 10; + box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1); + } + + .seal-txt { + writing-mode: vertical-rl; + font-family: var(--font-calligraphy); + font-size: 18px; + color: var(--ink-black); + letter-spacing: 4px; + } + + .seal-count { + position: absolute; + bottom: -10px; + background: var(--vermilion); + color: #fff; + font-size: 10px; + padding: 0 4px; + border-radius: 2px; + } + } +} + +// --- 尾声 --- +.ny-ending-section { + .horse-totem { + width: 150px; + height: 150px; + opacity: 0.8; + color: var(--gold-foil); + margin-bottom: 40px; + } + + .final-poem { + writing-mode: vertical-rl; + font-family: var(--font-calligraphy); + font-size: 32px; + line-height: 2; + height: 300px; + color: rgba(255, 255, 255, 0.9); + + p { + margin-left: 20px; + } + } + + .stamp-logo { + margin-top: 60px; + border: 4px double var(--vermilion-dark); // 红色边框 + padding: 10px 20px; + color: var(--vermilion-dark); + background: var(--paper-beige); + font-weight: bold; + letter-spacing: 4px; + transform: rotate(-3deg); + } +} + +// 动画辅助 +.fade-in-up { + opacity: 0; + transform: translateY(30px); + transition: opacity 1s, transform 1s cubic-bezier(0.2, 0.8, 0.2, 1); + + &.visible { + opacity: 1; + transform: translateY(0); + } +} + +.scale-reveal { + opacity: 0; + transform: scale(0.8); + transition: all 1s cubic-bezier(0.2, 0.8, 0.2, 1); + + &.visible { + opacity: 1; + transform: scale(1); + } +} + +// 延迟辅助类 +@for $i from 1 through 10 { + .delay-#{$i}00 { + transition-delay: #{$i * 100}ms; + } +} \ No newline at end of file diff --git a/src/pages/AnnualReportNewYear.tsx b/src/pages/AnnualReportNewYear.tsx new file mode 100644 index 0000000..8790848 --- /dev/null +++ b/src/pages/AnnualReportNewYear.tsx @@ -0,0 +1,325 @@ +import { forwardRef, useEffect, useRef } from 'react' +import { Wind, Cloud, Zap, Sun, Moon } from 'lucide-react' +import './AnnualReportNewYear.scss' + +// Custom SVG Icons for "Horse" Theme +const OrientalIcons = { + Horse: ({ className = '' }: { className?: string }) => ( + + + {/* 抽象的奔马鬃毛线条, 可根据实际设计替换更复杂的 Path */} + + + + ), + CloudPattern: ({ className = '' }: { className?: string }) => ( + + + + + ), + Seal: ({ size = 24 }: { size?: number }) => ( + + + + + ) +} + +const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => { + const initial = name?.[0] || '友' + return ( +
+ {url ? ( + + ) : ( +
{initial}
+ )} +
+ ) +} + +interface NewYearReportProps { + data: { + year: number + totalMessages: number + totalFriends: number + coreFriends: Array<{ + username: string + displayName: string + avatarUrl?: string + messageCount: number + sentCount: number + receivedCount: number + }> + monthlyTopFriends: Array<{ + month: number + displayName: string + avatarUrl?: string + messageCount: number + }> + peakDay: { date: string; messageCount: number; topFriend?: string; topFriendCount?: number } | null + longestStreak: { friendName: string; days: number; startDate: string; endDate: string } | null + activityHeatmap: { data: number[][] } + midnightKing: { displayName: string; count: number; percentage: number } | null + selfAvatarUrl?: string + mutualFriend?: { displayName: string; avatarUrl?: string; sentCount: number; receivedCount: number; ratio: number } | null + socialInitiative?: { initiatedChats: number; receivedChats: number; initiativeRate: number } | null + responseSpeed?: { avgResponseTime: number; fastestFriend: string; fastestTime: number } | null + topPhrases?: { phrase: string; count: number }[] + } + sectionRefs: Record> +} + +const formatNumber = (num: number) => num.toLocaleString() + +const AnnualReportNewYear = forwardRef(({ data, sectionRefs }, ref) => { + const { year, totalMessages, totalFriends, coreFriends, peakDay, longestStreak, midnightKing, mutualFriend, topPhrases } = data + const topFriend = coreFriends[0] + const containerRef = useRef(null) + + // 视差滚动与入场动画观察者 + useEffect(() => { + if (!containerRef.current) return + + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.classList.add('visible') + // 触发背景大字的显示 + const bgText = entry.target.getAttribute('data-bg-text') + if (bgText) { + const bgEl = document.getElementById('ny-bg-char') + if (bgEl) { + bgEl.textContent = bgText + bgEl.classList.add('active') + // 偶数屏使用描边模式 + if (entry.target.classList.contains('theme-light')) { + bgEl.classList.remove('stroke-mode') + } else { + bgEl.classList.add('stroke-mode') + } + } + } + } + }) + }, { threshold: 0.3 }) + + containerRef.current.querySelectorAll('.ny-section').forEach(el => observer.observe(el)) + containerRef.current.querySelectorAll('.fade-in-up, .scale-reveal').forEach(el => observer.observe(el)) + + return () => observer.disconnect() + }, []) + + return ( +
+ {/* 动态背景层 */} +
+
+
+ +
+ + {/* Cover: 御风 */} +
+
+
乙巳
+

+ +

+
+ THE YEAR OF HORSE · {year} +
+
+
+ + {/* Overview: 浩瀚 */} +
+
+ OVERVIEW + 年度概览 +
+ +
+
+ TOTAL MESSAGES + {formatNumber(totalMessages)} + 条·鸿雁传书 +
+ +

+ 时光如白驹过隙,这一年,你与 {formatNumber(totalFriends)} 位故交新知 +
在数字世界中留下了深刻的足迹。 +

+
+
+ + {/* Top Friend: 知己 */} + {topFriend && ( +
+
+ SOULMATE + 高山流水 +
+ +
+ +

{topFriend.displayName}

+ +
+ {formatNumber(topFriend.messageCount)} + MESSAGES EXCHANGED +
+ +
+
+ SEND + {formatNumber(topFriend.sentCount)} +
+
+ RECEIVE + {formatNumber(topFriend.receivedCount)} +
+
+
+
+ )} + + {/* Mutual Friend: 默契 */} + {mutualFriend && ( +
+
+ RESONANCE + 心有灵犀 +
+ +
+
+ 与 {mutualFriend.displayName} 的对话 +
如双马并辔,步伐一致 +
+ +
+
+
发出
+
{formatNumber(mutualFriend.sentCount)}
+
+
+ +
+
+
收到
+
{formatNumber(mutualFriend.receivedCount)}
+
+
+
+
+ )} + + {/* Streak: 恒久 */} + {longestStreak && ( +
+
+ PERSEVERANCE + 日日相伴 +
+ +
+
+ {longestStreak.days} + 天·连绵不绝 +
+

+ 从 {longestStreak.startDate} 到 {longestStreak.endDate} +
你与 {longestStreak.friendName} 的联络从未间断 +
路遥知马力,日久见人心 +

+
+
+ )} + + {/* Midnight: 守夜 */} + {midnightKing && ( +
+
+ +

{midnightKing.displayName}

+
+ {midnightKing.count} + MIDNIGHT TALKS +
+

+ 当万籁俱寂,只有你们的灯火依旧 +

+
+
+ )} + + {/* Top Phrases: 锦句 */} + {topPhrases && topPhrases.length > 0 && ( +
+
+ KEYWORDS + 年度锦句 +
+ +
+
+ {topPhrases.slice(0, 8).map((p, i) => ( +
+
{p.phrase}
+
{p.count}
+
+ ))} +
+
+
+ )} + + {/* Ranking: 群贤 */} +
+
+ TOP FRIENDS + 群贤毕至 +
+ +
+
+ {coreFriends.slice(0, 5).map((friend, i) => ( +
+ 0{i + 1} + (e.currentTarget.style.display = 'none')} /> +
+
{friend.displayName}
+
{formatNumber(friend.messageCount)} MESSAGES
+
+
+ ))} +
+
+
+ + {/* Ending: 尾声 */} +
+ + +
+

老骥伏枥 · 志在千里

+

烈士暮年 · 壮心不已

+

愿你在新的一年

+

一马平川 · 前程似锦

+
+ +
+ CipherTalk · 密语 +
+
+ +
+
+ ) +}) + +AnnualReportNewYear.displayName = 'AnnualReportNewYear' + +export default AnnualReportNewYear diff --git a/src/pages/AnnualReportWindow.scss b/src/pages/AnnualReportWindow.scss index 27914fe..dd5e3b7 100644 --- a/src/pages/AnnualReportWindow.scss +++ b/src/pages/AnnualReportWindow.scss @@ -213,6 +213,18 @@ font-weight: 600; } +// 封面年份样式 +.cover-section { + .cover-year { + font-size: clamp(72px, 15vw, 120px); + font-weight: 900; + line-height: 1; + color: var(--ar-primary); + letter-spacing: 2px; + margin-bottom: 12px; + } +} + .hero-title { font-size: clamp(28px, 5vw, 44px); font-weight: 700; @@ -654,12 +666,14 @@ } .ending-year { - font-size: 100px; - font-weight: 700; - color: var(--ar-primary); - opacity: 0.1; + font-size: 120px; + font-weight: 900; + color: transparent; + -webkit-text-stroke: 2px var(--ar-primary); margin-top: 30px; user-select: none; + mask-image: linear-gradient(to bottom, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0) 100%); + -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0) 100%); } .ending-brand { diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index 39c3ce9..affecdb 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1,8 +1,9 @@ import { useState, useEffect, useRef } from 'react' -import { Loader2, Download, Image, Check, X } from 'lucide-react' -import html2canvas from 'html2canvas' +import { Loader2, Download, Image, Check, X, Palette } from 'lucide-react' +import domtoimage from 'dom-to-image-more' import JSZip from 'jszip' import { useThemeStore } from '../stores/themeStore' +import AnnualReportNewYear from './AnnualReportNewYear' import './AnnualReportWindow.scss' // SVG 背景图案 (用于导出) @@ -15,12 +16,12 @@ const drawPatternBackground = async (ctx: CanvasRenderingContext2D, width: numbe // 先填充背景色 ctx.fillStyle = bgColor ctx.fillRect(0, 0, width, height) - + // 加载 SVG 图案 const svgString = isDark ? PATTERN_DARK_SVG : PATTERN_LIGHT_SVG const blob = new Blob([svgString], { type: 'image/svg+xml' }) const url = URL.createObjectURL(blob) - + return new Promise((resolve) => { const img = new window.Image() img.onload = () => { @@ -84,7 +85,7 @@ interface SectionInfo { const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: 'sm' | 'md' | 'lg' }) => { const [imgError, setImgError] = useState(false) const initial = name?.[0] || '友' - + return (
{url && !imgError ? ( @@ -100,7 +101,7 @@ const Avatar = ({ url, name, size = 'md' }: { url?: string; name: string; size?: const Heatmap = ({ data }: { data: number[][] }) => { const maxHeat = Math.max(...data.flat()) const weekLabels = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'] - + return (
@@ -116,13 +117,13 @@ const Heatmap = ({ data }: { data: number[][] }) => { {weekLabels.map(w =>
{w}
)}
- {data.map((row, wi) => + {data.map((row, wi) => row.map((val, hi) => { const alpha = maxHeat > 0 ? (val / maxHeat * 0.85 + 0.1).toFixed(2) : '0.1' return ( -
@@ -140,16 +141,16 @@ const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => const maxCount = words.length > 0 ? words[0].count : 1 const topWords = words.slice(0, 32) const baseSize = 520 - + // 使用确定性随机数生成器 const seededRandom = (seed: number) => { const x = Math.sin(seed) * 10000 return x - Math.floor(x) } - + // 计算词云位置 const placedItems: { x: number; y: number; w: number; h: number }[] = [] - + const canPlace = (x: number, y: number, w: number, h: number): boolean => { const halfW = w / 2 const halfH = h / 2 @@ -158,25 +159,25 @@ const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => const dist = Math.sqrt(dx * dx + dy * dy) const maxR = 49 - Math.max(halfW, halfH) if (dist > maxR) return false - + const pad = 1.8 for (const p of placedItems) { if ((x - halfW - pad) < (p.x + p.w / 2) && - (x + halfW + pad) > (p.x - p.w / 2) && - (y - halfH - pad) < (p.y + p.h / 2) && - (y + halfH + pad) > (p.y - p.h / 2)) { + (x + halfW + pad) > (p.x - p.w / 2) && + (y - halfH - pad) < (p.y + p.h / 2) && + (y + halfH + pad) > (p.y - p.h / 2)) { return false } } return true } - + const wordItems = topWords.map((item, i) => { const ratio = item.count / maxCount const fontSize = Math.round(12 + Math.pow(ratio, 0.65) * 20) const opacity = Math.min(1, Math.max(0.35, 0.35 + ratio * 0.65)) const delay = (i * 0.04).toFixed(2) - + // 计算词语宽度 const charCount = Math.max(1, item.phrase.length) const hasCjk = /[\u4e00-\u9fff]/.test(item.phrase) @@ -186,12 +187,12 @@ const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => const heightPx = fontSize * 1.1 const widthPct = (widthPx / baseSize) * 100 const heightPct = (heightPx / baseSize) * 100 - + // 寻找位置 let x = 50, y = 50 let placedOk = false const tries = i === 0 ? 1 : 420 - + for (let t = 0; t < tries; t++) { if (i === 0) { x = 50 @@ -208,10 +209,10 @@ const WordCloud = ({ words }: { words: { phrase: string; count: number }[] }) => break } } - + if (!placedOk) return null placedItems.push({ x, y, w: widthPct, h: heightPct }) - + return ( ) }).filter(Boolean) - + return (
@@ -250,6 +251,7 @@ function AnnualReportWindow() { const [fabOpen, setFabOpen] = useState(false) const [loadingProgress, setLoadingProgress] = useState(0) const [loadingStage, setLoadingStage] = useState('正在初始化...') + const [reportTheme, setReportTheme] = useState<'default' | 'newyear'>('default') const { currentTheme, themeMode, loadTheme } = useThemeStore() @@ -295,7 +297,7 @@ function AnnualReportWindow() { setIsLoading(true) setError(null) setLoadingProgress(0) - + // 模拟加载进度的各个阶段 const stages = [ { progress: 10, stage: '正在连接数据库...' }, @@ -308,7 +310,7 @@ function AnnualReportWindow() { { progress: 80, stage: '年度常用语' }, { progress: 90, stage: '生成报告...' }, ] - + let stageIndex = 0 const progressInterval = setInterval(() => { if (stageIndex < stages.length) { @@ -317,13 +319,13 @@ function AnnualReportWindow() { stageIndex++ } }, 300) - + try { const result = await window.electronAPI.annualReport.generateReport(year) clearInterval(progressInterval) setLoadingProgress(100) setLoadingStage('完成') - + if (result.success && result.data) { setTimeout(() => { setReportData(result.data!) @@ -419,12 +421,12 @@ function AnnualReportWindow() { const wordTags = element.querySelectorAll('.word-tag') as NodeListOf let wordCloudOriginalStyle = '' const wordTagOriginalStyles: string[] = [] - + if (wordCloudInner) { wordCloudOriginalStyle = wordCloudInner.style.cssText wordCloudInner.style.transform = 'none' } - + wordTags.forEach((tag, i) => { wordTagOriginalStyles[i] = tag.style.cssText tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') @@ -435,15 +437,29 @@ function AnnualReportWindow() { const computedStyle = getComputedStyle(document.documentElement) const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(element, { - backgroundColor: null, // 透明背景,让 SVG 图案显示 + + const dataUrl = await domtoimage.toPng(element, { + bgcolor: null, scale: 2, - useCORS: true, - allowTaint: true, - logging: false, + style: { + transform: 'none', + } }) + // 将 dataUrl 转换为 canvas 以便后续处理 + const img = new window.Image() + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = reject + img.src = dataUrl + }) + + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const tempCtx = canvas.getContext('2d')! + tempCtx.drawImage(img, 0, 0) + // 恢复样式 element.style.cssText = originalStyle if (wordCloudInner) { @@ -458,21 +474,30 @@ function AnnualReportWindow() { outputCanvas.width = OUTPUT_WIDTH outputCanvas.height = OUTPUT_HEIGHT const ctx = outputCanvas.getContext('2d')! - - // 绘制带 SVG 图案的背景 - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark) - + + if (reportTheme === 'newyear') { + // 绘制“御风·赤兔”主题背景:朱砂红 + ctx.fillStyle = '#C21F30' + ctx.fillRect(0, 0, OUTPUT_WIDTH, OUTPUT_HEIGHT) + + // 模拟纸质纹理(可选,这里用简单的噪点模拟) + // 为了导出速度和代码简洁,纯色填充在视觉上也足够高级 + } else { + // 绘制带 SVG 图案的背景 (默认主题) + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, OUTPUT_WIDTH, OUTPUT_HEIGHT, bgColor, isDark) + } + // 边距 (留出更多空白) const PADDING = 80 const contentWidth = OUTPUT_WIDTH - PADDING * 2 const contentHeight = OUTPUT_HEIGHT - PADDING * 2 - + // 计算缩放和居中位置 const srcRatio = canvas.width / canvas.height const dstRatio = contentWidth / contentHeight let drawWidth: number, drawHeight: number, drawX: number, drawY: number - + if (srcRatio > dstRatio) { // 源图更宽,以宽度为准 drawWidth = contentWidth @@ -486,7 +511,7 @@ function AnnualReportWindow() { drawX = PADDING + (contentWidth - drawWidth) / 2 drawY = PADDING } - + ctx.drawImage(canvas, drawX, drawY, drawWidth, drawHeight) return { name: section.name, data: outputCanvas.toDataURL('image/png') } @@ -508,7 +533,7 @@ function AnnualReportWindow() { const container = containerRef.current const sections = container.querySelectorAll('.section') const originalStyles: string[] = [] - + sections.forEach((section, i) => { const el = section as HTMLElement originalStyles[i] = el.style.cssText @@ -521,12 +546,12 @@ function AnnualReportWindow() { const wordTags = container.querySelectorAll('.word-tag') as NodeListOf let wordCloudOriginalStyle = '' const wordTagOriginalStyles: string[] = [] - + if (wordCloudInner) { wordCloudOriginalStyle = wordCloudInner.style.cssText wordCloudInner.style.transform = 'none' } - + wordTags.forEach((tag, i) => { wordTagOriginalStyles[i] = tag.style.cssText tag.style.opacity = String(tag.style.getPropertyValue('--final-opacity') || '1') @@ -535,29 +560,43 @@ function AnnualReportWindow() { // 等待样式生效 await new Promise(r => setTimeout(r, 100)) - + // 获取计算后的背景色 const computedStyle = getComputedStyle(document.documentElement) const bgColor = computedStyle.getPropertyValue('--bg-primary').trim() || '#F9F8F6' - - const canvas = await html2canvas(container, { - backgroundColor: null, // 透明背景 + + const dataUrl = await domtoimage.toPng(container, { + bgcolor: null, scale: 2, - useCORS: true, - allowTaint: true, - logging: false, + style: { + transform: 'none', + } }) + // 将 dataUrl 转换为 canvas 以便后续处理 + const img = new window.Image() + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = reject + img.src = dataUrl + }) + + const canvas = document.createElement('canvas') + canvas.width = img.width + canvas.height = img.height + const tempCtx = canvas.getContext('2d')! + tempCtx.drawImage(img, 0, 0) + // 恢复原始样式 sections.forEach((section, i) => { const el = section as HTMLElement el.style.cssText = originalStyles[i] }) - + if (wordCloudInner) { wordCloudInner.style.cssText = wordCloudOriginalStyle } - + wordTags.forEach((tag, i) => { tag.style.cssText = wordTagOriginalStyles[i] }) @@ -567,18 +606,25 @@ function AnnualReportWindow() { outputCanvas.width = canvas.width outputCanvas.height = canvas.height const ctx = outputCanvas.getContext('2d')! - - // 绘制 SVG 图案背景 - const isDark = themeMode === 'dark' - await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) - + + // 绘制背景 + if (reportTheme === 'newyear') { + // 绘制“御风·赤兔”主题背景:朱砂红 + ctx.fillStyle = '#C21F30' + ctx.fillRect(0, 0, canvas.width, canvas.height) + } else { + // 绘制 SVG 图案背景 + const isDark = themeMode === 'dark' + await drawPatternBackground(ctx, canvas.width, canvas.height, bgColor, isDark) + } + // 绘制内容 ctx.drawImage(canvas, 0, 0) - const dataUrl = outputCanvas.toDataURL('image/png') + const finalDataUrl = outputCanvas.toDataURL('image/png') const link = document.createElement('a') link.download = `${reportData?.year}年度报告.png` - link.href = dataUrl + link.href = finalDataUrl document.body.appendChild(link) link.click() document.body.removeChild(link) @@ -606,7 +652,7 @@ function AnnualReportWindow() { for (let i = 0; i < sections.length; i++) { const section = sections[i] setExportProgress(`正在导出: ${section.name} (${i + 1}/${sections.length})`) - + const result = await exportSection(section) if (result) { exportedImages.push(result) @@ -629,13 +675,13 @@ function AnnualReportWindow() { } else { setExportProgress('正在打包...') const zip = new JSZip() - + for (const img of exportedImages) { // 从 data URL 提取 base64 数据 const base64Data = img.data.split(',')[1] zip.file(`${reportData?.year}年度报告_${img.name}.png`, base64Data, { base64: true }) } - + const blob = await zip.generateAsync({ type: 'blob' }) const link = document.createElement('a') link.download = `${reportData?.year}年度报告_分模块.zip` @@ -676,8 +722,8 @@ function AnnualReportWindow() {
- @@ -713,18 +759,31 @@ function AnnualReportWindow() { return (
- + {/* 背景装饰 */} -
-
-
-
-
-
-
- + {/* 背景装饰 - 仅默认主题显示 */} + {reportTheme === 'default' && ( +
+
+
+
+
+
+
+ )} + {/* 浮动操作按钮 */}
+ @@ -762,8 +821,8 @@ function AnnualReportWindow() {
{getAvailableSections().map(section => ( -
toggleSection(section.id)} > @@ -778,8 +837,8 @@ function AnnualReportWindow() { -
)} -
- {/* 封面 */} -
-
CipherTalk · ANNUAL REPORT
-

{year}年
微信聊天报告

-
-

时光匆匆,转眼又是一年
让我们一起回顾这一年的点点滴滴

-
+ {/* 默认主题 */} + {reportTheme === 'default' && ( +
+ {/* 封面 */} +
+
CipherTalk · ANNUAL REPORT
+
{year}
+

微信聊天报告

+
+

时光匆匆,转眼又是一年
让我们一起回顾这一年的点点滴滴

+
- {/* 年度概览 */} -
-
年度概览
-

你和你的朋友们
互相发过

-
- {formatNumber(totalMessages)} - 条消息 -
-

- 在这段时光里,你与 {formatNumber(totalFriends)} 位好友交换过喜怒哀乐。 -
每一个对话,都是一段故事的开始。 -

-
- - {/* 年度挚友 */} - {topFriend && ( -
-
年度挚友
-

{topFriend.displayName}

+ {/* 年度概览 */} +
+
年度概览
+

你和你的朋友们
互相发过

- {formatNumber(topFriend.messageCount)} + {formatNumber(totalMessages)} 条消息

- 你发出 {formatNumber(topFriend.sentCount)} 条, - 收到 {formatNumber(topFriend.receivedCount)} 条 -
在一起,就可以 + 在这段时光里,你与 {formatNumber(totalFriends)} 位好友交换过喜怒哀乐。 +
每一个对话,都是一段故事的开始。

- )} - {/* 月度好友 */} -
-
月度好友
-

{year}年月度好友

-

根据12个月的聊天习惯
每个月陪你最多的人

-
- {monthlyTopFriends.map((m, i) => ( -
-
{m.month}月
- -
{m.displayName}
+ {/* 年度挚友 */} + {topFriend && ( +
+
年度挚友
+

{topFriend.displayName}

+
+ {formatNumber(topFriend.messageCount)} + 条消息
- ))} -
- -
-
-
+

+ 你发出 {formatNumber(topFriend.sentCount)} 条, + 收到 {formatNumber(topFriend.receivedCount)} 条 +
在一起,就可以 +

+
+ )} - {/* 双向奔赴 */} - {mutualFriend && ( -
-
双向奔赴
-

最默契的朋友

-
-
+ {/* 月度好友 */} +
+
月度好友
+

{year}年月度好友

+

根据12个月的聊天习惯
每个月陪你最多的人

+
+ {monthlyTopFriends.map((m, i) => ( +
+
{m.month}月
+ +
{m.displayName}
+
+ ))} +
-
- {formatNumber(mutualFriend.sentCount)} -
-
-
-
-
🤝
-
{mutualFriend.ratio}:1
-
-
-
- {formatNumber(mutualFriend.receivedCount)} -
-
-
-
{mutualFriend.displayName}
-

势均力敌,有来有往

- )} - {/* 社交主动性 */} - {socialInitiative && ( -
-
社交主动性
-

{socialInitiative.initiativeRate >= 50 ? '主动出击型' : '佛系社交型'}

-
- {socialInitiative.initiativeRate}% - 主动发起率 -
-

- 你主动发起了 {formatNumber(socialInitiative.initiatedChats)} 次对话 -
被动回复了 {formatNumber(socialInitiative.receivedChats)} 次对话 + {/* 双向奔赴 */} + {mutualFriend && ( +

+
双向奔赴
+

最默契的朋友

+
+
+ +
+ {formatNumber(mutualFriend.sentCount)} +
+
+
+
+
🤝
+
{mutualFriend.ratio}:1
+
+
+
+ {formatNumber(mutualFriend.receivedCount)} +
+
+ +
+
+
{mutualFriend.displayName}
+

势均力敌,有来有往

+
+ )} + + {/* 社交主动性 */} + {socialInitiative && ( +
+
社交主动性
+

{socialInitiative.initiativeRate >= 50 ? '主动出击型' : '佛系社交型'}

+
+ {socialInitiative.initiativeRate}% + 主动发起率 +
+

+ 你主动发起了 {formatNumber(socialInitiative.initiatedChats)} 次对话 +
被动回复了 {formatNumber(socialInitiative.receivedChats)} 次对话 +

+
+ )} + + {/* 巅峰时刻 */} + {peakDay && ( +
+
巅峰时刻
+

{peakDay.date}

+
+ {formatNumber(peakDay.messageCount)} + 条消息 +
+

+ 这是你聊天最多的一天 + {peakDay.topFriend && ( + <>
那天,你和 {peakDay.topFriend} 聊了 {formatNumber(peakDay.topFriendCount || 0)} 条 + )} +

+
+ )} + + {/* 聊天火花 */} + {longestStreak && ( +
+
聊天火花
+

持之以恒

+

{longestStreak.friendName} 保持了

+
+ {longestStreak.days} + +
+

从 {longestStreak.startDate} 到 {longestStreak.endDate} 的陪伴

+

是最长情的告白

+
+ )} + + {/* 作息规律 */} +
+
作息规律
+

时间的痕迹

+

+ 在 {mostActive.weekday} {mostActive.hour}:00 最活跃

+
- )} - {/* 巅峰时刻 */} - {peakDay && ( -
-
巅峰时刻
-

{peakDay.date}

-
- {formatNumber(peakDay.messageCount)} - 条消息 -
-

- 这是你聊天最多的一天 - {peakDay.topFriend && ( - <>
那天,你和 {peakDay.topFriend} 聊了 {formatNumber(peakDay.topFriendCount || 0)} 条 + {/* 深夜好友 */} + {midnightKing && ( +

+
深夜好友
+

当城市睡去

+
+ {midnightKing.count} + 次深夜对话 +
+

+ {midnightKing.displayName} 常常在深夜陪着你 +
占深夜聊天的 {midnightKing.percentage}% +

+
+ )} + + {/* 回应速度 */} + {responseSpeed && ( +
+
回应速度
+

秒回达人

+
+ {formatTime(responseSpeed.avgResponseTime)} + 平均回复时间 +
+

+ 你回复 {responseSpeed.fastestFriend} 最快 +
平均只需 {formatTime(responseSpeed.fastestTime)} +

+
+ )} + + {/* 年度常用语 - 词云 */} + {topPhrases && topPhrases.length > 0 && ( +
+
年度常用语
+

你在{year}年的年度常用语

+

+ 这一年,你说得最多的是: +
+ + {topPhrases.slice(0, 3).map(p => p.phrase).join('、')} + +

+ +

颜色越深代表出现频率越高

+
+ )} + + {/* 好友排行 */} +
+
年度好友榜
+

聊得最多的人

+ + {/* 领奖台 - 前三名 */} +
+ {/* 第二名 - 左边 */} + {coreFriends[1] && ( +
+ +
{coreFriends[1].displayName}
+
{formatNumber(coreFriends[1].messageCount)} 条
+
+ 2 +
+
)} -

-
- )} - {/* 聊天火花 */} - {longestStreak && ( -
-
聊天火花
-

持之以恒

-

{longestStreak.friendName} 保持了

-
- {longestStreak.days} - -
-

陪伴,是最长情的告白

-
- )} - - {/* 作息规律 */} -
-
作息规律
-

时间的痕迹

-

- 在 {mostActive.weekday} {mostActive.hour}:00 最活跃 -

- -
- - {/* 深夜好友 */} - {midnightKing && ( -
-
深夜好友
-

当城市睡去

-
- {midnightKing.count} - 次深夜对话 -
-

- {midnightKing.displayName} 常常在深夜陪着你 -
占深夜聊天的 {midnightKing.percentage}% -

-
- )} - - {/* 回应速度 */} - {responseSpeed && ( -
-
回应速度
-

秒回达人

-
- {formatTime(responseSpeed.avgResponseTime)} - 平均回复时间 -
-

- 你回复 {responseSpeed.fastestFriend} 最快 -
平均只需 {formatTime(responseSpeed.fastestTime)} -

-
- )} - - {/* 年度常用语 - 词云 */} - {topPhrases && topPhrases.length > 0 && ( -
-
年度常用语
-

你在{year}年的年度常用语

-

- 这一年,你说得最多的是: -
- - {topPhrases.slice(0, 3).map(p => p.phrase).join('、')} - -

- -

颜色越深代表出现频率越高

-
- )} - - {/* 好友排行 */} -
-
年度好友榜
-

聊得最多的人

- - {/* 领奖台 - 前三名 */} -
- {/* 第二名 - 左边 */} - {coreFriends[1] && ( -
- -
{coreFriends[1].displayName}
-
{formatNumber(coreFriends[1].messageCount)} 条
-
- 2 + {/* 第一名 - 中间最高 */} + {coreFriends[0] && ( +
+
👑
+ +
{coreFriends[0].displayName}
+
{formatNumber(coreFriends[0].messageCount)} 条
+
+ 1 +
-
- )} - - {/* 第一名 - 中间最高 */} - {coreFriends[0] && ( -
-
👑
- -
{coreFriends[0].displayName}
-
{formatNumber(coreFriends[0].messageCount)} 条
-
- 1 -
-
- )} - - {/* 第三名 - 右边 */} - {coreFriends[2] && ( -
- -
{coreFriends[2].displayName}
-
{formatNumber(coreFriends[2].messageCount)} 条
-
- 3 -
-
- )} -
-
+ )} - {/* 结尾 */} -
-
尾声
-

感谢每一次对话

-

- 我们总是在向前走,却很少有机会回头看看 -
愿新的一年,所有期待,皆有回声 -

-
{year}
-
密语-CipherTalk
-
-
+ {/* 第三名 - 右边 */} + {coreFriends[2] && ( +
+ +
{coreFriends[2].displayName}
+
{formatNumber(coreFriends[2].messageCount)} 条
+
+ 3 +
+
+ )} +
+
+ + {/* 结尾 */} +
+
尾声
+

感谢每一次对话

+

+ 我们总是在向前走,却很少有机会回头看看 +
愿新的一年,所有期待,皆有回声 +

+
{year}
+
密语-CipherTalk
+
+
+ )} + + {/* 新年红金主题 */} + {reportTheme === 'newyear' && reportData && ( + } + data={reportData} + sectionRefs={sectionRefs} + /> + )}
) } diff --git a/src/pages/BrowserWindowPage.tsx b/src/pages/BrowserWindowPage.tsx new file mode 100644 index 0000000..2d2353b --- /dev/null +++ b/src/pages/BrowserWindowPage.tsx @@ -0,0 +1,115 @@ +import React, { useEffect, useState, useRef } from 'react'; +import TitleBar from '../components/TitleBar'; + +// 简化的内置浏览器窗口 +// 实际上由于 Electron 的限制,iframe 无法直接加载大部分外部网站(同源/CSP限制) +// 但为了满足"不显示 Electron 默认菜单和图标"的需求,我们可以使用 标签 +// 而 需要在 webPreferences 中启用 webviewTag: true +// 在本项目中,可能更简单的方法是: +// 1. Electron 主进程直接创建一个普通的 BrowserWindow +// 2. 加载一个空白的 HTML,其中包含 TitleBar 和 +// 或者 +// 3. (当前方案) React 页面中使用 +// 注意:这需要在主进程 main.ts 的 webPreferences 中为 browser-window 启用 webviewTag + +const BrowserWindowPage = () => { + const [params, setParams] = useState<{ url: string; title: string }>({ url: '', title: 'Browser' }); + const [isLoading, setIsLoading] = useState(true); + const [pageTitle, setPageTitle] = useState('加载中...'); + const webviewRef = useRef(null); + + useEffect(() => { + // 从 URL 参数获取 + const searchParams = new URLSearchParams(window.location.hash.split('?')[1]); + const url = searchParams.get('url') || ''; + const title = searchParams.get('title') || ''; + + // 解码 URL + const decodedUrl = decodeURIComponent(url); + const decodedTitle = decodeURIComponent(title); + + setParams({ url: decodedUrl, title: decodedTitle }); + if (decodedTitle) setPageTitle(decodedTitle); + + // 设置窗口标题 + document.title = decodedTitle || '浏览器'; + }, []); + + useEffect(() => { + const webview = webviewRef.current; + if (!webview) return; + + // 监听加载事件 + const handleDidStartLoading = () => setIsLoading(true); + const handleDidStopLoading = () => { + setIsLoading(false); + // 尝试获取网页标题 + try { + if (!params.title) { + setPageTitle(webview.getTitle()); + } + } catch (e) { } + }; + + // 监听标题变化 + const handlePageTitleUpdated = (e: any) => { + if (!params.title) { + setPageTitle(e.title); + document.title = e.title; + } + }; + + webview.addEventListener('did-start-loading', handleDidStartLoading); + webview.addEventListener('did-stop-loading', handleDidStopLoading); + webview.addEventListener('page-title-updated', handlePageTitleUpdated); + + return () => { + webview.removeEventListener('did-start-loading', handleDidStartLoading); + webview.removeEventListener('did-stop-loading', handleDidStopLoading); + webview.removeEventListener('page-title-updated', handlePageTitleUpdated); + }; + }, [params.title]); + + if (!params.url) return null; + + return ( +
+ + + {/* 简单的进度条 */} + {isLoading && ( +
+ )} + + {/* + webview 是 Electron 特有的标签,类似于 iframe 但权限更高 + 注意: 需要在 main.ts 的 createBrowserWindow 中的 webPreferences 设置 webviewTag: true + */} + + + +
+ ); +}; + +export default BrowserWindowPage; diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index 790f205..bd25bb2 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -2,13 +2,13 @@ display: flex; height: 100%; gap: 16px; - + // 独立窗口模式 - EchoTrace 特色风格(使用主题变量) &.standalone { height: 100vh; gap: 0; background: var(--bg-gradient); - + .session-sidebar { flex: none; background: var(--card-bg); @@ -16,7 +16,7 @@ border-right: 1px solid var(--border-color); -webkit-app-region: no-drag; backdrop-filter: blur(20px); - + .session-header { padding-top: 38px; padding-bottom: 12px; @@ -24,15 +24,15 @@ padding-right: 16px; background: transparent; -webkit-app-region: drag; - + h2 { display: none; } - + .header-actions { display: none; } - + .search-row { display: flex; align-items: center; @@ -40,7 +40,7 @@ -webkit-app-region: no-drag; min-width: 0; } - + .search-box { flex: 1; min-width: 0; @@ -52,13 +52,13 @@ align-items: center; gap: 8px; transition: all 0.2s; - + &:focus-within { background: var(--bg-hover); border-color: var(--primary); box-shadow: 0 0 0 3px var(--primary-light); } - + input { flex: 1; border: none; @@ -66,17 +66,17 @@ outline: none; color: var(--text-primary); font-size: 13px; - + &::placeholder { color: var(--text-tertiary); } } - + svg { color: var(--text-tertiary); flex-shrink: 0; } - + .close-search { width: 18px; height: 18px; @@ -90,14 +90,14 @@ align-items: center; justify-content: center; flex-shrink: 0; - + &:hover { background: var(--border-color); color: var(--text-primary); } } } - + .refresh-btn { width: 32px; height: 32px; @@ -112,95 +112,96 @@ justify-content: center; flex-shrink: 0; transition: all 0.2s; - + &:hover { background: var(--bg-hover); color: var(--text-primary); } - + &:disabled { opacity: 0.4; cursor: not-allowed; } - + .spin { animation: spin 1s linear infinite; } } } - + .session-list { background: transparent; } - + .session-item { border-bottom: 1px solid var(--border-color); padding: 12px 16px; transition: all 0.2s; - + &:hover { background: var(--bg-hover); } - + &.active { background: var(--primary-light); border-left: 3px solid var(--primary); padding-left: 13px; } - + .session-name { color: var(--text-primary); font-size: 14px; font-weight: 500; } - + .session-time { color: var(--text-tertiary); font-size: 11px; } - + .session-summary { color: var(--text-secondary); font-size: 12px; } } - + .session-avatar { border-radius: 50%; width: 44px !important; height: 44px !important; } - + .unread-badge { background: var(--primary-gradient); box-shadow: 0 2px 8px var(--primary-light); } - + .connection-error { background: rgba(220, 53, 69, 0.08); border: 1px solid rgba(220, 53, 69, 0.2); margin: 8px 16px; border-radius: 10px; - - svg, span { + + svg, + span { color: var(--danger); } - + button { background: var(--danger); border-radius: 6px; } } - + .empty-sessions { color: var(--text-tertiary); - + svg { color: var(--text-tertiary); opacity: 0.5; } } - + .skeleton-item { .skeleton-avatar { width: 44px; @@ -208,42 +209,42 @@ border-radius: 50%; background: var(--bg-tertiary); } - + .skeleton-content .skeleton-line { background: var(--bg-tertiary); } } - + .loading-sessions { background: transparent; } - + .session-list { &::-webkit-scrollbar { width: 6px; } - + &::-webkit-scrollbar-track { background: transparent; } - + &::-webkit-scrollbar-thumb { background: var(--text-tertiary); opacity: 0.3; border-radius: 3px; - + &:hover { opacity: 0.5; } } } } - + .message-area { border-radius: 0; background: var(--bg-secondary); -webkit-app-region: no-drag; - + .message-header { height: auto; padding: 0 24px; @@ -253,23 +254,23 @@ border-bottom: 1px solid var(--border-color); -webkit-app-region: drag; backdrop-filter: blur(10px); - + .session-avatar { width: 36px; height: 36px; border-radius: 50%; -webkit-app-region: no-drag; } - + .header-info { -webkit-app-region: no-drag; - + h3 { font-size: 15px; font-weight: 600; color: var(--text-primary); } - + .header-subtitle { font-size: 11px; color: var(--text-tertiary); @@ -295,7 +296,7 @@ justify-content: center; color: var(--text-secondary); transition: all 0.2s; - + &:hover { background: var(--bg-hover); color: var(--text-primary); @@ -323,52 +324,52 @@ display: flex; overflow: hidden; } - + .message-list { flex: 1; background-color: var(--bg-secondary); padding: 20px 24px; position: relative; overflow-y: auto; - + &::-webkit-scrollbar { width: 6px; } - + &::-webkit-scrollbar-track { background: transparent; } - + &::-webkit-scrollbar-thumb { background: var(--text-tertiary); opacity: 0.3; border-radius: 3px; - + &:hover { opacity: 0.5; } } - + .scroll-to-bottom { background: var(--primary); border: none; color: #fff; box-shadow: 0 4px 15px var(--primary-light); - + &:hover { background: var(--primary-hover); box-shadow: 0 6px 20px var(--primary-light); } } } - + .message-wrapper { margin-bottom: 16px; } - + .message-bubble { max-width: 65%; - + &.sent { .bubble-content { background: var(--primary-gradient); @@ -380,7 +381,7 @@ box-shadow: 0 2px 10px var(--primary-light); } } - + &.received { .bubble-content { background: var(--card-bg); @@ -393,18 +394,46 @@ border: 1px solid var(--border-color); } } - + &.system { max-width: 100%; - + .bubble-content { background: transparent; color: var(--text-tertiary); font-size: 12px; padding: 4px 0; + + .quoted-message { + background: var(--bg-tertiary); + border-left: 3px solid var(--primary); + padding: 8px; + margin-bottom: 8px; + border-radius: 4px; + font-size: 13px; + display: flex; + flex-direction: column; + gap: 2px; + + .quoted-sender { + font-size: 12px; + color: var(--primary); + font-weight: 500; + } + + .quoted-text { + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + } + } } } - + &.emoji { .bubble-content { background: transparent !important; @@ -413,55 +442,199 @@ border: none; } } + + // 语音转文字样式 + .voice-bubble-container { + display: flex; + flex-direction: column; + gap: 6px; + } + + .stt-transcript { + padding: 8px 12px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 12px; + color: var(--text-primary); + font-size: 13px; + line-height: 1.5; + max-width: 100%; + word-break: break-word; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); + user-select: text; + } + + .stt-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: transparent; + border: 1px solid var(--border-color); + border-radius: 12px; + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + transition: all 0.2s; + outline: none; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + border-color: var(--primary); + } + + &.loading { + opacity: 0.7; + cursor: wait; + } + + &.error { + color: var(--danger); + border-color: var(--danger); + background: rgba(220, 53, 69, 0.05); + + &:hover { + background: rgba(220, 53, 69, 0.1); + } + } + } + + .bubble-body { + display: flex; + flex-direction: column; + } + + // 发送/接收方对齐调整 + &.sent { + .voice-bubble-container { + align-items: flex-end; + } + + .bubble-body { + align-items: flex-end; + } + + + .stt-transcript { + border-top-right-radius: 4px; + } + + .bubble-quote { + align-items: flex-end; + + .quote-content { + background: var(--primary-light); + border-color: transparent; + border-top-right-radius: 4px; + /* 上方与主气泡连接 */ + border-bottom-right-radius: 12px; + /* 下方圆角恢复 */ + text-align: left; + color: var(--text-secondary); + /* 次要信息颜色 */ + } + + .quote-sender { + color: var(--primary); + } + } + } + + &.received { + .voice-bubble-container { + align-items: flex-start; + } + + .bubble-body { + align-items: flex-start; + } + + .stt-transcript { + border-top-left-radius: 4px; + } + + .bubble-quote { + align-items: flex-start; + + .quote-content { + border-top-left-radius: 4px; + /* 上方与主气泡连接 */ + border-bottom-left-radius: 12px; + } + } + } } - + .bubble-avatar { width: 36px; height: 36px; border-radius: 50%; } - + .sender-name { font-size: 12px; color: var(--text-secondary); margin-bottom: 4px; } - - .quoted-message { - background: var(--bg-tertiary); - border-left: 2px solid var(--primary); - padding: 6px 10px; - margin-bottom: 8px; - border-radius: 4px; - font-size: 12px; - - .quoted-sender { - color: var(--primary); + + .bubble-quote { + margin-top: 2px; + max-width: 100%; + display: flex; + flex-direction: column; + + .quote-content { + background: var(--bg-tertiary); + padding: 6px 10px; + border-radius: 12px; + font-size: 11px; + /* 字体更小 */ + color: var(--text-tertiary); + /* 颜色更淡,体现次要地位 */ + position: relative; + border: 1px solid var(--border-color); + cursor: default; + line-height: 1.4; } - - .quoted-text { - color: var(--text-secondary); + + .quote-text { + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + /* 限制行数 */ + line-clamp: 2; + -webkit-box-orient: vertical; + word-break: break-all; + } + + .quote-sender { + margin-right: 4px; + font-weight: 500; + opacity: 0.9; } } - - .time-divider, .date-divider { + + .time-divider, + .date-divider { span { font-size: 11px; color: var(--text-tertiary); } } - + .date-divider span { background: var(--bg-tertiary); padding: 4px 12px; border-radius: 12px; } - + .load-more-trigger { color: var(--text-tertiary); font-size: 12px; } - + .empty-header { -webkit-app-region: drag; } @@ -474,12 +647,12 @@ opacity: 0.4; } } - + .loading-messages { color: var(--text-tertiary); } } - + .resize-handle { width: 4px; margin-left: -2px; @@ -490,13 +663,13 @@ transition: background 0.2s; position: relative; z-index: 10; - + &:hover { background: var(--primary); opacity: 0.4; } } - + &.resizing .resize-handle { background: var(--primary); } @@ -521,7 +694,7 @@ align-items: center; justify-content: space-between; min-height: 56px; - + h2 { font-size: 18px; font-weight: 600; @@ -529,12 +702,12 @@ color: var(--text-primary); white-space: nowrap; } - + .header-actions { display: flex; gap: 4px; } - + .icon-btn { width: 32px; height: 32px; @@ -546,16 +719,16 @@ align-items: center; justify-content: center; color: var(--text-secondary); - + &:hover { background: var(--bg-hover); } - + &:disabled { opacity: 0.5; cursor: not-allowed; } - + .spin { animation: spin 1s linear infinite; } @@ -570,12 +743,12 @@ background: var(--bg-primary); border-radius: 8px; animation: searchExpand 0.25s ease-out; - + svg { color: var(--text-tertiary); flex-shrink: 0; } - + input { flex: 1; border: none; @@ -584,7 +757,7 @@ font-size: 14px; color: var(--text-primary); min-width: 0; - + &::placeholder { color: var(--text-tertiary); } @@ -602,7 +775,7 @@ justify-content: center; color: var(--text-tertiary); flex-shrink: 0; - + &:hover { background: var(--border-color); color: var(--text-primary); @@ -617,6 +790,7 @@ transform: scaleX(0.8); transform-origin: right center; } + to { opacity: 1; transform: scaleX(1); @@ -628,20 +802,20 @@ flex: 1; overflow-y: auto; overflow-x: hidden; - + // 滚动条样式 &::-webkit-scrollbar { width: 8px; } - + &::-webkit-scrollbar-track { background: transparent; } - + &::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 4px; - + &:hover { background: rgba(0, 0, 0, 0.3); } @@ -656,15 +830,15 @@ cursor: pointer; transition: background 0.15s; border-bottom: 1px solid var(--border-color); - + &:last-child { border-bottom: none; } - + &:hover { background: var(--bg-hover); } - + &.active { background: var(--primary-light); } @@ -681,36 +855,36 @@ flex-shrink: 0; overflow: hidden; position: relative; - + img { width: 100%; height: 100%; object-fit: cover; opacity: 0; transition: opacity 0.3s ease; - + &.loaded { opacity: 1; } } - + .avatar-letter { color: white; font-size: 18px; font-weight: 600; } - + .avatar-skeleton { position: absolute; inset: 0; background: var(--bg-tertiary); animation: pulse 1.5s infinite; } - + &.group { background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); } - + &.loading { background: var(--bg-tertiary); animation: pulse 1.5s infinite; @@ -796,29 +970,46 @@ align-items: center; gap: 12px; border-bottom: 1px solid var(--border-color); - + .session-avatar { width: 40px; height: 40px; } - + .header-info { flex: 1; - + h3 { font-size: 16px; font-weight: 600; margin: 0; color: var(--text-primary); } - + .header-subtitle { font-size: 12px; color: var(--text-tertiary); margin-top: 2px; } } - + + .update-indicator-header { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--primary-light); + border-radius: 12px; + color: var(--primary); + font-size: 12px; + font-weight: 500; + animation: fadeIn 0.3s ease-in-out; + + svg { + animation: spin 1s linear infinite; + } + } + .refresh-messages-btn { width: 32px; height: 32px; @@ -831,17 +1022,17 @@ justify-content: center; color: var(--text-secondary); flex-shrink: 0; - + &:hover { background: var(--bg-hover); color: var(--text-primary); } - + &:disabled { opacity: 0.5; cursor: not-allowed; } - + .spin { animation: spin 1s linear infinite; } @@ -858,20 +1049,20 @@ gap: 16px; background-color: var(--bg-tertiary); position: relative; - + // 滚动条样式 &::-webkit-scrollbar { width: 8px; } - + &::-webkit-scrollbar-track { background: transparent; } - + &::-webkit-scrollbar-thumb { background: rgba(0, 0, 0, 0.2); border-radius: 4px; - + &:hover { background: rgba(0, 0, 0, 0.3); } @@ -900,13 +1091,13 @@ transform: translateY(20px); pointer-events: none; transition: all 0.3s ease; - + &.show { opacity: 1; transform: translateY(0); pointer-events: auto; } - + &:hover { background: var(--bg-tertiary); color: var(--text-primary); @@ -921,7 +1112,7 @@ background: transparent; transition: background 0.2s; flex-shrink: 0; - + &:hover { background: var(--primary); } @@ -930,7 +1121,7 @@ // 拖动时禁用选择 .chat-page.resizing { user-select: none; - + .resize-handle { background: var(--primary); } @@ -940,15 +1131,15 @@ .message-wrapper { display: flex; flex-direction: column; - + &.sent { align-items: flex-end; } - + &.received { align-items: flex-start; } - + &.system { align-items: center; } @@ -959,7 +1150,7 @@ padding: 12px; color: var(--text-tertiary); font-size: 13px; - + &.loading { display: flex; align-items: center; @@ -974,38 +1165,48 @@ gap: 10px; max-width: 70%; align-items: flex-start; - + // 自己发送的消息 - 右侧绿色 &.sent { flex-direction: row-reverse; - + .bubble-content { background: var(--primary); color: white; border-radius: 18px 18px 4px 18px; } + + .bubble-body { + align-items: flex-end; + } } - + // 对方发送的消息 - 左侧白色 &.received { .bubble-content { background: var(--bg-secondary); color: var(--text-primary); border-radius: 18px 18px 18px 4px; + backdrop-filter: blur(10px); + border: 1px solid var(--border-color); + } + + .bubble-body { + align-items: flex-start; } } - + &.system { max-width: 85%; - + .bubble-avatar { display: none; } - + .bubble-body { width: 100%; } - + .bubble-content { background: rgba(0, 0, 0, 0.04); color: var(--text-tertiary); @@ -1027,13 +1228,13 @@ align-items: center; justify-content: center; overflow: hidden; - + img { width: 100%; height: 100%; object-fit: cover; } - + .avatar-letter { font-size: 14px; font-weight: 600; @@ -1075,7 +1276,7 @@ border-radius: 8px; font-size: 12px; color: var(--text-tertiary); - + .spin { animation: spin 1s linear infinite; } @@ -1092,11 +1293,11 @@ background: var(--bg-tertiary); border-radius: 8px; color: var(--text-quaternary); - + svg { opacity: 0.5; } - + span { font-size: 10px; opacity: 0.7; @@ -1107,7 +1308,7 @@ .image-message-wrapper { position: relative; display: inline-block; - + .image-message { max-width: 200px; max-height: 200px; @@ -1116,12 +1317,12 @@ cursor: pointer; pointer-events: auto; transition: opacity 0.2s; - + &:hover { opacity: 0.9; } } - + .image-update-button { position: absolute; bottom: 8px; @@ -1137,12 +1338,12 @@ align-items: center; justify-content: center; transition: background 0.2s; - + &:hover { background: rgba(0, 0, 0, 0.8); } } - + .image-loading-overlay { position: absolute; top: 0; @@ -1154,7 +1355,7 @@ display: flex; align-items: center; justify-content: center; - + .spin { animation: spin 1s linear infinite; color: white; @@ -1167,6 +1368,7 @@ opacity: 0; transform: translateX(-50%) translateY(4px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); @@ -1181,7 +1383,7 @@ justify-content: center; background: var(--bg-tertiary); border-radius: 8px; - + .spin { animation: spin 1s linear infinite; color: var(--text-tertiary); @@ -1197,7 +1399,7 @@ background: var(--bg-tertiary); border-radius: 8px; color: var(--text-quaternary); - + svg { opacity: 0.3; } @@ -1214,11 +1416,11 @@ background: var(--bg-tertiary); border-radius: 8px; color: var(--text-quaternary); - + svg { opacity: 0.4; } - + span { font-size: 11px; opacity: 0.8; @@ -1239,24 +1441,24 @@ border: none; cursor: pointer; transition: background 0.2s; - + &:hover { background: var(--bg-secondary); } - + &.clicked { background: var(--bg-secondary); } - + svg { opacity: 0.5; } - + span { font-size: 11px; opacity: 0.8; } - + .image-action { font-size: 10px; color: var(--primary); @@ -1264,54 +1466,8 @@ } } -// 图片预览遮罩 -.image-preview-overlay { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: rgba(0, 0, 0, 0.9); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - cursor: pointer; - - img { - max-width: 90vw; - max-height: 90vh; - object-fit: contain; - cursor: default; - pointer-events: auto; // 覆盖全局的 pointer-events: none - } - - .image-preview-no-hd { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - color: rgba(255, 255, 255, 0.8); - text-align: center; - cursor: default; - - svg { - margin-bottom: 20px; - opacity: 0.6; - } - - p { - font-size: 20px; - font-weight: 500; - margin: 0 0 8px 0; - } - - span { - font-size: 14px; - opacity: 0.6; - } - } -} + + // 图片消息气泡样式调整 .message-bubble.image { @@ -1322,6 +1478,98 @@ } // 群聊发送者名称 +// 链接/分享消息卡片 +.link-message { + width: 280px; + background: var(--card-bg); + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid var(--border-color); + + &:hover { + background: var(--bg-hover); + border-color: var(--primary); + } + + .link-header { + padding: 10px 12px 6px; + display: flex; + gap: 8px; + + .link-title { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; + } + } + + .link-body { + padding: 6px 12px 10px; + display: flex; + gap: 10px; + + .link-desc { + font-size: 12px; + color: var(--text-secondary); + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 3; + line-clamp: 3; + -webkit-box-orient: vertical; + overflow: hidden; + flex: 1; + } + + .link-thumb { + width: 48px; + height: 48px; + border-radius: 4px; + object-fit: cover; + flex-shrink: 0; + background: var(--bg-tertiary); + } + + .link-thumb-placeholder { + width: 48px; + height: 48px; + border-radius: 4px; + background: var(--bg-tertiary); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--text-tertiary); + + svg { + opacity: 0.5; + } + } + } +} + +// 适配发送/接收样式 +.message-bubble.sent .link-message { + background: #fff; + border: 1px solid rgba(0, 0, 0, 0.1); + + .link-title { + color: #333; + } + + .link-desc { + color: #666; + } +} + .sender-name { font-size: 12px; color: var(--text-tertiary); @@ -1336,17 +1584,17 @@ margin-bottom: 8px; border-radius: 4px; font-size: 13px; - + .quoted-sender { color: var(--primary); font-weight: 500; margin-right: 4px; - + &::after { content: ':'; } } - + .quoted-text { color: var(--text-secondary); } @@ -1356,11 +1604,11 @@ .message-bubble.sent .quoted-message { background: rgba(255, 255, 255, 0.15); border-left-color: rgba(255, 255, 255, 0.5); - + .quoted-sender { color: rgba(255, 255, 255, 0.9); } - + .quoted-text { color: rgba(255, 255, 255, 0.8); } @@ -1381,7 +1629,7 @@ padding: 8px 0; width: 100%; align-self: center; - + span { font-size: 12px; color: var(--text-tertiary); @@ -1396,7 +1644,7 @@ padding: 8px 0; width: 100%; align-self: center; - + span { font-size: 12px; color: var(--text-tertiary); @@ -1415,13 +1663,13 @@ justify-content: center; color: var(--text-tertiary); gap: 12px; - + svg { width: 64px; height: 64px; opacity: 0.5; } - + p { font-size: 14px; margin: 0; @@ -1437,18 +1685,18 @@ padding: 40px 20px; text-align: center; color: var(--text-tertiary); - + svg { width: 48px; height: 48px; margin-bottom: 12px; opacity: 0.5; } - + p { font-size: 13px; margin: 0; - + &.hint { font-size: 12px; margin-top: 4px; @@ -1470,7 +1718,7 @@ align-items: center; gap: 12px; padding: 12px 0; - + .skeleton-avatar { width: 48px; height: 48px; @@ -1478,21 +1726,21 @@ background: var(--bg-tertiary); animation: pulse 1.5s infinite; } - + .skeleton-content { flex: 1; - + .skeleton-line { height: 14px; background: var(--bg-tertiary); border-radius: 4px; animation: pulse 1.5s infinite; - + &:first-child { width: 50%; margin-bottom: 8px; } - + &:last-child { width: 80%; height: 12px; @@ -1502,13 +1750,25 @@ } @keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } } @keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } } // 连接错误 @@ -1520,18 +1780,18 @@ display: flex; align-items: center; gap: 8px; - + svg { color: #f56c6c; flex-shrink: 0; } - + span { flex: 1; font-size: 13px; color: #f56c6c; } - + button { padding: 4px 12px; font-size: 12px; @@ -1540,7 +1800,7 @@ border: none; border-radius: 4px; cursor: pointer; - + &:hover { background: #f78989; } @@ -1556,7 +1816,7 @@ justify-content: center; gap: 12px; color: var(--text-tertiary); - + svg { animation: spin 1s linear infinite; } @@ -1735,6 +1995,7 @@ opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); @@ -1748,15 +2009,15 @@ text-decoration: underline; cursor: pointer; word-break: break-all; - + &:hover { color: #1557b0; } - + // 自己发送的消息中的链接 .message-bubble.sent & { color: rgba(255, 255, 255, 0.95); - + &:hover { color: #fff; } @@ -1780,7 +2041,7 @@ background: var(--bg-tertiary); border-radius: 8px; color: var(--text-quaternary); - + svg { opacity: 0.3; } @@ -1794,7 +2055,7 @@ justify-content: center; background: var(--bg-tertiary); border-radius: 8px; - + .spin { animation: spin 1s linear infinite; color: var(--text-tertiary); @@ -1812,11 +2073,11 @@ background: var(--bg-tertiary); border-radius: 8px; color: var(--text-quaternary); - + svg { opacity: 0.5; } - + span { font-size: 12px; opacity: 0.8; @@ -1830,14 +2091,14 @@ cursor: pointer; border-radius: 8px; overflow: hidden; - + &:hover { .video-play-button { background: rgba(0, 0, 0, 0.7); transform: translate(-50%, -50%) scale(1.1); } } - + .video-thumb, .video-cover { max-width: 200px; @@ -1848,7 +2109,7 @@ display: block; border-radius: 8px; } - + .video-thumb-placeholder { width: 200px; height: 150px; @@ -1857,12 +2118,12 @@ justify-content: center; background: var(--bg-tertiary); color: var(--text-quaternary); - + svg { opacity: 0.5; } } - + .video-play-button { position: absolute; top: 50%; @@ -1876,7 +2137,7 @@ align-items: center; justify-content: center; transition: all 0.2s ease; - + svg { color: white; margin-left: 4px; // 视觉居中调整 @@ -1889,7 +2150,7 @@ display: inline-block; border-radius: 8px; overflow: hidden; - + .video-player { max-width: 320px; max-height: 400px; @@ -1897,7 +2158,7 @@ display: block; border-radius: 8px; background: #000; - + &:focus { outline: none; } @@ -1917,38 +2178,38 @@ justify-content: center; z-index: 1000; cursor: pointer; - + .video-preview-player { max-width: 90vw; max-height: 90vh; cursor: default; border-radius: 8px; background: #000; - + &:focus { outline: none; } - + // 隐藏全屏按钮 &::-webkit-media-controls-fullscreen-button { display: none !important; } - + // 隐藏更多按钮(三个点) &::-webkit-media-controls-overflow-button { display: none !important; } - + // 隐藏下载按钮 &::-webkit-media-controls-download-button { display: none !important; } - + // 隐藏画中画按钮 &::-webkit-media-controls-picture-in-picture-button { display: none !important; } - + // 隐藏整个溢出菜单 &::-internal-media-controls-overflow-button { display: none !important; @@ -1956,6 +2217,120 @@ } } +// 语音消息样式 +.voice-message { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; + min-height: 20px; + + &:hover { + opacity: 0.8; + } + + &:active { + opacity: 0.7; + } + + // 自己发送的语音,内容靠右 + &.sent { + justify-content: flex-end; + } + + .voice-icon { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + flex-shrink: 0; + + svg { + color: inherit; + } + + .spin { + animation: spin 1s linear infinite; + } + + .voice-error-icon { + color: #ff4d4f; + } + } + + .voice-waves { + display: flex; + align-items: center; + gap: 2px; + height: 18px; + + span { + display: block; + width: 3px; + background: currentColor; + border-radius: 2px; + animation: voiceWave 0.8s ease-in-out infinite; + + &:nth-child(1) { + height: 6px; + animation-delay: 0s; + } + + &:nth-child(2) { + height: 12px; + animation-delay: 0.2s; + } + + &:nth-child(3) { + height: 8px; + animation-delay: 0.4s; + } + } + + // 自己发送的语音,波浪动画方向反转 + &.sent span { + &:nth-child(1) { + animation-delay: 0.4s; + } + + &:nth-child(2) { + animation-delay: 0.2s; + } + + &:nth-child(3) { + animation-delay: 0s; + } + } + } + + .voice-duration { + font-size: 14px; + flex-shrink: 0; + } + + &.error { + opacity: 0.7; + } +} + +@keyframes voiceWave { + + 0%, + 100% { + transform: scaleY(0.5); + } + + 50% { + transform: scaleY(1); + } +} + +// 语音气泡特殊样式 +.bubble-content.voice-bubble { + cursor: pointer; +} + // 全局禁用视频控件的某些按钮 video::-webkit-media-controls-overflow-button { display: none !important; @@ -1977,14 +2352,46 @@ video::-webkit-media-controls-fullscreen-button { .context-menu { position: fixed; - background: var(--card-bg); + background: var(--bg-primary); + /* Solid background */ border: 1px solid var(--border-color); - border-radius: 8px; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + border-radius: 16px; + /* Increased radius */ + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); min-width: 160px; - padding: 4px; + padding: 6px; z-index: 10001; - animation: fadeIn 0.15s ease; + transform-origin: top left; + animation: menuEnter 0.2s cubic-bezier(0.2, 0, 0.13, 1.5) forwards; + + &.closing { + animation: menuExit 0.15s ease-in forwards; + pointer-events: none; + } +} + +@keyframes menuEnter { + from { + opacity: 0; + transform: scale(0.8); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +@keyframes menuExit { + from { + opacity: 1; + transform: scale(1); + } + + to { + opacity: 0; + transform: scale(0.9); + } } .context-menu-item { @@ -2094,6 +2501,7 @@ video::-webkit-media-controls-fullscreen-button { from { opacity: 0; } + to { opacity: 1; } @@ -2104,6 +2512,7 @@ video::-webkit-media-controls-fullscreen-button { transform: translateY(20px); opacity: 0; } + to { transform: translateY(0); opacity: 1; @@ -2140,8 +2549,349 @@ video::-webkit-media-controls-fullscreen-button { transform: translateX(-50%) translateY(20px); opacity: 0; } + to { transform: translateX(-50%) translateY(0); opacity: 1; } } + +// 语音气泡容器 +.voice-bubble-container { + display: flex; + flex-direction: column; + gap: 4px; +} + +// 语音转文字按钮 +.stt-button { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + border: none; + border-radius: 12px; + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 11px; + cursor: pointer; + transition: all 0.2s; + align-self: flex-start; + + &:hover:not(:disabled) { + background: var(--bg-hover); + color: var(--primary); + } + + &:disabled { + opacity: 0.7; + cursor: not-allowed; + } + + &.loading { + color: var(--primary); + } + + &.error { + color: var(--danger); + + &:hover:not(:disabled) { + color: var(--danger); + } + } + + svg { + flex-shrink: 0; + } + + .spin { + animation: spin 1s linear infinite; + } +} + +// 转写结果 +.stt-transcript { + padding: 8px 12px; + background: var(--bg-tertiary); + border-radius: 12px; + font-size: 13px; + color: var(--text-primary); + line-height: 1.5; + max-width: 100%; + word-wrap: break-word; + animation: fadeIn 0.3s ease; +} + +// 发送方的样式调整 +.message-bubble.sent { + .voice-bubble-container { + align-items: flex-end; + } + + .stt-button { + align-self: flex-end; + } + + .stt-transcript { + background: rgba(255, 255, 255, 0.15); + color: rgba(255, 255, 255, 0.9); + } +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +// STT Edit Mode +.stt-edit-container { + width: 100%; + min-width: 200px; + background: var(--bg-tertiary); + border-radius: 12px; + padding: 8px; + margin-top: 4px; + + .stt-edit-textarea { + width: 100%; + min-height: 80px; + padding: 8px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 13px; + line-height: 1.5; + resize: none; + outline: none; + font-family: inherit; + + &:focus { + border-color: var(--primary); + } + } + + .stt-edit-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 8px; + + .stt-edit-btn { + padding: 4px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + border: 1px solid transparent; + transition: all 0.2s; + + &.cancel { + background: transparent; + color: var(--text-secondary); + + &:hover { + background: var(--bg-hover); + } + } + + &.save { + background: var(--primary); + color: white; + + &:hover { + opacity: 0.9; + } + } + } + } +} + +// 消息信息弹窗样式 +.message-info-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + animation: modalFadeIn 0.3s ease; +} + +.message-info-modal { + width: 90%; + max-width: 600px; + max-height: 85vh; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 16px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3); + overflow: hidden; + + .modal-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + + .header-title { + display: flex; + align-items: center; + gap: 10px; + color: var(--primary); + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + } + } + + .close-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: none; + background: transparent; + color: var(--text-tertiary); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + + &:hover { + background: var(--bg-hover); + color: var(--text-primary); + transform: rotate(90deg); + } + } + } + + .modal-body { + padding: 20px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 20px; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--text-tertiary); + border-radius: 3px; + } + + .info-section { + h4 { + margin: 0 0 12px 0; + font-size: 13px; + font-weight: 600; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.5px; + } + } + + .info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 12px; + } + + .info-list { + display: flex; + flex-direction: column; + gap: 12px; + } + + .info-item { + padding: 8px 12px; + background: var(--bg-tertiary); + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 4px; + + &.block { + width: 100%; + } + + .label { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 500; + } + + .value { + font-size: 13px; + color: var(--text-primary); + word-break: break-all; + + &.code { + font-family: 'Consolas', 'Monaco', monospace; + background: rgba(0, 0, 0, 0.1); + padding: 2px 4px; + border-radius: 4px; + } + + &.break-all { + word-break: break-all; + } + } + + .select-text { + user-select: text; + cursor: text; + } + } + + .raw-content-container { + background: var(--bg-dark, #1e1e1e); + padding: 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + + pre { + margin: 0; + white-space: pre-wrap; + word-break: break-all; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 12px; + color: #d4d4d4; + user-select: text; + cursor: text; + } + } + } +} + +@keyframes modalFadeIn { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} \ No newline at end of file diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index 0c7f01a..46ac02c 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -1,13 +1,16 @@ import { useState, useEffect, useRef, useCallback } from 'react' import { createPortal } from 'react-dom' -import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check } from 'lucide-react' +import { Search, MessageSquare, AlertCircle, Loader2, RefreshCw, X, ChevronDown, Info, Calendar, Database, Hash, Image as ImageIcon, Play, Video, Copy, ZoomIn, CheckSquare, Check, Edit, Link } from 'lucide-react' import { useChatStore } from '../stores/chatStore' +import { useUpdateStatusStore } from '../stores/updateStatusStore' import ChatBackground from '../components/ChatBackground' import MessageContent from '../components/MessageContent' -import { getImageXorKey, getImageAesKey } from '../services/config' +import { getImageXorKey, getImageAesKey, getQuoteStyle } from '../services/config' import type { ChatSession, Message } from '../types/models' import './ChatPage.scss' + + interface ChatPageProps { // 保留接口以备将来扩展 } @@ -25,13 +28,15 @@ interface SessionDetail { messageTables: { dbName: string; tableName: string; count: number }[] } -// 头像组件 - 支持骨架屏加载 +// 头像组件 - 支持骨架屏加载和懒加载 function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: number }) { const [imageLoaded, setImageLoaded] = useState(false) const [imageError, setImageError] = useState(false) + const [isVisible, setIsVisible] = useState(false) const imgRef = useRef(null) + const containerRef = useRef(null) const isGroup = session.username.includes('@chatroom') - + const getAvatarLetter = (): string => { const name = session.displayName || session.username if (!name) return '?' @@ -39,36 +44,133 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu return chars[0] || '?' } - // 当 avatarUrl 变化时重置状态 + // 懒加载:使用 IntersectionObserver 检测头像是否进入可视区域 useEffect(() => { - setImageLoaded(false) - setImageError(false) + if (!containerRef.current) return + + const element = containerRef.current + + // 如果没有 avatarUrl,不需要懒加载,直接显示首字母 + if (!session.avatarUrl) { + setIsVisible(false) + return + } + + // 检查是否已经在可视区域内 + const checkVisibility = () => { + const rect = element.getBoundingClientRect() + const viewportHeight = window.innerHeight + // 检查是否在可视区域(包括提前 100px 的预加载区域) + const isInViewport = rect.top < viewportHeight + 100 && rect.bottom > -100 + return isInViewport + } + + // 立即检查一次,如果已经在可视区域内,直接设置为可见 + if (checkVisibility()) { + setIsVisible(true) + return + } + + // 如果不在可视区域内,使用 IntersectionObserver 监听 + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setIsVisible(true) + observer.disconnect() + } + }) + }, + { + rootMargin: '100px', // 提前 100px 开始加载 + threshold: 0 + } + ) + + observer.observe(element) + + return () => { + observer.disconnect() + } + }, [session.avatarUrl]) + + // 当 avatarUrl 变化时重置加载状态(但保持 isVisible,避免闪烁) + useEffect(() => { + if (session.avatarUrl) { + setImageLoaded(false) + setImageError(false) + // 不重置 isVisible,避免已经可见的头像重新隐藏 + } }, [session.avatarUrl]) // 检查图片是否已经从缓存加载完成 useEffect(() => { - if (imgRef.current?.complete && imgRef.current?.naturalWidth > 0) { - setImageLoaded(true) + if (isVisible && session.avatarUrl && imgRef.current) { + // 如果图片已经加载完成(可能是从缓存加载的) + if (imgRef.current.complete && imgRef.current.naturalWidth > 0) { + setImageLoaded(true) + setImageError(false) + } } - }, [session.avatarUrl]) + }, [isVisible, session.avatarUrl]) + + // 添加超时处理,避免一直显示骨架屏 + useEffect(() => { + if (!isVisible || !session.avatarUrl || imageLoaded || imageError) return + + const timeoutId = setTimeout(() => { + // 如果 5 秒后还没加载完成,检查图片状态 + if (imgRef.current) { + if (imgRef.current.complete) { + if (imgRef.current.naturalWidth > 0) { + setImageLoaded(true) + } else { + setImageError(true) + } + } + } + }, 5000) + + return () => clearTimeout(timeoutId) + }, [isVisible, session.avatarUrl, imageLoaded, imageError]) const hasValidUrl = session.avatarUrl && !imageError + const shouldLoadImage = hasValidUrl && isVisible return ( -
- {hasValidUrl ? ( + {shouldLoadImage && !imageError ? ( <> - {!imageLoaded &&
} - +
+ {getAvatarLetter()} + + )} + setImageLoaded(true)} - onError={() => setImageError(true)} + style={{ + opacity: imageLoaded ? 1 : 0, + transition: 'opacity 0.2s ease-in-out', + position: imageLoaded ? 'relative' : 'absolute', + zIndex: imageLoaded ? 1 : 0 + }} + onLoad={() => { + setImageLoaded(true) + setImageError(false) + }} + onError={() => { + setImageError(true) + setImageLoaded(false) + }} + loading="lazy" /> ) : ( @@ -79,6 +181,12 @@ function SessionAvatar({ session, size = 48 }: { session: ChatSession; size?: nu } function ChatPage(_props: ChatPageProps) { + const [quoteStyle, setQuoteStyle] = useState<'default' | 'wechat'>('default') + + useEffect(() => { + getQuoteStyle().then(setQuoteStyle).catch(console.error) + }, []) + const { isConnected, isConnecting, @@ -110,7 +218,17 @@ function ChatPage(_props: ChatPageProps) { const messageListRef = useRef(null) const searchInputRef = useRef(null) const sidebarRef = useRef(null) + const messagesRef = useRef([]) + const currentSessionIdRef = useRef(null) + const lastUpdateTimeRef = useRef(0) + const updateTimerRef = useRef(null) + const updateStatusTimerRef = useRef(null) + const isUserOperatingRef = useRef(false) // 标记用户是否正在操作 const [currentOffset, setCurrentOffset] = useState(0) + + // 更新状态管理 + const setIsUpdating = useUpdateStatusStore(state => state.setIsUpdating) + const isUpdating = useUpdateStatusStore(state => state.isUpdating) const [myAvatarUrl, setMyAvatarUrl] = useState(undefined) const [showScrollToBottom, setShowScrollToBottom] = useState(false) const [sidebarWidth, setSidebarWidth] = useState(260) @@ -124,10 +242,21 @@ function ChatPage(_props: ChatPageProps) { y: number message: Message session: ChatSession + handlers?: { + reTranscribe?: () => void + editStt?: () => void + } } | null>(null) + + const [isMenuClosing, setIsMenuClosing] = useState(false) + + const closeContextMenu = useCallback(() => { + setIsMenuClosing(true) + }, []) const [selectedMessages, setSelectedMessages] = useState>(new Set()) const [showEnlargeView, setShowEnlargeView] = useState<{ message: Message; content: string } | null>(null) const [copyToast, setCopyToast] = useState(false) + const [showMessageInfo, setShowMessageInfo] = useState(null) // 消息信息弹窗 // 检查图片密钥配置(XOR 和 AES 都需要配置) useEffect(() => { @@ -216,6 +345,7 @@ function ChatPage(_props: ChatPageProps) { const handleRefreshMessages = async () => { if (!currentSessionId || isRefreshingMessages) return setIsRefreshingMessages(true) + setIsUpdating(true) // 显示更新指示器 try { // 清空后端缓存 await window.electronAPI.chat.refreshCache() @@ -226,16 +356,19 @@ function ChatPage(_props: ChatPageProps) { console.error('刷新消息失败:', e) } finally { setIsRefreshingMessages(false) + setIsUpdating(false) // 隐藏更新指示器 } } // 加载消息 const loadMessages = async (sessionId: string, offset = 0) => { const listEl = messageListRef.current - + if (offset === 0) { setLoadingMessages(true) setMessages([]) + // 标记用户正在操作(首次加载) + isUserOperatingRef.current = true } else { setLoadingMore(true) } @@ -244,6 +377,17 @@ function ChatPage(_props: ChatPageProps) { const firstMsgEl = listEl?.querySelector('.message-wrapper') as HTMLElement | null try { + // 确保连接已建立(如果未连接,先连接) + if (!isConnected) { + console.log('[ChatPage] 加载消息前检查连接状态,未连接,先连接...') + const connectResult = await window.electronAPI.chat.connect() + if (!connectResult.success) { + setConnectionError(connectResult.error || '连接失败') + return + } + setConnected(true) + } + const result = await window.electronAPI.chat.getMessages(sessionId, offset, 50) if (result.success && result.messages) { if (offset === 0) { @@ -271,6 +415,12 @@ function ChatPage(_props: ChatPageProps) { } finally { setLoadingMessages(false) setLoadingMore(false) + // 加载完成后,延迟重置用户操作标记(给一点缓冲时间) + if (offset === 0) { + setTimeout(() => { + isUserOperatingRef.current = false + }, 2000) // 2秒后允许自动更新 + } } } @@ -300,7 +450,7 @@ function ChatPage(_props: ChatPageProps) { return } const lower = keyword.toLowerCase() - const filtered = sessions.filter(s => + const filtered = sessions.filter(s => s.displayName?.toLowerCase().includes(lower) || s.username.toLowerCase().includes(lower) || s.summary.toLowerCase().includes(lower) @@ -317,13 +467,13 @@ function ChatPage(_props: ChatPageProps) { // 滚动加载更多 + 显示/隐藏回到底部按钮 const handleScroll = useCallback(() => { if (!messageListRef.current) return - + const { scrollTop, clientHeight, scrollHeight } = messageListRef.current - + // 显示回到底部按钮:距离底部超过 300px const distanceFromBottom = scrollHeight - scrollTop - clientHeight setShowScrollToBottom(distanceFromBottom > 300) - + // 预加载:当滚动到顶部 30% 区域时开始加载 if (!isLoadingMore && hasMoreMessages && currentSessionId) { const threshold = clientHeight * 0.3 @@ -347,26 +497,35 @@ function ChatPage(_props: ChatPageProps) { const handleResizeStart = useCallback((e: React.MouseEvent) => { e.preventDefault() setIsResizing(true) - + const startX = e.clientX const startWidth = sidebarWidth - + const handleMouseMove = (e: MouseEvent) => { const delta = e.clientX - startX const newWidth = Math.min(Math.max(startWidth + delta, 200), 400) setSidebarWidth(newWidth) } - + const handleMouseUp = () => { setIsResizing(false) document.removeEventListener('mousemove', handleMouseMove) document.removeEventListener('mouseup', handleMouseUp) } - + document.addEventListener('mousemove', handleMouseMove) document.addEventListener('mouseup', handleMouseUp) }, [sidebarWidth]) + // 同步 messages 和 currentSessionId 到 ref,供自动更新使用 + useEffect(() => { + messagesRef.current = messages + }, [messages]) + + useEffect(() => { + currentSessionIdRef.current = currentSessionId + }, [currentSessionId]) + // 初始化连接 useEffect(() => { if (!isConnected && !isConnecting) { @@ -374,29 +533,240 @@ function ChatPage(_props: ChatPageProps) { } }, []) + // 自动增量更新:启用文件监听和定时检查(带性能优化) + useEffect(() => { + if (!isConnected) return + + // 监听数据管理进度事件(数据库更新时显示状态) + const removeProgressListener = window.electronAPI.dataManagement.onProgress((data) => { + if (data.type === 'update') { + // 数据库更新开始 + setIsUpdating(true) + } else if (data.type === 'complete' || data.type === 'error') { + // 数据库更新完成或失败 + // 延迟一点隐藏,确保前端更新也完成 + setTimeout(() => { + setIsUpdating(false) + }, 500) + } + }) + + // 启用自动更新(文件监听实时检测 + 每30秒定时检查作为备选) + window.electronAPI.dataManagement.enableAutoUpdate(30).catch(console.error) + + // 执行更新的实际函数(带防抖和频率限制) + const performUpdate = async () => { + const now = Date.now() + const MIN_UPDATE_INTERVAL = 1000 // 最小更新间隔:1秒(保证及时性,同时避免过于频繁) + + // 如果距离上次更新不足1秒,延迟执行 + const timeSinceLastUpdate = now - lastUpdateTimeRef.current + if (timeSinceLastUpdate < MIN_UPDATE_INTERVAL) { + const delay = MIN_UPDATE_INTERVAL - timeSinceLastUpdate + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current) + } + updateTimerRef.current = setTimeout(() => { + performUpdate() + }, delay) + return + } + + lastUpdateTimeRef.current = now + + try { + // 记录当前滚动位置和是否在底部附近 + const listEl = messageListRef.current + let isNearBottom = false + if (listEl) { + const { scrollTop, scrollHeight, clientHeight } = listEl + const distanceFromBottom = scrollHeight - scrollTop - clientHeight + isNearBottom = distanceFromBottom < 300 // 距离底部 300px 内认为是在底部附近 + } + + // 静默执行增量更新(不显示进度弹窗) + const result = await window.electronAPI.dataManagement.autoIncrementalUpdate(true) + + if (result.success && result.updated) { + console.log('[ChatPage] 自动增量更新完成,无感刷新数据...') + + // 重新连接聊天服务(因为增量更新会关闭连接) + const connectResult = await window.electronAPI.chat.connect() + if (!connectResult.success) { + console.error('[ChatPage] 重新连接失败:', connectResult.error) + return + } + + // 刷新会话列表(不影响当前聊天) + const sessionsResult = await window.electronAPI.chat.getSessions() + if (sessionsResult.success && sessionsResult.sessions) { + setSessions(sessionsResult.sessions) + } + + // 如果当前有打开的会话,增量更新消息(不清空现有消息) + const currentId = currentSessionIdRef.current + const currentMessages = messagesRef.current + + if (currentId && currentMessages.length > 0) { + // 获取最新消息(只获取比当前最新消息更新的) + const lastMessage = currentMessages[currentMessages.length - 1] + const lastTime = lastMessage.createTime + + // 获取最新50条消息 + const messagesResult = await window.electronAPI.chat.getMessages(currentId, 0, 50) + if (messagesResult.success && messagesResult.messages) { + // 过滤出真正的新消息(比最后一条消息更新的) + const newMessages = messagesResult.messages.filter(msg => { + // 如果时间戳更大,或者是同一条消息但内容可能更新了 + return msg.createTime > lastTime || + (msg.createTime === lastTime && msg.localId !== lastMessage.localId) + }) + + // 去重:检查是否已存在(使用更高效的 Map) + const existingKeys = new Set(currentMessages.map(m => `${m.localId}-${m.createTime}`)) + const uniqueNewMessages = newMessages.filter(msg => + !existingKeys.has(`${msg.localId}-${msg.createTime}`) + ) + + if (uniqueNewMessages.length > 0) { + console.log(`[ChatPage] 发现 ${uniqueNewMessages.length} 条新消息,增量添加`) + + // 增量添加到末尾 + appendMessages(uniqueNewMessages, false) + + // 只有在底部附近时才自动滚动到底部,否则保持当前位置 + requestAnimationFrame(() => { + if (messageListRef.current) { + if (isNearBottom) { + // 用户在底部附近,自动滚动到底部显示新消息 + messageListRef.current.scrollTop = messageListRef.current.scrollHeight + } + // 用户在看历史消息,保持当前位置(新消息会在底部,但不会打断用户) + } + }) + } + } + } else if (currentId && currentMessages.length === 0) { + // 如果没有消息,正常加载(首次加载) + setCurrentOffset(0) + const messagesResult = await window.electronAPI.chat.getMessages(currentId, 0, 50) + if (messagesResult.success && messagesResult.messages) { + setMessages(messagesResult.messages) + setHasMoreMessages(messagesResult.hasMore ?? false) + } + } + } + } catch (e) { + console.error('[ChatPage] 自动增量更新失败:', e) + } + } + + // 监听更新可用事件(文件监听实时触发,带防抖合并) + const removeListener = window.electronAPI.dataManagement.onUpdateAvailable(async (hasUpdate) => { + if (!hasUpdate) return + + // 如果用户正在操作(点击会话、加载消息),延迟自动更新,避免影响用户体验 + if (isUserOperatingRef.current || isLoadingMessages) { + console.log('[ChatPage] 用户正在操作,延迟自动更新') + // 延迟3秒再检查(给用户足够时间完成操作) + setTimeout(() => { + // 如果3秒后用户还在操作,继续延迟 + if (!isUserOperatingRef.current && !isLoadingMessages) { + // 用户已完成操作,可以更新了 + triggerUpdate() + } else { + // 继续延迟 + setTimeout(() => { + if (!isUserOperatingRef.current && !isLoadingMessages) { + triggerUpdate() + } + }, 2000) + } + }, 3000) + return + } + + triggerUpdate() + }) + + // 提取更新触发逻辑,便于复用 + const triggerUpdate = () => { + // 提前1秒显示更新指示器(但立即显示,不等待) + setIsUpdating(true) + console.log('[ChatPage] 显示更新指示器(提前1秒)') + + // 如果更新被取消,1秒后隐藏 + if (updateStatusTimerRef.current) { + clearTimeout(updateStatusTimerRef.current) + } + updateStatusTimerRef.current = setTimeout(() => { + // 如果1秒后还没有开始更新,可能是误触发,先隐藏 + // 但实际更新开始时会重新显示 + }, 1000) + + // 使用防抖机制,合并300ms内的多次文件变化(文件监听本身也有500ms防抖) + // 这样可以合并短时间内的多次变化,同时保证及时性 + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current) + } + + // 延迟300ms执行,合并短时间内的多次更新请求 + // 这样文件监听检测到变化后,最多延迟300ms+1秒=1.3秒就能更新 + updateTimerRef.current = setTimeout(async () => { + try { + console.log('[ChatPage] 开始执行更新') + setIsUpdating(true) // 确保显示 + await performUpdate() + } finally { + // 更新完成后隐藏指示器 + console.log('[ChatPage] 更新完成,隐藏指示器') + setIsUpdating(false) + if (updateStatusTimerRef.current) { + clearTimeout(updateStatusTimerRef.current) + updateStatusTimerRef.current = null + } + } + }, 300) + } + + return () => { + removeListener() + removeProgressListener() + if (updateTimerRef.current) { + clearTimeout(updateTimerRef.current) + } + if (updateStatusTimerRef.current) { + clearTimeout(updateStatusTimerRef.current) + } + setIsUpdating(false) + // 组件卸载时禁用自动更新 + window.electronAPI.dataManagement.disableAutoUpdate().catch(console.error) + } + }, [isConnected, isLoadingMessages, setSessions, setMessages, setHasMoreMessages, setCurrentOffset, appendMessages, setIsUpdating, setConnected, setConnectionError]) + // 点击外部或右键其他地方关闭右键菜单 useEffect(() => { const handleClick = () => { if (contextMenu) { - setContextMenu(null) + closeContextMenu() } } - + const handleContextMenu = () => { // 右键其他地方时,先关闭当前菜单 // 新菜单会在 onContextMenu 处理函数中打开 if (contextMenu) { - setContextMenu(null) + closeContextMenu() } } - + if (contextMenu) { // 延迟添加事件监听,避免立即触发 const timer = setTimeout(() => { document.addEventListener('click', handleClick) document.addEventListener('contextmenu', handleContextMenu) }, 0) - + return () => { clearTimeout(timer) document.removeEventListener('click', handleClick) @@ -408,26 +778,26 @@ function ChatPage(_props: ChatPageProps) { // 格式化会话时间(相对时间)- 与原项目一致 const formatSessionTime = (timestamp: number): string => { if (!timestamp) return '' - + const now = Date.now() const msgTime = timestamp * 1000 const diff = now - msgTime - + const minutes = Math.floor(diff / 60000) const hours = Math.floor(diff / 3600000) - + if (minutes < 1) return '刚刚' if (minutes < 60) return `${minutes}分钟前` if (hours < 24) return `${hours}小时前` - + // 超过24小时显示日期 const date = new Date(msgTime) const nowDate = new Date() - + if (date.getFullYear() === nowDate.getFullYear()) { return `${date.getMonth() + 1}/${date.getDate()}` } - + return `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}` } @@ -449,25 +819,25 @@ function ChatPage(_props: ChatPageProps) { const date = new Date(timestamp * 1000) const now = new Date() const isToday = date.toDateString() === now.toDateString() - + if (isToday) return '今天' - + const yesterday = new Date(now) yesterday.setDate(yesterday.getDate() - 1) if (date.toDateString() === yesterday.toDateString()) return '昨天' - - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: 'long', - day: 'numeric' + + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric' }) } return (
{/* 左侧会话列表 */} -
@@ -576,16 +946,22 @@ function ChatPage(_props: ChatPageProps) {
群聊
)}
+ {isUpdating && ( +
+ + 正在更新... +
+ )}
- -
) : ( -
@@ -623,78 +999,84 @@ function ChatPage(_props: ChatPageProps) { {messages.map((msg, index) => { const prevMsg = index > 0 ? messages[index - 1] : undefined - const showDateDivider = shouldShowDateDivider(msg, prevMsg) - - // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 - const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) - const isSent = msg.isSend === 1 - const isSystem = msg.localType === 10000 - - // 系统消息居中显示 - const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') - - return ( -
- {showDateDivider && ( -
- {formatDateDivider(msg.createTime)} -
- )} - { - // 只对文本消息显示右键菜单 - const isSystem = message.localType === 10000 - const isEmoji = message.localType === 47 - const isImage = message.localType === 3 - const isVideo = message.localType === 43 - - // 只有普通文本消息才显示右键菜单 - if (isSystem || isEmoji || isImage || isVideo) { - return - } - - e.preventDefault() - e.stopPropagation() - - // 计算菜单位置,确保不超出屏幕 - const menuWidth = 160 - const menuHeight = 120 - let x = e.clientX - let y = e.clientY - - if (x + menuWidth > window.innerWidth) { - x = window.innerWidth - menuWidth - 10 - } - if (y + menuHeight > window.innerHeight) { - y = window.innerHeight - menuHeight - 10 - } - - // 直接设置新菜单,React 会自动处理状态更新 - setContextMenu({ - x, - y, - message, - session: currentSession - }) - }} - isSelected={selectedMessages.has(msg.localId)} - /> -
- ) - })} + const showDateDivider = shouldShowDateDivider(msg, prevMsg) - {/* 回到底部按钮 */} -
- - 回到底部 + // 显示时间:第一条消息,或者与上一条消息间隔超过5分钟 + const showTime = !prevMsg || (msg.createTime - prevMsg.createTime > 300) + const isSent = msg.isSend === 1 + const isPatAppMsg = (() => { + const content = msg.rawContent || msg.parsedContent || '' + if (!content) return false + return /[\s\S]*?\s*62\s*<\/type>/i.test(content) || //i.test(content) + })() + const isSystem = msg.localType === 10000 || isPatAppMsg + + // 系统消息居中显示 + const wrapperClass = isSystem ? 'system' : (isSent ? 'sent' : 'received') + + return ( +
+ {showDateDivider && ( +
+ {formatDateDivider(msg.createTime)} +
+ )} + { + // 系统消息、图片、视频不显示右键菜单 + const isSystem = message.localType === 10000 + const isImage = message.localType === 3 + const isVideo = message.localType === 43 + + // 系统消息、图片、视频不显示右键菜单(表情包可以) + if (isSystem || isImage || isVideo) { + return + } + + e.preventDefault() + e.stopPropagation() + + // 计算菜单位置,确保不超出屏幕 + const menuWidth = 160 + const menuHeight = 120 + let x = e.clientX + let y = e.clientY + + if (x + menuWidth > window.innerWidth) { + x = window.innerWidth - menuWidth - 10 + } + if (y + menuHeight > window.innerHeight) { + y = window.innerHeight - menuHeight - 10 + } + + // 直接设置新菜单,React 会自动处理状态更新 + setContextMenu({ + x, + y, + message, + session: currentSession, + handlers + }) + }} + isSelected={selectedMessages.has(msg.localId)} + /> +
+ ) + })} + + {/* 回到底部按钮 */} +
+ + 回到底部 +
-
)} {/* 会话详情面板 */} @@ -810,53 +1192,63 @@ function ChatPage(_props: ChatPageProps) { {/* 右键菜单 */} {contextMenu && createPortal( -
setContextMenu(null)} +
closeContextMenu()} onContextMenu={(e) => { e.preventDefault() e.stopPropagation() // 右键菜单外部时关闭菜单 - setContextMenu(null) + closeContextMenu() }} > -
e.stopPropagation()} onContextMenu={(e) => e.stopPropagation()} - > -
{ - try { - await navigator.clipboard.writeText(contextMenu.message.parsedContent || '') - setContextMenu(null) - setCopyToast(true) - setTimeout(() => setCopyToast(false), 2000) - } catch (e) { - console.error('复制失败:', e) - setContextMenu(null) - } - }} - > - - 复制 -
-
{ - setShowEnlargeView({ - message: contextMenu.message, - content: contextMenu.message.parsedContent || '' - }) + onAnimationEnd={() => { + if (isMenuClosing) { setContextMenu(null) - }} - > - - 放大阅读 -
-
+ {contextMenu.message.localType !== 34 && ( + <> +
{ + try { + await navigator.clipboard.writeText(contextMenu.message.parsedContent || '') + closeContextMenu() + setCopyToast(true) + setTimeout(() => setCopyToast(false), 2000) + } catch (e) { + console.error('复制失败:', e) + closeContextMenu() + } + }} + > + + 复制 +
+
{ + setShowEnlargeView({ + message: contextMenu.message, + content: contextMenu.message.parsedContent || '' + }) + closeContextMenu() + }} + > + + 放大阅读 +
+ + )} +
{ setSelectedMessages(prev => { @@ -868,12 +1260,130 @@ function ChatPage(_props: ChatPageProps) { } return newSet }) - setContextMenu(null) + closeContextMenu() }} > 多选
+ + {/* 语音消息:重新转文字 */} + {contextMenu.handlers?.reTranscribe && ( +
{ + contextMenu.handlers!.reTranscribe!() + closeContextMenu() + }} + > + + 重新转文字 +
+ )} + + {/* 语音消息:修改识别文字 */} + {contextMenu.handlers?.editStt && ( +
{ + contextMenu.handlers!.editStt!() + closeContextMenu() + }} + > + + 修改识别文字 +
+ )} + + {/* 查看消息信息 */} +
{ + setShowMessageInfo(contextMenu.message) + closeContextMenu() + }} + > + + 查看消息信息 +
+
+
, + document.body + )} + + {/* 消息信息弹窗 */} + {showMessageInfo && createPortal( +
setShowMessageInfo(null)}> +
e.stopPropagation()}> +
+
+ +

消息详细信息

+
+ +
+
+
+

基础字段

+
+
+ Local ID + {showMessageInfo.localId} +
+
+ Server ID + {showMessageInfo.serverId} +
+
+ Local Type + {showMessageInfo.localType} +
+
+ 发送者 + {showMessageInfo.senderUsername} +
+
+ 创建时间 + {new Date(showMessageInfo.createTime * 1000).toLocaleString()} ({showMessageInfo.createTime}) +
+
+ 发送状态 + {showMessageInfo.isSend === 1 ? '发送' : '接收'} +
+
+
+ + {(showMessageInfo.emojiMd5 || showMessageInfo.emojiCdnUrl) && ( +
+

表情包信息

+
+ {showMessageInfo.emojiMd5 && ( +
+ MD5 + {showMessageInfo.emojiMd5} +
+ )} + {showMessageInfo.emojiCdnUrl && ( +
+ CDN URL + {showMessageInfo.emojiCdnUrl} +
+ )} +
+
+ )} + + {showMessageInfo.rawContent && ( +
+

原始消息内容 (XML/Raw)

+
+
{showMessageInfo.rawContent}
+
+
+ )} +
, document.body @@ -922,11 +1432,11 @@ const MAX_CONCURRENT_DECRYPTS = 3 async function processDecryptQueue() { if (isProcessingQueue) return isProcessingQueue = true - + try { while (imageDecryptQueue.length > 0) { const batch = imageDecryptQueue.splice(0, MAX_CONCURRENT_DECRYPTS) - await Promise.all(batch.map(fn => fn().catch(() => {}))) + await Promise.all(batch.map(fn => fn().catch(() => { }))) } } finally { isProcessingQueue = false @@ -938,24 +1448,33 @@ function enqueueDecrypt(fn: () => Promise) { void processDecryptQueue() } - // 视频信息缓存 - const videoInfoCache = new Map() +// 视频信息缓存 +const videoInfoCache = new Map() // 消息气泡组件 -function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, hasImageKey, onContextMenu, isSelected }: { - message: Message; - session: ChatSession; +function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, hasImageKey, onContextMenu, isSelected, quoteStyle = 'default' }: { + message: Message; + session: ChatSession; showTime?: boolean; myAvatarUrl?: string; isGroupChat?: boolean; hasImageKey?: boolean; - onContextMenu?: (e: React.MouseEvent, message: Message) => void; + onContextMenu?: (e: React.MouseEvent, message: Message, handlers?: any) => void; isSelected?: boolean; + quoteStyle?: 'default' | 'wechat'; }) { - const isSystem = message.localType === 10000 + const isPatAppMsg = (() => { + const content = message.rawContent || message.parsedContent || '' + if (!content) return false + // WeChat “拍一拍”通常是 appmsg.type=62,并携带 patinfo + return /[\s\S]*?\s*62\s*<\/type>/i.test(content) || //i.test(content) + })() + + const isSystem = message.localType === 10000 || isPatAppMsg const isEmoji = message.localType === 47 const isImage = message.localType === 3 const isVideo = message.localType === 43 + const isVoice = message.localType === 34 const isSent = message.isSend === 1 const [senderAvatarUrl, setSenderAvatarUrl] = useState(undefined) const [senderName, setSenderName] = useState(undefined) @@ -963,43 +1482,50 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h const [emojiLoading, setEmojiLoading] = useState(false) const [imageError, setImageError] = useState(false) const [imageLoading, setImageLoading] = useState(false) + + // 语音相关状态 + const [voiceLoading, setVoiceLoading] = useState(false) + const [voicePlaying, setVoicePlaying] = useState(false) + const [voiceError, setVoiceError] = useState(null) + const [voiceDataUrl, setVoiceDataUrl] = useState(null) + const voiceRef = useRef(null) + + // 语音转文字 (STT) 状态 + const [sttTranscript, setSttTranscript] = useState(null) + const [sttLoading, setSttLoading] = useState(false) + const [sttError, setSttError] = useState(null) + const [isEditingStt, setIsEditingStt] = useState(false) + const [editContent, setEditContent] = useState('') const [imageHasUpdate, setImageHasUpdate] = useState(false) const [imageClicked, setImageClicked] = useState(false) - const [imageNoHd, setImageNoHd] = useState(false) // 没有高清图 const imageUpdateCheckedRef = useRef(null) const imageClickTimerRef = useRef(null) - const [showImagePreview, setShowImagePreview] = useState(false) const [isVisible, setIsVisible] = useState(false) const imageContainerRef = useRef(null) - + // 视频相关状态 const [videoInfo, setVideoInfo] = useState<{ videoUrl?: string; coverUrl?: string; thumbUrl?: string; exists: boolean } | null>(null) const [videoLoading, setVideoLoading] = useState(false) - const [videoPlaying, setVideoPlaying] = useState(false) - const [videoEnded, setVideoEnded] = useState(false) - const [videoDataUrl, setVideoDataUrl] = useState(null) - const [videoDataLoading, setVideoDataLoading] = useState(false) - const videoRef = useRef(null) const videoContainerRef = useRef(null) - + // 从缓存获取表情包 data URL const cacheKey = message.emojiMd5 || message.emojiCdnUrl || '' const [emojiLocalPath, setEmojiLocalPath] = useState( () => emojiDataUrlCache.get(cacheKey) ) - + // 图片缓存 const imageCacheKey = message.imageMd5 || message.imageDatName || `local:${message.localId}` const [imageLocalPath, setImageLocalPath] = useState( () => imageDataUrlCache.get(imageCacheKey) ) - + const formatTime = (timestamp: number): string => { const date = new Date(timestamp * 1000) - return date.toLocaleDateString('zh-CN', { - year: 'numeric', - month: '2-digit', - day: '2-digit' + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' }) + ' ' + date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' }) } @@ -1012,8 +1538,13 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h // 下载表情包 const downloadEmoji = () => { - if (!message.emojiCdnUrl || emojiLoading) return - + if (emojiLoading) return + + // 没有 cdnUrl 也没有 md5,无法获取 + if (!message.emojiCdnUrl && !message.emojiMd5) { + return + } + // 先检查缓存 const cached = emojiDataUrlCache.get(cacheKey) if (cached) { @@ -1021,17 +1552,20 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h setEmojiError(false) return } - + setEmojiLoading(true) setEmojiError(false) - window.electronAPI.chat.downloadEmoji(message.emojiCdnUrl, message.emojiMd5).then((result: { success: boolean; localPath?: string; error?: string }) => { + + // 如果有 cdnUrl,优先下载;否则仅通过 md5 查找本地缓存 + const cdnUrl = message.emojiCdnUrl || '' + window.electronAPI.chat.downloadEmoji(cdnUrl, message.emojiMd5, message.productId, message.createTime).then((result: { success: boolean; localPath?: string; error?: string }) => { if (result.success && result.localPath) { emojiDataUrlCache.set(cacheKey, result.localPath) setEmojiLocalPath(result.localPath) } else { setEmojiError(true) } - }).catch(() => { + }).catch((e) => { setEmojiError(true) }).finally(() => { setEmojiLoading(false) @@ -1043,9 +1577,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h if (!isImage || imageLoading) return setImageLoading(true) setImageError(false) - if (forceUpdate) { - setImageNoHd(false) // 重置状态 - } + try { if (message.imageMd5 || message.imageDatName) { const result = await window.electronAPI.image.decrypt({ @@ -1054,25 +1586,21 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h imageDatName: message.imageDatName, force: forceUpdate }) - + // 先检查错误情况 if (!result.success) { - // 如果是请求高清图失败,标记没有高清图 - if (forceUpdate && result.error?.includes('未找到高清图')) { - setImageNoHd(true) - return - } + setImageError(true) return } - + // 成功情况 if (result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) setImageLocalPath(result.localPath) // 如果返回的是缩略图,标记有更新可用 setImageHasUpdate(Boolean((result as { isThumb?: boolean }).isThumb)) - setImageNoHd(false) + return } } @@ -1108,7 +1636,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h // 使用 IntersectionObserver 检测图片是否进入可视区域(懒加载) useEffect(() => { if (!isImage || !imageContainerRef.current) return - + const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { @@ -1123,16 +1651,16 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h threshold: 0 } ) - + observer.observe(imageContainerRef.current) - + return () => observer.disconnect() }, [isImage]) // 视频懒加载 useEffect(() => { if (!isVideo || !videoContainerRef.current) return - + const observer = new IntersectionObserver( (entries) => { entries.forEach((entry) => { @@ -1147,9 +1675,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h threshold: 0 } ) - + observer.observe(videoContainerRef.current) - + return () => observer.disconnect() }, [isVideo]) @@ -1157,14 +1685,14 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h useEffect(() => { if (!isVideo || !isVisible || videoInfo || videoLoading) return if (!message.videoMd5) return - + // 先检查缓存 const cached = videoInfoCache.get(message.videoMd5) if (cached) { setVideoInfo(cached) return } - + setVideoLoading(true) window.electronAPI.video.getVideoInfo(message.videoMd5).then((result) => { if (result && result.success) { @@ -1186,45 +1714,161 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h }) }, [isVideo, isVisible, videoInfo, videoLoading, message.videoMd5]) - // 播放视频 + // 播放视频 - 打开独立窗口 const handlePlayVideo = useCallback(async () => { if (!videoInfo?.videoUrl) return - - // 如果已有视频数据,直接播放 - if (videoDataUrl) { - setVideoPlaying(true) - setVideoEnded(false) - requestAnimationFrame(() => { - videoRef.current?.play() - }) - return - } - - // 加载视频数据(videoUrl 现在是文件路径) - setVideoDataLoading(true) + + // 直接打开独立视频播放窗口 try { - const result = await window.electronAPI.video.readFile(videoInfo.videoUrl) - if (result.success && result.data) { - setVideoDataUrl(result.data) - setVideoPlaying(true) - setVideoEnded(false) - requestAnimationFrame(() => { - videoRef.current?.play() - }) - } + await window.electronAPI.window.openVideoPlayerWindow(videoInfo.videoUrl) } catch { // 忽略错误 - } finally { - setVideoDataLoading(false) } - }, [videoInfo?.videoUrl, videoDataUrl]) + }, [videoInfo?.videoUrl]) - // 视频播放结束 - const handleVideoEnded = useCallback(() => { - setVideoPlaying(false) - setVideoEnded(true) + // 语音播放处理 + const handlePlayVoice = useCallback(async () => { + if (voiceLoading) return + + // 如果已经有数据,直接播放/暂停 + if (voiceDataUrl && voiceRef.current) { + if (voicePlaying) { + voiceRef.current.pause() + setVoicePlaying(false) + } else { + voiceRef.current.currentTime = 0 + voiceRef.current.play() + setVoicePlaying(true) + } + return + } + + // 加载语音数据 + setVoiceLoading(true) + setVoiceError(null) + try { + const result = await window.electronAPI.chat.getVoiceData(session.username, String(message.localId), message.createTime) + if (result.success && result.data) { + const dataUrl = `data:audio/wav;base64,${result.data}` + setVoiceDataUrl(dataUrl) + // 等待状态更新后播放 + requestAnimationFrame(() => { + if (voiceRef.current) { + voiceRef.current.play() + setVoicePlaying(true) + } + }) + } else { + setVoiceError(result.error || '加载失败') + } + } catch (e) { + setVoiceError(String(e)) + } finally { + setVoiceLoading(false) + } + }, [voiceLoading, voiceDataUrl, voicePlaying, session.username, message.localId]) + + // 语音播放结束 + const handleVoiceEnded = useCallback(() => { + setVoicePlaying(false) }, []) + // 语音转文字处理 + const handleTranscribeVoice = useCallback(async (e?: React.MouseEvent, force = false) => { + e?.stopPropagation() // 阻止触发播放 + + if (sttLoading || (sttTranscript && !force)) return // 已转写或正在转写 + + console.log('[STT] 开始转写...') + setSttLoading(true) + setSttError(null) + + try { + // 先检查模型是否已下载 + console.log('[STT] 检查模型状态...') + const modelStatus = await window.electronAPI.stt.getModelStatus() + console.log('[STT] 模型状态:', modelStatus) + if (!modelStatus.success || !modelStatus.exists) { + if (window.confirm('语音识别模型未下载,是否立即下载?(约245MB)\n下载完成后将自动开始转写。')) { + setSttLoading(true) + setSttTranscript('准备下载模型...') + + const removeProgress = window.electronAPI.stt.onDownloadProgress((p) => { + const pct = p.percent || 0 + setSttTranscript(`正在下载模型... ${pct.toFixed(1)}%`) + }) + + try { + const dlResult = await window.electronAPI.stt.downloadModel() + removeProgress() + + if (dlResult.success) { + setSttTranscript('模型下载完成,正在初始化引擎...') + // 给予文件系统缓冲时间,避免刚下载完无法读取 + await new Promise(r => setTimeout(r, 2000)) + setSttLoading(false) // Reset checking + await handleTranscribeVoice(undefined, true) + return + } else { + setSttError(dlResult.error || '模型下载失败') + setSttTranscript(null) + } + } catch (e) { + removeProgress() + setSttError(`模型下载出错: ${e}`) + setSttTranscript(null) + } + } + setSttLoading(false) + return + } + + // 如果没有语音数据,先获取 + let wavBase64 = voiceDataUrl?.replace('data:audio/wav;base64,', '') + + if (!wavBase64) { + console.log('[STT] 获取语音数据...') + const result = await window.electronAPI.chat.getVoiceData( + session.username, + String(message.localId), + message.createTime + ) + console.log('[STT] 语音数据:', { success: result.success, dataLength: result.data?.length }) + if (!result.success || !result.data) { + setSttError(result.error || '获取语音数据失败') + setSttLoading(false) + return + } + wavBase64 = result.data + // 同时缓存语音数据 + setVoiceDataUrl(`data:audio/wav;base64,${wavBase64}`) + } + + // 监听实时结果(缓存命中时不会触发) + // 监听实时结果(缓存命中时不会触发) + const removeListener = window.electronAPI.stt.onPartialResult((text) => { + setSttTranscript(text) + }) + + // 开始转写 - 传递 sessionId 和 createTime 用于缓存 + // 开始转写 - 传递 sessionId 和 createTime 用于缓存 + const result = await window.electronAPI.stt.transcribe(wavBase64, session.username, message.createTime, force) + + removeListener() + + if (result.success && result.transcript) { + setSttTranscript(result.transcript) + } else { + setSttError(result.error || '转写失败') + } + } catch (e) { + console.error('[STT] 转写异常:', e) + setSttError(String(e)) + } finally { + setSttLoading(false) + } + }, [sttLoading, sttTranscript, voiceDataUrl, session.username, message.localId, message.createTime]) + // 群聊中获取发送者信息 useEffect(() => { if (isGroupChat && !isSent && message.senderUsername) { @@ -1233,17 +1877,18 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h setSenderAvatarUrl(result.avatarUrl) setSenderName(result.displayName) } - }).catch(() => {}) + }).catch(() => { }) } }, [isGroupChat, isSent, message.senderUsername]) // 自动下载表情包 useEffect(() => { if (emojiLocalPath) return - if (isEmoji && message.emojiCdnUrl && !emojiLoading && !emojiError) { + // 有 cdnUrl 或 md5 都可以尝试获取 + if (isEmoji && (message.emojiCdnUrl || message.emojiMd5) && !emojiLoading && !emojiError) { downloadEmoji() } - }, [isEmoji, message.emojiCdnUrl, emojiLocalPath, emojiLoading, emojiError]) + }, [isEmoji, message.emojiCdnUrl, message.emojiMd5, message.productId, emojiLocalPath, emojiLoading, emojiError]) // 自动尝试从缓存解析图片,如果没有缓存则自动解密(仅在可见时触发,5秒超时) useEffect(() => { @@ -1253,18 +1898,18 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h if (imageUpdateCheckedRef.current === imageCacheKey) return if (imageLocalPath) return // 如果已经有本地路径,不需要再解析 if (imageLoading) return // 已经在加载中 - + imageUpdateCheckedRef.current = imageCacheKey - + let cancelled = false let timeoutId: number | null = null - + const doDecrypt = async () => { // 设置 5 秒超时 const timeoutPromise = new Promise<{ timeout: true }>((resolve) => { timeoutId = window.setTimeout(() => resolve({ timeout: true }), 5000) }) - + const decryptPromise = (async () => { // 先尝试从缓存获取 try { @@ -1280,9 +1925,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h } catch { // 继续尝试解密 } - + if (cancelled) return { cancelled: true } - + // 缓存中没有,自动尝试解密 try { const decryptResult = await window.electronAPI.image.decrypt({ @@ -1300,26 +1945,26 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h } return { failed: true } })() - + setImageLoading(true) const result = await Promise.race([decryptPromise, timeoutPromise]) - + if (timeoutId) { window.clearTimeout(timeoutId) timeoutId = null } - + if (cancelled) return - + if ('timeout' in result) { // 超时,显示手动解密按钮 setImageError(true) setImageLoading(false) return } - + if ('cancelled' in result) return - + if ('success' in result && result.localPath) { imageDataUrlCache.set(imageCacheKey, result.localPath) setImageLocalPath(result.localPath) @@ -1332,17 +1977,32 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h } setImageLoading(false) } - + // 使用队列控制并发 enqueueDecrypt(doDecrypt) - + return () => { cancelled = true - if (timeoutId) { - window.clearTimeout(timeoutId) - } + if (timeoutId) window.clearTimeout(timeoutId) } - }, [isImage, imageLocalPath, message.imageMd5, message.imageDatName, imageCacheKey, session.username, isVisible]) + }, [isImage, message.imageMd5, message.imageDatName, isVisible, imageCacheKey, imageLocalPath, session.username]) + + // 自动检查转写缓存 + useEffect(() => { + if (!isVoice || sttTranscript || sttLoading) return + + window.electronAPI.stt.getCachedTranscript(session.username, message.createTime).then((result) => { + if (result.success && result.transcript) { + setSttTranscript(result.transcript) + } + }).catch(() => { + }) + }, [isVoice, session.username, message.createTime, sttTranscript, sttLoading]) + + + + + // 监听图片更新事件 useEffect(() => { @@ -1383,24 +2043,37 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h }, [isImage, imageCacheKey, message.imageDatName, message.imageMd5]) if (isSystem) { + // 系统类消息:包含“拍一拍”等 appmsg(type=62) + let systemText = message.parsedContent || '[系统消息]' + if (isPatAppMsg) { + try { + const content = message.rawContent || message.parsedContent || '' + const xmlContent = content.includes('') ? content.substring(content.indexOf('')) : content + const parser = new DOMParser() + const doc = parser.parseFromString(xmlContent, 'text/xml') + systemText = (doc.querySelector('title')?.textContent || systemText || '[拍一拍]').trim() + } catch { + // ignore + } + } return (
-
+
) } const bubbleClass = isSent ? 'sent' : 'received' - + // 头像逻辑: // - 自己发的:使用 myAvatarUrl // - 群聊中对方发的:使用发送者头像 // - 私聊中对方发的:使用会话头像 - const avatarUrl = isSent - ? myAvatarUrl + const avatarUrl = isSent + ? myAvatarUrl : (isGroupChat ? senderAvatarUrl : session.avatarUrl) - const avatarLetter = isSent - ? '我' + const avatarLetter = isSent + ? '我' : getAvatarLetter(isGroupChat ? (senderName || message.senderUsername || '?') : (session.displayName || session.username)) // 是否有引用消息 @@ -1408,6 +2081,19 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h // 渲染消息内容 const renderContent = () => { + // 带引用的消息 (经典模式) + if (hasQuote && quoteStyle === 'default') { + return ( +
+
+ {message.quotedSender && {message.quotedSender}} + {message.quotedContent} +
+
+
+ ) + } + // 图片消息 if (isImage) { // 没有配置密钥时显示提示(优先级最高) @@ -1419,7 +2105,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
) } - + // 已有缓存图片,直接显示 if (imageLocalPath) { return ( @@ -1431,7 +2117,9 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h className="image-message" onClick={() => { void requestImageDecrypt(true) - setShowImagePreview(true) + if (imageLocalPath) { + window.electronAPI.window.openImageViewerWindow(imageLocalPath) + } }} onLoad={() => setImageError(false)} onError={() => setImageError(true)} @@ -1442,24 +2130,11 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
)}
- {showImagePreview && createPortal( -
setShowImagePreview(false)}> - {imageNoHd ? ( -
e.stopPropagation()}> - -

未找到高清图

- 请在微信中点开该图片查看后重试 -
- ) : ( - 图片预览 e.stopPropagation()} /> - )} -
, - document.body - )} + ) } - + // 未进入可视区域时显示占位符 if (!isVisible) { return ( @@ -1468,7 +2143,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
) } - + if (imageLoading) { return (
@@ -1476,7 +2151,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
) } - + // 解密失败或未解密 return (
) } - + // 加载中 if (videoLoading) { return ( @@ -1512,7 +2187,7 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
) } - + // 视频不存在 if (!videoInfo?.exists || !videoInfo.videoUrl) { return ( @@ -1522,64 +2197,187 @@ function MessageBubble({ message, session, showTime, myAvatarUrl, isGroupChat, h
) } - - // 默认显示缩略图,点击打开全屏播放器 + + // 默认显示缩略图,点击打开独立播放窗口 const thumbSrc = videoInfo.thumbUrl || videoInfo.coverUrl return ( - <> -
- {thumbSrc ? ( - 视频缩略图 - ) : ( -
-
- )} -
- {videoDataLoading ? ( - +
+ {thumbSrc ? ( + 视频缩略图 + ) : ( +
+
+ )} +
+ +
+
+ ) + } + + // 语音消息 + if (isVoice) { + const duration = message.voiceDuration || 0 + const displayDuration = duration > 0 ? `${Math.round(duration)}"` : '' + // 根据时长计算宽度(最小60px,最大200px,每秒增加约10px) + const minWidth = 60 + const maxWidth = 200 + const width = Math.min(maxWidth, Math.max(minWidth, minWidth + duration * 10)) + + // 语音图标组件 + const VoiceIcon = () => { + if (voiceLoading) { + return + } + if (voiceError) { + return + } + if (voicePlaying) { + return ( +
+ + + +
+ ) + } + return ( + + + + + + + ) + } + + return ( +
+
+
+ {isSent ? ( + <> + {displayDuration} +
+ ) : ( - + <> +
+ {displayDuration} + + )} + {voiceDataUrl && ( +
- {videoPlaying && videoDataUrl && createPortal( -
setVideoPlaying(false)}> -
, - document.body + + {/* 转文字按钮或转写结果 */} + {sttTranscript ? ( + isEditingStt ? ( +
e.stopPropagation()}> +