mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-03-25 15:25:50 +00:00
Merge pull request #8 from 5xiao0qing5/codex/format-txt-export-for-messages
Adjust txt/excel export message formatting
This commit is contained in:
@@ -305,6 +305,121 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatPlainExportContent(
|
||||||
|
content: string,
|
||||||
|
localType: number,
|
||||||
|
options: { exportVoiceAsText?: boolean },
|
||||||
|
voiceTranscript?: string
|
||||||
|
): string {
|
||||||
|
const safeContent = content || ''
|
||||||
|
|
||||||
|
if (localType === 3) return '[图片]'
|
||||||
|
if (localType === 1) return this.stripSenderPrefix(safeContent)
|
||||||
|
if (localType === 34) {
|
||||||
|
if (options.exportVoiceAsText) {
|
||||||
|
return voiceTranscript || '[语音消息 - 转文字失败]'
|
||||||
|
}
|
||||||
|
return '[其他消息]'
|
||||||
|
}
|
||||||
|
if (localType === 42) {
|
||||||
|
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||||
|
const nickname =
|
||||||
|
this.extractXmlValue(normalized, 'nickname') ||
|
||||||
|
this.extractXmlValue(normalized, 'displayname') ||
|
||||||
|
this.extractXmlValue(normalized, 'name')
|
||||||
|
return nickname ? `[名片]${nickname}` : '[名片]'
|
||||||
|
}
|
||||||
|
if (localType === 43) {
|
||||||
|
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||||
|
const lengthValue =
|
||||||
|
this.extractXmlValue(normalized, 'playlength') ||
|
||||||
|
this.extractXmlValue(normalized, 'playLength') ||
|
||||||
|
this.extractXmlValue(normalized, 'length') ||
|
||||||
|
this.extractXmlValue(normalized, 'duration')
|
||||||
|
const seconds = lengthValue ? this.parseDurationSeconds(lengthValue) : null
|
||||||
|
return seconds ? `[视频]${seconds}s` : '[视频]'
|
||||||
|
}
|
||||||
|
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}` : '[定位]'
|
||||||
|
}
|
||||||
|
if (localType === 50) {
|
||||||
|
return this.parseVoipMessage(safeContent)
|
||||||
|
}
|
||||||
|
if (localType === 10000 || localType === 266287972401) {
|
||||||
|
return this.cleanSystemMessage(safeContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = this.normalizeAppMessageContent(safeContent)
|
||||||
|
const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>')
|
||||||
|
if (localType === 49 || isAppMessage) {
|
||||||
|
const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
|
||||||
|
const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0
|
||||||
|
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')
|
||||||
|
if (subType === 3 || normalized.includes('<musicurl') || normalized.includes('<songname')) {
|
||||||
|
const songName = this.extractXmlValue(normalized, 'songname') || title || '音乐'
|
||||||
|
return `[音乐]${songName}`
|
||||||
|
}
|
||||||
|
if (subType === 6) {
|
||||||
|
const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件'
|
||||||
|
return `[文件]${fileName}`
|
||||||
|
}
|
||||||
|
if (title.includes('转账') || normalized.includes('transfer')) {
|
||||||
|
const amount = this.extractAmountFromText(
|
||||||
|
[
|
||||||
|
title,
|
||||||
|
this.extractXmlValue(normalized, 'des'),
|
||||||
|
this.extractXmlValue(normalized, 'money'),
|
||||||
|
this.extractXmlValue(normalized, 'amount'),
|
||||||
|
this.extractXmlValue(normalized, 'fee')
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
)
|
||||||
|
return amount ? `[转账]${amount}` : '[转账]'
|
||||||
|
}
|
||||||
|
if (title.includes('红包') || normalized.includes('hongbao')) {
|
||||||
|
return `[红包]${title || '微信红包'}`
|
||||||
|
}
|
||||||
|
if (subType === 19 || normalized.includes('<recorditem')) {
|
||||||
|
const forwardName =
|
||||||
|
this.extractXmlValue(normalized, 'nickname') ||
|
||||||
|
this.extractXmlValue(normalized, 'title') ||
|
||||||
|
this.extractXmlValue(normalized, 'des') ||
|
||||||
|
this.extractXmlValue(normalized, 'displayname')
|
||||||
|
return forwardName ? `[转发的聊天记录]${forwardName}` : '[转发的聊天记录]'
|
||||||
|
}
|
||||||
|
if (subType === 33 || subType === 36) {
|
||||||
|
const appName = this.extractXmlValue(normalized, 'appname') || title || '小程序'
|
||||||
|
return `[小程序]${appName}`
|
||||||
|
}
|
||||||
|
if (title) {
|
||||||
|
return `[链接]${title}`
|
||||||
|
}
|
||||||
|
return '[其他消息]'
|
||||||
|
}
|
||||||
|
|
||||||
|
return '[其他消息]'
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDurationSeconds(value: string): number | null {
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) return null
|
||||||
|
if (numeric >= 1000) return Math.round(numeric / 1000)
|
||||||
|
return Math.round(numeric)
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractAmountFromText(text: string): string | null {
|
||||||
|
if (!text) return null
|
||||||
|
const match = /([¥¥]\s*\d+(?:\.\d+)?|\d+(?:\.\d+)?)/.exec(text)
|
||||||
|
return match ? match[1].replace(/\s+/g, '') : null
|
||||||
|
}
|
||||||
|
|
||||||
private stripSenderPrefix(content: string): string {
|
private stripSenderPrefix(content: string): string {
|
||||||
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '')
|
return content.replace(/^[\s]*([a-zA-Z0-9_-]+):(?!\/\/)/, '')
|
||||||
}
|
}
|
||||||
@@ -555,31 +670,15 @@ class ExportService {
|
|||||||
private formatHtmlMessageText(content: string, localType: number): string {
|
private formatHtmlMessageText(content: string, localType: number): string {
|
||||||
if (!content) return ''
|
if (!content) return ''
|
||||||
|
|
||||||
const normalized = this.normalizeAppMessageContent(content)
|
if (localType === 1) {
|
||||||
const isAppMessage = normalized.includes('<appmsg') || normalized.includes('<msg>')
|
return this.stripSenderPrefix(content)
|
||||||
|
|
||||||
if (localType === 49 || isAppMessage) {
|
|
||||||
const typeMatch = /<type>(\d+)<\/type>/i.exec(normalized)
|
|
||||||
const subType = typeMatch ? parseInt(typeMatch[1], 10) : 0
|
|
||||||
const title = this.extractXmlValue(normalized, 'title') || this.extractXmlValue(normalized, 'appname')
|
|
||||||
if (subType === 6) {
|
|
||||||
const fileName = this.extractXmlValue(normalized, 'filename') || title || '文件'
|
|
||||||
return `[文件] ${fileName}`.trim()
|
|
||||||
}
|
|
||||||
if (subType === 33 || subType === 36) {
|
|
||||||
const appName = this.extractXmlValue(normalized, 'appname')
|
|
||||||
const miniTitle = title || appName || '小程序'
|
|
||||||
return `[小程序] ${miniTitle}`.trim()
|
|
||||||
}
|
|
||||||
return title || '[链接]'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (localType === 42) {
|
if (localType === 34) {
|
||||||
const nickname = this.extractXmlValue(normalized, 'nickname')
|
return this.parseMessageContent(content, localType) || ''
|
||||||
return nickname ? `[名片] ${nickname}` : '[名片]'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.parseMessageContent(content, localType) || ''
|
return this.formatPlainExportContent(content, localType, { exportVoiceAsText: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1967,10 +2066,6 @@ class ExportService {
|
|||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
|
|
||||||
// 从缓存获取媒体信息
|
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
|
||||||
const mediaItem = mediaCache.get(mediaKey) || null
|
|
||||||
|
|
||||||
// 确定发送者信息
|
// 确定发送者信息
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
@@ -2018,16 +2113,12 @@ class ExportService {
|
|||||||
const row = worksheet.getRow(currentRow)
|
const row = worksheet.getRow(currentRow)
|
||||||
row.height = 24
|
row.height = 24
|
||||||
|
|
||||||
// 确定内容:优先使用预处理的缓存
|
const contentValue = this.formatPlainExportContent(
|
||||||
let contentValue: string
|
msg.content,
|
||||||
if (mediaItem) {
|
msg.localType,
|
||||||
contentValue = mediaItem.relativePath
|
options,
|
||||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
voiceTranscriptMap.get(msg.localId)
|
||||||
// 使用预处理的语音转文字结果
|
)
|
||||||
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
|
||||||
} else {
|
|
||||||
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调试日志
|
// 调试日志
|
||||||
if (msg.localType === 3 || msg.localType === 47) {
|
if (msg.localType === 3 || msg.localType === 47) {
|
||||||
@@ -2190,24 +2281,16 @@ class ExportService {
|
|||||||
phase: 'exporting'
|
phase: 'exporting'
|
||||||
})
|
})
|
||||||
|
|
||||||
const columnOrder = this.normalizeTxtColumns(options.txtColumns)
|
|
||||||
const columnLabelMap = new Map(TXT_COLUMN_DEFINITIONS.map((col) => [col.id, col.label]))
|
|
||||||
const lines: string[] = []
|
const lines: string[] = []
|
||||||
lines.push(columnOrder.map((id) => columnLabelMap.get(id) || id).join('\t'))
|
|
||||||
|
|
||||||
for (let i = 0; i < sortedMessages.length; i++) {
|
for (let i = 0; i < sortedMessages.length; i++) {
|
||||||
const msg = sortedMessages[i]
|
const msg = sortedMessages[i]
|
||||||
const mediaKey = `${msg.localType}_${msg.localId}`
|
const contentValue = this.formatPlainExportContent(
|
||||||
const mediaItem = mediaCache.get(mediaKey) || null
|
msg.content,
|
||||||
|
msg.localType,
|
||||||
let contentValue: string
|
options,
|
||||||
if (mediaItem) {
|
voiceTranscriptMap.get(msg.localId)
|
||||||
contentValue = mediaItem.relativePath
|
)
|
||||||
} else if (msg.localType === 34 && options.exportVoiceAsText) {
|
|
||||||
contentValue = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
|
||||||
} else {
|
|
||||||
contentValue = this.parseMessageContent(msg.content, msg.localType) || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
let senderRole: string
|
let senderRole: string
|
||||||
let senderWxid: string
|
let senderWxid: string
|
||||||
@@ -2242,21 +2325,9 @@ class ExportService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const values: Record<string, string> = {
|
lines.push(`${this.formatTimestamp(msg.createTime)} '${senderRole}'`)
|
||||||
index: String(i + 1),
|
lines.push(contentValue)
|
||||||
time: this.formatTimestamp(msg.createTime),
|
lines.push('')
|
||||||
senderRole,
|
|
||||||
senderNickname,
|
|
||||||
senderWxid,
|
|
||||||
senderRemark,
|
|
||||||
messageType: this.getMessageTypeName(msg.localType),
|
|
||||||
content: contentValue
|
|
||||||
}
|
|
||||||
|
|
||||||
const line = columnOrder
|
|
||||||
.map((id) => this.sanitizeTxtValue(values[id] ?? ''))
|
|
||||||
.join('\t')
|
|
||||||
lines.push(line)
|
|
||||||
|
|
||||||
if ((i + 1) % 200 === 0) {
|
if ((i + 1) % 200 === 0) {
|
||||||
const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
|
const progress = 60 + Math.floor((i + 1) / sortedMessages.length * 30)
|
||||||
@@ -2417,7 +2488,7 @@ class ExportService {
|
|||||||
if (msg.localType === 34 && useVoiceTranscript) {
|
if (msg.localType === 34 && useVoiceTranscript) {
|
||||||
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
textContent = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
|
||||||
}
|
}
|
||||||
if (mediaItem && (msg.localType === 3 || msg.localType === 43 || msg.localType === 47)) {
|
if (mediaItem && (msg.localType === 3 || msg.localType === 47)) {
|
||||||
textContent = ''
|
textContent = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ function SettingsPage() {
|
|||||||
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
const [exportDefaultMedia, setExportDefaultMedia] = useState(false)
|
||||||
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
const [exportDefaultVoiceAsText, setExportDefaultVoiceAsText] = useState(true)
|
||||||
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
const [exportDefaultExcelCompactColumns, setExportDefaultExcelCompactColumns] = useState(true)
|
||||||
const [exportDefaultTxtColumns, setExportDefaultTxtColumns] = useState<string[]>(['index', 'time', 'senderRole', 'messageType', 'content'])
|
|
||||||
|
|
||||||
const [isLoading, setIsLoadingState] = useState(false)
|
const [isLoading, setIsLoadingState] = useState(false)
|
||||||
const [isTesting, setIsTesting] = useState(false)
|
const [isTesting, setIsTesting] = useState(false)
|
||||||
@@ -142,8 +141,6 @@ function SettingsPage() {
|
|||||||
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
const savedExportDefaultMedia = await configService.getExportDefaultMedia()
|
||||||
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
const savedExportDefaultVoiceAsText = await configService.getExportDefaultVoiceAsText()
|
||||||
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
const savedExportDefaultExcelCompactColumns = await configService.getExportDefaultExcelCompactColumns()
|
||||||
const savedExportDefaultTxtColumns = await configService.getExportDefaultTxtColumns()
|
|
||||||
const defaultTxtColumns = ['index', 'time', 'senderRole', 'messageType', 'content']
|
|
||||||
|
|
||||||
if (savedKey) setDecryptKey(savedKey)
|
if (savedKey) setDecryptKey(savedKey)
|
||||||
if (savedPath) setDbPath(savedPath)
|
if (savedPath) setDbPath(savedPath)
|
||||||
@@ -161,11 +158,6 @@ function SettingsPage() {
|
|||||||
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
setExportDefaultMedia(savedExportDefaultMedia ?? false)
|
||||||
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
setExportDefaultVoiceAsText(savedExportDefaultVoiceAsText ?? true)
|
||||||
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
setExportDefaultExcelCompactColumns(savedExportDefaultExcelCompactColumns ?? true)
|
||||||
setExportDefaultTxtColumns(
|
|
||||||
savedExportDefaultTxtColumns && savedExportDefaultTxtColumns.length > 0
|
|
||||||
? savedExportDefaultTxtColumns
|
|
||||||
: defaultTxtColumns
|
|
||||||
)
|
|
||||||
|
|
||||||
// 如果语言列表为空,保存默认值
|
// 如果语言列表为空,保存默认值
|
||||||
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
if (!savedTranscribeLanguages || savedTranscribeLanguages.length === 0) {
|
||||||
@@ -174,9 +166,6 @@ function SettingsPage() {
|
|||||||
await configService.setTranscribeLanguages(defaultLanguages)
|
await configService.setTranscribeLanguages(defaultLanguages)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!savedExportDefaultTxtColumns || savedExportDefaultTxtColumns.length === 0) {
|
|
||||||
await configService.setExportDefaultTxtColumns(defaultTxtColumns)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
if (savedWhisperModelDir) setWhisperModelDir(savedWhisperModelDir)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -911,16 +900,6 @@ function SettingsPage() {
|
|||||||
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
{ value: 'compact', label: '精简列', desc: '序号、时间、发送者身份、消息类型、内容' },
|
||||||
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
|
||||||
]
|
]
|
||||||
const exportTxtColumnOptions = [
|
|
||||||
{ value: 'index', label: '序号' },
|
|
||||||
{ value: 'time', label: '时间' },
|
|
||||||
{ value: 'senderRole', label: '发送者身份' },
|
|
||||||
{ value: 'messageType', label: '消息类型' },
|
|
||||||
{ value: 'content', label: '内容' },
|
|
||||||
{ value: 'senderNickname', label: '发送者昵称' },
|
|
||||||
{ value: 'senderWxid', label: '发送者微信ID' },
|
|
||||||
{ value: 'senderRemark', label: '发送者备注' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
|
||||||
return options.find((option) => option.value === value)?.label ?? value
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
@@ -1097,40 +1076,6 @@ function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
|
||||||
<label>TXT 导出栏目</label>
|
|
||||||
<span className="form-hint">默认与 Excel 精简列一致,可多选调整输出字段</span>
|
|
||||||
<div className="language-checkboxes">
|
|
||||||
{exportTxtColumnOptions.map((column) => {
|
|
||||||
const checked = exportDefaultTxtColumns.includes(column.value)
|
|
||||||
return (
|
|
||||||
<label key={column.value} className="language-checkbox">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={checked}
|
|
||||||
onChange={async (e) => {
|
|
||||||
const enabled = e.target.checked
|
|
||||||
const nextColumns = enabled
|
|
||||||
? [...exportDefaultTxtColumns, column.value]
|
|
||||||
: exportDefaultTxtColumns.filter((value) => value !== column.value)
|
|
||||||
if (nextColumns.length === 0) {
|
|
||||||
showMessage('至少选择一个 TXT 导出栏目', false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setExportDefaultTxtColumns(nextColumns)
|
|
||||||
await configService.setExportDefaultTxtColumns(nextColumns)
|
|
||||||
showMessage('已更新 TXT 导出栏目', true)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="checkbox-custom">
|
|
||||||
<Check size={14} />
|
|
||||||
<span>{column.label}</span>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user