导出方面优化

This commit is contained in:
xuncha
2026-01-30 20:46:02 +08:00
parent bca387c54b
commit 592ca6128f
5 changed files with 200 additions and 54 deletions

View File

@@ -73,6 +73,7 @@ export interface ExportOptions {
exportAvatars?: boolean
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
excelCompactColumns?: boolean
@@ -186,6 +187,20 @@ class ExportService {
return info
}
private async preloadContacts(
usernames: Iterable<string>,
cache: Map<string, { success: boolean; contact?: any; error?: string }>,
limit = 8
): Promise<void> {
const unique = Array.from(new Set(Array.from(usernames).filter(Boolean)))
if (unique.length === 0) return
await parallelLimit(unique, limit, async (username) => {
if (cache.has(username)) return
const result = await wcdbService.getContact(username)
cache.set(username, result)
})
}
/**
* 解析 ext_buffer 二进制数据,提取群成员的群昵称
* ext_buffer 包含类似 protobuf 编码的数据,格式示例:
@@ -859,10 +874,10 @@ class ExportService {
options: {
exportImages?: boolean
exportVoices?: boolean
exportVideos?: boolean
exportEmojis?: boolean
exportVoiceAsText?: boolean
includeVoiceWithTranscript?: boolean
exportVideos?: boolean
}
): Promise<MediaExportItem | null> {
const localType = msg.localType
@@ -877,8 +892,7 @@ class ExportService {
// 语音消息
if (localType === 34) {
const shouldKeepVoiceFile = options.includeVoiceWithTranscript || !options.exportVoiceAsText
if (shouldKeepVoiceFile && options.exportVoices) {
if (options.exportVoices) {
return this.exportVoice(msg, sessionId, mediaRootDir, mediaRelativePrefix)
}
if (options.exportVoiceAsText) {
@@ -1233,7 +1247,7 @@ class ExportService {
mediaRelativePrefix: string
} {
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const outputDir = path.dirname(outputPath)
const outputBaseName = path.basename(outputPath, path.extname(outputPath))
const useSharedMediaLayout = options.sessionLayout === 'shared'
@@ -1681,10 +1695,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
const allMessages = collected.rows
@@ -1693,6 +1703,14 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
if (isGroup) {
await this.mergeGroupMembers(sessionId, collected.memberSet, options.exportAvatars === true)
}
@@ -1707,7 +1725,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) || // 图片
(t === 47 && options.exportEmojis) || // 表情
(t === 34 && options.exportVoices && !options.exportVoiceAsText) // 语音文件(非转文字)
(t === 43 && options.exportVideos) || // 视频
(t === 34 && options.exportVoices) // 语音文件
})
: []
@@ -1729,6 +1748,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
@@ -1738,10 +1758,6 @@ class ExportService {
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? allMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
@@ -1895,10 +1911,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
@@ -1906,6 +1918,21 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
// ========== 阶段1并行导出媒体文件 ==========
@@ -1914,7 +1941,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices)
})
: []
@@ -1935,6 +1963,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
@@ -1944,10 +1973,6 @@ class ExportService {
}
// ========== 阶段2并行语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
@@ -1988,10 +2013,10 @@ class ExportService {
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
if (mediaItem) {
content = mediaItem.relativePath
} else if (msg.localType === 34 && options.exportVoiceAsText) {
if (msg.localType === 34 && options.exportVoiceAsText) {
content = voiceTranscriptMap.get(msg.localId) || '[语音消息 - 转文字失败]'
} else if (mediaItem) {
content = mediaItem.relativePath
} else {
content = this.parseMessageContent(msg.content, msg.localType)
}
@@ -2156,10 +2181,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
@@ -2167,6 +2188,21 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
onProgress?.({
current: 30,
total: 100,
@@ -2297,7 +2333,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices)
})
: []
@@ -2318,6 +2355,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
@@ -2327,10 +2365,6 @@ class ExportService {
}
// ========== 并行预处理:语音转文字 ==========
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
@@ -2416,13 +2450,21 @@ class ExportService {
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
: (mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
))
// 调试日志
if (msg.localType === 3 || msg.localType === 47) {
@@ -2549,6 +2591,16 @@ class ExportService {
const sessionInfo = await this.getContactInfo(sessionId)
const myInfo = await this.getContactInfo(cleanedMyWxid)
const contactCache = new Map<string, { success: boolean; contact?: any; error?: string }>()
const getContactCached = async (username: string) => {
if (contactCache.has(username)) {
return contactCache.get(username)!
}
const result = await wcdbService.getContact(username)
contactCache.set(username, result)
return result
}
onProgress?.({
current: 0,
total: 100,
@@ -2556,10 +2608,6 @@ class ExportService {
phase: 'preparing'
})
if (options.exportVoiceAsText) {
await this.ensureVoiceModel(onProgress)
}
const collected = await this.collectMessages(sessionId, cleanedMyWxid, options.dateRange)
// 如果没有消息,不创建文件
@@ -2567,6 +2615,21 @@ class ExportService {
return { success: false, error: '该会话在指定时间范围内没有消息' }
}
const voiceMessages = options.exportVoiceAsText
? collected.rows.filter(msg => msg.localType === 34)
: []
if (options.exportVoiceAsText && voiceMessages.length > 0) {
await this.ensureVoiceModel(onProgress)
}
const senderUsernames = new Set<string>()
for (const msg of collected.rows) {
if (msg.senderUsername) senderUsernames.add(msg.senderUsername)
}
senderUsernames.add(sessionId)
await this.preloadContacts(senderUsernames, contactCache)
const sortedMessages = collected.rows.sort((a, b) => a.createTime - b.createTime)
const { exportMediaEnabled, mediaRootDir, mediaRelativePrefix } = this.getMediaLayout(outputPath, options)
@@ -2575,7 +2638,8 @@ class ExportService {
const t = msg.localType
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices && !options.exportVoiceAsText)
(t === 43 && options.exportVideos) ||
(t === 34 && options.exportVoices)
})
: []
@@ -2596,6 +2660,7 @@ class ExportService {
const mediaItem = await this.exportMediaForMessage(msg, sessionId, mediaRootDir, mediaRelativePrefix, {
exportImages: options.exportImages,
exportVoices: options.exportVoices,
exportVideos: options.exportVideos,
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText
})
@@ -2604,9 +2669,6 @@ class ExportService {
})
}
const voiceMessages = options.exportVoiceAsText
? sortedMessages.filter(msg => msg.localType === 34)
: []
const voiceTranscriptMap = new Map<number, string>()
if (voiceMessages.length > 0) {
@@ -2637,13 +2699,21 @@ class ExportService {
const msg = sortedMessages[i]
const mediaKey = `${msg.localType}_${msg.localId}`
const mediaItem = mediaCache.get(mediaKey)
const contentValue = mediaItem?.relativePath
|| this.formatPlainExportContent(
const shouldUseTranscript = msg.localType === 34 && options.exportVoiceAsText
const contentValue = shouldUseTranscript
? this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
)
: (mediaItem?.relativePath
|| this.formatPlainExportContent(
msg.content,
msg.localType,
options,
voiceTranscriptMap.get(msg.localId)
))
let senderRole: string
let senderWxid: string
@@ -2763,7 +2833,7 @@ class ExportService {
return (t === 3 && options.exportImages) ||
(t === 47 && options.exportEmojis) ||
(t === 34 && options.exportVoices) ||
t === 43
(t === 43 && options.exportVideos)
})
: []
@@ -2787,7 +2857,7 @@ class ExportService {
exportEmojis: options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText,
includeVoiceWithTranscript: true,
exportVideos: true
exportVideos: options.exportVideos
})
mediaCache.set(mediaKey, mediaItem)
}
@@ -3094,7 +3164,7 @@ class ExportService {
}
const exportMediaEnabled = options.exportMedia === true &&
Boolean(options.exportImages || options.exportVoices || options.exportEmojis)
Boolean(options.exportImages || options.exportVoices || options.exportVideos || options.exportEmojis)
const sessionLayout = exportMediaEnabled
? (options.sessionLayout ?? 'per-session')
: 'shared'

2
package-lock.json generated
View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "1.4.1",
"version": "1.4.2",
"lockfileVersion": 3,
"requires": true,
"packages": {

View File

@@ -1,6 +1,6 @@
{
"name": "weflow",
"version": "1.4.1",
"version": "1.4.2",
"description": "WeFlow",
"main": "dist-electron/main.js",
"author": "cc",

View File

@@ -19,6 +19,7 @@ interface ExportOptions {
exportMedia: boolean
exportImages: boolean
exportVoices: boolean
exportVideos: boolean
exportEmojis: boolean
exportVoiceAsText: boolean
excelCompactColumns: boolean
@@ -65,6 +66,7 @@ function ExportPage() {
exportMedia: false,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: true,
excelCompactColumns: true,
@@ -257,6 +259,7 @@ function ExportPage() {
exportMedia: true,
exportImages: true,
exportVoices: true,
exportVideos: true,
exportEmojis: true,
exportVoiceAsText: true
}
@@ -286,6 +289,7 @@ function ExportPage() {
exportMedia: options.exportMedia,
exportImages: options.exportMedia && options.exportImages,
exportVoices: options.exportMedia && options.exportVoices,
exportVideos: options.exportMedia && options.exportVideos,
exportEmojis: options.exportMedia && options.exportEmojis,
exportVoiceAsText: options.exportVoiceAsText, // 即使不导出媒体,也可以导出语音转文字内容
excelCompactColumns: options.excelCompactColumns,
@@ -609,7 +613,7 @@ function ExportPage() {
)}
<div className="setting-section">
<h3></h3>
<p className="setting-subtitle">//</p>
<p className="setting-subtitle">///</p>
<div className="media-options-card">
<div className="media-switch-row">
<div className="media-switch-info">
@@ -661,7 +665,7 @@ function ExportPage() {
<label className="media-checkbox-row">
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
@@ -672,6 +676,21 @@ function ExportPage() {
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>
<span className="media-checkbox-desc"></span>
</div>
<input
type="checkbox"
checked={options.exportVideos}
disabled={!options.exportMedia}
onChange={e => setOptions({ ...options, exportVideos: e.target.checked })}
/>
</label>
<div className="media-option-divider"></div>
<label className={`media-checkbox-row ${!options.exportMedia ? 'disabled' : ''}`}>
<div className="media-checkbox-info">
<span className="media-checkbox-title"></span>

View File

@@ -62,9 +62,11 @@ function SettingsPage() {
const [showExportFormatSelect, setShowExportFormatSelect] = useState(false)
const [showExportDateRangeSelect, setShowExportDateRangeSelect] = useState(false)
const [showExportExcelColumnsSelect, setShowExportExcelColumnsSelect] = useState(false)
const [showExportConcurrencySelect, setShowExportConcurrencySelect] = useState(false)
const exportFormatDropdownRef = useRef<HTMLDivElement>(null)
const exportDateRangeDropdownRef = useRef<HTMLDivElement>(null)
const exportExcelColumnsDropdownRef = useRef<HTMLDivElement>(null)
const exportConcurrencyDropdownRef = useRef<HTMLDivElement>(null)
const [cachePath, setCachePath] = useState('')
const [logEnabled, setLogEnabled] = useState(false)
const [whisperModelName, setWhisperModelName] = useState('base')
@@ -144,10 +146,13 @@ function SettingsPage() {
if (showExportExcelColumnsSelect && exportExcelColumnsDropdownRef.current && !exportExcelColumnsDropdownRef.current.contains(target)) {
setShowExportExcelColumnsSelect(false)
}
if (showExportConcurrencySelect && exportConcurrencyDropdownRef.current && !exportConcurrencyDropdownRef.current.contains(target)) {
setShowExportConcurrencySelect(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect])
}, [showExportFormatSelect, showExportDateRangeSelect, showExportExcelColumnsSelect, showExportConcurrencySelect])
useEffect(() => {
const removeDb = window.electronAPI.key.onDbKeyStatus((payload) => {
@@ -1110,6 +1115,15 @@ function SettingsPage() {
{ value: 'full', label: '完整列', desc: '含发送者昵称/微信ID/备注' }
]
const exportConcurrencyOptions = [
{ value: 1, label: '1' },
{ value: 2, label: '2' },
{ value: 3, label: '3' },
{ value: 4, label: '4' },
{ value: 5, label: '5' },
{ value: 6, label: '6' }
]
const getOptionLabel = (options: { value: string; label: string }[], value: string) => {
return options.find((option) => option.value === value)?.label ?? value
}
@@ -1119,6 +1133,7 @@ function SettingsPage() {
const exportFormatLabel = getOptionLabel(exportFormatOptions, exportDefaultFormat)
const exportDateRangeLabel = getOptionLabel(exportDateRangeOptions, exportDefaultDateRange)
const exportExcelColumnsLabel = getOptionLabel(exportExcelColumnOptions, exportExcelColumnsValue)
const exportConcurrencyLabel = String(exportDefaultConcurrency)
return (
<div className="tab-content">
@@ -1133,6 +1148,7 @@ function SettingsPage() {
setShowExportFormatSelect(!showExportFormatSelect)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportFormatLabel}</span>
@@ -1172,6 +1188,7 @@ function SettingsPage() {
setShowExportDateRangeSelect(!showExportDateRangeSelect)
setShowExportFormatSelect(false)
setShowExportExcelColumnsSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportDateRangeLabel}</span>
@@ -1256,6 +1273,7 @@ function SettingsPage() {
setShowExportExcelColumnsSelect(!showExportExcelColumnsSelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportConcurrencySelect(false)
}}
>
<span className="select-value">{exportExcelColumnsLabel}</span>
@@ -1285,6 +1303,45 @@ function SettingsPage() {
</div>
</div>
<div className="form-group">
<label></label>
<span className="form-hint">1~6</span>
<div className="select-field" ref={exportConcurrencyDropdownRef}>
<button
type="button"
className={`select-trigger ${showExportConcurrencySelect ? 'open' : ''}`}
onClick={() => {
setShowExportConcurrencySelect(!showExportConcurrencySelect)
setShowExportFormatSelect(false)
setShowExportDateRangeSelect(false)
setShowExportExcelColumnsSelect(false)
}}
>
<span className="select-value">{exportConcurrencyLabel}</span>
<ChevronDown size={16} />
</button>
{showExportConcurrencySelect && (
<div className="select-dropdown">
{exportConcurrencyOptions.map((option) => (
<button
key={option.value}
type="button"
className={`select-option ${exportDefaultConcurrency === option.value ? 'active' : ''}`}
onClick={async () => {
setExportDefaultConcurrency(option.value)
await configService.setExportDefaultConcurrency(option.value)
showMessage(`已将导出并发数设为 ${option.value}`, true)
setShowExportConcurrencySelect(false)
}}
>
<span className="option-label">{option.label}</span>
</button>
))}
</div>
)}
</div>
</div>
</div>
)
}