From cfd76353232e90c59fa7a950c5e044d32960994b Mon Sep 17 00:00:00 2001 From: XiiTang Date: Mon, 12 Jan 2026 10:48:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0getXorKey=E6=96=B9?= =?UTF-8?q?=E6=B3=95=E4=BB=A5=E6=94=B9=E8=BF=9B=E5=AF=86=E9=92=A5=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E9=80=BB=E8=BE=91=E5=B9=B6=E6=B7=BB=E5=8A=A0PNG?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/keyService.ts | 56 ++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/electron/services/keyService.ts b/electron/services/keyService.ts index 971a79f..d78edbb 100644 --- a/electron/services/keyService.ts +++ b/electron/services/keyService.ts @@ -695,33 +695,41 @@ export class KeyService { } private getXorKey(templateFiles: string[]): number | null { - const counts = new Map() + const counts = new Map() + const tailSignatures = [ + Buffer.from([0xFF, 0xD9]), + Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82]) + ] for (const file of templateFiles) { try { const bytes = readFileSync(file) - if (bytes.length < 2) continue - const x = bytes[bytes.length - 2] - const y = bytes[bytes.length - 1] - const key = `${x}_${y}` - counts.set(key, (counts.get(key) ?? 0) + 1) + for (const signature of tailSignatures) { + if (bytes.length < signature.length) continue + const tail = bytes.subarray(bytes.length - signature.length) + const xorKey = tail[0] ^ signature[0] + let valid = true + for (let i = 1; i < signature.length; i++) { + if ((tail[i] ^ xorKey) !== signature[i]) { + valid = false + break + } + } + if (valid) { + counts.set(xorKey, (counts.get(xorKey) ?? 0) + 1) + } + } } catch { } } if (!counts.size) return null - let mostKey = '' - let mostCount = 0 + let bestKey: number | null = null + let bestCount = 0 for (const [key, count] of counts) { - if (count > mostCount) { - mostCount = count - mostKey = key + if (count > bestCount) { + bestCount = count + bestKey = key } } - if (!mostKey) return null - const [xStr, yStr] = mostKey.split('_') - const x = Number(xStr) - const y = Number(yStr) - const xorKey = x ^ 0xFF - const check = y ^ 0xD9 - return xorKey === check ? xorKey : null + return bestKey } private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null { @@ -766,7 +774,17 @@ export class KeyService { const decipher = crypto.createDecipheriv('aes-128-ecb', key, null) decipher.setAutoPadding(false) const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]) - return decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff + const isJpeg = decrypted.length >= 3 && decrypted[0] === 0xff && decrypted[1] === 0xd8 && decrypted[2] === 0xff + const isPng = decrypted.length >= 8 && + decrypted[0] === 0x89 && + decrypted[1] === 0x50 && + decrypted[2] === 0x4e && + decrypted[3] === 0x47 && + decrypted[4] === 0x0d && + decrypted[5] === 0x0a && + decrypted[6] === 0x1a && + decrypted[7] === 0x0a + return isJpeg || isPng } catch { return false } From ba65c5f3ad06926c5a65b59e88c09755d131f84a Mon Sep 17 00:00:00 2001 From: XiiTang Date: Mon, 12 Jan 2026 11:29:06 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=B9=B4?= =?UTF-8?q?=E5=BA=A6=E6=8A=A5=E5=91=8A=E7=9A=84=E8=87=AA=E5=AE=9A=E4=B9=89?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/AnnualReportWindow.tsx | 35 +++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/pages/AnnualReportWindow.tsx b/src/pages/AnnualReportWindow.tsx index e29f684..bf105f3 100644 --- a/src/pages/AnnualReportWindow.tsx +++ b/src/pages/AnnualReportWindow.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react' -import { Loader2, Download, Image, Check, X } from 'lucide-react' +import { Loader2, Download, Image, Check, X, SlidersHorizontal } from 'lucide-react' import html2canvas from 'html2canvas' import { useThemeStore } from '../stores/themeStore' import './AnnualReportWindow.scss' @@ -249,6 +249,7 @@ function AnnualReportWindow() { const [fabOpen, setFabOpen] = useState(false) const [loadingProgress, setLoadingProgress] = useState(0) const [loadingStage, setLoadingStage] = useState('正在初始化...') + const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate') const { currentTheme, themeMode } = useThemeStore() @@ -490,7 +491,7 @@ function AnnualReportWindow() { } // 导出整个报告为长图 - const exportFullReport = async () => { + const exportFullReport = async (filterIds?: Set) => { if (!containerRef.current) { return } @@ -516,6 +517,16 @@ function AnnualReportWindow() { el.style.padding = '40px 0' }) + // 如果有筛选,隐藏未选中的板块 + if (filterIds) { + const available = getAvailableSections() + available.forEach(s => { + if (!filterIds.has(s.id) && s.ref.current) { + s.ref.current.style.display = 'none' + } + }) + } + // 修复词云导出问题 const wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement const wordTags = container.querySelectorAll('.word-tag') as NodeListOf @@ -584,7 +595,7 @@ function AnnualReportWindow() { const dataUrl = outputCanvas.toDataURL('image/png') const link = document.createElement('a') - link.download = `${reportData?.year}年度报告.png` + link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png` link.href = dataUrl document.body.appendChild(link) link.click() @@ -607,6 +618,13 @@ function AnnualReportWindow() { return } + if (exportMode === 'long') { + setShowExportModal(false) + await exportFullReport(selectedSections) + setSelectedSections(new Set()) + return + } + setIsExporting(true) setShowExportModal(false) @@ -735,9 +753,12 @@ function AnnualReportWindow() { {/* 浮动操作按钮 */}
- + @@ -765,7 +786,7 @@ function AnnualReportWindow() {
setShowExportModal(false)}>
e.stopPropagation()}>
-

选择要导出的板块

+

{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}

@@ -793,7 +814,7 @@ function AnnualReportWindow() { onClick={exportSelectedSections} disabled={selectedSections.size === 0} > - 导出 {selectedSections.size > 0 ? `(${selectedSections.size})` : ''} + {exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
@@ -838,7 +859,7 @@ function AnnualReportWindow() { 你发出 {formatNumber(topFriend.sentCount)} 条 · TA发来 {formatNumber(topFriend.receivedCount)}

-
+

在一起,就可以

From 13cc3751b52be4adacd8e9d31ef39eb6911e61c3 Mon Sep 17 00:00:00 2001 From: XiiTang Date: Mon, 12 Jan 2026 14:38:34 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=80=9A?= =?UTF-8?q?=E8=AF=9D=E6=B6=88=E6=81=AF=E8=A7=A3=E6=9E=90=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- electron/services/chatService.ts | 73 +++++++++++++++++++++++++++--- electron/services/exportService.ts | 66 +++++++++++++++++++++++++-- 2 files changed, 129 insertions(+), 10 deletions(-) diff --git a/electron/services/chatService.ts b/electron/services/chatService.ts index a1fa1f3..e711121 100644 --- a/electron/services/chatService.ts +++ b/electron/services/chatService.ts @@ -192,7 +192,7 @@ class ChatService { // 转换为 ChatSession(先加载缓存,但不等待数据库查询) const sessions: ChatSession[] = [] const now = Date.now() - + for (const row of rows) { const username = row.username || @@ -261,10 +261,10 @@ class ChatService { /** * 异步补充会话列表的联系人信息(公开方法,供前端调用) */ - async enrichSessionsContactInfo(usernames: string[]): Promise<{ + async enrichSessionsContactInfo(usernames: string[]): Promise<{ success: boolean contacts?: Record - error?: string + error?: string }> { try { if (usernames.length === 0) { @@ -303,9 +303,9 @@ class ChatService { for (const username of missing) { const displayName = displayNames.success && displayNames.map ? displayNames.map[username] : undefined const avatarUrl = avatarUrls.success && avatarUrls.map ? avatarUrls.map[username] : undefined - + result[username] = { displayName, avatarUrl } - + // 更新缓存 this.avatarCache.set(username, { displayName: displayName || username, @@ -772,7 +772,7 @@ class ChatService { case 49: return this.parseType49(content) case 50: - return '[通话]' + return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) case 244813135921: @@ -898,6 +898,67 @@ class ChatService { } } + /** + * 解析通话消息 + * 格式: 0/1... + * room_type: 0 = 语音通话, 1 = 视频通话 + * msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等 + */ + private parseVoipMessage(content: string): string { + try { + if (!content) return '[通话]' + + // 提取 msg 内容(中文通话状态) + const msgMatch = /<\/msg>/i.exec(content) + const msg = msgMatch?.[1]?.trim() || '' + + // 提取 room_type(0=视频,1=语音) + const roomTypeMatch = /(\d+)<\/room_type>/i.exec(content) + const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1 + + // 构建通话类型标签 + let callType: string + if (roomType === 0) { + callType = '视频通话' + } else if (roomType === 1) { + callType = '语音通话' + } else { + callType = '通话' + } + + // 解析通话状态 + if (msg.includes('通话时长')) { + // 已接听的通话,提取时长 + const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg) + const duration = durationMatch?.[1] || '' + if (duration) { + return `[${callType}] ${duration}` + } + return `[${callType}] 已接听` + } else if (msg.includes('对方无应答')) { + return `[${callType}] 对方无应答` + } else if (msg.includes('已取消')) { + return `[${callType}] 已取消` + } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) { + return `[${callType}] 已在其他设备接听` + } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) { + return `[${callType}] 对方已拒绝` + } else if (msg.includes('忙线未接听') || msg.includes('忙线')) { + return `[${callType}] 忙线未接听` + } else if (msg.includes('未接听')) { + return `[${callType}] 未接听` + } else if (msg) { + // 其他状态直接使用 msg 内容 + return `[${callType}] ${msg}` + } + + return `[${callType}]` + } catch (e) { + console.error('[ChatService] Failed to parse VOIP message:', e) + return '[通话]' + } + } + private parseImageDatNameFromRow(row: Record): string | undefined { const packed = this.getRowField(row, [ 'packed_info_data', diff --git a/electron/services/exportService.ts b/electron/services/exportService.ts index 98f6363..de87244 100644 --- a/electron/services/exportService.ts +++ b/electron/services/exportService.ts @@ -232,7 +232,7 @@ class ExportService { const title = this.extractXmlValue(content, 'title') return title || '[链接]' } - case 50: return '[通话]' + case 50: return this.parseVoipMessage(content) case 10000: return this.cleanSystemMessage(content) default: if (content.includes('57')) { @@ -264,6 +264,64 @@ class ExportService { .trim() || '[系统消息]' } + /** + * 解析通话消息 + * 格式: 0/1... + * room_type: 0 = 语音通话, 1 = 视频通话 + */ + private parseVoipMessage(content: string): string { + try { + if (!content) return '[通话]' + + // 提取 msg 内容(中文通话状态) + const msgMatch = /<\/msg>/i.exec(content) + const msg = msgMatch?.[1]?.trim() || '' + + // 提取 room_type(0=视频,1=语音) + const roomTypeMatch = /(\d+)<\/room_type>/i.exec(content) + const roomType = roomTypeMatch ? parseInt(roomTypeMatch[1], 10) : -1 + + // 构建通话类型标签 + let callType: string + if (roomType === 0) { + callType = '视频通话' + } else if (roomType === 1) { + callType = '语音通话' + } else { + callType = '通话' + } + + // 解析通话状态 + if (msg.includes('通话时长')) { + const durationMatch = /通话时长\s*(\d{1,2}:\d{2}(?::\d{2})?)/i.exec(msg) + const duration = durationMatch?.[1] || '' + if (duration) { + return `[${callType}] ${duration}` + } + return `[${callType}] 已接听` + } else if (msg.includes('对方无应答')) { + return `[${callType}] 对方无应答` + } else if (msg.includes('已取消')) { + return `[${callType}] 已取消` + } else if (msg.includes('已在其它设备接听') || msg.includes('已在其他设备接听')) { + return `[${callType}] 已在其他设备接听` + } else if (msg.includes('对方已拒绝') || msg.includes('已拒绝')) { + return `[${callType}] 对方已拒绝` + } else if (msg.includes('忙线未接听') || msg.includes('忙线')) { + return `[${callType}] 忙线未接听` + } else if (msg.includes('未接听')) { + return `[${callType}] 未接听` + } else if (msg) { + return `[${callType}] ${msg}` + } + + return `[${callType}]` + } catch (e) { + console.error('[ExportService] Failed to parse VOIP message:', e) + return '[通话]' + } + } + /** * 获取消息类型名称 */ @@ -376,8 +434,8 @@ class ExportService { const data = Buffer.from(match[2], 'base64') const ext = mime.includes('png') ? '.png' : mime.includes('gif') ? '.gif' - : mime.includes('webp') ? '.webp' - : '.jpg' + : mime.includes('webp') ? '.webp' + : '.jpg' return { data, ext, mime } } if (avatarUrl.startsWith('file://')) { @@ -695,7 +753,7 @@ class ExportService { ...detailedExport.session, avatar: avatars[sessionId] } - ;(detailedExport as any).avatars = avatars + ; (detailedExport as any).avatars = avatars } }