From 2823607146b48f45a75d48a90f15ae59421956eb Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Fri, 27 Feb 2026 14:15:31 +0800 Subject: [PATCH 1/6] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/HTTP-API.md | 59 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md index 21e5265..c7b1aab 100644 --- a/docs/HTTP-API.md +++ b/docs/HTTP-API.md @@ -105,7 +105,8 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l "senderUsername": "wxid_sender", "mediaType": "image", "mediaFileName": "image_123.jpg", - "mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaUrl": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg", + "mediaLocalPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" } ] } @@ -140,7 +141,7 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l "timestamp": 1738713600000, "type": 0, "content": "消息内容", - "mediaPath": "C:\\Users\\Alice\\Documents\\WeFlow\\api-media\\wxid_xxx\\images\\image_123.jpg" + "mediaPath": "http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg" } ], "media": { @@ -153,7 +154,59 @@ GET http://127.0.0.1:5031/api/v1/messages?talker=wxid_xxx&keyword=项目进度&l --- -### 3. 获取会话列表 +### 3. 访问导出媒体文件 + +通过 HTTP 直接访问已导出的媒体文件(图片、语音、视频、表情)。 + +**请求** +``` +GET /api/v1/media/{relativePath} +``` + +**路径参数** + +| 参数名 | 类型 | 必填 | 说明 | +|--------|------|------|------| +| `relativePath` | string | ✅ | 媒体文件的相对路径,如 `wxid_xxx/images/image_123.jpg` | + +**支持的媒体类型** + +| 扩展名 | Content-Type | +|--------|-------------| +| `.png` | image/png | +| `.jpg` / `.jpeg` | image/jpeg | +| `.gif` | image/gif | +| `.webp` | image/webp | +| `.wav` | audio/wav | +| `.mp3` | audio/mpeg | +| `.mp4` | video/mp4 | + +**示例请求** +```bash +# 访问导出的图片 +GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/images/image_123.jpg + +# 访问导出的语音 +GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/voices/voice_456.wav + +# 访问导出的视频 +GET http://127.0.0.1:5031/api/v1/media/wxid_xxx/videos/video_789.mp4 +``` + +**响应** + +成功时直接返回文件内容,`Content-Type` 根据文件扩展名自动设置。 + +失败时返回: +```json +{ "error": "Media not found" } +``` + +> 注意:媒体文件需要先通过消息接口的 `media=1` 参数导出后才能访问。 + +--- + +### 4. 获取会话列表 获取所有会话列表。 From 4d5c744583f5882f05f63fdf7832f8a54b3379ac Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 28 Feb 2026 16:28:46 +0800 Subject: [PATCH 2/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/keyService.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 538e8cc..5fe0dd8 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -855,7 +855,7 @@ export class KeyService { } let found = null; - for (let upper = end; upper > start; upper--) { + for (let upper = end - 1; upper >= start; upper--) { // 我就写 -- if (upper % 100000 === 0 && upper !== start) { parentPort.postMessage({ type: 'progress', scanned: 100000 }); @@ -943,18 +943,15 @@ export class KeyService { cleanup() resolve(msg.key) } else if (msg.type === 'done') { - // 单个 worker 跑完了没有找到 - activeWorkers-- - if (activeWorkers === 0 && !resolved) resolve(null) + // 单个 worker 跑完了没有找到(计数统一在 exit 事件处理) } }) worker.on('error', (err) => { console.error('Worker error:', err) - activeWorkers-- - if (activeWorkers === 0 && !resolved) resolve(null) }) + // 统一在 exit 事件中做完成计数,避免 done/error + exit 双重递减 worker.on('exit', () => { activeWorkers-- if (activeWorkers === 0 && !resolved) resolve(null) @@ -984,7 +981,7 @@ export class KeyService { onProgress?.('正在读取加密模板区块...') const ciphertexts = this.getCiphertextsFromTemplate(templateFiles) - if (ciphertexts.length === 0) return { success: false, error: '无法读取加密模板数据' } + if (ciphertexts.length < 2) return { success: false, error: '可用的加密样本不足(至少需要2个),请确认账号目录下有足够的模板图片' } onProgress?.(`成功提取 ${ciphertexts.length} 个特征样本,准备交叉校验...`) onProgress?.(`准备启动 ${os.cpus().length || 4} 线程并发爆破引擎 (基于 wxid: ${wxid})...`) From c88aa2c9d81d6d13b625b0e7415a59e079efa7b8 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 28 Feb 2026 16:44:55 +0800 Subject: [PATCH 3/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E7=89=87?= =?UTF-8?q?=E8=A7=A3=E5=AF=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/imageDecryptService.ts | 117 ++++++++++++++++------- 1 file changed, 81 insertions(+), 36 deletions(-) diff --git a/electron/services/imageDecryptService.ts b/electron/services/imageDecryptService.ts index 7a8c043..15cbad7 100644 --- a/electron/services/imageDecryptService.ts +++ b/electron/services/imageDecryptService.ts @@ -301,7 +301,7 @@ export class ImageDecryptService { if (finalExt === '.hevc') { return { success: false, - error: '此图片为微信新格式(wxgf),需要安装 ffmpeg 才能显示', + error: '此图片为微信新格式(wxgf),ffmpeg 转换失败,请检查日志', isThumb: this.isThumbnailPath(datPath) } } @@ -1833,21 +1833,24 @@ export class ImageDecryptService { // 提取 HEVC NALU 裸流 const hevcData = this.extractHevcNalu(buffer) - if (!hevcData || hevcData.length < 100) { - return { data: buffer, isWxgf: true } - } + // 优先用提取的 NALU 裸流,提取失败则跳过 wxgf 头部直接用原始数据 + const feedData = (hevcData && hevcData.length >= 100) ? hevcData : buffer.subarray(4) + this.logInfo('unwrapWxgf: 准备 ffmpeg 转换', { + naluExtracted: !!(hevcData && hevcData.length >= 100), + feedSize: feedData.length + }) // 尝试用 ffmpeg 转换 try { - const jpgData = await this.convertHevcToJpg(hevcData) + const jpgData = await this.convertHevcToJpg(feedData) if (jpgData && jpgData.length > 0) { return { data: jpgData, isWxgf: false } } - } catch { - // ffmpeg 转换失败 + } catch (e) { + this.logError('unwrapWxgf: ffmpeg 转换失败', e) } - return { data: hevcData, isWxgf: true } + return { data: feedData, isWxgf: true } } /** @@ -1914,50 +1917,92 @@ export class ImageDecryptService { /** * 使用 ffmpeg 将 HEVC 裸流转换为 JPG */ - private convertHevcToJpg(hevcData: Buffer): Promise { + private async convertHevcToJpg(hevcData: Buffer): Promise { const ffmpeg = this.getFfmpegPath() this.logInfo('ffmpeg 转换开始', { ffmpegPath: ffmpeg, hevcSize: hevcData.length }) + const tmpDir = join(app.getPath('temp'), 'weflow_hevc') + if (!existsSync(tmpDir)) mkdirSync(tmpDir, { recursive: true }) + const ts = Date.now() + const tmpInput = join(tmpDir, `hevc_${ts}.hevc`) + const tmpOutput = join(tmpDir, `hevc_${ts}.jpg`) + + try { + await writeFile(tmpInput, hevcData) + + // 依次尝试: 1) -f hevc 裸流 2) 不指定格式让 ffmpeg 自动检测 + const attempts: { label: string; inputArgs: string[] }[] = [ + { label: 'hevc raw', inputArgs: ['-f', 'hevc', '-i', tmpInput] }, + { label: 'auto detect', inputArgs: ['-i', tmpInput] }, + ] + + for (const attempt of attempts) { + // 清理上一轮的输出 + try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} + + const result = await this.runFfmpegConvert(ffmpeg, attempt.inputArgs, tmpOutput, attempt.label) + if (result) return result + } + + return null + } catch (e) { + this.logError('ffmpeg 转换异常', e) + return null + } finally { + try { if (existsSync(tmpInput)) require('fs').unlinkSync(tmpInput) } catch {} + try { if (existsSync(tmpOutput)) require('fs').unlinkSync(tmpOutput) } catch {} + } + } + + private runFfmpegConvert(ffmpeg: string, inputArgs: string[], tmpOutput: string, label: string): Promise { return new Promise((resolve) => { const { spawn } = require('child_process') - const chunks: Buffer[] = [] const errChunks: Buffer[] = [] - const proc = spawn(ffmpeg, [ - '-hide_banner', - '-loglevel', 'error', - '-f', 'hevc', - '-i', 'pipe:0', - '-vframes', '1', - '-q:v', '3', - '-f', 'mjpeg', - 'pipe:1' - ], { - stdio: ['pipe', 'pipe', 'pipe'], + const args = [ + '-hide_banner', '-loglevel', 'error', + ...inputArgs, + '-vframes', '1', '-q:v', '2', '-f', 'image2', tmpOutput + ] + this.logInfo(`ffmpeg 尝试 [${label}]`, { args: args.join(' ') }) + + const proc = spawn(ffmpeg, args, { + stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true }) - proc.stdout.on('data', (chunk: Buffer) => chunks.push(chunk)) proc.stderr.on('data', (chunk: Buffer) => errChunks.push(chunk)) - proc.on('close', (code: number) => { - if (code === 0 && chunks.length > 0) { - this.logInfo('ffmpeg 转换成功', { outputSize: Buffer.concat(chunks).length }) - resolve(Buffer.concat(chunks)) - } else { - const errMsg = Buffer.concat(errChunks).toString() - this.logInfo('ffmpeg 转换失败', { code, error: errMsg }) - resolve(null) - } - }) + const timer = setTimeout(() => { + proc.kill('SIGKILL') + this.logError(`ffmpeg [${label}] 超时(15s)`) + resolve(null) + }, 15000) - proc.on('error', (err: Error) => { - this.logInfo('ffmpeg 进程错误', { error: err.message }) + proc.on('close', (code: number) => { + clearTimeout(timer) + if (code === 0 && existsSync(tmpOutput)) { + try { + const jpgBuf = readFileSync(tmpOutput) + if (jpgBuf.length > 0) { + this.logInfo(`ffmpeg [${label}] 成功`, { outputSize: jpgBuf.length }) + resolve(jpgBuf) + return + } + } catch (e) { + this.logError(`ffmpeg [${label}] 读取输出失败`, e) + } + } + const errMsg = Buffer.concat(errChunks).toString().trim() + this.logInfo(`ffmpeg [${label}] 失败`, { code, error: errMsg }) resolve(null) }) - proc.stdin.write(hevcData) - proc.stdin.end() + proc.on('error', (err: Error) => { + clearTimeout(timer) + this.logError(`ffmpeg [${label}] 进程错误`, err) + resolve(null) + }) }) } From d63c37cd781e9113feda0433966b1e227614a279 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 28 Feb 2026 16:51:18 +0800 Subject: [PATCH 4/6] =?UTF-8?q?=E8=A7=86=E9=A2=91=E8=A7=A3=E5=AF=86?= =?UTF-8?q?=E4=B8=B0=E5=AF=8C=E6=97=A5=E5=BF=97=20=E6=96=B9=E4=BE=BF?= =?UTF-8?q?=E5=AE=9A=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/videoService.ts | 78 +++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/electron/services/videoService.ts b/electron/services/videoService.ts index 6b44c2e..913c874 100644 --- a/electron/services/videoService.ts +++ b/electron/services/videoService.ts @@ -1,5 +1,6 @@ import { join } from 'path' -import { existsSync, readdirSync, statSync, readFileSync } from 'fs' +import { existsSync, readdirSync, statSync, readFileSync, appendFileSync, mkdirSync } from 'fs' +import { app } from 'electron' import { ConfigService } from './config' import Database from 'better-sqlite3' import { wcdbService } from './wcdbService' @@ -18,6 +19,16 @@ class VideoService { this.configService = new ConfigService() } + private log(message: string, meta?: Record): void { + try { + const timestamp = new Date().toISOString() + const metaStr = meta ? ` ${JSON.stringify(meta)}` : '' + const logDir = join(app.getPath('userData'), 'logs') + if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true }) + appendFileSync(join(logDir, 'wcdb.log'), `[${timestamp}] [VideoService] ${message}${metaStr}\n`, 'utf8') + } catch {} + } + /** * 获取数据库根目录 */ @@ -69,7 +80,12 @@ class VideoService { const wxid = this.getMyWxid() const cleanedWxid = this.cleanWxid(wxid) - if (!wxid) return undefined + this.log('queryVideoFileName 开始', { md5, wxid, cleanedWxid, cachePath, dbPath }) + + if (!wxid) { + this.log('queryVideoFileName: wxid 为空') + return undefined + } // 方法1:优先在 cachePath 下查找解密后的 hardlink.db if (cachePath) { @@ -84,20 +100,23 @@ class VideoService { for (const p of cacheDbPaths) { if (existsSync(p)) { try { + this.log('尝试缓存 hardlink.db', { path: p }) const db = new Database(p, { readonly: true }) const row = db.prepare(` - SELECT file_name, md5 FROM video_hardlink_info_v4 - WHERE md5 = ? + SELECT file_name, md5 FROM video_hardlink_info_v4 + WHERE md5 = ? LIMIT 1 `).get(md5) as { file_name: string; md5: string } | undefined db.close() if (row?.file_name) { const realMd5 = row.file_name.replace(/\.[^.]+$/, '') + this.log('缓存 hardlink.db 命中', { file_name: row.file_name, realMd5 }) return realMd5 } + this.log('缓存 hardlink.db 未命中', { path: p }) } catch (e) { - // 忽略错误 + this.log('缓存 hardlink.db 查询失败', { path: p, error: String(e) }) } } } @@ -105,7 +124,6 @@ class VideoService { // 方法2:使用 wcdbService.execQuery 查询加密的 hardlink.db if (dbPath) { - // 检查 dbPath 是否已经包含 wxid const dbPathLower = dbPath.toLowerCase() const wxidLower = wxid.toLowerCase() const cleanedWxidLower = cleanedWxid.toLowerCase() @@ -113,10 +131,8 @@ class VideoService { const encryptedDbPaths: string[] = [] if (dbPathContainsWxid) { - // dbPath 已包含 wxid,不需要再拼接 encryptedDbPaths.push(join(dbPath, 'db_storage', 'hardlink', 'hardlink.db')) } else { - // dbPath 不包含 wxid,需要拼接 encryptedDbPaths.push(join(dbPath, wxid, 'db_storage', 'hardlink', 'hardlink.db')) encryptedDbPaths.push(join(dbPath, cleanedWxid, 'db_storage', 'hardlink', 'hardlink.db')) } @@ -124,27 +140,29 @@ class VideoService { for (const p of encryptedDbPaths) { if (existsSync(p)) { try { + this.log('尝试加密 hardlink.db', { path: p }) const escapedMd5 = md5.replace(/'/g, "''") - - // 用 md5 字段查询,获取 file_name const sql = `SELECT file_name FROM video_hardlink_info_v4 WHERE md5 = '${escapedMd5}' LIMIT 1` - const result = await wcdbService.execQuery('media', p, sql) if (result.success && result.rows && result.rows.length > 0) { const row = result.rows[0] if (row?.file_name) { - // 提取不带扩展名的文件名作为实际视频 MD5 const realMd5 = String(row.file_name).replace(/\.[^.]+$/, '') + this.log('加密 hardlink.db 命中', { file_name: row.file_name, realMd5 }) return realMd5 } } + this.log('加密 hardlink.db 未命中', { path: p, result: JSON.stringify(result).slice(0, 200) }) } catch (e) { - // 忽略错误 + this.log('加密 hardlink.db 查询失败', { path: p, error: String(e) }) } + } else { + this.log('加密 hardlink.db 不存在', { path: p }) } } } + this.log('queryVideoFileName: 所有方法均未找到', { md5 }) return undefined } @@ -170,12 +188,16 @@ class VideoService { const dbPath = this.getDbPath() const wxid = this.getMyWxid() + this.log('getVideoInfo 开始', { videoMd5, dbPath, wxid }) + if (!dbPath || !wxid || !videoMd5) { + this.log('getVideoInfo: 参数缺失', { dbPath: !!dbPath, wxid: !!wxid, videoMd5: !!videoMd5 }) return { exists: false } } // 先尝试从数据库查询真正的视频文件名 const realVideoMd5 = await this.queryVideoFileName(videoMd5) || videoMd5 + this.log('realVideoMd5', { input: videoMd5, resolved: realVideoMd5, changed: realVideoMd5 !== videoMd5 }) // 检查 dbPath 是否已经包含 wxid,避免重复拼接 const dbPathLower = dbPath.toLowerCase() @@ -184,50 +206,58 @@ class VideoService { let videoBaseDir: string if (dbPathLower.includes(wxidLower) || dbPathLower.includes(cleanedWxid.toLowerCase())) { - // dbPath 已经包含 wxid,直接使用 videoBaseDir = join(dbPath, 'msg', 'video') } else { - // dbPath 不包含 wxid,需要拼接 videoBaseDir = join(dbPath, wxid, 'msg', 'video') } + this.log('videoBaseDir', { videoBaseDir, exists: existsSync(videoBaseDir) }) + if (!existsSync(videoBaseDir)) { + this.log('getVideoInfo: videoBaseDir 不存在') return { exists: false } } // 遍历年月目录查找视频文件 try { const allDirs = readdirSync(videoBaseDir) - - // 支持多种目录格式: YYYY-MM, YYYYMM, 或其他 const yearMonthDirs = allDirs .filter(dir => { const dirPath = join(videoBaseDir, dir) return statSync(dirPath).isDirectory() }) - .sort((a, b) => b.localeCompare(a)) // 从最新的目录开始查找 + .sort((a, b) => b.localeCompare(a)) + + this.log('扫描目录', { dirs: yearMonthDirs }) for (const yearMonth of yearMonthDirs) { const dirPath = join(videoBaseDir, yearMonth) - const videoPath = join(dirPath, `${realVideoMd5}.mp4`) - const coverPath = join(dirPath, `${realVideoMd5}.jpg`) - const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) - // 检查视频文件是否存在 if (existsSync(videoPath)) { + this.log('找到视频', { videoPath }) + const coverPath = join(dirPath, `${realVideoMd5}.jpg`) + const thumbPath = join(dirPath, `${realVideoMd5}_thumb.jpg`) return { - videoUrl: videoPath, // 返回文件路径,前端通过 readFile 读取 + videoUrl: videoPath, coverUrl: this.fileToDataUrl(coverPath, 'image/jpeg'), thumbUrl: this.fileToDataUrl(thumbPath, 'image/jpeg'), exists: true } } } + + // 没找到,列出第一个目录里的文件帮助排查 + if (yearMonthDirs.length > 0) { + const firstDir = join(videoBaseDir, yearMonthDirs[0]) + const files = readdirSync(firstDir).filter(f => f.endsWith('.mp4')).slice(0, 5) + this.log('未找到视频,最新目录样本', { dir: yearMonthDirs[0], sampleFiles: files, lookingFor: `${realVideoMd5}.mp4` }) + } } catch (e) { - // 忽略错误 + this.log('getVideoInfo 遍历出错', { error: String(e) }) } + this.log('getVideoInfo: 未找到视频', { videoMd5, realVideoMd5 }) return { exists: false } } From b26f8cc43c7e39d78bfde830ca54ac5c136abd9d Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 28 Feb 2026 17:32:28 +0800 Subject: [PATCH 5/6] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E8=A7=A3=E5=AF=86=E5=9B=BE=E7=89=87=E9=80=BB=E8=BE=91=20?= =?UTF-8?q?=E5=8A=A0=E5=BF=AB=E9=80=9F=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/ChatPage.scss | 75 +++++++++++++++++++++++++++++++++++++++-- src/pages/ChatPage.tsx | 68 ++++++++++++++++++++++++++++++++----- 2 files changed, 132 insertions(+), 11 deletions(-) diff --git a/src/pages/ChatPage.scss b/src/pages/ChatPage.scss index ea8c403..953341b 100644 --- a/src/pages/ChatPage.scss +++ b/src/pages/ChatPage.scss @@ -3331,9 +3331,12 @@ // 批量转写模态框基础样式(共享样式在 styles/batchTranscribe.scss) // 批量转写确认对话框 -.batch-confirm-modal { +.batch-modal-content.batch-confirm-modal { width: 480px; max-width: 90vw; + max-height: none; + overflow: visible; + overflow-y: visible; .batch-modal-header { display: flex; @@ -3470,6 +3473,74 @@ font-weight: 600; color: var(--primary-color); } + + .batch-concurrency-field { + position: relative; + + .batch-concurrency-trigger { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + border-radius: 9999px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + + &:hover { + border-color: var(--text-tertiary); + } + + &.open { + border-color: var(--primary); + } + + svg { + color: var(--text-tertiary); + transition: transform 0.2s; + } + + &.open svg { + transform: rotate(180deg); + } + } + + .batch-concurrency-dropdown { + position: absolute; + top: calc(100% + 6px); + right: 0; + min-width: 180px; + background: color-mix(in srgb, var(--bg-primary) 90%, var(--bg-secondary)); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12); + z-index: 100; + } + + .batch-concurrency-option { + width: 100%; + text-align: left; + padding: 8px 12px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + font-size: 13px; + cursor: pointer; + + &:hover { + background: var(--bg-tertiary); + } + + &.active { + color: var(--primary); + font-weight: 500; + } + } + } } } @@ -3527,7 +3598,7 @@ &.btn-primary, &.batch-transcribe-start-btn { background: var(--primary-color); - color: white; + color: #000; &:hover { opacity: 0.9; diff --git a/src/pages/ChatPage.tsx b/src/pages/ChatPage.tsx index e79506e..bf85c5c 100644 --- a/src/pages/ChatPage.tsx +++ b/src/pages/ChatPage.tsx @@ -345,6 +345,8 @@ function ChatPage(_props: ChatPageProps) { const [batchImageMessages, setBatchImageMessages] = useState(null) const [batchImageDates, setBatchImageDates] = useState([]) const [batchImageSelectedDates, setBatchImageSelectedDates] = useState>(new Set()) + const [batchDecryptConcurrency, setBatchDecryptConcurrency] = useState(6) + const [showConcurrencyDropdown, setShowConcurrencyDropdown] = useState(false) // 批量删除相关状态 const [isDeleting, setIsDeleting] = useState(false) @@ -1662,29 +1664,44 @@ function ChatPage(_props: ChatPageProps) { let successCount = 0 let failCount = 0 - for (let i = 0; i < images.length; i++) { - const img = images[i] + let completed = 0 + const concurrency = batchDecryptConcurrency + + const decryptOne = async (img: typeof images[0]) => { try { const r = await window.electronAPI.image.decrypt({ sessionId: session.username, imageMd5: img.imageMd5, imageDatName: img.imageDatName, - force: false + force: true }) if (r?.success) successCount++ else failCount++ } catch { failCount++ } - - updateDecryptProgress(i + 1, images.length) - if (i % 5 === 0) { - await new Promise(resolve => setTimeout(resolve, 0)) - } + completed++ + updateDecryptProgress(completed, images.length) } + // 并发池:同时跑 concurrency 个任务 + const pool: Promise[] = [] + for (const img of images) { + const p = decryptOne(img) + pool.push(p) + if (pool.length >= concurrency) { + await Promise.race(pool) + // 移除已完成的 + for (let j = pool.length - 1; j >= 0; j--) { + const settled = await Promise.race([pool[j].then(() => true), Promise.resolve(false)]) + if (settled) pool.splice(j, 1) + } + } + } + await Promise.all(pool) + finishDecrypt(successCount, failCount) - }, [batchImageMessages, batchImageSelectedDates, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) + }, [batchImageMessages, batchImageSelectedDates, batchDecryptConcurrency, currentSessionId, finishDecrypt, sessions, startDecrypt, updateDecryptProgress]) const batchImageCountByDate = useMemo(() => { const map = new Map() @@ -2623,6 +2640,39 @@ function ChatPage(_props: ChatPageProps) { 已选: {batchImageSelectedDates.size} 天,共 {batchImageSelectedCount} 张图片 +
+ 并发数: +
+ + {showConcurrencyDropdown && ( +
+ {[ + { value: 1, label: '1(最慢,最稳)' }, + { value: 3, label: '3' }, + { value: 6, label: '6(推荐)' }, + { value: 10, label: '10' }, + { value: 20, label: '20(最快,可能卡顿)' }, + ].map(opt => ( + + ))} +
+ )} +
+
From e12451911b2370d10758ca4c499fd592d3098cf9 Mon Sep 17 00:00:00 2001 From: xuncha <1658671838@qq.com> Date: Sat, 28 Feb 2026 17:33:24 +0800 Subject: [PATCH 6/6] =?UTF-8?q?=E5=AF=BC=E5=87=BA=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E4=BD=8D=E7=BD=AE=E6=B6=88=E6=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/exportService.ts | 48 ++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 920b226..bfd50fe 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -665,7 +665,18 @@ class ExportService { case 42: return '[名片]' case 43: return '[视频]' case 47: return '[动画表情]' - case 48: return '[位置]' + case 48: { + const normalized48 = this.normalizeAppMessageContent(content) + const locPoiname = this.extractXmlAttribute(normalized48, 'location', 'poiname') || this.extractXmlValue(normalized48, 'poiname') || this.extractXmlValue(normalized48, 'poiName') + const locLabel = this.extractXmlAttribute(normalized48, 'location', 'label') || this.extractXmlValue(normalized48, 'label') + const locLat = this.extractXmlAttribute(normalized48, 'location', 'x') || this.extractXmlAttribute(normalized48, 'location', 'latitude') + const locLng = this.extractXmlAttribute(normalized48, 'location', 'y') || this.extractXmlAttribute(normalized48, 'location', 'longitude') + const locParts: string[] = [] + if (locPoiname) locParts.push(locPoiname) + if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) + if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) + return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' + } case 49: { const title = this.extractXmlValue(content, 'title') const type = this.extractXmlValue(content, 'type') @@ -776,12 +787,15 @@ class ExportService { } if (localType === 48) { const normalized = this.normalizeAppMessageContent(safeContent) - const location = - this.extractXmlValue(normalized, 'label') || - this.extractXmlValue(normalized, 'poiname') || - this.extractXmlValue(normalized, 'poiName') || - this.extractXmlValue(normalized, 'name') - return location ? `[定位]${location}` : '[定位]' + const locPoiname = this.extractXmlAttribute(normalized, 'location', 'poiname') || this.extractXmlValue(normalized, 'poiname') || this.extractXmlValue(normalized, 'poiName') + const locLabel = this.extractXmlAttribute(normalized, 'location', 'label') || this.extractXmlValue(normalized, 'label') + const locLat = this.extractXmlAttribute(normalized, 'location', 'x') || this.extractXmlAttribute(normalized, 'location', 'latitude') + const locLng = this.extractXmlAttribute(normalized, 'location', 'y') || this.extractXmlAttribute(normalized, 'location', 'longitude') + const locParts: string[] = [] + if (locPoiname) locParts.push(locPoiname) + if (locLabel && locLabel !== locPoiname) locParts.push(locLabel) + if (locLat && locLng) locParts.push(`(${locLat},${locLng})`) + return locParts.length > 0 ? `[位置] ${locParts.join(' ')}` : '[位置]' } if (localType === 50) { return this.parseVoipMessage(safeContent) @@ -979,6 +993,12 @@ class ExportService { return '' } + private extractXmlAttribute(xml: string, tagName: string, attrName: string): string { + const tagRegex = new RegExp(`<${tagName}\\s+[^>]*${attrName}\\s*=\\s*"([^"]*)"`, 'i') + const match = tagRegex.exec(xml) + return match ? match[1] : '' + } + private cleanSystemMessage(content: string): string { if (!content) return '[系统消息]' @@ -2932,7 +2952,7 @@ class ExportService { options.displayNamePreference || 'remark' ) - allMessages.push({ + const msgObj: any = { localId: allMessages.length + 1, createTime: msg.createTime, formattedTime: this.formatTimestamp(msg.createTime), @@ -2944,7 +2964,17 @@ class ExportService { senderDisplayName, source, senderAvatarKey: msg.senderUsername - }) + } + + // 位置消息:附加结构化位置字段 + if (msg.localType === 48) { + if (msg.locationLat != null) msgObj.locationLat = msg.locationLat + if (msg.locationLng != null) msgObj.locationLng = msg.locationLng + if (msg.locationPoiname) msgObj.locationPoiname = msg.locationPoiname + if (msg.locationLabel) msgObj.locationLabel = msg.locationLabel + } + + allMessages.push(msgObj) } allMessages.sort((a, b) => a.createTime - b.createTime)