mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 07:16:51 +00:00
Merge pull request #19 from XiiTang/main
fix: 更新getXorKey方法以改进密钥提取逻辑并添加PNG支持
This commit is contained in:
@@ -772,7 +772,7 @@ class ChatService {
|
|||||||
case 49:
|
case 49:
|
||||||
return this.parseType49(content)
|
return this.parseType49(content)
|
||||||
case 50:
|
case 50:
|
||||||
return '[通话]'
|
return this.parseVoipMessage(content)
|
||||||
case 10000:
|
case 10000:
|
||||||
return this.cleanSystemMessage(content)
|
return this.cleanSystemMessage(content)
|
||||||
case 244813135921:
|
case 244813135921:
|
||||||
@@ -898,6 +898,67 @@ class ChatService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析通话消息
|
||||||
|
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
||||||
|
* room_type: 0 = 语音通话, 1 = 视频通话
|
||||||
|
* msg 状态: 通话时长 XX:XX, 对方无应答, 已取消, 已在其它设备接听, 对方已拒绝 等
|
||||||
|
*/
|
||||||
|
private parseVoipMessage(content: string): string {
|
||||||
|
try {
|
||||||
|
if (!content) return '[通话]'
|
||||||
|
|
||||||
|
// 提取 msg 内容(中文通话状态)
|
||||||
|
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
|
||||||
|
const msg = msgMatch?.[1]?.trim() || ''
|
||||||
|
|
||||||
|
// 提取 room_type(0=视频,1=语音)
|
||||||
|
const roomTypeMatch = /<room_type>(\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, any>): string | undefined {
|
private parseImageDatNameFromRow(row: Record<string, any>): string | undefined {
|
||||||
const packed = this.getRowField(row, [
|
const packed = this.getRowField(row, [
|
||||||
'packed_info_data',
|
'packed_info_data',
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ class ExportService {
|
|||||||
const title = this.extractXmlValue(content, 'title')
|
const title = this.extractXmlValue(content, 'title')
|
||||||
return title || '[链接]'
|
return title || '[链接]'
|
||||||
}
|
}
|
||||||
case 50: return '[通话]'
|
case 50: return this.parseVoipMessage(content)
|
||||||
case 10000: return this.cleanSystemMessage(content)
|
case 10000: return this.cleanSystemMessage(content)
|
||||||
default:
|
default:
|
||||||
if (content.includes('<type>57</type>')) {
|
if (content.includes('<type>57</type>')) {
|
||||||
@@ -264,6 +264,64 @@ class ExportService {
|
|||||||
.trim() || '[系统消息]'
|
.trim() || '[系统消息]'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析通话消息
|
||||||
|
* 格式: <voipmsg type="VoIPBubbleMsg"><VoIPBubbleMsg><msg><![CDATA[...]]></msg><room_type>0/1</room_type>...</VoIPBubbleMsg></voipmsg>
|
||||||
|
* room_type: 0 = 语音通话, 1 = 视频通话
|
||||||
|
*/
|
||||||
|
private parseVoipMessage(content: string): string {
|
||||||
|
try {
|
||||||
|
if (!content) return '[通话]'
|
||||||
|
|
||||||
|
// 提取 msg 内容(中文通话状态)
|
||||||
|
const msgMatch = /<msg><!\[CDATA\[(.*?)\]\]><\/msg>/i.exec(content)
|
||||||
|
const msg = msgMatch?.[1]?.trim() || ''
|
||||||
|
|
||||||
|
// 提取 room_type(0=视频,1=语音)
|
||||||
|
const roomTypeMatch = /<room_type>(\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 '[通话]'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取消息类型名称
|
* 获取消息类型名称
|
||||||
*/
|
*/
|
||||||
@@ -695,7 +753,7 @@ class ExportService {
|
|||||||
...detailedExport.session,
|
...detailedExport.session,
|
||||||
avatar: avatars[sessionId]
|
avatar: avatars[sessionId]
|
||||||
}
|
}
|
||||||
;(detailedExport as any).avatars = avatars
|
; (detailedExport as any).avatars = avatars
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -695,33 +695,41 @@ export class KeyService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private getXorKey(templateFiles: string[]): number | null {
|
private getXorKey(templateFiles: string[]): number | null {
|
||||||
const counts = new Map<string, number>()
|
const counts = new Map<number, number>()
|
||||||
|
const tailSignatures = [
|
||||||
|
Buffer.from([0xFF, 0xD9]),
|
||||||
|
Buffer.from([0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82])
|
||||||
|
]
|
||||||
for (const file of templateFiles) {
|
for (const file of templateFiles) {
|
||||||
try {
|
try {
|
||||||
const bytes = readFileSync(file)
|
const bytes = readFileSync(file)
|
||||||
if (bytes.length < 2) continue
|
for (const signature of tailSignatures) {
|
||||||
const x = bytes[bytes.length - 2]
|
if (bytes.length < signature.length) continue
|
||||||
const y = bytes[bytes.length - 1]
|
const tail = bytes.subarray(bytes.length - signature.length)
|
||||||
const key = `${x}_${y}`
|
const xorKey = tail[0] ^ signature[0]
|
||||||
counts.set(key, (counts.get(key) ?? 0) + 1)
|
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 { }
|
} catch { }
|
||||||
}
|
}
|
||||||
if (!counts.size) return null
|
if (!counts.size) return null
|
||||||
let mostKey = ''
|
let bestKey: number | null = null
|
||||||
let mostCount = 0
|
let bestCount = 0
|
||||||
for (const [key, count] of counts) {
|
for (const [key, count] of counts) {
|
||||||
if (count > mostCount) {
|
if (count > bestCount) {
|
||||||
mostCount = count
|
bestCount = count
|
||||||
mostKey = key
|
bestKey = key
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!mostKey) return null
|
return bestKey
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
|
private getCiphertextFromTemplate(templateFiles: string[]): Buffer | null {
|
||||||
@@ -766,7 +774,17 @@ export class KeyService {
|
|||||||
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
|
const decipher = crypto.createDecipheriv('aes-128-ecb', key, null)
|
||||||
decipher.setAutoPadding(false)
|
decipher.setAutoPadding(false)
|
||||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()])
|
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 {
|
} catch {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from 'react'
|
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 html2canvas from 'html2canvas'
|
||||||
import { useThemeStore } from '../stores/themeStore'
|
import { useThemeStore } from '../stores/themeStore'
|
||||||
import './AnnualReportWindow.scss'
|
import './AnnualReportWindow.scss'
|
||||||
@@ -249,6 +249,7 @@ function AnnualReportWindow() {
|
|||||||
const [fabOpen, setFabOpen] = useState(false)
|
const [fabOpen, setFabOpen] = useState(false)
|
||||||
const [loadingProgress, setLoadingProgress] = useState(0)
|
const [loadingProgress, setLoadingProgress] = useState(0)
|
||||||
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
const [loadingStage, setLoadingStage] = useState('正在初始化...')
|
||||||
|
const [exportMode, setExportMode] = useState<'separate' | 'long'>('separate')
|
||||||
|
|
||||||
const { currentTheme, themeMode } = useThemeStore()
|
const { currentTheme, themeMode } = useThemeStore()
|
||||||
|
|
||||||
@@ -490,7 +491,7 @@ function AnnualReportWindow() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 导出整个报告为长图
|
// 导出整个报告为长图
|
||||||
const exportFullReport = async () => {
|
const exportFullReport = async (filterIds?: Set<string>) => {
|
||||||
if (!containerRef.current) {
|
if (!containerRef.current) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -516,6 +517,16 @@ function AnnualReportWindow() {
|
|||||||
el.style.padding = '40px 0'
|
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 wordCloudInner = container.querySelector('.word-cloud-inner') as HTMLElement
|
||||||
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
const wordTags = container.querySelectorAll('.word-tag') as NodeListOf<HTMLElement>
|
||||||
@@ -584,7 +595,7 @@ function AnnualReportWindow() {
|
|||||||
|
|
||||||
const dataUrl = outputCanvas.toDataURL('image/png')
|
const dataUrl = outputCanvas.toDataURL('image/png')
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.download = `${reportData?.year}年度报告.png`
|
link.download = `${reportData?.year}年度报告${filterIds ? '_自定义' : ''}.png`
|
||||||
link.href = dataUrl
|
link.href = dataUrl
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
@@ -607,6 +618,13 @@ function AnnualReportWindow() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (exportMode === 'long') {
|
||||||
|
setShowExportModal(false)
|
||||||
|
await exportFullReport(selectedSections)
|
||||||
|
setSelectedSections(new Set())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsExporting(true)
|
setIsExporting(true)
|
||||||
setShowExportModal(false)
|
setShowExportModal(false)
|
||||||
|
|
||||||
@@ -735,9 +753,12 @@ function AnnualReportWindow() {
|
|||||||
|
|
||||||
{/* 浮动操作按钮 */}
|
{/* 浮动操作按钮 */}
|
||||||
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
<div className={`fab-container ${fabOpen ? 'open' : ''}`}>
|
||||||
<button className="fab-item" onClick={() => { setFabOpen(false); setShowExportModal(true) }} title="分模块导出">
|
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('separate'); setShowExportModal(true) }} title="分模块导出">
|
||||||
<Image size={18} />
|
<Image size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
<button className="fab-item" onClick={() => { setFabOpen(false); setExportMode('long'); setShowExportModal(true) }} title="自定义导出长图">
|
||||||
|
<SlidersHorizontal size={18} />
|
||||||
|
</button>
|
||||||
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
|
<button className="fab-item" onClick={() => { setFabOpen(false); exportFullReport() }} title="导出长图">
|
||||||
<Download size={18} />
|
<Download size={18} />
|
||||||
</button>
|
</button>
|
||||||
@@ -765,7 +786,7 @@ function AnnualReportWindow() {
|
|||||||
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
<div className="export-overlay" onClick={() => setShowExportModal(false)}>
|
||||||
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
|
<div className="export-modal section-selector" onClick={e => e.stopPropagation()}>
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h3>选择要导出的板块</h3>
|
<h3>{exportMode === 'long' ? '自定义导出长图' : '选择要导出的板块'}</h3>
|
||||||
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
<button className="close-btn" onClick={() => setShowExportModal(false)}>
|
||||||
<X size={20} />
|
<X size={20} />
|
||||||
</button>
|
</button>
|
||||||
@@ -793,7 +814,7 @@ function AnnualReportWindow() {
|
|||||||
onClick={exportSelectedSections}
|
onClick={exportSelectedSections}
|
||||||
disabled={selectedSections.size === 0}
|
disabled={selectedSections.size === 0}
|
||||||
>
|
>
|
||||||
导出 {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
{exportMode === 'long' ? '生成长图' : '导出'} {selectedSections.size > 0 ? `(${selectedSections.size})` : ''}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -838,7 +859,7 @@ function AnnualReportWindow() {
|
|||||||
你发出 <span className="hl">{formatNumber(topFriend.sentCount)}</span> 条 ·
|
你发出 <span className="hl">{formatNumber(topFriend.sentCount)}</span> 条 ·
|
||||||
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> 条
|
TA发来 <span className="hl">{formatNumber(topFriend.receivedCount)}</span> 条
|
||||||
</p>
|
</p>
|
||||||
<br/>
|
<br />
|
||||||
<p className="hero-desc">
|
<p className="hero-desc">
|
||||||
在一起,就可以
|
在一起,就可以
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user